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:
@@ -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}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user