diff --git a/protos/library.proto b/protos/library.proto index 353cec5..fc412fd 100644 --- a/protos/library.proto +++ b/protos/library.proto @@ -9,8 +9,8 @@ service Library { rpc ListPlaylists(google.protobuf.Empty) returns (ListPlaylistsResponse); rpc CreatePlaylist(CreatePlaylistRequest) returns (CreatePlaylistResponse); rpc DeletePlaylist(DeletePlaylistRequest) returns (google.protobuf.Empty); - rpc AddTrackToPlaylist(AddTrackToPlaylistRequest) returns (google.protobuf.Empty); - rpc RemoveTrackFromPlaylist(RemoveTrackFromPlaylistRequest) returns (google.protobuf.Empty); + rpc AddTrackToPlaylist(AddTrackToPlaylistRequest) returns (TrackList); + rpc RemoveTrackFromPlaylist(RemoveTrackFromPlaylistRequest) returns (TrackList); rpc SwapTracks(SwapTracksRequest) returns (TrackList); } diff --git a/src/lib/components/groove/AppSidebar.svelte b/src/lib/components/groove/AppSidebar.svelte index 618bec3..2c33c33 100644 --- a/src/lib/components/groove/AppSidebar.svelte +++ b/src/lib/components/groove/AppSidebar.svelte @@ -85,7 +85,7 @@ {/snippet} - {#each library.playlists as playlist} + {#each library.playlists as [_, playlist]} {@const playlistLocation = `/playlists/${playlist.id}`} ([]); - playlists = $state([]); + playlists = $state>(new SvelteMap()); constructor(tracks: Track[], playlists: Playlist[]) { this.tracks = tracks; - this.playlists = playlists; + this.playlists = new SvelteMap(playlists.map((p) => [p.id, p])); + } + + async addTrackToPlaylist(playlistId: number, trackHash: string, fetch: typeof globalThis.fetch) { + const response = await fetch(`/playlists/${playlistId}/add-track/${trackHash}`, { + method: 'POST', + headers: { + 'x-sveltekit-action': 'true' + }, + body: new FormData() + }); + + if (!response.ok) { + return; + } + + const result = deserialize(await response.text()); + + if (result.type === 'success' && result.data && 'tracks' in result.data) { + const playlist = this.playlists.get(playlistId); + + if (playlist) { + this.playlists.set(playlistId, { + ...playlist, + tracks: (result.data as unknown as TrackList).tracks + }); + } + } + } + + async removeTrackFromPlaylist( + playlistId: number, + trackRank: number, + fetch: typeof globalThis.fetch + ) { + const response = await fetch(`/playlists/${playlistId}/remove-track/${trackRank}`, { + method: 'POST', + headers: { + 'x-sveltekit-action': 'true' + }, + body: new FormData() + }); + + if (!response.ok) { + return; + } + + const result = deserialize(await response.text()); + + if (result.type === 'success' && result.data && 'tracks' in result.data) { + const playlist = this.playlists.get(playlistId); + + if (playlist) { + this.playlists.set(playlistId, { + ...playlist, + tracks: (result.data as unknown as TrackList).tracks + }); + } + } + } + + async swapPlaylistTracks(playlistId: number, aIndex: number, bIndex: number) { + const formData = new FormData(); + + formData.set('a', aIndex.toString()); + formData.set('b', bIndex.toString()); + + const response = await fetch(`/playlists/${playlistId}?/swap-tracks`, { + method: 'POST', + headers: { + 'x-sveltekit-action': 'true' + }, + body: formData + }); + + if (!response.ok) { + return; + } + + const result = deserialize(await response.text()); + + if (result.type === 'success' && result.data && 'tracks' in result.data) { + const playlist = this.playlists.get(playlistId); + + if (playlist) { + this.playlists.set(playlistId, { + ...playlist, + tracks: (result.data as unknown as TrackList).tracks + }); + } + } } } diff --git a/src/lib/proto/library.client.ts b/src/lib/proto/library.client.ts index 9483025..d99999d 100644 --- a/src/lib/proto/library.client.ts +++ b/src/lib/proto/library.client.ts @@ -37,13 +37,13 @@ export interface ILibraryClient { */ deletePlaylist(input: DeletePlaylistRequest, options?: RpcOptions): UnaryCall; /** - * @generated from protobuf rpc: AddTrackToPlaylist(library.AddTrackToPlaylistRequest) returns (google.protobuf.Empty); + * @generated from protobuf rpc: AddTrackToPlaylist(library.AddTrackToPlaylistRequest) returns (library.TrackList); */ - addTrackToPlaylist(input: AddTrackToPlaylistRequest, options?: RpcOptions): UnaryCall; + addTrackToPlaylist(input: AddTrackToPlaylistRequest, options?: RpcOptions): UnaryCall; /** - * @generated from protobuf rpc: RemoveTrackFromPlaylist(library.RemoveTrackFromPlaylistRequest) returns (google.protobuf.Empty); + * @generated from protobuf rpc: RemoveTrackFromPlaylist(library.RemoveTrackFromPlaylistRequest) returns (library.TrackList); */ - removeTrackFromPlaylist(input: RemoveTrackFromPlaylistRequest, options?: RpcOptions): UnaryCall; + removeTrackFromPlaylist(input: RemoveTrackFromPlaylistRequest, options?: RpcOptions): UnaryCall; /** * @generated from protobuf rpc: SwapTracks(library.SwapTracksRequest) returns (library.TrackList); */ @@ -87,18 +87,18 @@ export class LibraryClient implements ILibraryClient, ServiceInfo { return stackIntercept("unary", this._transport, method, opt, input); } /** - * @generated from protobuf rpc: AddTrackToPlaylist(library.AddTrackToPlaylistRequest) returns (google.protobuf.Empty); + * @generated from protobuf rpc: AddTrackToPlaylist(library.AddTrackToPlaylistRequest) returns (library.TrackList); */ - addTrackToPlaylist(input: AddTrackToPlaylistRequest, options?: RpcOptions): UnaryCall { + addTrackToPlaylist(input: AddTrackToPlaylistRequest, options?: RpcOptions): UnaryCall { const method = this.methods[4], opt = this._transport.mergeOptions(options); - return stackIntercept("unary", this._transport, method, opt, input); + return stackIntercept("unary", this._transport, method, opt, input); } /** - * @generated from protobuf rpc: RemoveTrackFromPlaylist(library.RemoveTrackFromPlaylistRequest) returns (google.protobuf.Empty); + * @generated from protobuf rpc: RemoveTrackFromPlaylist(library.RemoveTrackFromPlaylistRequest) returns (library.TrackList); */ - removeTrackFromPlaylist(input: RemoveTrackFromPlaylistRequest, options?: RpcOptions): UnaryCall { + removeTrackFromPlaylist(input: RemoveTrackFromPlaylistRequest, options?: RpcOptions): UnaryCall { const method = this.methods[5], opt = this._transport.mergeOptions(options); - return stackIntercept("unary", this._transport, method, opt, input); + return stackIntercept("unary", this._transport, method, opt, input); } /** * @generated from protobuf rpc: SwapTracks(library.SwapTracksRequest) returns (library.TrackList); diff --git a/src/lib/proto/library.ts b/src/lib/proto/library.ts index dff3b4f..b04204e 100644 --- a/src/lib/proto/library.ts +++ b/src/lib/proto/library.ts @@ -699,7 +699,7 @@ export const Library = new ServiceType("library.Library", [ { name: "ListPlaylists", options: {}, I: Empty, O: ListPlaylistsResponse }, { name: "CreatePlaylist", options: {}, I: CreatePlaylistRequest, O: CreatePlaylistResponse }, { name: "DeletePlaylist", options: {}, I: DeletePlaylistRequest, O: Empty }, - { name: "AddTrackToPlaylist", options: {}, I: AddTrackToPlaylistRequest, O: Empty }, - { name: "RemoveTrackFromPlaylist", options: {}, I: RemoveTrackFromPlaylistRequest, O: Empty }, + { name: "AddTrackToPlaylist", options: {}, I: AddTrackToPlaylistRequest, O: TrackList }, + { name: "RemoveTrackFromPlaylist", options: {}, I: RemoveTrackFromPlaylistRequest, O: TrackList }, { name: "SwapTracks", options: {}, I: SwapTracksRequest, O: TrackList } ]); diff --git a/src/routes/playlists/[id]/+page.svelte b/src/routes/playlists/[id]/+page.svelte index ac6a3c4..561d9f2 100644 --- a/src/routes/playlists/[id]/+page.svelte +++ b/src/routes/playlists/[id]/+page.svelte @@ -7,16 +7,16 @@ import duration from 'dayjs/plugin/duration'; import { getCoverUrl } from '$lib/covers'; import Play from 'virtual:icons/lucide/play'; + import Trash2 from 'virtual:icons/lucide/trash-2'; import { getPlayerState } from '$lib/player.svelte'; import { Button } from '$lib/components/ui/button'; - import { deserialize } from '$app/forms'; - import type { TrackList } from '$lib/proto/library'; + import * as ContextMenu from '$lib/components/ui/context-menu'; dayjs.extend(duration); const library = getLibraryState(); const player = getPlayerState(); - let playlist = $derived(library.playlists.find((p) => p.id === Number($page.params.id))); + let playlist = $derived(library.playlists.get(Number($page.params.id))); let totalDuration = $derived( playlist?.tracks.reduce((acc, track) => acc + Number(track.duration), 0) ?? 0 @@ -29,39 +29,14 @@ } async function onDrop(e: DragEvent) { - const aIndex = e.dataTransfer?.getData('text/plain')!; - const bIndex = (e.currentTarget as HTMLElement).getAttribute('data-playlist-index')!; - - const formData = new FormData(); - - formData.set('a', aIndex); - formData.set('b', bIndex); - - const response = await fetch(`/playlists/${playlist!.id}?/swap-tracks`, { - method: 'POST', - headers: { - 'x-sveltekit-action': 'true' - }, - body: formData - }); - - if (!response.ok) { + if (!playlist) { return; } - const result = deserialize(await response.text()); + const aIndex = e.dataTransfer?.getData('text/plain')!; + const bIndex = (e.currentTarget as HTMLElement).getAttribute('data-playlist-index')!; - if (result.type === 'success' && result.data && 'tracks' in result.data) { - const _playlist = library.playlists.find((p) => p.id === playlist?.id); - - if (playlist) { - playlist.tracks = (result.data as unknown as TrackList).tracks; - } - - if (_playlist) { - _playlist.tracks = (result.data as unknown as TrackList).tracks; - } - } + library.swapPlaylistTracks(playlist.id, Number(aIndex), Number(bIndex)); } @@ -99,27 +74,46 @@ {#each playlist.tracks as track, i} - player.playTrack(track.hash, fetch)} - data-playlist-index={i} - ondragstart={onDragStart} - ondrop={onDrop} - ondragover={(e) => e.preventDefault()} - draggable - > - {i + 1} - - - - {track.name} - {track.artistName} - {dayjs - .duration(Number(track.duration), 'milliseconds') - .format('mm:ss')} - + + + {#snippet child({ props })} + player.playTrack(track.hash, fetch)} + data-playlist-index={i} + ondragstart={onDragStart} + ondrop={onDrop} + ondragover={(e) => e.preventDefault()} + draggable + > + {i + 1} + + + + {track.name} + {track.artistName} + {dayjs + .duration(Number(track.duration), 'milliseconds') + .format('mm:ss')} + + {/snippet} + + + library.removeTrackFromPlaylist(playlist.id, i, fetch)} + > + + Remove from Playlist + + + {/each} diff --git a/src/routes/playlists/[id]/add-track/[hash]/+page.server.ts b/src/routes/playlists/[id]/add-track/[hash]/+page.server.ts index 3c44f23..cf78266 100644 --- a/src/routes/playlists/[id]/add-track/[hash]/+page.server.ts +++ b/src/routes/playlists/[id]/add-track/[hash]/+page.server.ts @@ -1,3 +1,5 @@ +import { serializable } from '$lib/proto'; +import type { TrackList } from '$lib/proto/library'; import { LibraryClient } from '$lib/proto/library.client'; import { protoTransport } from '../../../../../hooks.server'; import type { Actions } from './$types'; @@ -6,9 +8,11 @@ export const actions = { default: async ({ params }) => { const client = new LibraryClient(protoTransport); - const _response = await client.addTrackToPlaylist({ + const response = await client.addTrackToPlaylist({ playlistId: parseInt(params.id), trackHash: params.hash }); + + return serializable(response.response); } } satisfies Actions; diff --git a/src/routes/playlists/[id]/remove-track/[rank]/+page.server.ts b/src/routes/playlists/[id]/remove-track/[rank]/+page.server.ts new file mode 100644 index 0000000..f656b9a --- /dev/null +++ b/src/routes/playlists/[id]/remove-track/[rank]/+page.server.ts @@ -0,0 +1,18 @@ +import { serializable } from '$lib/proto'; +import { TrackList } from '$lib/proto/library'; +import { LibraryClient } from '$lib/proto/library.client'; +import { protoTransport } from '../../../../../hooks.server'; +import type { Actions } from './$types'; + +export const actions = { + default: async ({ params }) => { + const client = new LibraryClient(protoTransport); + + const response = await client.removeTrackFromPlaylist({ + playlistId: parseInt(params.id), + trackRank: Number(params.rank) + }); + + return serializable(response.response); + } +} satisfies Actions; diff --git a/src/routes/tracks/+page.svelte b/src/routes/tracks/+page.svelte index 7cd8258..53a3e1b 100644 --- a/src/routes/tracks/+page.svelte +++ b/src/routes/tracks/+page.svelte @@ -3,7 +3,6 @@ import TrackListing from '$lib/components/groove/TrackListing.svelte'; import { getPlayerState } from '$lib/player.svelte'; import type { SubmitFunction } from './[hash]/$types'; - import type { SubmitFunction as AddTrackSubmitFunction } from '../playlists/[id]/add-track/[hash]/$types'; import * as ContextMenu from '$lib/components/ui/context-menu'; import Music2 from 'virtual:icons/lucide/music-2'; import ListMusic from 'virtual:icons/lucide/list-music'; @@ -11,7 +10,6 @@ import ListPlus from 'virtual:icons/lucide/list-plus'; import { getSearchState } from '$lib/search.svelte'; import { getLibraryState } from '$lib/library.svelte'; - import { cn } from '$lib/utils'; const player = getPlayerState(); const search = getSearchState(); @@ -54,38 +52,6 @@ }; }; - // TODO: Optimize this - // - reduce indentation - // - get rid of the `.find()`s - const addTrackToPlaylist: AddTrackSubmitFunction = async ({ action }) => { - const playlistId = parseInt(action.pathname.split('/playlists/')[1].split('/')[0]); - - const targetHash = $state.snapshot(contextMenuTarget); - - contextMenuTarget = null; - - return async ({ update, result }) => { - await update({ - invalidateAll: false, - reset: false - }); - - if (result.type === 'success') { - const playlist = library.playlists.find((p) => p.id === playlistId); - - if (playlist) { - const track = library.tracks.find((t) => t.hash === targetHash); - - if (track) { - playlist.tracks.push(track); - } - } - - library.playlists = library.playlists; - } - }; - }; - function contextMenuItemClicked(e: MouseEvent) { e.stopPropagation(); } @@ -122,22 +88,9 @@ customAnchor={`div[data-track-hash="${contextMenuTarget!}"]`} strategy="fixed" > - -
- -
+ player.playTrack(contextMenuTarget!, fetch)}> + + Play @@ -187,19 +140,11 @@ {#snippet child({ props })}
- {#each library.playlists as playlist} - - {#snippet child({ props })} - - {/snippet} + {#each library.playlists as [_, playlist]} + library.addTrackToPlaylist(playlist.id, contextMenuTarget!, fetch)} + > + {playlist.name} {/each}