Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
539 lines
19 KiB
Svelte
539 lines
19 KiB
Svelte
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
|
|
<script lang="ts">
|
|
import type { MediaItem, Library } from "$lib/api/types";
|
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import { isConnected } from "$lib/stores/connectivity";
|
|
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
|
import { auth } from "$lib/stores/auth";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("MediaCard");
|
|
|
|
interface Props {
|
|
item: MediaItem | Library;
|
|
size?: "small" | "medium" | "large";
|
|
showProgress?: boolean;
|
|
showDownloadStatus?: boolean;
|
|
/**
|
|
* Secondary on-disk size label (e.g. "1.2 GB"), shown under the subtitle.
|
|
* Used by the Downloaded browse surface. TRACES: UR-056 | DR-085
|
|
*/
|
|
sizeLabel?: string;
|
|
/**
|
|
* "full" | "partial" — badges a downloaded container on the artwork so a
|
|
* fully-downloaded item reads differently from a partially-downloaded one.
|
|
* TRACES: UR-055 | DR-083
|
|
*/
|
|
downloadedBadge?: "full" | "partial";
|
|
/**
|
|
* When set, a hover/focus "remove from device" control appears on the card
|
|
* (Downloaded surface only). TRACES: UR-055, UR-056 | DR-083
|
|
*/
|
|
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;
|
|
/**
|
|
* Show the favourite heart on the artwork. On by default for media items;
|
|
* surfaces that are not about the item itself can opt out.
|
|
* TRACES: UR-068 | DR-119
|
|
*/
|
|
showFavorite?: boolean;
|
|
/**
|
|
* Force the artwork box to a fixed aspect ratio instead of deriving one from
|
|
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
|
|
* strip, where square music art next to 16:9 video art would otherwise give
|
|
* the cards different heights). Artwork still fills the box via object-cover.
|
|
*/
|
|
aspect?: "square" | "video" | "poster";
|
|
}
|
|
|
|
let {
|
|
item,
|
|
size = "medium",
|
|
showProgress = false,
|
|
showDownloadStatus = true,
|
|
sizeLabel,
|
|
downloadedBadge,
|
|
onRemove,
|
|
onclick,
|
|
onLongPress,
|
|
showFavorite = true,
|
|
aspect,
|
|
}: 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(
|
|
Object.values($downloads.downloads).find((d) => d.itemId === item.id),
|
|
);
|
|
|
|
const isDownloaded = $derived(downloadInfo?.status === "completed");
|
|
const isDownloading = $derived(
|
|
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
|
|
);
|
|
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
|
|
|
// A media item (not a library) is "server only" when it exists in the cache
|
|
// but isn't downloaded/downloading — and we're offline with the reveal toggle
|
|
// on. Such cards render greyed out with a "queue for download" button and are
|
|
// inert to tap (nothing to play offline).
|
|
const isMediaItem = $derived("type" in item);
|
|
const isQueued = $derived(downloadInfo?.status === "pending");
|
|
// Actively transferring (as opposed to merely queued/pending for reconnect).
|
|
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
|
|
// "Server only" = offline, reveal on, and not already downloaded or actively
|
|
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
|
|
// it can show the Queued badge in place of the queue button.
|
|
const isServerOnly = $derived(
|
|
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
|
|
);
|
|
|
|
// The heart is about an item, so libraries never get one, and a greyed
|
|
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
|
|
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
|
|
const isFavorited = $derived(
|
|
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false,
|
|
);
|
|
|
|
let queueError = $state<string | null>(null);
|
|
|
|
// Queue this item for download on next reconnect. Offline, this just persists
|
|
// a `pending` downloads row (no stream_url); the reconnect handler resolves
|
|
// the URL and the pump starts it. See offlineCatalog service.
|
|
async function queueForDownload(e: Event) {
|
|
e.stopPropagation();
|
|
if (!isMediaItem) return;
|
|
const media = item as MediaItem;
|
|
const userId = auth.getUserId();
|
|
if (!userId) {
|
|
queueError = "Not signed in";
|
|
return;
|
|
}
|
|
try {
|
|
queueError = null;
|
|
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
|
|
const filePath = `downloads/${media.id}`;
|
|
await downloads.downloadItem(
|
|
media.id,
|
|
userId,
|
|
filePath,
|
|
undefined,
|
|
undefined,
|
|
media.name,
|
|
media.artists?.join(", ") ?? undefined,
|
|
media.albumName ?? undefined,
|
|
);
|
|
} catch (err) {
|
|
log.error("Failed to queue download:", err);
|
|
queueError = "Failed to queue";
|
|
}
|
|
}
|
|
|
|
const sizeClasses = {
|
|
small: "w-24",
|
|
medium: "w-36",
|
|
large: "w-48",
|
|
};
|
|
|
|
const isMusicType = $derived(
|
|
"kind" in item &&
|
|
(item.kind === "track" ||
|
|
item.kind === "album" ||
|
|
item.kind === "artist" ||
|
|
item.kind === "playlist"),
|
|
);
|
|
|
|
const FIXED_ASPECT = {
|
|
square: "aspect-square",
|
|
video: "aspect-video",
|
|
poster: "aspect-[2/3]",
|
|
} as const;
|
|
|
|
const aspectRatio = $derived(() => {
|
|
if (aspect) return FIXED_ASPECT[aspect];
|
|
if ("kind" in item) {
|
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
|
}
|
|
// Library
|
|
return "collectionType" in item && item.collectionType === "music"
|
|
? "aspect-square"
|
|
: "aspect-video";
|
|
});
|
|
|
|
const imageTag = $derived(
|
|
"imageId" in item ? item.imageId : "imageTag" in item ? item.imageTag : undefined,
|
|
);
|
|
|
|
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
|
|
|
const progress = $derived(() => {
|
|
if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
|
|
return 0;
|
|
}
|
|
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
|
});
|
|
|
|
const subtitle = $derived(() => {
|
|
if (!("type" in item)) return "";
|
|
|
|
switch (item.type) {
|
|
case "Audio":
|
|
return item.artists?.join(", ") || item.albumName || "";
|
|
case "MusicAlbum":
|
|
return item.artistItems?.map((a) => a.name).join(", ") || "";
|
|
case "Episode":
|
|
return item.seriesName
|
|
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
|
|
: "";
|
|
case "Movie":
|
|
return item.productionYear?.toString() || "";
|
|
case "Series":
|
|
return item.productionYear?.toString() || "";
|
|
default:
|
|
return "";
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:element
|
|
this={isServerOnly ? "div" : "button"}
|
|
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'}"
|
|
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
|
|
itemId={item.id}
|
|
imageType="Primary"
|
|
tag={imageTag}
|
|
{maxWidth}
|
|
alt={item.name}
|
|
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly
|
|
? 'opacity-40 grayscale'
|
|
: ''}"
|
|
/>
|
|
|
|
<!-- Hover overlay with smooth gradient (play affordance; hidden for
|
|
server-only cards, which can't be played offline) -->
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 {isServerOnly
|
|
? ''
|
|
: 'group-hover/card:opacity-100'} transition-opacity duration-300 flex items-center justify-center"
|
|
>
|
|
<div
|
|
class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300"
|
|
>
|
|
<div
|
|
class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl"
|
|
>
|
|
<svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress bar -->
|
|
{#if progress() > 0}
|
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
|
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Top-right status stack: played tick, then the favourite heart. Grouped
|
|
so the two never land on the same pixels when both apply. -->
|
|
{#if ("userData" in item && item.userData?.isPlayed) || showHeart}
|
|
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
|
|
{#if "userData" in item && item.userData?.isPlayed}
|
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
</svg>
|
|
{/if}
|
|
{#if showHeart}
|
|
<!-- Always visible on touch (no hover to reveal it); on pointer
|
|
devices an unfavourited heart stays out of the way until the card
|
|
is hovered or focused. A favourited one is always shown — it is
|
|
state, not an affordance. TRACES: UR-068 | DR-119 -->
|
|
<div
|
|
class="transition-opacity {isFavorited
|
|
? ''
|
|
: 'opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/card:opacity-100 [@media(hover:hover)]:group-focus-within/card:opacity-100'}"
|
|
>
|
|
<FavoriteButton
|
|
itemId={item.id}
|
|
isFavorite={isFavorited}
|
|
size="sm"
|
|
variant="overlay"
|
|
stopPropagation
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Download indicator -->
|
|
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
|
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
|
{#if isDownloaded}
|
|
<!-- Downloaded badge -->
|
|
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
|
<svg
|
|
class="w-4 h-4 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
|
</svg>
|
|
</div>
|
|
{:else if isDownloading}
|
|
<!-- Downloading progress -->
|
|
<div class="w-6 h-6 relative">
|
|
<svg class="w-6 h-6 -rotate-90" viewBox="0 0 24 24">
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="rgba(0,0,0,0.6)"
|
|
stroke="rgba(255,255,255,0.3)"
|
|
stroke-width="2"
|
|
/>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="none"
|
|
stroke="#3b82f6"
|
|
stroke-width="2"
|
|
stroke-dasharray={2 * Math.PI * 10}
|
|
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
|
|
stroke-linecap="round"
|
|
class="transition-all duration-300"
|
|
/>
|
|
</svg>
|
|
<svg
|
|
class="absolute inset-0 m-auto w-3 h-3 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Remove-from-device control (Downloaded surface), shown on hover/focus -->
|
|
{#if onRemove}
|
|
<button
|
|
type="button"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove?.();
|
|
}}
|
|
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
|
|
title="Remove from device"
|
|
aria-label="Remove {item.name} from device"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Container downloaded badge (Downloaded surface): full vs partial -->
|
|
{#if downloadedBadge}
|
|
<div
|
|
class="absolute bottom-2 right-2"
|
|
title={downloadedBadge === "full" ? "Fully downloaded" : "Partially downloaded"}
|
|
>
|
|
{#if downloadedBadge === "full"}
|
|
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
|
<svg
|
|
class="w-4 h-4 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg"
|
|
aria-label="Partially downloaded"
|
|
>
|
|
<svg
|
|
class="w-4 h-4 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Server-only: queue-for-download control (kept at full opacity over the
|
|
greyed artwork). Queued items show a "queued" badge instead. -->
|
|
{#if isServerOnly}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
{#if isQueued}
|
|
<div
|
|
class="flex flex-col items-center gap-1 text-white"
|
|
title="Queued — will download on reconnect"
|
|
>
|
|
<div
|
|
class="w-11 h-11 rounded-full bg-black/60 flex items-center justify-center shadow-lg"
|
|
>
|
|
<svg
|
|
class="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full"
|
|
>Queued</span
|
|
>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
onclick={queueForDownload}
|
|
class="w-11 h-11 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-lg transition-colors"
|
|
title="Queue download for next connection"
|
|
aria-label="Queue download for {item.name}"
|
|
>
|
|
<svg
|
|
class="w-6 h-6 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{#if queueError}
|
|
<div
|
|
class="absolute bottom-1 left-1 right-1 text-center text-[10px] text-red-200 bg-black/70 rounded px-1 py-0.5"
|
|
>
|
|
{queueError}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-2 space-y-0.5 {isServerOnly ? 'opacity-60' : ''}">
|
|
<p
|
|
class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors"
|
|
>
|
|
{truncateMiddle(item.name, 40)}
|
|
</p>
|
|
{#if subtitle()}
|
|
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
|
{/if}
|
|
{#if sizeLabel}
|
|
<p class="text-xs text-gray-500 truncate">{sizeLabel}</p>
|
|
{/if}
|
|
</div>
|
|
</svelte:element>
|