fix(ui): stack episode title under the thumbnail on narrow rows

A fixed 160px thumbnail beside the title left phone-width rows cramped.
A container query stacks a full-width thumbnail above the info block when
the row is under 28rem, lets the title wrap to two lines there, and
requests a 640px image so the wider thumbnail stays sharp.
This commit is contained in:
2026-09-22 21:27:38 -04:00
parent b98cbfe28a
commit a90de67c54
+163 -149
View File
@@ -57,167 +57,181 @@
const episodeNumber = $derived(episode.indexNumber || 0); const episodeNumber = $derived(episode.indexNumber || 0);
</script> </script>
<button <!-- Narrow rows stack the title under a full-width thumbnail instead of
bind:this={buttonRef} squeezing it beside a fixed 160px one. A container query, not a viewport
type="button" breakpoint, so it follows the row's own width. -->
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused <div class="@container">
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' <button
: current bind:this={buttonRef}
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]' type="button"
: ''}" class="group/row flex flex-col @md:flex-row gap-3 @md:gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
{onclick} ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
> : current
<!-- Thumbnail --> ? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
<div : ''}"
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]" {onclick}
> >
<CachedImage <!-- Thumbnail -->
itemId={episode.id}
imageType="Primary"
tag={episode.imageId}
maxWidth={320}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
/>
<!-- Hover overlay with play icon -->
<div <div
class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center" class="relative flex-shrink-0 w-full @md:w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
> >
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity"> <CachedImage
<div itemId={episode.id}
class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center" imageType="Primary"
> tag={episode.imageId}
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24"> maxWidth={640}
<path d="M8 5v14l11-7z" /> alt={episode.name}
</svg> class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
/>
<!-- 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="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"
>
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div> </div>
</div> </div>
<!-- Progress bar -->
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
<!-- Download indicator -->
{#if isDownloaded || isDownloading}
<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"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4"
/>
</svg>
</div>
{:else if isDownloading}
<div class="w-5 h-5 relative">
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="rgba(0,0,0,0.6)"
stroke="rgba(255,255,255,0.3)"
stroke-width="2"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="#3b82f6"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
stroke-linecap="round"
class="transition-all duration-300"
/>
</svg>
</div>
{/if}
</div>
{/if}
</div> </div>
<!-- Progress bar --> <!-- Episode info -->
{#if progress() > 0} <div class="flex-1 min-w-0 @md:py-1">
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800"> <div class="flex items-start justify-between gap-4">
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div> <div class="min-w-0 flex-1">
</div> <!-- Episode number and title -->
{/if} <div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
<!-- Download indicator --> {episodeNumber}.
{#if isDownloaded || isDownloading} </span>
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}> <h3
{#if isDownloaded} class="text-white font-medium line-clamp-2 @md:truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
<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"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" /> {truncateMiddle(episode.name, 56)}
</svg> </h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/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>
{/if}
</div> </div>
{:else if isDownloading}
<div class="w-5 h-5 relative">
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="rgba(0,0,0,0.6)"
stroke="rgba(255,255,255,0.3)"
stroke-width="2"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="#3b82f6"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
stroke-linecap="round"
class="transition-all duration-300"
/>
</svg>
</div>
{/if}
</div>
{/if}
</div>
<!-- Episode info --> <!-- Overview -->
<div class="flex-1 min-w-0 py-1"> {#if episode.overview}
<div class="flex items-start justify-between gap-4"> <p class="text-gray-400 text-sm mt-1 line-clamp-2">
<div class="min-w-0 flex-1"> {episode.overview}
<!-- Episode number and title --> </p>
<div class="flex items-center gap-2"> {/if}
<span class="text-[var(--color-jellyfin)] font-semibold text-sm"> </div>
{episodeNumber}.
</span> <!-- Duration and Download -->
<h3 <div class="flex items-center gap-2 flex-shrink-0">
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors" {#if duration}
> <span class="text-gray-500 text-sm">
{truncateMiddle(episode.name, 56)} {duration}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span> </span>
{/if} {/if}
<!-- Played indicator --> <!-- Watched toggle - stop propagation to prevent episode play -->
{#if episode.userData?.isPlayed} <div onclick={(e) => e.stopPropagation()} role="none">
<svg <WatchedToggleButton
class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" itemId={episode.id}
fill="currentColor" watched={episode.userData?.isPlayed ?? false}
viewBox="0 0 24 24" scope="episode"
> size="sm"
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /> onChanged={onWatchedChanged}
</svg> />
{/if} </div>
</div> <!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<!-- Overview --> <VideoDownloadButton
{#if episode.overview} itemId={episode.id}
<p class="text-gray-400 text-sm mt-1 line-clamp-2"> itemName={episode.name}
{episode.overview} seriesName={episode.seriesName ?? undefined}
</p> seasonName={episode.seasonName ?? undefined}
{/if} episodeNumber={episode.indexNumber ?? undefined}
</div> seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm"
<!-- Duration and Download --> />
<div class="flex items-center gap-2 flex-shrink-0"> </div>
{#if duration}
<span class="text-gray-500 text-sm">
{duration}
</span>
{/if}
<!-- Watched toggle - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="sm"
onChanged={onWatchedChanged}
/>
</div>
<!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton
itemId={episode.id}
itemName={episode.name}
seriesName={episode.seriesName ?? undefined}
seasonName={episode.seasonName ?? undefined}
episodeNumber={episode.indexNumber ?? undefined}
seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </button>
</button> </div>