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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user