feat(playlists): remove tracks

This commit is contained in:
2024-12-03 01:32:54 +01:00
parent 98c46895b4
commit 73da50b9fd
9 changed files with 188 additions and 135 deletions

View File

@@ -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);
}

View File

@@ -85,7 +85,7 @@
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{#each library.playlists as playlist}
{#each library.playlists as [_, playlist]}
{@const playlistLocation = `/playlists/${playlist.id}`}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton

View File

@@ -1,15 +1,107 @@
import { getContext, setContext } from 'svelte';
import { Playlist, type Track } from './proto/library';
import { Playlist, TrackList, type Track } from './proto/library';
import { deserialize } from '$app/forms';
import { SvelteMap } from 'svelte/reactivity';
// TODO: Optimize this since storing entire tracks in
// playlists is redundant if we store all the tracks
class LibraryState {
tracks = $state<Track[]>([]);
playlists = $state<Playlist[]>([]);
playlists = $state<SvelteMap<number, Playlist>>(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
});
}
}
}
}

View File

@@ -37,13 +37,13 @@ export interface ILibraryClient {
*/
deletePlaylist(input: DeletePlaylistRequest, options?: RpcOptions): UnaryCall<DeletePlaylistRequest, Empty>;
/**
* @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<AddTrackToPlaylistRequest, Empty>;
addTrackToPlaylist(input: AddTrackToPlaylistRequest, options?: RpcOptions): UnaryCall<AddTrackToPlaylistRequest, TrackList>;
/**
* @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<RemoveTrackFromPlaylistRequest, Empty>;
removeTrackFromPlaylist(input: RemoveTrackFromPlaylistRequest, options?: RpcOptions): UnaryCall<RemoveTrackFromPlaylistRequest, TrackList>;
/**
* @generated from protobuf rpc: SwapTracks(library.SwapTracksRequest) returns (library.TrackList);
*/
@@ -87,18 +87,18 @@ export class LibraryClient implements ILibraryClient, ServiceInfo {
return stackIntercept<DeletePlaylistRequest, Empty>("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<AddTrackToPlaylistRequest, Empty> {
addTrackToPlaylist(input: AddTrackToPlaylistRequest, options?: RpcOptions): UnaryCall<AddTrackToPlaylistRequest, TrackList> {
const method = this.methods[4], opt = this._transport.mergeOptions(options);
return stackIntercept<AddTrackToPlaylistRequest, Empty>("unary", this._transport, method, opt, input);
return stackIntercept<AddTrackToPlaylistRequest, TrackList>("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<RemoveTrackFromPlaylistRequest, Empty> {
removeTrackFromPlaylist(input: RemoveTrackFromPlaylistRequest, options?: RpcOptions): UnaryCall<RemoveTrackFromPlaylistRequest, TrackList> {
const method = this.methods[5], opt = this._transport.mergeOptions(options);
return stackIntercept<RemoveTrackFromPlaylistRequest, Empty>("unary", this._transport, method, opt, input);
return stackIntercept<RemoveTrackFromPlaylistRequest, TrackList>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: SwapTracks(library.SwapTracksRequest) returns (library.TrackList);

View File

@@ -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 }
]);

View File

@@ -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<number>(
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));
}
</script>
@@ -99,27 +74,46 @@
</Table.Header>
<Table.Body>
{#each playlist.tracks as track, i}
<Table.Row
class="select-none"
ondblclick={() => player.playTrack(track.hash, fetch)}
data-playlist-index={i}
ondragstart={onDragStart}
ondrop={onDrop}
ondragover={(e) => e.preventDefault()}
draggable
>
<Table.Cell class="text-center">{i + 1}</Table.Cell>
<Table.Cell class="w-0">
<img class="overflow-hidden rounded-md" src={getCoverUrl(track.hash)} alt="" />
</Table.Cell>
<Table.Cell class="text-nowrap">{track.name}</Table.Cell>
<Table.Cell class="w-fit text-nowrap">{track.artistName}</Table.Cell>
<Table.Cell class="text-right"
>{dayjs
.duration(Number(track.duration), 'milliseconds')
.format('mm:ss')}</Table.Cell
>
</Table.Row>
<ContextMenu.Root>
<ContextMenu.Trigger>
{#snippet child({ props })}
<Table.Row
{...props}
class="select-none"
ondblclick={() => player.playTrack(track.hash, fetch)}
data-playlist-index={i}
ondragstart={onDragStart}
ondrop={onDrop}
ondragover={(e) => e.preventDefault()}
draggable
>
<Table.Cell class="text-center">{i + 1}</Table.Cell>
<Table.Cell class="w-0">
<img
class="overflow-hidden rounded-md"
src={getCoverUrl(track.hash)}
alt=""
/>
</Table.Cell>
<Table.Cell class="text-nowrap">{track.name}</Table.Cell>
<Table.Cell class="w-fit text-nowrap">{track.artistName}</Table.Cell>
<Table.Cell class="text-right"
>{dayjs
.duration(Number(track.duration), 'milliseconds')
.format('mm:ss')}</Table.Cell
>
</Table.Row>
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item
onclick={() => library.removeTrackFromPlaylist(playlist.id, i, fetch)}
>
<Trash2 class="mr-1 size-4" />
Remove from Playlist
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -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<TrackList>(response.response);
}
} satisfies Actions;

View File

@@ -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<TrackList>(response.response);
}
} satisfies Actions;

View File

@@ -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"
>
<ContextMenu.Item class="!p-0">
<form
class="flex w-full"
method="POST"
action="/tracks/{contextMenuTarget!}?/play"
use:enhance={submitPlayTrack}
>
<button
type="submit"
class="flex w-full items-center px-2 py-1.5 focus:outline-0"
onclick={contextMenuItemClicked}
>
<Music2 class="mr-1 size-4" />
Play
</button>
</form>
<ContextMenu.Item onclick={() => player.playTrack(contextMenuTarget!, fetch)}>
<Music2 class="mr-1 size-4" />
Play
</ContextMenu.Item>
<ContextMenu.Separator />
@@ -187,19 +140,11 @@
<ContextMenu.SubContent>
{#snippet child({ props })}
<form {...props} method="POST" use:enhance={addTrackToPlaylist}>
{#each library.playlists as playlist}
<ContextMenu.Item>
{#snippet child({ props })}
<button
{...props}
type="submit"
onclick={contextMenuItemClicked}
formaction="/playlists/{playlist.id}/add-track/{contextMenuTarget!}"
class={cn(props.class ?? '', 'w-full')}
>
{playlist.name}
</button>
{/snippet}
{#each library.playlists as [_, playlist]}
<ContextMenu.Item
onclick={() => library.addTrackToPlaylist(playlist.id, contextMenuTarget!, fetch)}
>
{playlist.name}
</ContextMenu.Item>
{/each}
</form>