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;
|
title: string;
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
onItemClick?: (item: MediaItem) => void;
|
onItemClick?: (item: MediaItem) => void;
|
||||||
|
onItemLongPress?: (item: MediaItem) => void;
|
||||||
showAll?: () => 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 scrollContainer: HTMLDivElement | null = $state(null);
|
||||||
let showLeftArrow = $state(false);
|
let showLeftArrow = $state(false);
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
size="medium"
|
size="medium"
|
||||||
showProgress={true}
|
showProgress={true}
|
||||||
onclick={() => onItemClick?.(item)}
|
onclick={() => onItemClick?.(item)}
|
||||||
|
onLongPress={onItemLongPress ? () => onItemLongPress(item) : undefined}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,69 @@
|
|||||||
*/
|
*/
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
onclick?: () => 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
|
// Check if this item is downloaded
|
||||||
const downloadInfo = $derived(
|
const downloadInfo = $derived(
|
||||||
@@ -150,7 +210,13 @@
|
|||||||
type={isServerOnly ? undefined : "button"}
|
type={isServerOnly ? undefined : "button"}
|
||||||
role={isServerOnly ? "group" : undefined}
|
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'}"
|
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">
|
<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
|
<CachedImage
|
||||||
|
|||||||
+40
-6
@@ -56,18 +56,47 @@
|
|||||||
previousServerReachable = serverReachable;
|
previousServerReachable = serverReachable;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tap → detail page. Non-playable containers already routed to /library; now
|
||||||
|
// movies and episodes go to their detail page too instead of playing straight
|
||||||
|
// away. Channel leaves (no detail page) still go direct to the player.
|
||||||
|
// TRACES: UR-058 | DR-087
|
||||||
function handleItemClick(item: MediaItem) {
|
function handleItemClick(item: MediaItem) {
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "series":
|
case "channelItem":
|
||||||
case "season":
|
case "liveChannel":
|
||||||
case "album":
|
goto(`/player/${item.id}`);
|
||||||
case "artist":
|
break;
|
||||||
case "folder":
|
case "episode":
|
||||||
case "channel":
|
// An episode is never browsed as a bare Episode page — it opens in its
|
||||||
|
// series' Episode Focus View so the series context loads (ux-flows §5B.1).
|
||||||
|
if (item.seriesId) {
|
||||||
|
goto(`/library/${item.seriesId}?episode=${item.id}`);
|
||||||
|
} else {
|
||||||
goto(`/library/${item.id}`);
|
goto(`/library/${item.id}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
goto(`/library/${item.id}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long press → play immediately, confirming first so an accidental hold on a
|
||||||
|
// half-watched item doesn't blow away the user's spot without warning.
|
||||||
|
// TRACES: UR-058 | DR-087
|
||||||
|
function handleItemLongPress(item: MediaItem) {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "movie":
|
||||||
|
case "episode":
|
||||||
|
case "channelItem":
|
||||||
|
case "liveChannel":
|
||||||
|
if (confirm(`Play "${item.name}" now?`)) {
|
||||||
goto(`/player/${item.id}`);
|
goto(`/player/${item.id}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Containers (series/season/album/…) have no single "play now" target.
|
||||||
|
goto(`/library/${item.id}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +174,7 @@
|
|||||||
title="Next Movie"
|
title="Next Movie"
|
||||||
items={resumeMovies}
|
items={resumeMovies}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onItemLongPress={handleItemLongPress}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -154,6 +184,7 @@
|
|||||||
title="Next Episode"
|
title="Next Episode"
|
||||||
items={nextUpItems}
|
items={nextUpItems}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onItemLongPress={handleItemLongPress}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -163,6 +194,7 @@
|
|||||||
title="Recently Listened"
|
title="Recently Listened"
|
||||||
items={recentlyPlayedAudio}
|
items={recentlyPlayedAudio}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onItemLongPress={handleItemLongPress}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -172,6 +204,7 @@
|
|||||||
title="Continue Watching"
|
title="Continue Watching"
|
||||||
items={resumeItems}
|
items={resumeItems}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onItemLongPress={handleItemLongPress}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -181,6 +214,7 @@
|
|||||||
title="Recently Added"
|
title="Recently Added"
|
||||||
items={latestItems}
|
items={latestItems}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onItemLongPress={handleItemLongPress}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -381,12 +381,29 @@
|
|||||||
<div class="flex-1 space-y-4">
|
<div class="flex-1 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
|
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
|
||||||
{#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)}
|
{#if item.kind === "episode"}
|
||||||
|
<!-- Links back to the parent series/season so the episode detail
|
||||||
|
page is a navigable hub, not a dead end. TRACES: UR-058 | DR-087 -->
|
||||||
|
{#if item.seriesId && item.seriesName}
|
||||||
|
<p class="text-lg mt-1">
|
||||||
|
<a
|
||||||
|
href={`/library/${item.seriesId}`}
|
||||||
|
class="text-[var(--color-jellyfin)] hover:underline"
|
||||||
|
>{item.seriesName}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if item.parentIndexNumber || item.indexNumber}
|
||||||
<p class="text-lg text-gray-400 mt-1">
|
<p class="text-lg text-gray-400 mt-1">
|
||||||
{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
{#if item.seasonId && item.parentIndexNumber}
|
||||||
|
<a
|
||||||
|
href={`/library/${item.seasonId}`}
|
||||||
|
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
|
||||||
|
>Season {item.parentIndexNumber}</a>
|
||||||
|
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
||||||
{#if item.parentIndexNumber && item.indexNumber}, {/if}
|
{#if item.parentIndexNumber && item.indexNumber}, {/if}
|
||||||
{#if item.indexNumber}Episode {item.indexNumber}{/if}
|
{#if item.indexNumber}Episode {item.indexNumber}{/if}
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
{:else if item.artistItems?.length || item.artists?.length}
|
{:else if item.artistItems?.length || item.artists?.length}
|
||||||
<p class="text-lg text-gray-400 mt-1">
|
<p class="text-lg text-gray-400 mt-1">
|
||||||
<ArtistLinks
|
<ArtistLinks
|
||||||
|
|||||||
Reference in New Issue
Block a user