refactor(web): share track target helpers between home and explore

Both pages reimplemented the same accessors over the target union and a
byte-identical queue-item mapper. Moves the domain type (renamed
ContextMenuTarget -> TrackTarget, since tiles and the queue use it too)
and its helpers to $lib/tracks.ts; TrackContextMenu keeps only its menu
geometry. Home drops from 341 to ~230 lines.

Unifies two divergences the duplication was hiding: home compared the
active tile against track.id while the queue is keyed by
queueIdForTrack, so YouTube-sourced tracks never highlighted; and it
hardcoded explicit to false for local tracks, hiding the badge.

Also cancels in-flight youtube filler results when the effect re-runs,
so a slow response cannot overwrite newer ones.
This commit is contained in:
2026-07-20 07:28:12 +02:00
parent c8bc26a5e0
commit db71c4a331
4 changed files with 167 additions and 199 deletions
+37 -72
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { player, queueIdForTrack, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import { player } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
@@ -16,14 +17,23 @@
import TrackTile from '$lib/components/TrackTile.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuTarget,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import { appendUnique } from '$lib/collections.js';
import { appendUnique } from '$lib/collections';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
let { data, form } = $props();
let searchValue = $state(data.query);
let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let ytItems = $state<YouTubeSong[]>([]);
@@ -45,31 +55,29 @@
localHasNext = Boolean(data.tracks.hasNextPage);
if (data.query) {
ytItems = appendUnique(
[],
(data.ytResults?.items ?? []) as YouTubeSong[],
item => item.videoId
);
ytItems = appendUnique([], (data.ytResults?.items ?? []) as YouTubeSong[], (i) => i.videoId);
ytContinuation = data.ytResults?.continuationToken ?? '';
ytSearchTerm = data.query;
return;
}
ytItems = [];
ytContinuation = '';
ytSearchTerm = '';
let cancelled = false;
data.youtubeFiller?.then((filler) => {
if (!filler) return;
ytItems = appendUnique(
[],
filler.items,
item => item.videoId
);
if (cancelled || !filler) return;
ytItems = appendUnique([], filler.items, (i) => i.videoId);
ytContinuation = filler.continuationToken;
ytSearchTerm = filler.query;
});
return () => {
cancelled = true;
};
});
const items = $derived<ContextMenuTarget[]>([
const items = $derived<TrackTarget[]>([
...localItems.map((track) => ({ kind: 'local' as const, track })),
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
@@ -80,41 +88,6 @@
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
);
function itemId(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
}
function itemTitle(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.title : item.track.title;
}
function itemArtist(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.artist : item.track.artist;
}
function itemExplicit(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.isExplicit : (item.track.isExplicit ?? false);
}
function itemActive(item: ContextMenuTarget) {
return (
player.current?.id === (item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track))
);
}
function queueItemFor(target: ContextMenuTarget): QueueItem {
return target.kind === 'youtube'
? {
id: target.song.videoId,
title: target.song.title,
artist: target.song.artist,
source: 'youtube',
coverUrl: target.song.thumbnailUrl,
explicit: target.song.isExplicit
}
: toQueueItems([target.track])[0];
}
function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
@@ -132,10 +105,10 @@
}
function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemFor), index);
player.playOrToggle(items.map(queueItemForTarget), index);
}
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target);
}
@@ -145,7 +118,7 @@
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu));
player.addToQueue(queueItemForTarget(contextMenu));
closeContextMenu();
}
@@ -158,11 +131,7 @@
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
);
localItems = appendUnique(localItems, next.items, (track) => track.id);
localPage = Number(next.pageNumber);
localHasNext = Boolean(next.hasNextPage);
}
@@ -172,11 +141,7 @@
const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
ytItems = appendUnique(
ytItems,
next.items,
item => item.videoId
);
ytItems = appendUnique(ytItems, next.items, (i) => i.videoId);
ytContinuation = next.continuationToken;
}
@@ -223,15 +188,15 @@
{#if items.length > 0}
<MediaGrid as="ul" class="mt-6">
{#each items as item, i (itemId(item))}
{#each items as item, i (targetId(item))}
<TrackTile
id={itemId(item)}
title={itemTitle(item)}
artist={itemArtist(item)}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
hue={hueFor(itemId(item))}
explicit={itemExplicit(item)}
active={itemActive(item)}
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={i}
onClick={() => togglePlay(i)}