Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):
- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
(CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
sessions.test.ts NowPlayingItem mock.
Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
176 lines
6.3 KiB
Svelte
176 lines
6.3 KiB
Svelte
<script lang="ts">
|
|
import type { MediaItem, Library } from "$lib/api/types";
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
item: MediaItem | Library;
|
|
size?: "small" | "medium" | "large";
|
|
showProgress?: boolean;
|
|
showDownloadStatus?: boolean;
|
|
onclick?: () => void;
|
|
}
|
|
|
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, 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);
|
|
|
|
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>
|
|
|
|
<button
|
|
type="button"
|
|
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 hover:scale-105"
|
|
{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"
|
|
/>
|
|
|
|
<!-- Hover overlay with smooth gradient -->
|
|
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 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}
|
|
</div>
|
|
|
|
<div class="mt-2 space-y-0.5">
|
|
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
|
{item.name}
|
|
</p>
|
|
{#if subtitle()}
|
|
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
|
{/if}
|
|
</div>
|
|
</button>
|