web-player: extract menu, paged list and play-all components from pages
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||
import type { Track } from '$lib/types';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
items: Track[];
|
||||
load?: () => Promise<Track[]>;
|
||||
variant?: 'primary' | 'secondary' | 'accent';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
idleLabel?: string;
|
||||
activeLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
load,
|
||||
variant = 'primary',
|
||||
size = 'sm',
|
||||
idleLabel = 'Reproducir',
|
||||
activeLabel = 'Pausar'
|
||||
}: Props = $props();
|
||||
|
||||
const active = $derived(isQueueCurrent(items) && player.playing);
|
||||
|
||||
async function play() {
|
||||
if (load && !isQueueCurrent(items)) playAllOrToggle(await load());
|
||||
else playAllOrToggle(items);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button {variant} {size} onclick={play} disabled={items.length === 0 && !load}>
|
||||
{active ? activeLabel : idleLabel}
|
||||
</Button>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { Playlist } from '$lib/types';
|
||||
import ContextMenu from './ContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: Menu<string>;
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
let { menu, playlists }: Props = $props();
|
||||
|
||||
async function playNext(albumId: string) {
|
||||
menu.close();
|
||||
player.playNext(await fetchAlbumTracks(albumId));
|
||||
}
|
||||
|
||||
async function addToQueue(albumId: string) {
|
||||
menu.close();
|
||||
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menu.state}
|
||||
{@const { target: albumId, x, y, openLeft } = menu.state}
|
||||
<ContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{openLeft}
|
||||
onClose={() => menu.close()}
|
||||
items={[
|
||||
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(albumId) },
|
||||
{ icon: ListEnd, label: 'Añadir a la cola', onclick: () => addToQueue(albumId) }
|
||||
]}
|
||||
playlistAction={{
|
||||
action: '?/addAlbumToPlaylist',
|
||||
fields: { albumId },
|
||||
label: 'Añadir álbum a una playlist'
|
||||
}}
|
||||
{playlists}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { Playlist, Track } from '$lib/types';
|
||||
import ContextMenu from './ContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: Menu<Track>;
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
let { menu, playlists }: Props = $props();
|
||||
|
||||
function playNext(track: Track) {
|
||||
player.playNext([track]);
|
||||
menu.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menu.state}
|
||||
{@const { target: track, x, y, openLeft } = menu.state}
|
||||
<ContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{openLeft}
|
||||
onClose={() => menu.close()}
|
||||
items={[{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(track) }]}
|
||||
playlistAction={{
|
||||
action: '?/addTrack',
|
||||
fields: { trackId: track.id },
|
||||
label: 'Añadir a una playlist'
|
||||
}}
|
||||
{playlists}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,15 +1,23 @@
|
||||
<script lang="ts">
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import Button from './Button.svelte';
|
||||
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
|
||||
|
||||
interface Props {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
error?: boolean;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
let { onLoadMore, hasMore, loading, rootMargin = INFINITE_SCROLL_ROOT_MARGIN }: Props = $props();
|
||||
let {
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
loading,
|
||||
error = false,
|
||||
rootMargin = INFINITE_SCROLL_ROOT_MARGIN
|
||||
}: Props = $props();
|
||||
|
||||
let sentinel = $state<HTMLDivElement>();
|
||||
let visible = $state(false);
|
||||
@@ -28,12 +36,17 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (visible && hasMore && !loading) onLoadMore();
|
||||
if (visible && hasMore && !loading && !error) onLoadMore();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
||||
{#if loading}
|
||||
<LoaderCircle class="size-5 animate-spin text-fg-2" />
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-3 text-sm text-fg-2">
|
||||
No se pudo cargar más.
|
||||
<Button variant="secondary" size="sm" onclick={onLoadMore}>Reintentar</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { contextMenuPosition, type MenuPosition } from '$lib/utils/menuPosition';
|
||||
|
||||
export type MenuState<T> = { target: T } & MenuPosition;
|
||||
|
||||
export function createMenu<T>() {
|
||||
let state = $state<MenuState<T> | null>(null);
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
open(event: MouseEvent, target: T) {
|
||||
event.preventDefault();
|
||||
state = { target, ...contextMenuPosition(event) };
|
||||
},
|
||||
close() {
|
||||
state = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type Menu<T> = ReturnType<typeof createMenu<T>>;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { contextMenuPosition, type MenuPosition } from '$lib/utils/menuPosition';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
export type TrackMenuState = { track: Track } & MenuPosition;
|
||||
|
||||
export function createTrackMenu() {
|
||||
let state = $state<TrackMenuState | null>(null);
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
open(event: MouseEvent, track: Track) {
|
||||
event.preventDefault();
|
||||
state = { track, ...contextMenuPosition(event) };
|
||||
},
|
||||
close() {
|
||||
state = null;
|
||||
},
|
||||
playNext() {
|
||||
if (!state) return;
|
||||
player.playNext([state.track]);
|
||||
state = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Paged } from '$lib/types';
|
||||
|
||||
export function createPagedList<T>(
|
||||
fetchPage: (pageNumber: number) => Promise<Paged<T>>,
|
||||
keyOf: (item: T) => string | number
|
||||
) {
|
||||
let items = $state<T[]>([]);
|
||||
let pageNumber = 1;
|
||||
let hasMore = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state(false);
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get hasMore() {
|
||||
return hasMore;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
reset(initial: Paged<T> | null) {
|
||||
items = initial?.items ?? [];
|
||||
pageNumber = initial?.pageNumber ?? 1;
|
||||
hasMore = initial?.hasNextPage ?? false;
|
||||
loading = false;
|
||||
error = false;
|
||||
},
|
||||
async loadMore() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
error = false;
|
||||
try {
|
||||
const next = await fetchPage(pageNumber + 1);
|
||||
const seen = new Set(items.map(keyOf));
|
||||
items = [...items, ...next.items.filter((item) => !seen.has(keyOf(item)))];
|
||||
pageNumber = next.pageNumber;
|
||||
hasMore = next.hasNextPage;
|
||||
} catch {
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user