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/<seriesId>?episode=<id>; the bare-episode detail
page links back to its parent series/season.

TRACES: UR-058 | DR-087
This commit is contained in:
2026-07-24 23:49:03 +02:00
parent e2c9d68311
commit 589f08b873
4 changed files with 136 additions and 17 deletions
+68 -2
View File
@@ -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<typeof setTimeout> | 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}
>
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
<CachedImage