From 589f08b8736af932fcd5eb90ce32e50325ee89f1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 24 Jul 2026 23:49:03 +0200 Subject: [PATCH] feat(home): tap opens detail, long-press plays from home cards Home carousel cards route a tap to the item's detail / Episode Focus View and a ~500ms long-press to a confirm-then-play flow. MediaCard gains an onLongPress prop with pointer-based detection (cancelled on >10px move so carousel scroll is unaffected, trailing click suppressed). Episode taps route to /library/?episode=; the bare-episode detail page links back to its parent series/season. TRACES: UR-058 | DR-087 --- src/lib/components/home/Carousel.svelte | 4 +- src/lib/components/library/MediaCard.svelte | 70 ++++++++++++++++++++- src/routes/+page.svelte | 50 ++++++++++++--- src/routes/library/[id]/+page.svelte | 29 +++++++-- 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/lib/components/home/Carousel.svelte b/src/lib/components/home/Carousel.svelte index c881b334..f1e4710c 100644 --- a/src/lib/components/home/Carousel.svelte +++ b/src/lib/components/home/Carousel.svelte @@ -7,10 +7,11 @@ title: string; items: MediaItem[]; onItemClick?: (item: MediaItem) => void; + onItemLongPress?: (item: MediaItem) => void; showAll?: () => void; } - let { title, items, onItemClick, showAll }: Props = $props(); + let { title, items, onItemClick, onItemLongPress, showAll }: Props = $props(); let scrollContainer: HTMLDivElement | null = $state(null); let showLeftArrow = $state(false); @@ -60,6 +61,7 @@ size="medium" showProgress={true} onclick={() => onItemClick?.(item)} + onLongPress={onItemLongPress ? () => onItemLongPress(item) : undefined} /> {/each} diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 0453626f..92d7cb83 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -30,9 +30,69 @@ */ onRemove?: () => void; onclick?: () => void; + /** + * When set, a long press (touch hold / mouse hold) fires this instead of the + * regular tap. The tap that would otherwise follow the release is suppressed. + * Used on the home page: tap opens the detail page, long-press plays now. + * TRACES: UR-058 | DR-087 + */ + onLongPress?: () => void; } - let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props(); + let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: Props = $props(); + + // Long-press detection. We arm a timer on pointerdown; if it fires before the + // pointer is released (or moves too far), we treat it as a long press and set a + // flag so the ensuing click is swallowed. Pointer events cover touch + mouse. + const LONG_PRESS_MS = 500; + const MOVE_CANCEL_PX = 10; + let pressTimer: ReturnType | null = null; + let longPressFired = false; + let pressStartX = 0; + let pressStartY = 0; + + function clearPressTimer() { + if (pressTimer !== null) { + clearTimeout(pressTimer); + pressTimer = null; + } + } + + function handlePointerDown(e: PointerEvent) { + if (!onLongPress || isServerOnly) return; + longPressFired = false; + pressStartX = e.clientX; + pressStartY = e.clientY; + clearPressTimer(); + pressTimer = setTimeout(() => { + longPressFired = true; + pressTimer = null; + onLongPress?.(); + }, LONG_PRESS_MS); + } + + function handlePointerMove(e: PointerEvent) { + if (pressTimer === null) return; + if ( + Math.abs(e.clientX - pressStartX) > MOVE_CANCEL_PX || + Math.abs(e.clientY - pressStartY) > MOVE_CANCEL_PX + ) { + clearPressTimer(); + } + } + + function handlePointerUp() { + clearPressTimer(); + } + + function handleClick() { + // A long press already handled this interaction; swallow the trailing click. + if (longPressFired) { + longPressFired = false; + return; + } + onclick?.(); + } // Check if this item is downloaded const downloadInfo = $derived( @@ -150,7 +210,13 @@ type={isServerOnly ? undefined : "button"} role={isServerOnly ? "group" : undefined} class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}" - onclick={isServerOnly ? undefined : onclick} + style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined} + onclick={isServerOnly ? undefined : handleClick} + onpointerdown={isServerOnly ? undefined : handlePointerDown} + onpointermove={isServerOnly ? undefined : handlePointerMove} + onpointerup={isServerOnly ? undefined : handlePointerUp} + onpointercancel={isServerOnly ? undefined : handlePointerUp} + oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined} >
{/if} @@ -154,6 +184,7 @@ title="Next Episode" items={nextUpItems} onItemClick={handleItemClick} + onItemLongPress={handleItemLongPress} /> {/if} @@ -163,6 +194,7 @@ title="Recently Listened" items={recentlyPlayedAudio} onItemClick={handleItemClick} + onItemLongPress={handleItemLongPress} /> {/if} @@ -172,6 +204,7 @@ title="Continue Watching" items={resumeItems} onItemClick={handleItemClick} + onItemLongPress={handleItemLongPress} /> {/if} @@ -181,6 +214,7 @@ title="Recently Added" items={latestItems} onItemClick={handleItemClick} + onItemLongPress={handleItemLongPress} /> {/if} diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index d6c9af7f..60abf7be 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -381,12 +381,29 @@

{item.name}

- {#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)} -

- {#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if} - {#if item.parentIndexNumber && item.indexNumber}, {/if} - {#if item.indexNumber}Episode {item.indexNumber}{/if} -

+ {#if item.kind === "episode"} + + {#if item.seriesId && item.seriesName} +

+ {item.seriesName} +

+ {/if} + {#if item.parentIndexNumber || item.indexNumber} +

+ {#if item.seasonId && item.parentIndexNumber} + Season {item.parentIndexNumber} + {:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if} + {#if item.parentIndexNumber && item.indexNumber}, {/if} + {#if item.indexNumber}Episode {item.indexNumber}{/if} +

+ {/if} {:else if item.artistItems?.length || item.artists?.length}