Replace the flat download list with a Downloaded browse surface that reuses the online grids/cards/detail pages, filtered to on-device media, plus a demoted Transfers tab. Add repository browse commands (getDownloadedLibraries/Items, disk usage) with offline/hybrid implementations, a downloadedCatalog service, formatBytes helper, and per-item/device disk-usage labels on cards and grids. Regenerated bindings. Also carries the inseparable UR-052 offline-filter hunks in offline.rs/hybrid.rs. TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
323 lines
13 KiB
Svelte
323 lines
13 KiB
Svelte
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
|
<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";
|
|
|
|
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;
|
|
}
|
|
|
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props();
|
|
|
|
// 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
|
|
);
|
|
|
|
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(
|
|
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
|
|
);
|
|
|
|
const aspectRatio = $derived(() => {
|
|
if ("type" in item) {
|
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
|
}
|
|
// Library
|
|
return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video";
|
|
});
|
|
|
|
const imageTag = $derived(
|
|
"primaryImageTag" in item ? item.primaryImageTag : ("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.runTimeTicks) {
|
|
return 0;
|
|
}
|
|
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 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'}"
|
|
onclick={isServerOnly ? undefined : onclick}
|
|
>
|
|
<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}
|
|
|
|
<!-- Played indicator -->
|
|
{#if "userData" in item && item.userData?.isPlayed}
|
|
<div class="absolute top-2 right-2">
|
|
<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>
|
|
</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>
|