feat(playlists): remove tracks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user