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.
This commit is contained in:
2026-08-21 17:41:44 +02:00
parent d095e1f410
commit ad48d89dfe
199 changed files with 4698 additions and 3453 deletions
@@ -19,36 +19,23 @@
// Calculate download status for all tracks in album
const downloadStatuses = $derived(
tracks.map((track) =>
Object.values($downloads.downloads).find((d) => d.itemId === track.id)
)
tracks.map((track) => Object.values($downloads.downloads).find((d) => d.itemId === track.id)),
);
const completedCount = $derived(
downloadStatuses.filter((d) => d?.status === "completed").length
);
const completedCount = $derived(downloadStatuses.filter((d) => d?.status === "completed").length);
const downloadingCount = $derived(
downloadStatuses.filter(
(d) => d?.status === "downloading" || d?.status === "pending"
).length
downloadStatuses.filter((d) => d?.status === "downloading" || d?.status === "pending").length,
);
const failedCount = $derived(
downloadStatuses.filter((d) => d?.status === "failed").length
);
const failedCount = $derived(downloadStatuses.filter((d) => d?.status === "failed").length);
const totalProgress = $derived(() => {
if (tracks.length === 0) return 0;
const activeDownloads = downloadStatuses.filter(
(d) => d?.status === "downloading"
);
const activeDownloads = downloadStatuses.filter((d) => d?.status === "downloading");
if (activeDownloads.length === 0) return completedCount / tracks.length;
const downloadingProgress = activeDownloads.reduce(
(sum, d) => sum + (d?.progress || 0),
0
);
const downloadingProgress = activeDownloads.reduce((sum, d) => sum + (d?.progress || 0), 0);
return (completedCount + downloadingProgress) / tracks.length;
});
@@ -77,10 +64,7 @@
} else if (isDownloading) {
// Cancel all active downloads for this album
for (const status of downloadStatuses) {
if (
status?.id &&
(status.status === "downloading" || status.status === "pending")
) {
if (status?.id && (status.status === "downloading" || status.status === "pending")) {
await downloads.cancel(status.id);
}
}
@@ -184,13 +168,7 @@
</svg>
{:else if isFullyDownloaded}
<!-- Checkmark icon -->
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<svg class="w-5 h-5" 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>
{:else if failedCount > 0}
@@ -210,13 +188,7 @@
</svg>
{:else}
<!-- Download icon -->
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -88,7 +88,7 @@
// x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic.
lastClientX,
clientY
clientY,
);
const letter = el?.getAttribute?.("data-letter");
return letter ?? null;
@@ -135,7 +135,9 @@
disabled={!enabled}
onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
{enabled
? 'text-gray-400 hover:text-[var(--color-jellyfin)]'
: 'text-gray-700 cursor-default'}
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`}
>
@@ -46,9 +46,9 @@
includeItemTypes: ["MusicAlbum"],
limit: 50,
sortBy: "DateCreated",
sortOrder: "Descending"
sortOrder: "Descending",
});
albums = albumsResult.items.filter(item => item.kind === "album");
albums = albumsResult.items.filter((item) => item.kind === "album");
} catch (e) {
log.warn("Failed to load albums:", e);
} finally {
@@ -61,9 +61,9 @@
includeItemTypes: ["Audio"],
limit: 10,
sortBy: "CommunityRating",
sortOrder: "Descending"
sortOrder: "Descending",
});
topTracks = tracksResult.items.filter(item => item.kind === "track");
topTracks = tracksResult.items.filter((item) => item.kind === "track");
} catch (e) {
log.warn("Failed to load tracks:", e);
} finally {
@@ -78,10 +78,10 @@
genres: artist.genres.slice(0, 2),
limit: 12,
sortBy: "CommunityRating",
sortOrder: "Descending"
sortOrder: "Descending",
});
relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.kind === "artist")
.filter((item) => item.id !== artist.id && item.kind === "artist")
.slice(0, 6);
}
} catch (e) {
@@ -110,7 +110,9 @@
maxWidth={1920}
class="w-full h-full object-cover opacity-40"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"
></div>
</div>
{/if}
@@ -168,11 +170,10 @@
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each albums as album (album.id)}
<a
href="/library/{album.id}"
class="group cursor-pointer"
>
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
<a href="/library/{album.id}" class="group cursor-pointer">
<div
class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity"
>
{#if album.imageId}
<CachedImage
itemId={album.id}
@@ -184,7 +185,9 @@
/>
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(album.name, 40)}
</p>
{#if album.productionYear}
@@ -226,11 +229,10 @@
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each relatedArtists as relatedArtist (relatedArtist.id)}
<a
href="/library/{relatedArtist.id}"
class="group text-center"
>
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
<a href="/library/{relatedArtist.id}" class="group text-center">
<div
class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity"
>
{#if relatedArtist.imageId}
<CachedImage
itemId={relatedArtist.id}
@@ -242,7 +244,9 @@
/>
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{relatedArtist.name}
</p>
</a>
@@ -29,9 +29,7 @@
onNavigate,
}: Props = $props();
const linkable = $derived(
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
);
const linkable = $derived((artistItems ?? []).filter((a) => a.id && a.id.trim() !== ""));
function handleClick(artistId: string, e: MouseEvent) {
e.preventDefault();
@@ -95,7 +95,9 @@
</div>
<!-- Name and role -->
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{person.name}
</p>
{#if person.role}
@@ -43,7 +43,7 @@
!confirm(
`Erase watch history for ${subject}?\n\n` +
"Every episode is marked unwatched and resume positions are cleared. " +
"This cannot be undone."
"This cannot be undone.",
)
) {
return;
@@ -55,9 +55,7 @@
onCleared?.();
} catch (e) {
log.error("Failed to clear watch history:", e);
alert(
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
);
alert(`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`);
} finally {
busy = false;
}
@@ -81,11 +79,7 @@
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else}
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class={size === "lg" ? "w-5 h-5" : "w-4 h-4"} fill="currentColor" viewBox="0 0 24 24">
<path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
+7 -12
View File
@@ -5,27 +5,22 @@
interface Props {
people: Person[];
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
label?: string; // e.g., "Directed by", "Written by"
maxShow?: number; // Default: 3
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
label?: string; // e.g., "Directed by", "Written by"
maxShow?: number; // Default: 3
}
let {
people,
roleFilter,
label,
maxShow = 3
}: Props = $props();
let { people, roleFilter, label, maxShow = 3 }: Props = $props();
// Filter and limit people by role
const filteredPeople = $derived(
people
.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
.slice(0, maxShow)
.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
.slice(0, maxShow),
);
const totalMatching = $derived(
people.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length
people.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length,
);
function handlePersonClick(personId: string, e: MouseEvent) {
@@ -24,13 +24,20 @@
className?: string;
}
let { itemId, itemName = "", artistName = "", albumName = "", size = "md", className = "" }: Props = $props();
let {
itemId,
itemName = "",
artistName = "",
albumName = "",
size = "md",
className = "",
}: Props = $props();
let isProcessing = $state(false);
// Find download for this item
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
);
const status = $derived(downloadInfo?.status || "not_downloaded");
@@ -130,5 +137,12 @@
</script>
<div class="p-2 rounded-full">
<DownloadButtonCore {size} state={buttonState} title={getTitle()} onClick={handleClick} {isProcessing} {className} />
<DownloadButtonCore
{size}
state={buttonState}
title={getTitle()}
onClick={handleClick}
{isProcessing}
{className}
/>
</div>
@@ -22,7 +22,14 @@
className?: string;
}
let { state, size = "md", title, onClick, isProcessing = false, className = "" }: Props = $props();
let {
state,
size = "md",
title,
onClick,
isProcessing = false,
className = "",
}: Props = $props();
const sizeMap = {
sm: { icon: "w-4 h-4", ring: "w-8 h-8" },
@@ -46,13 +53,21 @@
onclick={onClick}
disabled={isProcessing || state.status === "downloading"}
aria-label={title}
title={title}
{title}
class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`}
>
{#if state.status === "downloading"}
<!-- Progress Ring -->
<svg class="{sizeMap[size].ring} -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="2" class="opacity-20" />
<circle
cx="18"
cy="18"
r="15"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-20"
/>
<circle
cx="18"
cy="18"
@@ -67,7 +82,13 @@
style="transition: stroke-dashoffset 0.3s ease;"
/>
<!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) -->
<text x="18" y="20" text-anchor="middle" transform="rotate(90, 18, 18)" class="text-xs font-bold fill-current">
<text
x="18"
y="20"
text-anchor="middle"
transform="rotate(90, 18, 18)"
class="text-xs font-bold fill-current"
>
{Math.round(state.progress * 100)}%
</text>
</svg>
@@ -79,7 +100,9 @@
{:else if state.status === "failed"}
<!-- Error Icon -->
<svg class={sizeMap[size].icon} fill="currentColor" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
/>
</svg>
{:else if state.status === "pending"}
<!-- Pending Icon (clock) -->
@@ -90,7 +113,12 @@
{:else}
<!-- Download Icon -->
<svg class={sizeMap[size].icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{/if}
</button>
@@ -53,13 +53,21 @@
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
if (episode.backdropImageTags?.[0]) {
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
return {
itemId: episode.id,
imageType: "Backdrop" as const,
tag: episode.backdropImageTags[0],
};
}
if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
}
if (series?.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
return {
itemId: series.id,
imageType: "Backdrop" as const,
tag: series.backdropImageTags[0],
};
}
return null;
});
@@ -67,8 +75,8 @@
// Cast and genres are the episode's own when the server sent them, else the
// series' — a list-level episode fetch often carries neither, and an empty
// Cast row on an episode of a show with a known cast reads as broken.
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
const people = $derived(episode.people?.length ? episode.people : (series?.people ?? []));
const genres = $derived(episode.genres?.length ? episode.genres : (series?.genres ?? []));
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
// off the series rather than the episode.
@@ -77,7 +85,7 @@
const seasonHref = $derived(
series && episode.parentIndexNumber != null
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
: null
: null,
);
function formatDuration(ms?: number | null): string {
@@ -108,9 +116,7 @@
goto(`/library/${series.id}?episode=${ep.id}`);
}
const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode));
</script>
@@ -128,12 +134,16 @@
class="absolute inset-0 w-full h-full object-cover"
/>
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
<div
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"
></div>
{/if}
<!-- Gradient overlay -->
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"
></div>
<!-- Back button -->
{#if onBack}
@@ -143,7 +153,12 @@
title="Back to series"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
@@ -156,7 +171,10 @@
{#if seriesName}
<p class="text-lg">
{#if seriesHref}
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
<a
href={seriesHref}
class="text-gray-300 hover:text-white hover:underline transition-colors"
>
{seriesName}
</a>
{:else}
@@ -192,7 +210,9 @@
{#if episode.communityRating}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
{episode.communityRating.toFixed(1)}
</span>
@@ -200,7 +220,7 @@
{#if episode.userData?.isPlayed}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" 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"/>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
Watched
</span>
@@ -218,10 +238,7 @@
{#if progress > 0 && progress < 95}
<div class="w-64">
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
></div>
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">
{Math.round(progress)}% watched
@@ -237,7 +254,7 @@
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
@@ -271,89 +288,106 @@
strip — continuation content comes before discovery content
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
{#if hasEpisodeStrip}
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
disabled={isCurrent}
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
/>
<div
class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"
>
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent
? 'ring-2 ring-yellow-400 rounded-lg'
: ''}"
disabled={isCurrent}
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent
? ''
: 'group-hover/card:scale-105'}"
/>
<!-- Hover overlay -->
{#if !isCurrent}
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<!-- Hover overlay -->
{#if !isCurrent}
<div
class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center"
>
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div
class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
>
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
</div>
{/if}
{/if}
<!-- Now Playing indicator -->
{#if isCurrent}
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
Current
</div>
{/if}
<!-- Progress bar -->
{#if epProgress > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<!-- Now Playing indicator -->
{#if isCurrent}
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {epProgress}%"
></div>
</div>
{/if}
class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold"
>
Current
</div>
{/if}
<!-- Played indicator -->
{#if ep.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}
</div>
<!-- Progress bar -->
{#if epProgress > 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: {epProgress}%"></div>
</div>
{/if}
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
</p>
<!-- Played indicator -->
{#if ep.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}
</div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2">
{ep.overview}
</p>
{/if}
</div>
</button>
{/each}
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p
class="text-white font-medium truncate {isCurrent
? 'text-yellow-400'
: 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors"
>
{ep.name}
</p>
</div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2">
{ep.overview}
</p>
{/if}
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
@@ -45,9 +45,8 @@ vi.mock("$lib/stores/downloads", () => ({
}));
vi.mock("$lib/stores/favorites", async () => {
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>(
"$lib/stores/favorites"
);
const actual =
await vi.importActual<typeof import("$lib/stores/favorites")>("$lib/stores/favorites");
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
});
+30 -21
View File
@@ -22,13 +22,7 @@
onWatchedChanged?: () => void;
}
let {
episode,
focused = false,
current = false,
onclick,
onWatchedChanged,
}: Props = $props();
let { episode, focused = false, current = false, onclick, onWatchedChanged }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
@@ -43,12 +37,12 @@
// Check if this episode is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === episode.id)
Object.values($downloads.downloads).find((d) => d.itemId === episode.id),
);
const isDownloaded = $derived(downloadInfo?.status === "completed");
const isDownloading = $derived(
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
@@ -74,7 +68,9 @@
{onclick}
>
<!-- Thumbnail -->
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<div
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
>
<CachedImage
itemId={episode.id}
imageType="Primary"
@@ -85,11 +81,15 @@
/>
<!-- Hover overlay with play icon -->
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
<div
class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center"
>
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
<div class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<div
class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
>
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
@@ -98,10 +98,7 @@
<!-- 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 class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
@@ -110,7 +107,13 @@
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
{#if isDownloaded}
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class="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>
@@ -153,7 +156,9 @@
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
{episodeNumber}.
</span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
<h3
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
@@ -165,8 +170,12 @@
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 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
class="w-4 h-4 flex-shrink-0 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}
</div>
@@ -72,9 +72,7 @@
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) {
const match = genres.find(
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
const match = genres.find((g) => g.name.toLowerCase() === requestedGenre.toLowerCase());
if (match) {
await loadGenreItems(match);
}
@@ -157,14 +155,21 @@
}
}
const aspectRatioClass = $derived(config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square");
const aspectRatioClass = $derived(
config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square",
);
const gridColsClass = $derived(
config.itemDisplayMode === "poster"
? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6",
);
const searchPlaceholder = $derived(
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
);
const noItemsMessage = $derived(
config.noItemsMessage ||
`No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`,
);
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
const noItemsMessage = $derived(config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`);
</script>
<div class="space-y-6">
@@ -188,7 +193,7 @@
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
{#if !loading && filteredGenres.length > 0}
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} />
<ResultsCounter count={filteredGenres.length} itemType="genre" {searchQuery} />
{/if}
<!-- Genres Grid -->
@@ -220,7 +225,9 @@
{@html config.genreIcon}
</svg>
</div>
<p class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{genre.name}
</p>
</button>
@@ -244,11 +251,16 @@
</div>
{:else}
<div>
<ResultsCounter count={genreItems.length} itemType={config.itemTypes[0]?.toLowerCase() || "item"} />
<ResultsCounter
count={genreItems.length}
itemType={config.itemTypes[0]?.toLowerCase() || "item"}
/>
<div class="grid {gridColsClass} gap-4 mt-4">
{#each genreItems as item (item.id)}
<button onclick={() => handleItemClick(item)} class="group text-left">
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
<div
class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2"
>
<CachedImage
itemId={item.id}
imageType="Primary"
@@ -258,7 +270,9 @@
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
</div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(item.name, 40)}
</p>
{#if item.productionYear}
@@ -98,9 +98,7 @@ describe("GenericMediaListPage — two-phase search", () => {
await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull());
// Cache-only phase: nothing to show yet (the results counter reads zero).
await waitFor(() =>
expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()
);
await waitFor(() => expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy());
// Phase 2: backend emits the merged cache+server union for this request.
expect(capturedRequestId).toBeTypeOf("number");
@@ -116,9 +114,7 @@ describe("GenericMediaListPage — two-phase search", () => {
// The server result must now be reflected in the list. Old code (no
// listener) never reached this state — the count stayed at zero.
await waitFor(() =>
expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()
);
await waitFor(() => expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy());
});
it("ignores a search-event whose requestId is stale", async () => {
@@ -135,7 +135,7 @@
includeItemTypes: [config.itemType],
limit: 10000,
},
requestId
requestId,
);
// Only apply if this is still the active query.
if (requestId === searchRequestId) {
@@ -204,7 +204,9 @@
navigateUp(config.backPath);
}
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
const searchPlaceholder = $derived(
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
);
function handleItemClick(item: MediaItem | Library) {
// Navigate to detail page for browseable items
@@ -230,10 +232,7 @@
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
const isAlphaSorted = $derived(sortBy === "SortName");
const showAlphaBar = $derived(
isAlphaSorted &&
!loading &&
!debouncedSearchQuery.trim() &&
items.length > 30
isAlphaSorted && !loading && !debouncedSearchQuery.trim() && items.length > 30,
);
const availableLetters = $derived.by(() => {
@@ -264,9 +263,7 @@
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived(
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
);
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
</script>
<div class="space-y-6">
@@ -295,8 +292,8 @@
aria-pressed={favoritesOnly}
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
{favoritesOnly
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
>
<svg
@@ -306,7 +303,11 @@
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
Favourites
</button>
@@ -320,7 +321,7 @@
<!-- Results Count -->
{#if !loading}
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} {searchQuery} />
{/if}
<!-- Items List/Grid -->
@@ -349,7 +350,13 @@
<div class="flex gap-2">
<div bind:this={gridWrapper} class="flex-1 min-w-0">
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
<LibraryGrid
{items}
onItemClick={handleItemClick}
musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(
config.itemType,
)}
/>
{:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
@@ -200,7 +200,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -231,7 +231,7 @@ describe("GenericMediaListPage", () => {
includeItemTypes: ["Audio"],
limit: 10000,
}),
expect.any(Number)
expect.any(Number),
);
});
});
@@ -248,7 +248,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -266,11 +266,14 @@ describe("GenericMediaListPage", () => {
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
includeItemTypes: ["Audio"],
sortBy: "SortName",
sortOrder: "Ascending",
}));
expect(mockGetItemsFn).toHaveBeenCalledWith(
"lib123",
expect.objectContaining({
includeItemTypes: ["Audio"],
sortBy: "SortName",
sortOrder: "Ascending",
}),
);
});
});
@@ -293,7 +296,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -342,7 +345,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -363,10 +366,13 @@ describe("GenericMediaListPage", () => {
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
sortBy: "SortName",
sortOrder: "Ascending",
}));
expect(mockGetItemsFn).toHaveBeenCalledWith(
"lib123",
expect.objectContaining({
sortBy: "SortName",
sortOrder: "Ascending",
}),
);
});
});
@@ -382,7 +388,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -428,7 +434,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -446,9 +452,12 @@ describe("GenericMediaListPage", () => {
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
includeItemTypes: ["Audio"],
}));
expect(mockGetItemsFn).toHaveBeenCalledWith(
"lib123",
expect.objectContaining({
includeItemTypes: ["Audio"],
}),
);
});
});
@@ -469,7 +478,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -497,7 +506,7 @@ describe("GenericMediaListPage", () => {
expect.objectContaining({
includeItemTypes: ["MusicAlbum"],
}),
expect.any(Number)
expect.any(Number),
);
});
});
@@ -506,10 +515,10 @@ describe("GenericMediaListPage", () => {
describe("Loading State", () => {
it("should show loading indicator during data fetch", async () => {
const mockGetItemsFn = vi.fn(
() => new Promise((resolve) => setTimeout(
() => resolve({ items: [], totalRecordCount: 0 }),
100
))
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ items: [], totalRecordCount: 0 }), 100),
),
);
const mockRepository = {
@@ -518,7 +527,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -554,7 +563,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -589,7 +598,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
// Deliver a null current library for this test only.
@@ -38,8 +38,8 @@
onclick={() => handleToggleGenre(genre.name)}
class="px-3 py-1 rounded-full text-sm transition-colors
{isSelected
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
>
{genre.name}
</button>
+9 -16
View File
@@ -6,17 +6,12 @@
interface Props {
genres: string[];
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemKind?: MediaKind; // Determines which genre browse page to open
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemKind?: MediaKind; // Determines which genre browse page to open
}
let {
genres,
maxShow,
clickable = true,
itemKind
}: Props = $props();
let { genres, maxShow, clickable = true, itemKind }: Props = $props();
// Map the item kind to its genre-browse surface. Video genres are a tab of
// the library page now, not a route of their own (DR-105); linking straight
@@ -39,13 +34,9 @@
}
}
const displayGenres = $derived(
maxShow ? genres.slice(0, maxShow) : genres
);
const displayGenres = $derived(maxShow ? genres.slice(0, maxShow) : genres);
const hiddenCount = $derived(
maxShow && genres.length > maxShow ? genres.length - maxShow : 0
);
const hiddenCount = $derived(maxShow && genres.length > maxShow ? genres.length - maxShow : 0);
function handleGenreClick(genre: string) {
if (clickable) {
@@ -60,7 +51,9 @@
<button
onclick={() => handleGenreClick(genre)}
disabled={!clickable}
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable ? 'hover:bg-[var(--color-surface-hover)] cursor-pointer' : 'cursor-default'}"
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable
? 'hover:bg-[var(--color-surface-hover)] cursor-pointer'
: 'cursor-default'}"
>
{genre}
</button>
+25 -7
View File
@@ -23,7 +23,17 @@
onItemRemove?: (item: MediaItem | Library) => void;
}
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
let {
items,
title,
loading = false,
showViewToggle = true,
musicContent = false,
onItemClick,
sizeLabelFor,
downloadedBadgeFor,
onItemRemove,
}: Props = $props();
</script>
<div class="space-y-4">
@@ -38,22 +48,26 @@
<div class="flex gap-1">
<button
onclick={() => library.setViewMode("grid")}
class="p-2 rounded transition-colors {$viewMode === 'grid' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
class="p-2 rounded transition-colors {$viewMode === 'grid'
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:bg-white/10 hover:text-white'}"
aria-label="Grid view"
title="Grid view"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/>
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
</svg>
</button>
<button
onclick={() => library.setViewMode("list")}
class="p-2 rounded transition-colors {$viewMode === 'list' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
class="p-2 rounded transition-colors {$viewMode === 'list'
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:bg-white/10 hover:text-white'}"
aria-label="List view"
title="List view"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/>
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z" />
</svg>
</button>
</div>
@@ -64,7 +78,11 @@
<div class="flex gap-4 overflow-hidden">
{#each Array(6) as _}
<div class="w-36 flex-shrink-0 animate-pulse">
<div class="{musicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"></div>
<div
class="{musicContent
? 'aspect-square'
: 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"
></div>
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
<div class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
</div>
@@ -75,7 +93,7 @@
<p>No items found</p>
</div>
{:else if $viewMode === "list"}
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
<LibraryListView {items} showProgress={true} {onItemClick} />
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each items as item, index (item.id)}
@@ -19,7 +19,11 @@
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
return "imageId" in item
? (item.imageId ?? undefined)
: "imageTag" in item
? (item.imageTag ?? undefined)
: undefined;
}
function getSubtitle(item: MediaItem | Library): string {
@@ -31,7 +35,9 @@
case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode":
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
return item.seriesName
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
: "";
case "Movie":
case "Series":
return item.productionYear?.toString() || "";
@@ -40,9 +46,14 @@
}
}
function getProgress(item: MediaItem | Library): number {
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
if (
!showProgress ||
!("userData" in item) ||
!item.userData ||
!("durationMs" in item) ||
!item.durationMs
) {
return 0;
}
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
@@ -65,7 +76,8 @@
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
{@const downloadInfo = getDownloadInfo(item.id)}
{@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
{@const isDownloading =
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
<button
type="button"
@@ -79,7 +91,9 @@
</span>
<!-- Thumbnail -->
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
<div
class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative"
>
<CachedImage
itemId={item.id}
imageType="Primary"
@@ -90,9 +104,11 @@
/>
<!-- Play overlay on hover -->
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
@@ -106,7 +122,9 @@
<!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(item.name, 56)}
</p>
{#if subtitle}
@@ -118,19 +136,41 @@
{#if showDownloadStatus && (isDownloaded || isDownloading)}
{#if isDownloaded}
<span title="Downloaded">
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-label="Downloaded">
<svg
class="w-4 h-4 text-green-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
aria-label="Downloaded"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</span>
{:else if isDownloading}
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3" class="text-blue-500" />
<circle
cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
opacity="0.3"
class="text-blue-500"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))}
stroke-linecap="round" class="text-blue-500 transition-all duration-300"
stroke-linecap="round"
class="text-blue-500 transition-all duration-300"
/>
</svg>
</div>
@@ -139,8 +179,12 @@
<!-- Played indicator -->
{#if isPlayed}
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" 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
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
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}
@@ -35,8 +35,8 @@
aria-current={view === active ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{view === active
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{labels[view]}
</button>
+135 -39
View File
@@ -57,7 +57,19 @@
aspect?: "square" | "video" | "poster";
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
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
@@ -114,12 +126,12 @@
// Check if this item is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
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"
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
@@ -135,14 +147,14 @@
// 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
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
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false,
);
let queueError = $state<string | null>(null);
@@ -171,7 +183,7 @@
undefined,
media.name,
media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined
media.albumName ?? undefined,
);
} catch (err) {
log.error("Failed to queue download:", err);
@@ -186,7 +198,11 @@
};
const isMusicType = $derived(
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
"kind" in item &&
(item.kind === "track" ||
item.kind === "album" ||
item.kind === "artist" ||
item.kind === "playlist"),
);
const FIXED_ASPECT = {
@@ -201,11 +217,13 @@
return isMusicType ? "aspect-square" : "aspect-[2/3]";
}
// Library
return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video";
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)
"imageId" in item ? item.imageId : "imageTag" in item ? item.imageTag : undefined,
);
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
@@ -226,7 +244,9 @@
case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode":
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
return item.seriesName
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
: "";
case "Movie":
return item.productionYear?.toString() || "";
case "Series":
@@ -241,7 +261,9 @@
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'}"
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}
@@ -250,23 +272,35 @@
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">
<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}
{maxWidth}
alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly ? 'opacity-40 grayscale' : ''}"
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">
<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"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
@@ -275,10 +309,7 @@
<!-- 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 class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
@@ -288,7 +319,7 @@
<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"/>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
{#if showHeart}
@@ -319,7 +350,13 @@
{#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">
<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>
@@ -348,7 +385,13 @@
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">
<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>
@@ -360,13 +403,20 @@
{#if onRemove}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
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" />
<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}
@@ -379,13 +429,28 @@
>
{#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">
<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">
<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>
@@ -398,13 +463,30 @@
{#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" />
<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>
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full"
>Queued</span
>
</div>
{:else}
<button
@@ -414,14 +496,26 @@
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
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">
<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}
@@ -429,7 +523,9 @@
</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">
<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()}
+4 -7
View File
@@ -16,12 +16,7 @@
<script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte";
import { onDestroy } from "svelte";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicTile,
} from "./mosaic";
import { layoutMosaic, layoutMosaicStrip, mosaicTargetHeight, type MosaicTile } from "./mosaic";
interface Props {
items: T[];
@@ -67,7 +62,9 @@
});
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
const sized = $derived(
items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })),
);
const rows = $derived(
layout === "strip"
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
+6 -2
View File
@@ -82,8 +82,12 @@
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
<div
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5"
>
<p
class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors"
>
{label}
</p>
</div>
@@ -34,8 +34,8 @@
});
// Separate movies and series
movies = result.items.filter(item => item.kind === "movie");
series = result.items.filter(item => item.kind === "series");
movies = result.items.filter((item) => item.kind === "movie");
series = result.items.filter((item) => item.kind === "series");
} catch (e) {
log.error("Failed to load filmography:", e);
} finally {
@@ -27,11 +27,9 @@
let showDeleteConfirm = $state(false);
// Extract MediaItem[] from PlaylistEntry[] for TrackList
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const tracks = $derived(entries.map((e) => ({ ...e }) as MediaItem));
const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
);
const totalDuration = $derived(entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0));
onMount(() => {
loadPlaylistItems();
@@ -53,7 +51,7 @@
async function handlePlayAll() {
if (entries.length === 0) return;
try {
const trackIds = entries.map(e => e.id);
const trackIds = entries.map((e) => e.id);
await playerController.playTracks({
trackIds,
startIndex: 0,
@@ -73,7 +71,7 @@
async function handleShufflePlay() {
if (entries.length === 0) return;
try {
const trackIds = entries.map(e => e.id);
const trackIds = entries.map((e) => e.id);
await playerController.playTracks({
trackIds,
startIndex: 0,
@@ -129,7 +127,7 @@
try {
const repo = auth.getRepository();
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
entries = entries.filter((e) => e.playlistItemId !== entry.playlistItemId);
toast.success("Track removed");
} catch (e) {
log.error("Failed to remove track:", e);
@@ -161,9 +159,13 @@
class="w-full rounded-lg shadow-lg"
/>
{:else}
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
<div
class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center"
>
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
</div>
{/if}
@@ -183,7 +185,10 @@
{:else}
<button
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
onclick={() => { editingName = true; editName = playlist.name; }}
onclick={() => {
editingName = true;
editName = playlist.name;
}}
title="Click to rename"
>
{playlist.name}
@@ -205,7 +210,7 @@
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
Play All
</button>
@@ -215,7 +220,9 @@
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
<path
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
/>
</svg>
Shuffle
</button>
@@ -227,11 +234,13 @@
className="self-center"
/>
<button
onclick={() => showDeleteConfirm = true}
onclick={() => (showDeleteConfirm = true)}
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
<path
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
/>
</svg>
Delete
</button>
@@ -264,7 +273,7 @@
title="Remove from playlist"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z"/>
<path d="M19 13H5v-2h14v2z" />
</svg>
</button>
</div>
@@ -278,8 +287,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={() => showDeleteConfirm = false}
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }}
onclick={() => (showDeleteConfirm = false)}
onkeydown={(e) => {
if (e.key === "Escape") showDeleteConfirm = false;
}}
role="dialog"
aria-modal="true"
tabindex="-1"
@@ -296,7 +307,7 @@
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => showDeleteConfirm = false}
onclick={() => (showDeleteConfirm = false)}
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
@@ -23,7 +23,7 @@
genres = [],
people = [],
artistIds = [],
limit = 12
limit = 12,
}: Props = $props();
let relatedItems = $state<MediaItem[]>([]);
@@ -53,7 +53,7 @@
if (itemKind === "movie" || itemKind === "series") {
try {
const result = await repo.getSimilarItems(currentItemId, limit);
items = result.items.filter(item => item.id !== currentItemId);
items = result.items.filter((item) => item.id !== currentItemId);
if (items.length > 0) {
relatedItems = items.slice(0, limit);
@@ -72,14 +72,18 @@
// maps the neutral kind to the concrete Jellyfin item type it needs.
const searchTerm = genres[0];
const itemTypeForKind: Record<string, string> = {
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
movie: "Movie",
series: "Series",
album: "MusicAlbum",
track: "Audio",
artist: "MusicArtist",
};
const result = await repo.search(searchTerm, {
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
limit: limit * 2
limit: limit * 2,
});
items = result.items.filter(item => item.id !== currentItemId);
items = result.items.filter((item) => item.id !== currentItemId);
} catch (e) {
log.warn("Failed to load related items by genre:", e);
}
@@ -91,10 +95,10 @@
// Search for other albums by artist name from first artist
const result = await repo.search(artistIds[0], {
includeItemTypes: ["MusicAlbum"],
limit: limit * 2
limit: limit * 2,
});
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
const artistAlbums = result.items.filter((item) => item.id !== currentItemId);
items = [...items, ...artistAlbums];
} catch (e) {
log.warn("Failed to load albums by artist:", e);
@@ -102,9 +106,10 @@
}
// Remove duplicates and limit results
const uniqueItems = Array.from(
new Map(items.map(item => [item.id, item])).values()
).slice(0, limit);
const uniqueItems = Array.from(new Map(items.map((item) => [item.id, item])).values()).slice(
0,
limit,
);
relatedItems = uniqueItems;
} catch (e) {
@@ -144,7 +149,11 @@
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
{#each Array(6) as _}
<div class="animate-pulse">
<div class="{isMusicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"></div>
<div
class="{isMusicContent
? 'aspect-square'
: 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"
></div>
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
</div>
@@ -17,25 +17,28 @@
size?: "sm" | "md" | "lg";
}
let { seasonId, seriesName, seasonName, seasonNumber, episodeCount, className = "", size = "md" }: Props = $props();
let {
seasonId,
seriesName,
seasonName,
seasonNumber,
episodeCount,
className = "",
size = "md",
}: Props = $props();
let isProcessing = $state(false);
let showQualityPicker = $state(false);
// Count downloads for this season
const seasonDownloads = $derived(
$videoDownloads.filter((d) =>
d.seriesName === seriesName &&
d.seasonName === seasonName
)
$videoDownloads.filter((d) => d.seriesName === seriesName && d.seasonName === seasonName),
);
const completedCount = $derived(
seasonDownloads.filter((d) => d.status === "completed").length
);
const completedCount = $derived(seasonDownloads.filter((d) => d.status === "completed").length);
const inProgressCount = $derived(
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length,
);
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
@@ -67,7 +70,7 @@
seasonNumber,
userId,
basePath,
quality
quality,
);
log.debug(`✅ Queued ${downloadIds.length} episodes for download`);
@@ -102,7 +105,9 @@
return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`;
}
if (completedCount > 0) {
return size === "sm" ? `${completedCount}/${episodeCount}` : `Download (${completedCount}/${episodeCount})`;
return size === "sm"
? `${completedCount}/${episodeCount}`
: `Download (${completedCount}/${episodeCount})`;
}
return size === "sm" ? "⬇" : "Download Season";
}
@@ -118,39 +123,49 @@
}
const sizeClasses = $derived(
size === "sm" ? "px-2 py-1 text-xs" :
size === "lg" ? "px-6 py-3 text-base" :
"px-4 py-2 text-sm"
size === "sm"
? "px-2 py-1 text-xs"
: size === "lg"
? "px-6 py-3 text-base"
: "px-4 py-2 text-sm",
);
const iconSize = $derived(
size === "sm" ? "w-3 h-3" :
size === "lg" ? "w-6 h-6" :
"w-4 h-4"
);
const iconSize = $derived(size === "sm" ? "w-3 h-3" : size === "lg" ? "w-6 h-6" : "w-4 h-4");
</script>
<div class="relative {className}">
<button
onclick={handleClick}
disabled={isProcessing || allDownloaded}
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing ||
allDownloaded
? 'opacity-70 cursor-not-allowed'
: ''}"
>
{#if inProgressCount > 0}
<!-- Spinner -->
<svg class="{iconSize} animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if allDownloaded}
<!-- Checkmark -->
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else}
<!-- Download icon -->
<svg class="{iconSize}" 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-4m-9 7h10" />
<svg class={iconSize} 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-4m-9 7h10"
/>
</svg>
{/if}
{#if size !== "sm"}
@@ -162,7 +177,9 @@
<!-- Quality picker dropdown -->
{#if showQualityPicker}
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
<div
class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
>
<div class="p-3 border-b border-gray-700">
<div class="text-sm font-medium text-white">Download Quality</div>
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</div>
@@ -174,14 +191,16 @@
>
<span class="text-sm text-white">{preset.label}</span>
{#if preset.videoBitrate}
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
<span class="text-xs text-gray-500"
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
>
{:else}
<span class="text-xs text-gray-500">Direct</span>
{/if}
</button>
{/each}
<button
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
@@ -194,7 +213,7 @@
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker"
></button>
{/if}
@@ -37,14 +37,14 @@
}: Props = $props();
const holdsCurrentEpisode = $derived(
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId),
);
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived(
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season"),
);
// Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list.
@@ -55,7 +55,9 @@
<!-- Season header -->
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster -->
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
<div
class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]"
>
<CachedImage
itemId={season.id}
imageType="Primary"
@@ -135,7 +137,7 @@
<SeasonDownloadButton
seasonId={season.id}
seriesName={season.seriesName || ""}
seasonName={seasonName}
{seasonName}
seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
{episodeCount}
size="sm"
@@ -20,16 +20,12 @@
let showQualityPicker = $state(false);
// Count downloads for this series
const seriesDownloads = $derived(
$videoDownloads.filter((d) => d.seriesName === seriesName)
);
const seriesDownloads = $derived($videoDownloads.filter((d) => d.seriesName === seriesName));
const completedCount = $derived(
seriesDownloads.filter((d) => d.status === "completed").length
);
const completedCount = $derived(seriesDownloads.filter((d) => d.status === "completed").length);
const inProgressCount = $derived(
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length,
);
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
@@ -59,7 +55,7 @@
seriesName,
userId,
basePath,
quality
quality,
);
log.debug(` Queued ${downloadIds.length} episodes for download`);
@@ -113,13 +109,21 @@
<button
onclick={handleClick}
disabled={isProcessing || allDownloaded}
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing ||
allDownloaded
? 'opacity-70 cursor-not-allowed'
: ''}"
>
{#if inProgressCount > 0}
<!-- Spinner -->
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if allDownloaded}
<!-- Checkmark -->
@@ -129,7 +133,11 @@
{:else}
<!-- Download icon -->
<svg class="w-5 h-5" 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-4m-9 7h10" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
/>
</svg>
{/if}
<span>{getButtonText()}</span>
@@ -137,7 +145,9 @@
<!-- Quality picker dropdown -->
{#if showQualityPicker}
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
<div
class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
>
<div class="p-3 border-b border-gray-700">
<div class="text-sm font-medium text-white">Download Quality</div>
{#if episodeCount}
@@ -151,14 +161,16 @@
>
<span class="text-sm text-white">{preset.label}</span>
{#if preset.videoBitrate}
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
<span class="text-xs text-gray-500"
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
>
{:else}
<span class="text-xs text-gray-500">Direct</span>
{/if}
</button>
{/each}
<button
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
@@ -171,7 +183,7 @@
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker"
></button>
{/if}
@@ -84,7 +84,7 @@ describe("TrackList Logic Tests", () => {
mediaType: "Audio",
streamUrl: await repo.getAudioStreamUrl(t.id),
jellyfinItemId: t.id,
}))
})),
);
expect(queueItems).toHaveLength(2);
@@ -110,7 +110,7 @@ describe("TrackList Logic Tests", () => {
id: t.id,
streamUrl,
};
})
}),
);
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2);
@@ -279,7 +279,7 @@ describe("TrackList Logic Tests", () => {
startIndex: 0,
shuffle: false,
},
})
}),
).rejects.toThrow("Network error");
});
});
+61 -26
View File
@@ -40,7 +40,7 @@
showArtist = true,
showDownload = false,
context,
onTrackClick
onTrackClick,
}: Props = $props();
let isPlayingTrack = $state<string | null>(null);
@@ -93,7 +93,7 @@
// Queue will auto-update from Rust backend event
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
const errorMessage = e instanceof Error ? e.message : "Unknown error";
log.error("Failed to play track:", errorMessage);
toast.error(`Failed to play track: ${errorMessage}`, 5000);
} finally {
@@ -110,7 +110,6 @@
}
}
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
e.stopPropagation();
@@ -158,9 +157,7 @@
{#if loading}
<div class="space-y-2">
{#each Array(10) as _}
<div
class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4"
>
<div class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4">
<div class="w-12 h-12 bg-gray-700 rounded"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-700 rounded w-1/3"></div>
@@ -199,7 +196,13 @@
<!-- Track Rows -->
<div class="space-y-1">
{#each tracks as track, index (track.id)}
<div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<div
data-grid-index={index}
class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId ===
track.id
? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]'
: ''}"
>
<!-- Desktop View -->
<button
onclick={() => handleTrackClick(track, index)}
@@ -212,7 +215,9 @@
<!-- Index/Play Button -->
<div class="w-12 flex items-center justify-center">
{#if isPlayingTrack === track.id}
<div class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<div
class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
{:else}
<span class="group-hover:hidden text-gray-400">{index + 1}</span>
<svg
@@ -230,12 +235,21 @@
{#if currentlyPlayingId === track.id}
<div class="flex flex-col items-center justify-center">
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"></div>
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 150ms"></div>
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 300ms"></div>
<div
class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"
style="animation-delay: 150ms"
></div>
<div
class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"
style="animation-delay: 300ms"
></div>
</div>
{/if}
<span
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId ===
track.id
? 'text-[var(--color-jellyfin)]'
: ''}"
>
{truncateMiddle(track.name, 48)}
</span>
@@ -250,7 +264,7 @@
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
>
{artist.name}
@@ -273,7 +287,7 @@
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
>
{track.albumName || "-"}
@@ -317,7 +331,9 @@
aria-label="More options"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
@@ -332,7 +348,9 @@
<!-- Track Number -->
<div class="w-8 flex-shrink-0 text-center">
{#if isPlayingTrack === track.id}
<div class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"></div>
<div
class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"
></div>
{:else}
<span class="group-hover:hidden text-gray-400 text-sm">{index + 1}</span>
<svg
@@ -346,7 +364,10 @@
</div>
<div class="flex-1 min-w-0">
<p
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId ===
track.id
? 'text-[var(--color-jellyfin)]'
: ''}"
>
{#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span>
@@ -361,7 +382,7 @@
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{artist.name}
@@ -379,7 +400,7 @@
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{track.albumName || "-"}
@@ -394,7 +415,7 @@
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{artist.name}
@@ -412,7 +433,7 @@
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{track.albumName || "-"}
@@ -447,7 +468,9 @@
aria-label="More options"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
@@ -459,7 +482,7 @@
<!-- Portal Menu (rendered at document.body to avoid overflow clipping) -->
{#if openMenuId && menuPosition}
{@const selectedTrack = tracks.find(t => t.id === openMenuId)}
{@const selectedTrack = tracks.find((t) => t.id === openMenuId)}
{#if selectedTrack}
<Portal>
<div
@@ -472,7 +495,12 @@
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Play Next
</button>
@@ -482,7 +510,12 @@
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
Add to Queue
</button>
@@ -496,7 +529,9 @@
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
Add to Playlist
</button>
@@ -508,7 +543,7 @@
<!-- Add to Playlist Modal -->
<AddToPlaylistModal
isOpen={addToPlaylistTrackId !== null}
onClose={() => addToPlaylistTrackId = null}
onClose={() => (addToPlaylistTrackId = null)}
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
/>
+56 -36
View File
@@ -28,7 +28,12 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(),
addToQueue: vi.fn(),
},
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
currentQueueItem: {
subscribe: vi.fn((fn: any) => {
fn(null);
return () => {};
}),
},
}));
vi.mock("./DownloadButton.svelte", () => ({
@@ -42,10 +47,30 @@ vi.mock("$lib/stores/library", () => ({
loadItem: vi.fn(),
setCurrentLibrary: vi.fn(),
},
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) },
libraries: {
subscribe: vi.fn((fn: any) => {
fn([]);
return () => {};
}),
},
libraryItems: {
subscribe: vi.fn((fn: any) => {
fn([]);
return () => {};
}),
},
currentLibrary: {
subscribe: vi.fn((fn: any) => {
fn(null);
return () => {};
}),
},
isLibraryLoading: {
subscribe: vi.fn((fn: any) => {
fn(false);
return () => {};
}),
},
}));
// Now import the modules after mocks are set up
@@ -120,7 +145,9 @@ describe("TrackList", () => {
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
expect(getAllByText("Song 2").length).toBeGreaterThan(0);
// Long names are abbreviated in the middle via truncateMiddle(name, 48).
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(0);
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(
0,
);
});
it("shows loading skeleton when loading=true", () => {
@@ -234,7 +261,7 @@ describe("TrackList", () => {
// Find and click the first track button
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
expect(firstTrackButton).toBeTruthy();
@@ -250,7 +277,7 @@ describe("TrackList", () => {
startIndex: 0,
shuffle: false,
}),
})
}),
);
});
});
@@ -261,7 +288,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2")
btn.textContent?.includes("Song 2"),
);
await fireEvent.click(secondTrackButton!);
@@ -278,7 +305,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const thirdTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3")
btn.textContent?.includes("Song 3"),
);
await fireEvent.click(thirdTrackButton!);
@@ -297,7 +324,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -305,7 +332,7 @@ describe("TrackList", () => {
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
expect.anything(),
);
});
@@ -322,7 +349,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -330,7 +357,7 @@ describe("TrackList", () => {
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
expect.anything(),
);
});
@@ -339,7 +366,6 @@ describe("TrackList", () => {
// Restore mock for other tests
(auth.getRepository as any).mockReturnValue(mockRepository as any);
});
});
describe("Custom Callback Tests", () => {
@@ -351,7 +377,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -363,7 +389,7 @@ describe("TrackList", () => {
it("does not call player_play_queue when custom callback provided", async () => {
const onTrackClick = vi.fn();
const invokeMock = (invoke as any);
const invokeMock = invoke as any;
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
@@ -371,7 +397,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -391,7 +417,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2")
btn.textContent?.includes("Song 2"),
);
await fireEvent.click(secondTrackButton!);
@@ -409,7 +435,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -429,7 +455,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -454,9 +480,7 @@ describe("TrackList", () => {
const { container } = render(TrackList, { props: { tracks: singleTrack } });
const buttons = container.querySelectorAll("button");
const trackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
const trackButton = Array.from(buttons).find((btn) => btn.textContent?.includes("Song 1"));
await fireEvent.click(trackButton!);
@@ -473,7 +497,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -490,7 +514,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const lastTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3")
btn.textContent?.includes("Song 3"),
);
await fireEvent.click(lastTrackButton!);
@@ -505,15 +529,13 @@ describe("TrackList", () => {
describe("Loading State", () => {
it("shows loading spinner when track is clicked", async () => {
// Make invoke slow to capture loading state
(invoke as any).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
(invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
fireEvent.click(firstTrackButton!);
@@ -526,15 +548,13 @@ describe("TrackList", () => {
});
it("disables track buttons during loading", async () => {
(invoke as any).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
(invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
fireEvent.click(firstTrackButton!);
@@ -542,8 +562,8 @@ describe("TrackList", () => {
// Track selection buttons should be disabled during loading
await waitFor(() => {
// Find track buttons (ones containing song names)
const trackButtons = Array.from(container.querySelectorAll("button")).filter(
(btn) => btn.textContent?.includes("Song")
const trackButtons = Array.from(container.querySelectorAll("button")).filter((btn) =>
btn.textContent?.includes("Song"),
);
expect(trackButtons.length).toBeGreaterThan(0);
trackButtons.forEach((btn) => {
@@ -30,7 +30,7 @@
episodeNumber,
seasonNumber,
size = "md",
className = ""
className = "",
}: Props = $props();
const sizeClasses = {
@@ -46,7 +46,7 @@
// Find download for this item
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
);
const status = $derived(downloadInfo?.status || "not_downloaded");
@@ -83,7 +83,7 @@
filePath = `videos/movies/${safeName}.mp4`;
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_");
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}_${safeName}.mp4`;
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}_${safeName}.mp4`;
} else {
filePath = `videos/${safeName}.mp4`;
}
@@ -96,13 +96,13 @@
userId,
filePath,
"video/mp4",
isMovie ? 500 : (1000 - (episodeNumber || 0)), // Movies have medium priority, episodes ordered by number
isMovie ? 500 : 1000 - (episodeNumber || 0), // Movies have medium priority, episodes ordered by number
itemName || undefined,
quality,
seriesName,
seasonName,
episodeNumber,
seasonNumber
seasonNumber,
);
log.debug(" Download queued with ID:", downloadId);
@@ -232,27 +232,59 @@
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
{:else if status === "completed"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class={sizeClasses[size]}
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>
{:else if status === "pending"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" />
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z"
/>
</svg>
{:else if status === "failed"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{:else}
<svg class={sizeClasses[size]} 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-4m-9 7h10" />
<svg
class={sizeClasses[size]}
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-4m-9 7h10"
/>
</svg>
{/if}
</div>
@@ -263,16 +295,14 @@
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker"
></button>
<div
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
>
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
Select Quality
</div>
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">Select Quality</div>
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
<button
onclick={() => startDownload(key as QualityPreset)}
@@ -280,14 +310,16 @@
>
<span>{preset.label}</span>
{#if preset.videoBitrate}
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
<span class="text-xs text-gray-500"
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
>
{:else}
<span class="text-xs text-gray-500">Direct</span>
{/if}
</button>
{/each}
<button
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
@@ -32,14 +32,7 @@
onChanged?: (watched: boolean) => void;
}
let {
itemId,
watched,
scope,
size = "lg",
showLabel = false,
onChanged,
}: Props = $props();
let { itemId, watched, scope, size = "lg", showLabel = false, onChanged }: Props = $props();
let busy = $state(false);
@@ -60,7 +53,7 @@
});
const subject = $derived(
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
scope === "series" ? "series" : scope === "season" ? "season" : "episode",
);
const label = $derived(isWatched ? "Watched" : "Mark watched");
const title = $derived(
@@ -68,7 +61,7 @@
? `Mark this ${subject} unwatched`
: scope === "episode"
? "Mark this episode watched"
: `Mark every episode in this ${subject} watched`
: `Mark every episode in this ${subject} watched`,
);
async function handleClick() {
@@ -106,7 +99,13 @@
{isWatched
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
{showLabel
? size === 'lg'
? 'px-6 py-2'
: 'px-3 py-1.5 text-sm'
: size === 'lg'
? 'p-2'
: 'p-1.5'}"
>
{#if busy}
<div
+17 -11
View File
@@ -1,13 +1,14 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
import {
isCurrentEpisode,
adjacentEpisodes,
compareSeriesOrder,
stripCardLabel,
} from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
id: string,
season: number | null,
number: number | null,
): MediaItem {
function ep(id: string, season: number | null, number: number | null): MediaItem {
return {
id,
name: `S${season}E${number}`,
@@ -79,8 +80,15 @@ describe("adjacentEpisodes", () => {
const current = eps[4]; // S1E5 — the season finale
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e2", "s1e3", "s1e4", "s1e5",
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
"s1e2",
"s1e3",
"s1e4",
"s1e5",
"s2e1",
"s2e2",
"s2e3",
"s2e4",
"s2e5",
]);
});
@@ -96,9 +104,7 @@ describe("adjacentEpisodes", () => {
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
const current = eps[3]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
]);
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3"]);
});
it("sorts specials (season 0) after the numbered seasons", () => {
+5 -4
View File
@@ -26,14 +26,15 @@ const SPECIALS_SEASON = 0;
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
ep.indexNumber == null ||
current.indexNumber == null ||
ep.parentIndexNumber == null ||
current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
ep.parentIndexNumber === current.parentIndexNumber && ep.indexNumber === current.indexNumber
);
}
@@ -42,7 +42,11 @@ describe("buildLibraryMosaic", () => {
it("links a category tile to that category's favourites tab", () => {
const entries = buildLibraryMosaic([SHOWS]);
const tile = entries.find((e) => e.label === "Favourite Shows");
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
expect(tile).toMatchObject({
kind: "favorites",
scope: "tv",
href: "/library/favorites?scope=tv",
});
});
it("offers a category's favourites once, however many libraries share it", () => {
+8 -3
View File
@@ -31,8 +31,7 @@ export type LibraryMosaicEntry = {
ratio: number;
label: string;
} & (
| { kind: "library"; library: Library }
| { kind: "favorites"; scope: FavoritesScope; href: string }
{ kind: "library"; library: Library } | { kind: "favorites"; scope: FavoritesScope; href: string }
);
/**
@@ -68,7 +67,13 @@ export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
for (const lib of libraries) {
const ratio = assumedLibraryRatio(lib);
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
entries.push({
key: `library:${lib.id}`,
kind: "library",
library: lib,
ratio,
label: lib.name,
});
const scope = asFavoritesScope(lib.favoritesScope);
if (!scope || seenScopes.has(scope)) continue;
@@ -88,9 +88,7 @@ describe("seasonRedirectTarget", () => {
});
it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
`/library/${SERIES}#${seasonAnchorId(2)}`
);
expect(seasonRedirectTarget(seasonHeader(2))).toBe(`/library/${SERIES}#${seasonAnchorId(2)}`);
});
});
@@ -130,7 +128,7 @@ describe("groupEpisodesBySeason", () => {
it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)],
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)],
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
});
@@ -156,7 +154,7 @@ describe("groupEpisodesBySeason", () => {
it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)]
[ep("s2e1", 2, 1)],
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
});
@@ -171,12 +169,7 @@ describe("groupEpisodesBySeason", () => {
describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[
ep("s1e1", 1, 1),
ep("s2e1", 2, 1),
ep("s2e2", 2, 2),
ep("s3e1", 3, 1),
]
[ep("s1e1", 1, 1), ep("s2e1", 2, 1), ep("s2e2", 2, 2), ep("s3e1", 3, 1)],
);
it("expands only the season holding the current episode", () => {
@@ -114,10 +114,7 @@ export function seriesPlayLabel(current: MediaItem | null): string {
* server did not return one for (a flat series, or a season fetch that failed).
* Seasons with no episodes are dropped an empty accordion row is noise.
*/
export function groupEpisodesBySeason(
seasons: MediaItem[],
episodes: MediaItem[]
): SeasonData[] {
export function groupEpisodesBySeason(seasons: MediaItem[], episodes: MediaItem[]): SeasonData[] {
const headerFor = new Map<number, MediaItem>();
for (const season of seasons) {
const number = season.indexNumber ?? season.parentIndexNumber;
@@ -164,7 +161,7 @@ export function groupEpisodesBySeason(
export function initialExpandedSeasons(
seasons: SeasonData[],
currentEpisodeId: string | null | undefined,
focusedEpisodeId?: string | null
focusedEpisodeId?: string | null,
): Set<string> {
if (seasons.length === 0) return new Set();