diff --git a/src/@types.d.ts b/src/@types.d.ts index ade0c9b6..698e2c19 100644 --- a/src/@types.d.ts +++ b/src/@types.d.ts @@ -81,6 +81,9 @@ export type Song = { // Colors primaryColor?: string; secondaryColor?: string; + + // Playlists + playlists?: string[]; } & Resource; // Serialization is in JSON that's why properties are only single letter @@ -126,12 +129,19 @@ export type TableMap = { songs: { [key: ResourceID]: Song }; audio: { [key: ResourceID]: AudioSource }; images: { [key: ResourceID]: ImageSource }; + playlists: { [key: string]: Playlist }; colors: { [key: ResourceID]: ColorsSource }; - playlists: { [key: string]: ResourceID[] }; settings: Settings; system: System; }; +export type Playlist = { + name: string; + count: number; + length: number; // total length in seconds + songs: Song[]; +}; + // I guess this is definition of all binary blob files that can be access from the database code? export type BlobMap = { times; @@ -174,7 +184,14 @@ export type Tag = { isSpecial?: boolean; }; -export type OrderOptions = "title" | "artist" | "creator" | "bpm" | "duration" | "dateAdded"; +export type OrderOptions = + | "title" + | "artist" + | "creator" + | "bpm" + | "duration" + | "dateAdded" + | "none"; export type OrderDirection = "asc" | "desc"; @@ -201,6 +218,11 @@ export type QueueCreatePayload = { startSong: ResourceID; }; +// idk if this is necessary because i'm only passing a string +export type PlaylistSongsQueryPayload = { + playlistName: string; +}; + export type OsuSearchAbleProperties = | "bpm" | "artist" @@ -239,3 +261,7 @@ export type InfiniteScrollerInitResponse = Optional<{ initialIndex: number; count: number; }>; + +export type PlaylistNamesResponse = Optional<{ + playlistNames: string[]; +}>; diff --git a/src/ListenAPI.d.ts b/src/ListenAPI.d.ts index d8499784..86c2b46b 100644 --- a/src/ListenAPI.d.ts +++ b/src/ListenAPI.d.ts @@ -14,6 +14,9 @@ export type ListenAPI = { "songView::reset": () => void; + "playlist::resetList": () => void; + "playlist::resetSongList": () => void; + "window::maximizeChange": (maximized: boolean) => void; notify: (notice: NoticeType) => void; diff --git a/src/RequestAPI.d.ts b/src/RequestAPI.d.ts index 3784e3c2..b96877c8 100644 --- a/src/RequestAPI.d.ts +++ b/src/RequestAPI.d.ts @@ -5,6 +5,9 @@ import type { InfiniteScrollerRequest, InfiniteScrollerResponse, Optional, + Playlist, + PlaylistNamesResponse, + PlaylistSongsQueryPayload, QueueCreatePayload, ResourceID, ResourceTables, @@ -37,6 +40,12 @@ export type RequestAPI = { "queue::create": (payload: QueueCreatePayload) => void; "queue::shuffle": () => void; + "playlist::add": (playlistName: string, song: Song) => Result; + "playlist::remove": (playlistName: string, song: Song) => Result; + "playlist::create": (name: string) => Result; + "playlist::delete": (name: string) => Result; + "playlist::rename": (oldName: string, newName: string) => Result; + "dir::select": () => Optional; "dir::autoGetOsuDir": () => Optional; "dir::submit": (dir: string) => void; @@ -66,6 +75,16 @@ export type RequestAPI = { ) => InfiniteScrollerResponse; "query::queue::init": () => InfiniteScrollerInitResponse; "query::queue": (request: InfiniteScrollerRequest) => InfiniteScrollerResponse; + "query::playlists::init": () => InfiniteScrollerInitResponse; + "query::playlists": (request: InfiniteScrollerRequest) => InfiniteScrollerResponse; + "query::playlistSongs::init": ( + payload: PlaylistSongsQueryPayload, + ) => InfiniteScrollerInitResponse; + "query::playlistSongs": ( + request: InfiniteScrollerRequest, + payload: PlaylistSongsQueryPayload, + ) => InfiniteScrollerResponse; + "query::playlistNames": () => PlaylistNamesResponse; "save::localVolume": (volume: number, song: ResourceID) => void; diff --git a/src/main/lib/song/order.ts b/src/main/lib/song/order.ts index 40fc7c74..a9eeccd0 100644 --- a/src/main/lib/song/order.ts +++ b/src/main/lib/song/order.ts @@ -1,5 +1,5 @@ import { Order, Result, Song } from "../../../@types"; -import { ok } from "../rust-like-utils-backend/Result"; +import { fail, ok } from "../rust-like-utils-backend/Result"; import { averageBPM, msToBPM } from "./average-bpm"; export default function order(ordering: Order): Result<(a: Song, b: Song) => number, string> { @@ -46,6 +46,9 @@ export default function order(ordering: Order): Result<(a: Song, b: Song) => num case "duration": return ok((a: Song, b: Song) => (a.duration - b.duration) * sortDirection); + case "none": + return fail(""); + default: return ok((a: Song, b: Song) => { if (a[option] === "") { diff --git a/src/main/router/import.ts b/src/main/router/import.ts index f98f5ffb..5821b64f 100644 --- a/src/main/router/import.ts +++ b/src/main/router/import.ts @@ -6,6 +6,7 @@ import "./discord-router"; import "./error-router"; import "./local-volume-router"; import "./parser-router"; +import "./playlist-router"; import "./queue-router"; import "./resource-router"; import "./settings-router"; diff --git a/src/main/router/playlist-router.ts b/src/main/router/playlist-router.ts new file mode 100644 index 00000000..8063063c --- /dev/null +++ b/src/main/router/playlist-router.ts @@ -0,0 +1,316 @@ +import { Playlist } from "../../@types"; +import { Router } from "../lib/route-pass/Router"; +import { none, some } from "../lib/rust-like-utils-backend/Optional"; +import { fail, ok } from "../lib/rust-like-utils-backend/Result"; +import { Storage } from "../lib/storage/Storage"; +import errorIgnored from "../lib/tungsten/errorIgnored"; +import { mainWindow } from "../main"; + +const BUFFER_SIZE = 50; + +Router.respond("playlist::add", async (_evt, playlistName, song) => { + const playlists = Storage.getTable("playlists"); + const playlist = playlists.get(playlistName); + + if (playlist.isNone) { + return fail("Playlist does not exist"); + } + + const songs = Storage.getTable("songs"); + const songNew = songs.get(song.audio); + + if (songNew.isNone) { + return fail("Couldn't fetch song"); + } + + if (songNew.value.playlists === undefined) { + songNew.value.playlists = []; + } + + // insert playlist name in the song + songNew.value.playlists.push(playlistName); + + // rewrite song in storage + songs.write(song.audio, songNew.value); + + // update the playlist + playlist.value.songs.push(song); + playlist.value.count = playlist.value.count + 1; + playlist.value.length = playlist.value.length + song.duration; + + // rewrite the playlist in storage + playlists.write(playlistName, playlist.value); + + // refresh windows, a song could be added from the queue so we basically refresh everything + await Router.dispatch(mainWindow, "playlist::resetList").catch(errorIgnored); + await Router.dispatch(mainWindow, "playlist::resetSongList").catch(errorIgnored); + await Router.dispatch(mainWindow, "songView::reset").catch(errorIgnored); + + return ok({}); +}); + +Router.respond("playlist::create", (_evt, name) => { + const playlists = Storage.getTable("playlists"); + const playlistNames = Object.keys(playlists.getStruct()); + + if (playlistNames.includes(name)) { + return fail("Playlist already exists"); + } + + // write an empty playlist + const empty = { name: name, count: 0, length: 0, songs: [] }; + playlists.write(name, empty); + + return ok({}); +}); + +Router.respond("playlist::delete", (_evt, name) => { + const playlists = Storage.getTable("playlists"); + const playlist = playlists.get(name); + + if (playlist.isNone) { + return fail("Couldn't find playlist"); + } + + const songs = Storage.getTable("songs"); + + playlist.value.songs.forEach((song) => { + // getting the song everytime because the one in the playlist may not be up to date + const songNew = songs.get(song.audio); + + if (songNew.isNone) { + return fail("Couldn't fetch song"); + } + + if (songNew.value.playlists === undefined) { + songNew.value.playlists = []; + } else { + // get the playlist index + const playlistIndex = songNew.value.playlists.findIndex((p) => p === name); + + if (playlistIndex > -1) { + // remove the playlist from the song + songNew.value.playlists.splice(playlistIndex, 1); + songs.write(song.audio, songNew.value); + } + } + + return; + }); + + // delete the playlist + playlists.delete(name); + + return ok({}); +}); + +Router.respond("playlist::remove", async (_evt, playlistName, song) => { + const playlists = Storage.getTable("playlists"); + const playlist = playlists.get(playlistName); + + if (playlist.isNone) { + return fail("Playlist does not exist"); + } + + // find the position of the song in the playlist, i assume that song.audio is the primary key + const songIndex = playlist.value.songs.findIndex((s) => s.audio === song.audio); + + if (songIndex > -1) { + // update the playlist + playlist.value.songs.splice(songIndex, 1); + playlist.value.count = playlist.value.count - 1; + playlist.value.length = playlist.value.length - song.duration; + + // rewrite with the new playlist + playlists.write(playlistName, playlist.value); + + // get the song to remove the playlist from its list + const songs = Storage.getTable("songs"); + const songNew = songs.get(song.audio); + + if (songNew.isNone) { + return fail("Couldn't fetch song"); + } + + if (songNew.value.playlists === undefined) { + songNew.value.playlists = []; + } else { + // get the playlist index + const playlistIndex = songNew.value.playlists.findIndex((p) => p === playlistName); + + if (playlistIndex > -1) { + // remove the playlist from the song + songNew.value.playlists.splice(playlistIndex, 1); + songs.write(song.audio, songNew.value); + } + } + + // refresh the songList screen + await Router.dispatch(mainWindow, "playlist::resetSongList").catch(errorIgnored); + return ok({}); + } + return fail("Song not found in playlist"); +}); + +Router.respond("playlist::rename", (_evt, oldName, newName) => { + const playlists = Storage.getTable("playlists"); + const oldPlaylist = playlists.get(oldName); + + if (oldPlaylist.isNone) { + return fail("Playlist does not exist"); + } + + const playlistNames = Object.keys(playlists.getStruct()); + + if (playlistNames.includes(newName)) { + return fail("Playlist already exists"); + } + + // replace playlist with new name + oldPlaylist.value.name = newName; + playlists.write(newName, oldPlaylist.value); + playlists.delete(oldName); + + const songs = Storage.getTable("songs"); + + // replace the old playlist name with the new one for every song in the playlist + oldPlaylist.value.songs.forEach((song) => { + // getting the song everytime because the one in the playlist may not be up to date + const songNew = songs.get(song.audio); + + if (songNew.isNone) { + return fail("Couldn't fetch song"); + } + + if (songNew.value.playlists === undefined) { + songNew.value.playlists = [newName]; + } else { + // get the playlist index + const playlistIndex = songNew.value.playlists.findIndex((p) => p === oldName); + + if (playlistIndex > -1) { + // replace the playlist in the song + songNew.value.playlists.splice(playlistIndex, 1); + songNew.value.playlists.push(newName); + songs.write(song.audio, songNew.value); + } + } + + return; + }); + + return ok({}); +}); + +Router.respond("query::playlists::init", () => { + const playlists = Storage.getTable("playlists").getStruct(); + const count = Object.keys(playlists).length; + + return some({ + initialIndex: 0, + count: count, + }); +}); + +Router.respond("query::playlistNames", () => { + const playlists = Storage.getTable("playlists").getStruct(); + const names = Object.keys(playlists); + + return some({ + playlistNames: names, + }); +}); + +Router.respond("query::playlists", (_evt, request) => { + const playlistNames = Object.keys(Storage.getTable("playlists").getStruct()); + + const playlists = Storage.getTable("playlists"); + const playlistsInfo: Playlist[] = []; + playlistNames.forEach((name) => { + const plist = playlists.get(name); + + if (plist.isNone) { + return; + } + + playlistsInfo.push({ + name: name, + count: plist.value.count, + length: plist.value.length, + songs: plist.value.songs, + }); + }); + + if ( + playlistsInfo === undefined || + request.index < 0 || + request.index > Math.floor(playlistsInfo.length / BUFFER_SIZE) + ) { + return none(); + } + + const start = request.index * BUFFER_SIZE; + + if (request.direction === "up") { + return some({ + index: request.index - 1, + total: playlistsInfo.length, + items: playlistsInfo.slice(start, start + BUFFER_SIZE), + }); + } + + return some({ + index: request.index + 1, + total: playlistsInfo.length, + items: playlistsInfo.slice(start, start + BUFFER_SIZE), + }); +}); + +Router.respond("query::playlistSongs::init", (_evt, payload) => { + const songs = Storage.getTable("playlists").get(payload.playlistName); + + if (songs.isNone) { + return none(); + } + + const count = Object.keys(songs.value.songs).length; + + return some({ + initialIndex: 0, + count: count, + }); +}); + +Router.respond("query::playlistSongs", (_evt, request, payload) => { + const playlist = Storage.getTable("playlists").get(payload.playlistName); + + if (playlist.isNone) { + return none(); + } + + const songs = playlist.value.songs; + + if ( + songs === undefined || + request.index < 0 || + request.index > Math.floor(songs.length / BUFFER_SIZE) + ) { + return none(); + } + + const start = request.index * BUFFER_SIZE; + + if (request.direction === "up") { + return some({ + index: request.index - 1, + total: songs.length, + items: songs.slice(start, start + BUFFER_SIZE), + }); + } + + return some({ + index: request.index + 1, + total: songs.length, + items: songs.slice(start, start + BUFFER_SIZE), + }); +}); diff --git a/src/main/router/queue-router.ts b/src/main/router/queue-router.ts index b62e7abd..7bb84244 100644 --- a/src/main/router/queue-router.ts +++ b/src/main/router/queue-router.ts @@ -79,8 +79,23 @@ function getIndexes(view: QueueView): SongIndex[] { } if (view.playlist) { - //todo get playlist - return []; + const indexes = Storage.getTable("system").get("indexes"); + const playlist = Storage.getTable("playlists").get(view.playlist); + + if (indexes.isNone || playlist.isNone) { + return []; + } + + const songs: SongIndex[] = []; + // is there a more efficient way (or is this good enough)? for every song in the playlist it iterates the entire indexes array... + for (let i = 0; i < playlist.value.count; ++i) { + const song = indexes.value.find((v) => v.id === playlist.value.songs[i].audio); + if (song !== undefined) { + songs.push(song); + } + } + + return songs; } return []; diff --git a/src/renderer/src/components/playlist/playlist-create/PlaylistCreateBox.tsx b/src/renderer/src/components/playlist/playlist-create/PlaylistCreateBox.tsx new file mode 100644 index 00000000..8be11041 --- /dev/null +++ b/src/renderer/src/components/playlist/playlist-create/PlaylistCreateBox.tsx @@ -0,0 +1,113 @@ +import { noticeError, setShowPlaylistCreateBox } from "../playlist.utils"; +import Button from "@renderer/components/button/Button"; +import { Input } from "@renderer/components/input/Input"; +import { addNotice } from "@renderer/components/notice/NoticeContainer"; +import SongImage from "@renderer/components/song/SongImage"; +import Impulse from "@renderer/lib/Impulse"; +import { CircleCheckIcon, XIcon } from "lucide-solid"; +import { Component, createSignal, Signal } from "solid-js"; +import { Song } from "src/@types"; + +export type PlaylistCreateBoxProps = { + group: string; + reset: Impulse; + songSignal: Signal; +}; +const PlaylistCreateBox: Component = (props) => { + const [playlistName, setPlaylistName] = createSignal(""); + const [createPlaylistBoxSong, setCreatePlaylistBoxSong] = props.songSignal; + + const createPlaylist = async () => { + // last check is probably unnecessary + const name = playlistName().trim(); + if (name.length === 0 || name === undefined || name === "") { + return; + } + const result = await window.api.request("playlist::create", name); + if (result.isError) { + noticeError(result.error); + return; + } + + const song = createPlaylistBoxSong(); + + setPlaylistName(""); + if (song === undefined) { + props.reset.pulse(); + } + setShowPlaylistCreateBox(false); + + addNotice({ + title: "Playlist created", + description: "The playlist " + name + " has been successfully created!", + variant: "success", + icon: , + }); + + if (song !== undefined) { + const songResult = await window.api.request("playlist::add", name, song); + setCreatePlaylistBoxSong(undefined); + + if (songResult.isError) { + noticeError(songResult.error); + return; + } + + addNotice({ + title: "Song added", + description: "Successfully added song to playlist " + name + "!", + variant: "success", + icon: , + }); + } + }; + + return ( +
+
+

Create a new playlist

+ +
+
+
+ +
+
+ { + setPlaylistName(e.target.value); + }} + onKeyPress={(k) => { + if (k.key == "Enter") { + createPlaylist(); + } + }} + /> + +
+
+
+ ); +}; + +export default PlaylistCreateBox; diff --git a/src/renderer/src/components/playlist/playlist-item/PlaylistItem.tsx b/src/renderer/src/components/playlist/playlist-item/PlaylistItem.tsx new file mode 100644 index 00000000..69a536c2 --- /dev/null +++ b/src/renderer/src/components/playlist/playlist-item/PlaylistItem.tsx @@ -0,0 +1,173 @@ +import SongImage from "../../song/SongImage"; +import { + deletePlaylist, + PLAYLIST_SCENE_SONGS, + setActivePlaylistName, + setPlaylistActiveScene, +} from "../playlist.utils"; +import { renamePlaylist } from "../playlist.utils"; +import { getSongImage, ignoreClickInContextMenu } from "./playlist-item.utils"; +import DropdownList from "@renderer/components/dropdown-list/DropdownList"; +import { Input } from "@renderer/components/input/Input"; +import Popover from "@renderer/components/popover/Popover"; +import Impulse from "@renderer/lib/Impulse"; +import draggable from "@renderer/lib/draggable/draggable"; +import { EllipsisVerticalIcon, ListXIcon, PencilLineIcon } from "lucide-solid"; +import { Component, createSignal, Match, onMount, Setter, Switch } from "solid-js"; +import { Portal } from "solid-js/web"; +import { Playlist } from "src/@types"; +import { twMerge } from "tailwind-merge"; + +export type PlaylistItemProps = { + playlist: Playlist; + group: string; + reset: Impulse; +}; + +const PlaylistItem: Component = (props) => { + let item: HTMLDivElement | undefined; + + const [playlistName, setPlaylistName] = createSignal(""); + const [editMode, setEditMode] = createSignal(false); + const [localShow, setLocalShow] = createSignal(false); + const [mousePos, setMousePos] = createSignal<[number, number]>([0, 0]); + + onMount(() => { + if (!item) return; + + draggable(item, { + onClick: ignoreClickInContextMenu(() => { + if (!editMode()) { + setActivePlaylistName(props.playlist.name); + setPlaylistActiveScene(PLAYLIST_SCENE_SONGS); + } + }), + onDrop: () => {}, + useOnlyAsOnClickBinder: true, + }); + }); + + return ( + + + + { + e.stopImmediatePropagation(); + setLocalShow(false); + }} + > + {/* can't pass this as a prop like in song-item because i need the editMode signal */} + + + +
{ + e.preventDefault(); + setMousePos([e.clientX, e.clientY]); + setLocalShow(true); + }} + class="group" + ref={item} + > +
+
+ +
+ +
+
+ + + { + setPlaylistName(e.target.value); + }} + onKeyPress={async (e) => { + if (e.key == "Enter") { + await renamePlaylist(props.playlist.name, playlistName()); + setEditMode(false); + props.reset.pulse(); + } + }} + onKeyDown={(e) => { + if (e.key == "Escape") { + setEditMode(false); + } + }} + onFocusOut={() => { + setEditMode(false); + }} + /> + + +

{props.playlist.name}

+
+
+

{props.playlist.count} songs

+
+ + + +
+
+
+
+ ); +}; + +type PlaylistItemContextMenuContentProps = { + playlist: Playlist; + reset: Impulse; + editMode: Setter; +}; +const PlaylistItemContextMenuContent: Component = (props) => { + return ( + + { + props.editMode(true); + }} + > + Rename playlist + + + { + deletePlaylist(props.playlist.name, props.reset); + }} + class="text-danger" + > + Delete playlist + + + + ); +}; + +export default PlaylistItem; diff --git a/src/renderer/src/components/playlist/playlist-item/playlist-item.utils.ts b/src/renderer/src/components/playlist/playlist-item/playlist-item.utils.ts new file mode 100644 index 00000000..26d31f8d --- /dev/null +++ b/src/renderer/src/components/playlist/playlist-item/playlist-item.utils.ts @@ -0,0 +1,22 @@ +import { Playlist } from "src/@types"; + +export function getSongImage(playlist: Playlist) { + const songs = playlist.songs; + if (songs.length === 0 || songs[0].bg === undefined || songs[0].bg === "") { + return ""; + } else { + return songs[0].bg; + } +} + +export function ignoreClickInContextMenu(fn: (evt: MouseEvent) => any): (evt: MouseEvent) => void { + return (evt: MouseEvent) => { + const t = evt.target; + + if (!(t instanceof HTMLElement)) { + return; + } + + fn(evt); + }; +} diff --git a/src/renderer/src/components/playlist/playlist-list/PlaylistList.tsx b/src/renderer/src/components/playlist/playlist-list/PlaylistList.tsx new file mode 100644 index 00000000..9a94fc86 --- /dev/null +++ b/src/renderer/src/components/playlist/playlist-list/PlaylistList.tsx @@ -0,0 +1,82 @@ +import InfiniteScroller from "../../InfiniteScroller"; +import PlaylistCreateBox from "../playlist-create/PlaylistCreateBox"; +import PlaylistItem from "../playlist-item/PlaylistItem"; +import { + createPlaylistBoxSong, + setCreatePlaylistBoxSong, + setShowPlaylistCreateBox, + showPlaylistCreateBox, +} from "../playlist.utils"; +import { namespace } from "@renderer/App"; +import Button from "@renderer/components/button/Button"; +import { Input } from "@renderer/components/input/Input"; +import Impulse from "@renderer/lib/Impulse"; +import { PlusIcon, SearchIcon } from "lucide-solid"; +import { Component, onCleanup, onMount, Show } from "solid-js"; + +const PlaylistList: Component = () => { + const resetListing = new Impulse(); + const group = namespace.create(true); + + onMount(() => { + window.api.listen("playlist::resetList", resetListing.pulse.bind(resetListing)); + }); + + onCleanup(() => { + window.api.removeListener("playlist::resetList", resetListing.pulse.bind(resetListing)); + }); + + return ( +
+
+
+
+ + +
+ +
+ + + +
+ +
+ No playlists...
} + class="flex w-full flex-col gap-4" + builder={(s) => } + /> +
+ + ); +}; + +export default PlaylistList; diff --git a/src/renderer/src/components/playlist/playlist-song-list/PlaylistSongList.tsx b/src/renderer/src/components/playlist/playlist-song-list/PlaylistSongList.tsx new file mode 100644 index 00000000..580a33dc --- /dev/null +++ b/src/renderer/src/components/playlist/playlist-song-list/PlaylistSongList.tsx @@ -0,0 +1,133 @@ +import InfiniteScroller from "../../InfiniteScroller"; +import SongItem from "../../song/song-item/SongItem"; +import { deleteSong, PLAYLIST_SCENE_LIST, setPlaylistActiveScene } from "../playlist.utils"; +import { namespace } from "@renderer/App"; +import Button from "@renderer/components/button/Button"; +import DropdownList from "@renderer/components/dropdown-list/DropdownList"; +import Impulse from "@renderer/lib/Impulse"; +import { ArrowLeftIcon, DeleteIcon, PencilIcon, PencilOffIcon, Trash2Icon } from "lucide-solid"; +import { Component, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { PlaylistSongsQueryPayload, ResourceID, Song } from "src/@types"; + +type PlaylistSongListProps = { + playlistName: string; +}; + +const PlaylistSongList: Component = (props) => { + const group = namespace.create(true); + + const [payload] = createSignal({ + playlistName: props.playlistName, + }); + + const [editMode, setEditMode] = createSignal(false); + const [, setIsQueueExist] = createSignal(false); + + const reset = new Impulse(); + + onMount(async () => { + window.api.listen("playlist::resetSongList", reset.pulse.bind(reset)); + setIsQueueExist(await window.api.request("queue::exists")); + }); + + onCleanup(() => window.api.removeListener("playlist::resetSongList", reset.pulse.bind(reset))); + + const createQueue = async (songResource: ResourceID) => { + await window.api.request("queue::create", { + startSong: songResource, + order: { direction: "asc", option: "none" }, + tags: [], + view: { playlist: props.playlistName }, + }); + setIsQueueExist(true); + }; + + return ( +
+
+
+ +

{props.playlistName}

+
+ +
+
+ No songs in playlist...
} + builder={(s) => ( +
+ + } + /> +
+ + + +
+
+ )} + /> +
+ + ); +}; + +type PlaylistSongListContextMenuContentProps = { song: Song; playlistName: string }; +const PlaylistSongListContextMenuContent: Component = ( + props, +) => { + return ( + + { + deleteSong(props.playlistName, props.song); + }} + class="text-danger" + > + Remove from Playlist + + + + ); +}; + +export default PlaylistSongList; diff --git a/src/renderer/src/components/playlist/playlist-view/PlaylistView.tsx b/src/renderer/src/components/playlist/playlist-view/PlaylistView.tsx new file mode 100644 index 00000000..c1bc06bb --- /dev/null +++ b/src/renderer/src/components/playlist/playlist-view/PlaylistView.tsx @@ -0,0 +1,26 @@ +import PlaylistList from "../playlist-list/PlaylistList"; +import PlaylistSongList from "../playlist-song-list/PlaylistSongList"; +import { + PLAYLIST_SCENE_SONGS, + PLAYLIST_SCENE_LIST, + playlistActiveScene, + activePlaylistName, +} from "../playlist.utils"; +import { Component, Match, Switch } from "solid-js"; + +const PlaylistView: Component = () => { + return ( +
+ + + + + + + + +
+ ); +}; + +export default PlaylistView; diff --git a/src/renderer/src/components/playlist/playlist.utils.ts b/src/renderer/src/components/playlist/playlist.utils.ts new file mode 100644 index 00000000..0ab5cbce --- /dev/null +++ b/src/renderer/src/components/playlist/playlist.utils.ts @@ -0,0 +1,71 @@ +import { addNotice } from "../notice/NoticeContainer"; +import Impulse from "@renderer/lib/Impulse"; +import { CircleCheckIcon, CircleXIcon } from "lucide-solid"; +import { createSignal } from "solid-js"; +import { Song } from "src/@types"; + +const PLAYLIST_SCENE_LIST = 0; +const PLAYLIST_SCENE_SONGS = 1; + +const [playlistActiveScene, setPlaylistActiveScene] = createSignal(PLAYLIST_SCENE_LIST); +const [activePlaylistName, setActivePlaylistName] = createSignal(""); +const [createPlaylistBoxSong, setCreatePlaylistBoxSong] = createSignal(undefined); +const [showPlaylistCreateBox, setShowPlaylistCreateBox] = createSignal(false); + +export { playlistActiveScene, setPlaylistActiveScene }; +export { activePlaylistName, setActivePlaylistName }; +export { createPlaylistBoxSong, setCreatePlaylistBoxSong }; +export { showPlaylistCreateBox, setShowPlaylistCreateBox }; +export { PLAYLIST_SCENE_SONGS, PLAYLIST_SCENE_LIST }; + +export function noticeError(error: string) { + addNotice({ + title: "Error", + description: error, + variant: "error", + icon: CircleXIcon({ size: 20 }), + }); +} + +export async function deletePlaylist(name: string, reset: Impulse) { + const result = await window.api.request("playlist::delete", name); + if (result.isError) { + noticeError(result.error); + } else { + reset.pulse(); + addNotice({ + variant: "success", + title: "Playlist deleted", + description: "Playlist " + name + " successfully deleted!", + icon: CircleCheckIcon({ size: 20 }), + }); + } +} + +export async function renamePlaylist(oldName: string, newName: string) { + newName = newName.trim(); + if (newName === undefined || newName === "" || newName === oldName) { + return; + } + + const result = await window.api.request("playlist::rename", oldName, newName); + if (result.isError) { + noticeError(result.error); + return; + } + + addNotice({ + title: "Renamed playlist", + description: "Playlist renamed successfully!", + variant: "success", + icon: CircleCheckIcon({ size: 20 }), + }); +} + +export async function deleteSong(playlistName: string, song: Song) { + const result = await window.api.request("playlist::remove", playlistName, song); + if (result.isError) { + noticeError(result.error); + return; + } +} diff --git a/src/renderer/src/components/song/context-menu/items/AddToPlaylist.tsx b/src/renderer/src/components/song/context-menu/items/AddToPlaylist.tsx new file mode 100644 index 00000000..75b3a3fd --- /dev/null +++ b/src/renderer/src/components/song/context-menu/items/AddToPlaylist.tsx @@ -0,0 +1,71 @@ +import { Song } from "../../../../../../@types"; +import PlaylistChooser from "./PlaylistChooser"; +import DropdownList from "@renderer/components/dropdown-list/DropdownList"; +import Popover from "@renderer/components/popover/Popover"; +import { ChevronRightIcon } from "lucide-solid"; +import { Component, createSignal, onMount } from "solid-js"; + +type AddToPlaylistProps = { + song: Song; +}; + +const AddToPlaylist: Component = (props) => { + const [playlistNames, setPlaylistNames] = createSignal([]); + const [showChooser, setShowChooser] = createSignal(false); + const [timeoutId, setTimeoutId] = createSignal(); + + onMount(async () => { + const playlists = await window.api.request("query::playlistNames"); + if (playlists.isNone) { + return; + } + + setPlaylistNames(playlists.value.playlistNames); + }); + + return ( + { + e.stopPropagation(); + setShowChooser(true); + }} + onMouseOver={() => { + setShowChooser(true); + clearTimeout(timeoutId()); + }} + onMouseLeave={() => { + setTimeoutId( + setTimeout(() => { + setShowChooser(false); + }, 320), + ); + }} + > + + + + +
+ Add to Playlist + +
+ +
+
+ ); +}; + +export default AddToPlaylist; diff --git a/src/renderer/src/components/song/context-menu/items/PlaylistChooser.tsx b/src/renderer/src/components/song/context-menu/items/PlaylistChooser.tsx new file mode 100644 index 00000000..21fa3037 --- /dev/null +++ b/src/renderer/src/components/song/context-menu/items/PlaylistChooser.tsx @@ -0,0 +1,92 @@ +import DropdownList from "@renderer/components/dropdown-list/DropdownList"; +import { addNotice } from "@renderer/components/notice/NoticeContainer"; +import { + noticeError, + PLAYLIST_SCENE_LIST, + setCreatePlaylistBoxSong, + setPlaylistActiveScene, + setShowPlaylistCreateBox, +} from "@renderer/components/playlist/playlist.utils"; +import { setSidebarActiveTab, SIDEBAR_PAGES } from "@renderer/scenes/main-scene/main.utils"; +import { CheckIcon, CircleCheckIcon, PlusIcon } from "lucide-solid"; +import { Accessor, Component, For, Setter, Show } from "solid-js"; +import { Song } from "src/@types"; + +type PlaylistChooserProps = { + song: Song; + playlistNames: string[]; + setShowChooser: Setter; + timeoutId: Accessor; + setTimeoutId: Setter; +}; + +const PlaylistChooser: Component = (props) => { + const addToPlaylist = async (name: string) => { + const result = await window.api.request("playlist::add", name, props.song); + if (result.isError) { + noticeError(result.error); + return; + } + addNotice({ + title: "Song added", + description: "Successfully added song to playlist " + name + "!", + variant: "success", + icon: , + }); + }; + + const isInPlaylist = (song: Song, playlistName: string) => { + if (song.playlists === undefined) { + return false; + } + + return song.playlists.includes(playlistName); + }; + + return ( + { + clearTimeout(props.timeoutId()); + }} + onMouseLeave={() => { + props.setTimeoutId( + setTimeout(() => { + props.setShowChooser(false); + }, 320), + ); + }} + class="max-h-72 w-40 overflow-auto pr-1.5" + > + { + setCreatePlaylistBoxSong(props.song); + setShowPlaylistCreateBox(true); + setSidebarActiveTab(SIDEBAR_PAGES.PLAYLISTS.value); + setPlaylistActiveScene(PLAYLIST_SCENE_LIST); + }} + > + Create Playlist + + + No playlists...} + each={props.playlistNames} + > + {(child, index) => ( + { + addToPlaylist(props.playlistNames[index()]); + }} + > + {child} + + + + + )} + + + ); +}; + +export default PlaylistChooser; diff --git a/src/renderer/src/components/song/song-detail/SongControls.tsx b/src/renderer/src/components/song/song-detail/SongControls.tsx index b885c8ae..9a27a456 100644 --- a/src/renderer/src/components/song/song-detail/SongControls.tsx +++ b/src/renderer/src/components/song/song-detail/SongControls.tsx @@ -1,5 +1,5 @@ import { isSongUndefined } from "../../../lib/song"; -import Popover from "../../popover/Popover"; +import AddToPlaylist from "../context-menu/items/AddToPlaylist"; import { isPlaying, next, @@ -13,6 +13,8 @@ import { handleMuteSong, } from "../song.utils"; import Button from "@renderer/components/button/Button"; +import DropdownList from "@renderer/components/dropdown-list/DropdownList"; +import Popover from "@renderer/components/popover/Popover"; import Slider from "@renderer/components/slider/Slider"; import { CirclePlusIcon, @@ -30,6 +32,7 @@ import { import { Component, createMemo, createSignal, Match, Show, Switch, For } from "solid-js"; import { ParentComponent } from "solid-js"; import { Portal } from "solid-js/web"; +import { Song } from "src/@types"; // Add a prop to accept the averageColor type SongControlsProps = { @@ -240,9 +243,17 @@ const RightPart = () => { - + + + + + + + + + ); }; @@ -261,4 +272,13 @@ const SpeedOption: ParentComponent = (props) => { ); }; +type AddButtonContextMenuContentProps = { song: Song }; +const AddButtonContextMenuContent: Component = (props) => { + return ( + + + + ); +}; + export default SongControls; diff --git a/src/renderer/src/components/song/song-item/SongItem.tsx b/src/renderer/src/components/song/song-item/SongItem.tsx index 18d3209e..bdeb2b5a 100644 --- a/src/renderer/src/components/song/song-item/SongItem.tsx +++ b/src/renderer/src/components/song/song-item/SongItem.tsx @@ -103,7 +103,7 @@ const SongItem: Component = (props) => { onMouseLeave={() => { setIsHovering(false); }} - class="active: group relative isolate min-h-[72px] overflow-hidden rounded-lg py-0.5 pl-1.5 pr-0.5 transition-colors" + class="active: group relative isolate min-h-[72px] w-full overflow-hidden rounded-lg py-0.5 pl-1.5 pr-0.5 transition-colors" classList={{ "shadow-glow-blue": isSelected(), }} diff --git a/src/renderer/src/components/song/song-list/SongList.tsx b/src/renderer/src/components/song/song-list/SongList.tsx index a55ecdd9..98d0832d 100644 --- a/src/renderer/src/components/song/song-list/SongList.tsx +++ b/src/renderer/src/components/song/song-list/SongList.tsx @@ -4,11 +4,12 @@ import { namespace } from "../../../App"; import Impulse from "../../../lib/Impulse"; import { none, some } from "../../../lib/rust-like-utils-client/Optional"; import InfiniteScroller from "../../InfiniteScroller"; +import AddToPlaylist from "../context-menu/items/AddToPlaylist"; import SongItem from "../song-item/SongItem"; import SongListSearch from "../song-list-search/SongListSearch"; import { songsSearch } from "./song-list.utils"; import DropdownList from "@renderer/components/dropdown-list/DropdownList"; -import { ListPlus, ListStartIcon } from "lucide-solid"; +import { ListStartIcon } from "lucide-solid"; import { Component, createEffect, createSignal, onCleanup, onMount } from "solid-js"; export type SongViewProps = { @@ -105,10 +106,7 @@ type SongListContextMenuContentProps = { song: Song }; const SongListContextMenuContent: Component = (props) => { return ( - - Add to Playlist - - + { window.api.request("queue::playNext", props.song.path); diff --git a/src/renderer/src/components/song/song-queue/SongQueue.tsx b/src/renderer/src/components/song/song-queue/SongQueue.tsx index f4658f52..219e5e66 100644 --- a/src/renderer/src/components/song/song-queue/SongQueue.tsx +++ b/src/renderer/src/components/song/song-queue/SongQueue.tsx @@ -3,9 +3,10 @@ import { namespace } from "../../../App"; import Impulse from "../../../lib/Impulse"; import scrollIfNeeded from "../../../lib/tungsten/scroll-if-needed"; import InfiniteScroller from "../../InfiniteScroller"; +import AddToPlaylist from "../context-menu/items/AddToPlaylist"; import SongItem from "../song-item/SongItem"; import DropdownList from "@renderer/components/dropdown-list/DropdownList"; -import { DeleteIcon, ListPlus } from "lucide-solid"; +import { DeleteIcon } from "lucide-solid"; import { Component, createSignal, onCleanup, onMount } from "solid-js"; const SongQueue: Component = () => { @@ -112,10 +113,7 @@ type QueueContextMenuContentProps = { song: Song }; const QueueContextMenuContent: Component = (props) => { return ( - - Add to Playlist - - + window.api.request("queue::removeSong", props.song.path)} class="text-danger" diff --git a/src/renderer/src/scenes/main-scene/Sidebar.tsx b/src/renderer/src/scenes/main-scene/Sidebar.tsx index 8cc6fff1..9456d1bf 100644 --- a/src/renderer/src/scenes/main-scene/Sidebar.tsx +++ b/src/renderer/src/scenes/main-scene/Sidebar.tsx @@ -8,6 +8,7 @@ import { sidebarExpanded, } from "./main.utils"; import Button from "@renderer/components/button/Button"; +import PlaylistView from "@renderer/components/playlist/playlist-view/PlaylistView"; import ResizablePanel, { useResizablePanel, } from "@renderer/components/resizable-panel/ResizablePanel"; @@ -122,6 +123,9 @@ const SidebarContent: Component = () => { + + +