web-player: extract menu, paged list and play-all components from pages

This commit is contained in:
2026-09-24 11:51:14 +02:00
parent 8cb9386447
commit 1cad36b929
14 changed files with 306 additions and 297 deletions
@@ -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"> <script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import Button from './Button.svelte';
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config'; import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
interface Props { interface Props {
onLoadMore: () => void; onLoadMore: () => void;
hasMore: boolean; hasMore: boolean;
loading: boolean; loading: boolean;
error?: boolean;
rootMargin?: string; 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 sentinel = $state<HTMLDivElement>();
let visible = $state(false); let visible = $state(false);
@@ -28,12 +36,17 @@
}); });
$effect(() => { $effect(() => {
if (visible && hasMore && !loading) onLoadMore(); if (visible && hasMore && !loading && !error) onLoadMore();
}); });
</script> </script>
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8"> <div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading} {#if loading}
<LoaderCircle class="size-5 animate-spin text-fg-2" /> <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} {/if}
</div> </div>
+22
View File
@@ -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>>;
-27
View File
@@ -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;
}
}
};
}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { albumCover } from '$lib/utils/hrefs'; import { albumCover } from '$lib/utils/hrefs';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions'; import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
@@ -27,12 +27,6 @@
let editing = $state(false); let editing = $state(false);
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
playAllOrToggle(tracks);
}
function playTrackFrom(index: number) { function playTrackFrom(index: number) {
player.playOrToggle(tracks, index); player.playOrToggle(tracks, index);
} }
@@ -69,13 +63,7 @@
/> />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}> <PlayAllButton items={tracks} />
{#if isCurrentQueue && player.playing}
Pausar
{:else}
Reproducir
{/if}
</Button>
{#if isOwner} {#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
+35 -107
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { fetchAlbumTracks } from '$lib/data/albums'; import type { Paged, Track } from '$lib/types';
import type { Track } from '$lib/types';
import { fmtTime, fmtPlays, plural } from '$lib/utils/format'; import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
@@ -19,12 +18,10 @@
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config'; import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { genreHref, playlistCover } from '$lib/utils/hrefs'; import { genreHref, playlistCover } from '$lib/utils/hrefs';
import { createTrackMenu } from '$lib/state/menus.svelte'; import { createMenu } from '$lib/state/menu.svelte';
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import { createPagedList } from '$lib/state/pagedList.svelte';
import { contextMenuPosition } from '$lib/utils/menuPosition'; import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import AlbumContextMenu from '$lib/components/ui/overlay/AlbumContextMenu.svelte';
import ListEnd from '@lucide/svelte/icons/list-end';
import { appendUnique } from '$lib/utils/collections';
import { genreInfo, genreTiles } from '$lib/data/genres'; import { genreInfo, genreTiles } from '$lib/data/genres';
import { import {
type SearchFilter, type SearchFilter,
@@ -37,8 +34,6 @@
findTopResult findTopResult
} from '$lib/data/search'; } from '$lib/data/search';
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = { const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
track: 'Canción', track: 'Canción',
album: 'Álbum', album: 'Álbum',
@@ -48,25 +43,33 @@
let { data, form } = $props(); let { data, form } = $props();
const trackMenu = createTrackMenu(); const trackMenu = createMenu<Track>();
let albumMenu = $state<AlbumMenuState | null>(null); const albumMenu = createMenu<string>();
let sfilter = $state<SearchFilter>('all'); let sfilter = $state<SearchFilter>('all');
let localItems = $state<Track[]>([]); const tracksList = createPagedList<Track>(
let localPage = $state(1); async (pageNumber) => {
let hasMore = $state(false); const params = new URLSearchParams({
let loadingMore = $state(false); pageNumber: String(pageNumber),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) params.set('name', data.query);
if (data.genre) params.set('genre', data.genre);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
return (await res.json()) as Paged<Track>;
},
(track) => track.id
);
$effect(() => { $effect(() => {
trackMenu.close(); trackMenu.close();
albumMenu = null; albumMenu.close();
sfilter = data.genre ? 'tracks' : 'all'; sfilter = data.genre ? 'tracks' : 'all';
localItems = data.tracks.items; tracksList.reset(data.tracks);
localPage = data.tracks.pageNumber;
hasMore = data.tracks.hasNextPage;
}); });
const items = $derived(localItems); const items = $derived(tracksList.items);
const tiles = $derived(genreTiles(data.genres)); const tiles = $derived(genreTiles(data.genres));
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null); const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
@@ -127,52 +130,6 @@
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`; if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
return `/user/${topResult.user.id}`; return `/user/${topResult.user.id}`;
}); });
function togglePlay(index: number) {
player.playOrToggle(items, index);
}
function openAlbumMenu(event: MouseEvent, albumId: string) {
event.preventDefault();
albumMenu = { ...contextMenuPosition(event), albumId };
}
async function albumPlayNext() {
if (!albumMenu) return;
const { albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumTracks(albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumTracks(albumId));
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) params.set('name', data.query);
if (data.genre) params.set('genre', data.genre);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(localItems, next.items, (track) => track.id);
localPage = next.pageNumber;
hasMore = next.hasNextPage;
} catch {
hasMore = false;
} finally {
loadingMore = false;
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -334,7 +291,7 @@
active={player.currentId === track.id} active={player.currentId === track.id}
size="lg" size="lg"
trackId={track.id} trackId={track.id}
onclick={() => togglePlay(i)} onclick={() => player.playOrToggle(items, i)}
oncontextmenu={(e) => trackMenu.open(e, track)} oncontextmenu={(e) => trackMenu.open(e, track)}
> >
{#snippet overlay()} {#snippet overlay()}
@@ -352,8 +309,13 @@
{/each} {/each}
</ul> </ul>
{#if sfilter === 'tracks' && hasMore} {#if sfilter === 'tracks' && tracksList.hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} /> <InfiniteScroll
onLoadMore={() => tracksList.loadMore()}
hasMore={tracksList.hasMore}
loading={tracksList.loading}
error={tracksList.error}
/>
{/if} {/if}
{:else} {:else}
<EmptyState <EmptyState
@@ -377,7 +339,7 @@
href="/albums/{album.id}" href="/albums/{album.id}"
size="lg" size="lg"
trackIds={album.coverTrackIds} trackIds={album.coverTrackIds}
oncontextmenu={(e) => openAlbumMenu(e, album.id)} oncontextmenu={(e) => albumMenu.open(e, album.id)}
> >
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
@@ -427,39 +389,5 @@
{/if} {/if}
</Page> </Page>
{#if trackMenu.state} <TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
<ContextMenu <AlbumContextMenu menu={albumMenu} playlists={data.userPlaylists} />
x={trackMenu.state.x}
y={trackMenu.state.y}
openLeft={trackMenu.state.openLeft}
onClose={() => trackMenu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
/>
{/if}
{#if albumMenu}
<ContextMenu
x={albumMenu.x}
y={albumMenu.y}
openLeft={albumMenu.openLeft}
onClose={() => (albumMenu = null)}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: albumPlayNext },
{ icon: ListEnd, label: 'Añadir a la cola', onclick: albumAddToQueue }
]}
playlistAction={{
action: '?/addAlbumToPlaylist',
fields: { albumId: albumMenu.albumId },
label: 'Añadir álbum a una playlist'
}}
playlists={data.userPlaylists}
/>
{/if}
@@ -2,7 +2,7 @@
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions'; import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import { plural } from '$lib/utils/format'; import { plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
@@ -13,15 +13,10 @@
const tracks = $derived(liked.list); const tracks = $derived(liked.list);
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt }))); const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playFrom(index: number) { function playFrom(index: number) {
player.playOrToggle(tracks, index); player.playOrToggle(tracks, index);
} }
function playAll() {
playAllOrToggle(tracks);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -44,9 +39,7 @@
</div> </div>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}> <PlayAllButton items={tracks} />
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
</Button>
{/snippet} {/snippet}
</PageHeader> </PageHeader>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Shuffle from '@lucide/svelte/icons/shuffle'; import Shuffle from '@lucide/svelte/icons/shuffle';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/actions'; import { playShuffled } from '$lib/player/actions';
import { fmtTime, plural } from '$lib/utils/format'; import { fmtTime, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte'; import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
@@ -10,9 +10,10 @@
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte';
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import { createMenu } from '$lib/state/menu.svelte';
import { createTrackMenu } from '$lib/state/menus.svelte'; import type { Track } from '$lib/types';
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -20,9 +21,8 @@
const tracks = $derived(mix.tracks); const tracks = $derived(mix.tracks);
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0)); const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
const trackMenu = createTrackMenu(); const trackMenu = createMenu<Track>();
function playFrom(index: number) { function playFrom(index: number) {
player.playOrToggle(tracks, index); player.playOrToggle(tracks, index);
@@ -52,13 +52,7 @@
/> />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={() => playAllOrToggle(tracks)} disabled={tracks.length === 0}> <PlayAllButton items={tracks} />
{#if isCurrentQueue && player.playing}
Pausar
{:else}
Reproducir
{/if}
</Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -79,24 +73,8 @@
{tracks} {tracks}
columns={['plays']} columns={['plays']}
onPlay={playFrom} onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])} oncontextmenu={(e, track) => trackMenu.open(e, track)}
/> />
</Page> </Page>
{#if trackMenu.state} <TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
<ContextMenu
x={trackMenu.state.x}
y={trackMenu.state.y}
openLeft={trackMenu.state.openLeft}
onClose={() => trackMenu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
/>
{/if}
@@ -3,7 +3,7 @@
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions'; import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte'; import Modal from '$lib/components/ui/overlay/Modal.svelte';
@@ -25,12 +25,6 @@
let editing = $state(false); let editing = $state(false);
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
playAllOrToggle(tracks);
}
function playFrom(index: number) { function playFrom(index: number) {
player.playOrToggle(tracks, index); player.playOrToggle(tracks, index);
} }
@@ -66,13 +60,7 @@
/> />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}> <PlayAllButton items={tracks} />
{#if isCurrentQueue && player.playing}
Pausar
{:else}
Reproducir
{/if}
</Button>
{#if isOwner} {#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
@@ -6,44 +6,29 @@
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import UserRow from '$lib/components/ui/media/UserRow.svelte'; import UserRow from '$lib/components/ui/media/UserRow.svelte';
import { appendUnique } from '$lib/utils/collections'; import { createPagedList } from '$lib/state/pagedList.svelte';
import { plural } from '$lib/utils/format'; import { plural } from '$lib/utils/format';
let { data } = $props(); let { data } = $props();
type ListUser = NonNullable<typeof data.users>['items'][number]; type ListUser = NonNullable<typeof data.users>['items'][number];
let items = $state<ListUser[]>([]); const list = createPagedList<ListUser>(
let page = $state(1); async (pageNumber) => {
let hasMore = $state(false); const res = await fetch(
let loadingMore = $state(false); `/api/users/${data.profile.id}/${data.list}?pageNumber=${pageNumber}`
);
if (!res.ok) throw new Error(String(res.status));
return (await res.json()) as NonNullable<typeof data.users>;
},
(user) => user.id
);
$effect(() => { $effect(() => list.reset(data.users));
items = data.users?.items ?? [];
page = data.users?.pageNumber ?? 1;
hasMore = data.users?.hasNextPage ?? false;
});
const isFollowers = $derived(data.list === 'followers'); const isFollowers = $derived(data.list === 'followers');
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo'); const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
const total = $derived(data.users?.totalItemCount ?? 0); const total = $derived(data.users?.totalItemCount ?? 0);
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
const res = await fetch(`/api/users/${data.profile.id}/${data.list}?pageNumber=${page + 1}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as NonNullable<typeof data.users>;
items = appendUnique(items, next.items, (u) => u.id);
page = next.pageNumber;
hasMore = next.hasNextPage;
} catch {
hasMore = false;
} finally {
loadingMore = false;
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -67,7 +52,7 @@
title="Seguidores privados" title="Seguidores privados"
description="Solo puedes ver los seguidores de {data.profile.name} si os seguís mutuamente." description="Solo puedes ver los seguidores de {data.profile.name} si os seguís mutuamente."
/> />
{:else if items.length === 0} {:else if list.items.length === 0}
<EmptyState <EmptyState
icon={Users} icon={Users}
title={isFollowers ? 'Sin seguidores' : 'No sigue a nadie'} title={isFollowers ? 'Sin seguidores' : 'No sigue a nadie'}
@@ -77,12 +62,17 @@
/> />
{:else} {:else}
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each items as user (user.id)} {#each list.items as user (user.id)}
<li> <li>
<UserRow {user} /> <UserRow {user} />
</li> </li>
{/each} {/each}
</ul> </ul>
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} /> <InfiniteScroll
onLoadMore={() => list.loadMore()}
hasMore={list.hasMore}
loading={list.loading}
error={list.error}
/>
{/if} {/if}
</Page> </Page>
+32 -63
View File
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { playlistCover } from '$lib/utils/hrefs'; import { playlistCover } from '$lib/utils/hrefs';
import { goto } from '$app/navigation';
import History from '@lucide/svelte/icons/history'; import History from '@lucide/svelte/icons/history';
import Shuffle from '@lucide/svelte/icons/shuffle'; import Shuffle from '@lucide/svelte/icons/shuffle';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/actions'; import { playShuffled } from '$lib/player/actions';
import type { Track } from '$lib/types'; import type { Track } from '$lib/types';
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
import { fetchPlaylistTracks } from '$lib/data/albums'; import { fetchPlaylistTracks } from '$lib/data/albums';
@@ -17,43 +16,20 @@
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
import { createTrackMenu } from '$lib/state/menus.svelte'; import { createMenu } from '$lib/state/menu.svelte';
import { HOME_CONTINUE_LIMIT } from '$lib/config'; import { HOME_CONTINUE_LIMIT } from '$lib/config';
let { data } = $props(); let { data } = $props();
const trackMenu = createTrackMenu(); const trackMenu = createMenu<Track>();
const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists);
const spotlight = $derived(data.spotlightPlaylist); const spotlight = $derived(data.spotlightPlaylist);
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT)); const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
function playContinue(index: number) {
player.playOrToggle(continueItems, index);
}
function playSpotlightTrack(items: Track[], index: number) {
player.playOrToggle(items, index);
}
async function playSpotlight(previewItems: Track[]) {
if (!spotlight) return;
if (isQueueCurrent(previewItems)) {
player.toggle();
return;
}
playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
}
async function shuffleSpotlight() { async function shuffleSpotlight() {
if (!spotlight) return; if (data.spotlightPlaylist) playShuffled(await fetchPlaylistTracks(data.spotlightPlaylist.id));
playShuffled(await fetchPlaylistTracks(spotlight.id));
}
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
event.preventDefault();
player.playQueue(mix.tracks, 0);
} }
</script> </script>
@@ -90,11 +66,14 @@
</p> </p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5"> <div class="mt-6.5 flex flex-wrap items-center gap-2.5">
{#if spotlight} {#if spotlight}
{#await data.spotlightTracks then { items: previewItems }} {#await data.spotlightTracks then { items }}
<Button variant="primary" onclick={() => playSpotlight(previewItems)}> <PlayAllButton
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'} {items}
{spotlight.name} load={() => fetchPlaylistTracks(spotlight.id)}
</Button> size="md"
idleLabel="Reanudar {spotlight.name}"
activeLabel="Pausar {spotlight.name}"
/>
{/await} {/await}
{/if} {/if}
<Button href="/explore" variant="secondary">Explorar música</Button> <Button href="/explore" variant="secondary">Explorar música</Button>
@@ -130,7 +109,7 @@
{#each continueItems as item, i (item.id)} {#each continueItems as item, i (item.id)}
{@const isCurrent = player.currentId === item.id} {@const isCurrent = player.currentId === item.id}
<ListRow <ListRow
onclick={() => playContinue(i)} onclick={() => player.playOrToggle(continueItems, i)}
oncontextmenu={(e) => trackMenu.open(e, item)} oncontextmenu={(e) => trackMenu.open(e, item)}
active={isCurrent} active={isCurrent}
size="lg" size="lg"
@@ -185,16 +164,19 @@
{spotlight.description || 'Tu colección, siempre a mano.'} {spotlight.description || 'Tu colección, siempre a mano.'}
</p> </p>
<div class="mt-5 flex gap-2.5"> <div class="mt-5 flex gap-2.5">
<Button variant="accent" onclick={() => playSpotlight(previewItems)} <PlayAllButton
>Reproducir</Button items={previewItems}
> load={() => fetchPlaylistTracks(spotlight.id)}
variant="accent"
size="md"
/>
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button> <Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 p-5.5 sm:p-6"> <div class="flex flex-col gap-2 p-5.5 sm:p-6">
{#each previewItems as track, i (track.id)} {#each previewItems as track, i (track.id)}
<ListRow <ListRow
onclick={() => playSpotlightTrack(previewItems, i)} onclick={() => player.playOrToggle(previewItems, i)}
active={player.currentId === track.id} active={player.currentId === track.id}
size="sm" size="sm"
title={track.title} title={track.title}
@@ -224,15 +206,18 @@
<section> <section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" /> <SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0} {#if data.mixes.length > 0}
<div class="grid-tiles"> <div class="grid-tiles">
{#each mixes as mix, i (mix.id)} {#each data.mixes as mix, i (mix.id)}
<MediaCard <MediaCard
href="/mixes/{mix.id}" href="/mixes/{mix.id}"
title={mix.title} title={mix.title}
subtitle={mix.subtitle} subtitle={mix.subtitle}
trackIds={mix.tracks.map((track) => track.id)} trackIds={mix.tracks.map((track) => track.id)}
onPlay={(event) => playMix(mix, event)} onPlay={(event) => {
event.preventDefault();
player.playQueue(mix.tracks, 0);
}}
index={i} index={i}
/> />
{/each} {/each}
@@ -252,11 +237,11 @@
subtitle="Creadas y guardadas por ti" subtitle="Creadas y guardadas por ti"
href={data.playlistsHasMore ? '/playlists' : undefined} href={data.playlistsHasMore ? '/playlists' : undefined}
/> />
{#if playlists.length > 0} {#if data.playlists.length > 0}
<div class="grid-wide"> <div class="grid-wide">
{#each playlists as playlist, i (playlist.id)} {#each data.playlists as playlist, i (playlist.id)}
<ListRow <ListRow
onclick={() => goto(`/playlists/${playlist.id}`)} href="/playlists/{playlist.id}"
size="lg" size="lg"
variant="card" variant="card"
title={playlist.name} title={playlist.name}
@@ -282,20 +267,4 @@
</div> </div>
</Page> </Page>
{#if trackMenu.state} <TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
<ContextMenu
x={trackMenu.state.x}
y={trackMenu.state.y}
openLeft={trackMenu.state.openLeft}
onClose={() => trackMenu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
/>
{/if}