Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently.
426 lines
17 KiB
Svelte
426 lines
17 KiB
Svelte
<!-- TRACES: UR-051, UR-052, UR-068 | 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";
|
|
|
|
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;
|
|
}
|
|
|
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: 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) {
|
|
console.error("[MediaCard] 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 aspectRatio = $derived(() => {
|
|
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={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>
|