web-player: TypeScript helpers (partial)
Add src/lib/actions/pressable.ts and clickOutside.ts (Svelte actions; role/tabindex stay static in markup for SSR accessibility, the action only adds click + Enter/Space) and src/lib/toggle.svelte.ts (createToggle), used by playlists.svelte.ts and queuePanel.svelte.ts. format.ts: adds plural(n, one, many); migrates the repo's 15 manual pluralizations, unifying them to lowercase (visible diff in Playlists, Home, liked, Explore). player.svelte.ts: adds toQueueItem, isQueueCurrent, playAllOrToggle, playShuffled; renames addToQueue -> playNextItem (the old name was misleading: it inserts next, not at the end). Migrates the 5 places that repeated the isCurrentQueue/playAll pattern (album, playlist, liked, mix, Home). Simplifies liked.svelte.ts and recentlyPlayed.ts with object spread instead of rebuilding every field by hand. navigation.svelte.ts: adds searchHref (migrates the duplicated buildHref from TopBar and Explore) and appNavLinks (added; its adoption in Sidebar/TabsBar is left for a later pass, as noted there). menus.svelte.ts: createTrackMenu() replaces the manual context-menu state (contextMenu + contextMenuStateFor + addToQueue) in Home, Explore and Mix. Deliberately left pending: removing tracks.ts's TrackTarget wrapper and its 8 accessors is deferred — it's the riskiest change in the plan, touches types across Home/Explore/Mix/menus, and couldn't be visually verified without Docker this session. Verified with npm run check/build.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
@@ -19,12 +19,10 @@
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
|
||||
const isCurrentQueue = $derived(tracks.some((track) => track.id === player.current.id));
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
function playAll() {
|
||||
if (tracks.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(toQueueItems(tracks), 0);
|
||||
playAllOrToggle(toQueueItems(tracks));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
||||
import { plural } from '$lib/format';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -35,10 +36,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const meta = $derived(
|
||||
[
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
`${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
[releaseYear ? String(releaseYear) : undefined, plural(trackIds.length, 'canción', 'canciones')]
|
||||
.filter((part) => part !== undefined)
|
||||
.join(' · ')
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import { fmtTime, fmtPlays } from '$lib/format';
|
||||
import { fmtTime, fmtPlays, plural } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
@@ -15,10 +15,9 @@
|
||||
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import { searchHref } from '$lib/navigation.svelte';
|
||||
import { createTrackMenu } from '$lib/menus.svelte';
|
||||
import TrackContextMenu from '$lib/components/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu, {
|
||||
albumContextMenuStateFor,
|
||||
type AlbumMenuState
|
||||
@@ -73,7 +72,7 @@
|
||||
type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
||||
const SEARCH_GROUP_PREVIEW = 4;
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
const trackMenu = createTrackMenu();
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
let sfilter = $state<SearchFilter>('Todo');
|
||||
|
||||
@@ -85,7 +84,7 @@
|
||||
let loadingMore = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
contextMenu = null;
|
||||
trackMenu.close();
|
||||
albumMenu = null;
|
||||
sfilter = 'Todo';
|
||||
localItems = data.tracks.items.map((item) => item.track);
|
||||
@@ -170,10 +169,6 @@
|
||||
return sfilter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
||||
}
|
||||
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
function playTopResult() {
|
||||
if (topResult?.kind !== 'track') return;
|
||||
player.playOrToggle([queueItemForTarget(topResult.target)], 0);
|
||||
@@ -191,7 +186,7 @@
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
trackMenu.open(event, target);
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||
@@ -212,12 +207,6 @@
|
||||
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemForTarget(contextMenu));
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
@@ -270,8 +259,7 @@
|
||||
Resultados para «{data.query}»
|
||||
</h1>
|
||||
<p class="mt-1.75 text-sm text-fg-2">
|
||||
{totalHits}
|
||||
{totalHits === 1 ? 'Coincidencia' : 'Coincidencias'} en canciones, álbumes, playlists y usuarios
|
||||
{plural(totalHits, 'coincidencia', 'coincidencias')} en canciones, álbumes, playlists y usuarios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -377,7 +365,7 @@
|
||||
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
|
||||
{#each genreTiles.slice(0, 4) as genre (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
href={searchHref(genre.query)}
|
||||
class="rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg"
|
||||
>
|
||||
{genre.label}
|
||||
@@ -392,7 +380,7 @@
|
||||
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||
{#each genreTiles as genre, i (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
href={searchHref(genre.query)}
|
||||
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-110"
|
||||
style="animation-delay:{Math.min(i, 10) *
|
||||
40}ms; background:linear-gradient(140deg, oklch(var(--mf-tile-l) var(--mf-tile-c) {genre.hue}), oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) {genre.hue}))"
|
||||
@@ -415,8 +403,7 @@
|
||||
{#snippet actions()}
|
||||
<div class="h-px flex-1 self-center bg-line"></div>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
||||
{searchCounts.Canciones}
|
||||
{searchCounts.Canciones === 1 ? 'Resultado' : 'Resultados'}
|
||||
{plural(searchCounts.Canciones, 'resultado', 'resultados')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
@@ -466,8 +453,7 @@
|
||||
{#snippet actions()}
|
||||
<div class="h-px flex-1 self-center bg-line"></div>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
||||
{searchCounts.Álbumes}
|
||||
{searchCounts.Álbumes === 1 ? 'Resultado' : 'Resultados'}
|
||||
{plural(searchCounts.Álbumes, 'resultado', 'resultados')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
@@ -496,8 +482,7 @@
|
||||
{#snippet actions()}
|
||||
<div class="h-px flex-1 self-center bg-line"></div>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
||||
{searchCounts.Usuarios}
|
||||
{searchCounts.Usuarios === 1 ? 'Resultado' : 'Resultados'}
|
||||
{plural(searchCounts.Usuarios, 'resultado', 'resultados')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
@@ -521,8 +506,7 @@
|
||||
{#snippet actions()}
|
||||
<div class="h-px flex-1 self-center bg-line"></div>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
||||
{searchCounts.Playlists}
|
||||
{searchCounts.Playlists === 1 ? 'Resultado' : 'Resultados'}
|
||||
{plural(searchCounts.Playlists, 'resultado', 'resultados')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
@@ -554,12 +538,12 @@
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
{#if trackMenu.state}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
menu={trackMenu.state}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (contextMenu = null)}
|
||||
onAddToQueue={addToQueue}
|
||||
onClose={() => trackMenu.close()}
|
||||
onAddToQueue={() => trackMenu.playNext()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { fmtDate, fmtTime } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
@@ -16,24 +16,14 @@
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
|
||||
const tracks = $derived(liked.list);
|
||||
const queueItems = $derived<QueueItem[]>(
|
||||
tracks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
explicit: t.explicit
|
||||
}))
|
||||
);
|
||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(queueItems, index);
|
||||
player.playOrToggle(tracks, index);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (queueItems.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(queueItems, 0);
|
||||
playAllOrToggle(tracks);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
@@ -16,10 +15,8 @@
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import TrackContextMenu from '$lib/components/TrackContextMenu.svelte';
|
||||
import { createTrackMenu } from '$lib/menus.svelte';
|
||||
import { mixItemKey, mixItemTarget } from '$lib/mixes';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
@@ -40,24 +37,13 @@
|
||||
const totalSeconds = $derived(
|
||||
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
||||
);
|
||||
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
|
||||
const isCurrentQueue = $derived(isQueueCurrent(queue));
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
const trackMenu = createTrackMenu();
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(queue, index);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (queue.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(queue, 0);
|
||||
}
|
||||
|
||||
function playShuffled() {
|
||||
if (queue.length === 0) return;
|
||||
player.playQueue(shuffle(queue), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -83,7 +69,7 @@
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
@@ -92,7 +78,12 @@
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => playShuffled(queue)}
|
||||
disabled={queue.length === 0}
|
||||
>
|
||||
<Shuffle class="h-4 w-4" />
|
||||
Aleatorio
|
||||
</Button>
|
||||
@@ -106,7 +97,7 @@
|
||||
<TrackTable index meta={[{ label: 'Escuchas', width: '100px' }]} class="mt-6 sm:mt-8">
|
||||
{#each items as item, i (mixItemKey(item))}
|
||||
{@const active = isTargetCurrent(targets[i])}
|
||||
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||
<TrackRow {active} oncontextmenu={(e) => trackMenu.open(e, targets[i])}>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={targetId(targets[i])}
|
||||
@@ -122,18 +113,11 @@
|
||||
</TrackTable>
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
{#if trackMenu.state}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
menu={trackMenu.state}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (contextMenu = null)}
|
||||
onAddToQueue={() => {
|
||||
const menu = contextMenu;
|
||||
if (menu) {
|
||||
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
|
||||
if (index >= 0) player.addToQueue(queue[index]);
|
||||
}
|
||||
contextMenu = null;
|
||||
}}
|
||||
onClose={() => trackMenu.close()}
|
||||
onAddToQueue={() => trackMenu.playNext()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
@@ -17,12 +17,10 @@
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
|
||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||
|
||||
function playAll() {
|
||||
if (tracks.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(toQueueItems(tracks), 0);
|
||||
playAllOrToggle(toQueueItems(tracks));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user