Files
jellytau/src/lib/components/library/MediaCard.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
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.
2026-08-21 17:41:44 +02:00

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>