Many improvemtns and fixes related to decoupling of svelte and rust on android.
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s

This commit is contained in:
2026-02-28 19:50:47 +01:00
parent 07f3bf04ca
commit e8e37649fa
53 changed files with 2309 additions and 792 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core";
import type { MediaItem } from "$lib/api/types";
interface Props {
@@ -82,9 +83,32 @@
}
}
} else {
// Download the album
// Download the album: queue all tracks, then start each one
const repo = auth.getRepository();
const basePath = `albums/${albumId}`;
await downloads.downloadAlbum(albumId, userId, basePath);
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
// Get target directory for downloads
const targetDir = await invoke<string>("storage_get_path");
// Start each queued track download
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await invoke("start_download", {
downloadId: downloadIds[i],
streamUrl,
targetDir,
});
}
} catch (e) {
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
}
}
// Refresh to get updated statuses
await downloads.refresh(userId);
}
} catch (error) {
console.error("Album download operation failed:", error);
+9 -43
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Person, PersonType } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
people: Person[];
@@ -10,9 +10,6 @@
let { people, title = "Cast & Crew" }: Props = $props();
// Map of person IDs to their image URLs, loaded asynchronously
let personImageUrls = $state<Map<string, string>>(new Map());
// Group people by type
const groupedPeople = $derived.by(() => {
const groups: Record<string, Person[]> = {
@@ -61,31 +58,6 @@
}
}
// Load image URL for a single person
async function loadPersonImageUrl(person: Person): Promise<void> {
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(person.id, "Primary", {
maxWidth: 200,
tag: person.primaryImageTag,
});
personImageUrls.set(person.id, url);
} catch {
personImageUrls.set(person.id, "");
}
}
// Load image URLs for all people
$effect(() => {
people.forEach((person) => {
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
loadPersonImageUrl(person);
}
});
});
function handlePersonClick(person: Person) {
goto(`/library/${person.id}`);
}
@@ -110,20 +82,14 @@
>
<!-- Person image -->
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
{#if person.primaryImageTag && personImageUrls.get(person.id)}
<img
src={personImageUrls.get(person.id)}
alt={person.name}
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-500">
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
{/if}
<CachedImage
itemId={person.id}
imageType="Primary"
tag={person.primaryImageTag}
maxWidth={200}
alt={person.name}
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
/>
</div>
<!-- Name and role -->
@@ -66,8 +66,8 @@
class="transition-all"
style="transition: stroke-dashoffset 0.3s ease;"
/>
<!-- Download Icon in Center -->
<text x="18" y="20" text-anchor="middle" class="text-xs font-bold fill-current">
<!-- 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">
{Math.round(state.progress * 100)}%
</text>
</svg>
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
episode: MediaItem;
@@ -12,9 +12,6 @@
let { episode, series, allEpisodes, onBack }: Props = $props();
let backdropUrl = $state<string>("");
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
// Check if an episode matches the focused episode (by ID or season/episode number)
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
@@ -73,72 +70,18 @@
return allEpisodes.slice(start, end);
});
// Load backdrop URL asynchronously
async function loadBackdropUrl(): Promise<void> {
try {
const repo = auth.getRepository();
// Try episode backdrop first
if (episode.backdropImageTags?.[0]) {
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
maxWidth: 1920,
tag: episode.backdropImageTags[0],
});
return;
}
// Try episode primary (thumbnail)
if (episode.primaryImageTag) {
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
maxWidth: 1920,
tag: episode.primaryImageTag,
});
return;
}
// Fall back to series backdrop
if (series.backdropImageTags?.[0]) {
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
maxWidth: 1920,
tag: series.backdropImageTags[0],
});
return;
}
backdropUrl = "";
} catch {
backdropUrl = "";
// 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] };
}
}
// Load episode thumbnail URL for a single episode
async function loadEpisodeThumbnailUrl(ep: MediaItem): Promise<void> {
if (!ep.primaryImageTag || episodeThumbnailUrls.has(ep.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(ep.id, "Primary", {
maxWidth: 400,
tag: ep.primaryImageTag,
});
episodeThumbnailUrls.set(ep.id, url);
} catch {
episodeThumbnailUrls.set(ep.id, "");
if (episode.primaryImageTag) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
}
}
// Load backdrop when episode changes
$effect(() => {
loadBackdropUrl();
});
// Load episode thumbnail URLs when adjacent episodes change
$effect(() => {
adjacentEpisodes().forEach((ep) => {
if (ep.primaryImageTag && !episodeThumbnailUrls.has(ep.id)) {
loadEpisodeThumbnailUrl(ep);
}
});
if (series.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
}
return null;
});
function formatDuration(ticks?: number): string {
@@ -178,9 +121,12 @@
<div class="space-y-8">
<!-- Hero section -->
<div class="relative h-[450px] rounded-xl overflow-hidden">
{#if backdropUrl}
<img
src={backdropUrl}
{#if backdropSource}
<CachedImage
itemId={backdropSource.itemId}
imageType={backdropSource.imageType}
tag={backdropSource.tag}
maxWidth={1920}
alt={episode.name}
class="absolute inset-0 w-full h-full object-cover"
/>
@@ -288,7 +234,6 @@
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
@@ -296,20 +241,14 @@
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
{#if thumbUrl}
<img
src={thumbUrl}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.primaryImageTag}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
/>
<!-- Hover overlay -->
{#if !isCurrent}
+9 -34
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
episode: MediaItem;
@@ -15,7 +15,6 @@
let { episode, focused = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
let imageUrl = $state<string>("");
onMount(() => {
if (focused && buttonRef) {
@@ -37,24 +36,6 @@
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
maxWidth: 320,
tag: episode.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when episode changes
$effect(() => {
loadImageUrl();
});
const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) {
return 0;
@@ -74,20 +55,14 @@
>
<!-- Thumbnail -->
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
{#if imageUrl}
<img
src={imageUrl}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<CachedImage
itemId={episode.id}
imageType="Primary"
tag={episode.primaryImageTag}
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 class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
@@ -6,6 +6,7 @@
import SearchBar from "$lib/components/common/SearchBar.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import type { Genre, MediaItem } from "$lib/api/types";
@@ -41,8 +42,6 @@
let selectedGenre = $state<Genre | null>(null);
let genreItems = $state<MediaItem[]>([]);
let loadingItems = $state(false);
let genreItemImageUrls = $state<Map<string, string>>(new Map());
const { markLoaded } = useServerReachabilityReload(async () => {
await loadGenres();
if (selectedGenre) {
@@ -80,7 +79,6 @@
try {
loadingItems = true;
selectedGenre = genre;
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
const repo = auth.getRepository();
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: config.itemTypes,
@@ -98,31 +96,6 @@
}
}
// Load image URL for a single item
async function loadGenreItemImage(item: MediaItem): Promise<void> {
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(item.id, "Primary", {
maxWidth: 300,
tag: item.primaryImageTag,
});
genreItemImageUrls.set(item.id, url);
} catch {
genreItemImageUrls.set(item.id, "");
}
}
// Load image URLs for all genre items
$effect(() => {
genreItems.forEach((item) => {
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
loadGenreItemImage(item);
}
});
});
function applyFilter() {
let result = [...genres];
@@ -245,19 +218,14 @@
{#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">
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
<img
src={genreItemImageUrls.get(item.id)}
alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
{:else}
<div class="w-full h-full flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
</svg>
</div>
{/if}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.primaryImageTag}
maxWidth={300}
alt={item.name}
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">
{item.name}
@@ -1,8 +1,8 @@
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
items: (MediaItem | Library)[];
@@ -13,37 +13,14 @@
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
// Map of item IDs to their image URLs, loaded asynchronously
let imageUrls = $state<Map<string, string>>(new Map());
function getDownloadInfo(itemId: string) {
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
}
// Load image URL for a single item
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
try {
const repo = auth.getRepository();
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
const url = await repo.getImageUrl(item.id, "Primary", {
maxWidth: 80,
tag,
});
imageUrls.set(item.id, url);
} catch {
imageUrls.set(item.id, "");
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
}
// Load image URLs whenever items change
$effect(() => {
items.forEach((item) => {
if (!imageUrls.has(item.id)) {
loadImageUrl(item);
}
});
});
function getSubtitle(item: MediaItem | Library): string {
if (!("type" in item)) return "";
@@ -80,7 +57,6 @@
<div class="space-y-1">
{#each items as item, index (item.id)}
{@const imageUrl = imageUrls.get(item.id) ?? ""}
{@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const progress = getProgress(item)}
@@ -102,20 +78,14 @@
<!-- Thumbnail -->
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
{#if imageUrl}
<img
src={imageUrl}
alt={item.name}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
</div>
{/if}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={getImageTag(item)}
maxWidth={80}
alt={item.name}
class="w-full h-full object-cover"
/>
<!-- 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">
+13 -37
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
item: MediaItem | Library;
@@ -13,9 +13,6 @@
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
// Image URL state - loaded asynchronously
let imageUrl = $state<string>("");
// Check if this item is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
@@ -42,26 +39,11 @@
return "aspect-video";
});
// Load image URL asynchronously from backend
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
const imageTag = $derived(
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
);
imageUrl = await repo.getImageUrl(item.id, "Primary", {
maxWidth,
tag,
});
} catch {
imageUrl = "";
}
}
// Load image URL whenever item or size changes
$effect(() => {
loadImageUrl();
});
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
const progress = $derived(() => {
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
@@ -96,20 +78,14 @@
{onclick}
>
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
{#if imageUrl}
<img
src={imageUrl}
alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={imageTag}
maxWidth={maxWidth}
alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
/>
<!-- Hover overlay with smooth gradient -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center">
@@ -3,6 +3,7 @@
import { auth } from "$lib/stores/auth";
import { onMount } from "svelte";
import LibraryGrid from "./LibraryGrid.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { goto } from "$app/navigation";
interface Props {
@@ -14,7 +15,6 @@
let movies = $state<MediaItem[]>([]);
let series = $state<MediaItem[]>([]);
let loading = $state(true);
let imageUrl = $state<string>("");
onMount(async () => {
await loadFilmography();
@@ -39,24 +39,6 @@
}
}
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(person.id, "Primary", {
maxWidth: 400,
tag: person.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when person changes
$effect(() => {
loadImageUrl();
});
function handleItemClick(item: MediaItem) {
goto(`/library/${item.id}`);
}
@@ -67,19 +49,14 @@
<div class="flex gap-6 pt-4">
<!-- Profile image -->
<div class="flex-shrink-0 w-48">
{#if imageUrl && person.primaryImageTag}
<img
src={imageUrl}
alt={person.name}
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">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
{/if}
<CachedImage
itemId={person.id}
imageType="Primary"
tag={person.primaryImageTag}
maxWidth={400}
alt={person.name}
class="w-full rounded-lg shadow-lg"
/>
</div>
<!-- Info -->
@@ -1,8 +1,8 @@
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
season: MediaItem;
@@ -13,26 +13,6 @@
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
let imageUrl = $state<string>("");
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(season.id, "Primary", {
maxWidth: 200,
tag: season.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when season changes
$effect(() => {
loadImageUrl();
});
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
const seasonName = $derived(
@@ -45,20 +25,14 @@
<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)]">
{#if imageUrl}
<img
src={imageUrl}
alt={seasonName}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<CachedImage
itemId={season.id}
imageType="Primary"
tag={season.primaryImageTag}
maxWidth={200}
alt={seasonName}
class="w-full h-full object-cover"
/>
</div>
<!-- Season info -->
@@ -38,6 +38,8 @@
let isProcessing = $state(false);
let showQualityPicker = $state(false);
let buttonEl: HTMLButtonElement;
let dropdownPos = $state({ top: 0, left: 0 });
// Find download for this item
const downloadInfo = $derived(
@@ -135,13 +137,26 @@
}
} else if (status === "failed") {
// Show quality picker to retry
showQualityPicker = true;
openQualityPicker();
} else {
// Show quality picker
showQualityPicker = true;
openQualityPicker();
}
}
function openQualityPicker() {
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect();
const dropdownWidth = 160; // w-40
const padding = 8;
let left = rect.right - dropdownWidth;
// Clamp to viewport bounds
left = Math.max(padding, Math.min(left, window.innerWidth - dropdownWidth - padding));
dropdownPos = { top: rect.bottom + 4, left };
}
showQualityPicker = true;
}
function getTitle(): string {
switch (status) {
case "completed":
@@ -176,6 +191,7 @@
<div class="relative">
<button
bind:this={buttonEl}
onclick={handleClick}
disabled={isProcessing}
class="p-2 rounded-full transition-all {getColor()} {isProcessing
@@ -242,41 +258,40 @@
{/if}
</div>
</button>
<!-- Quality picker dropdown -->
{#if showQualityPicker}
<div class="absolute z-50 mt-1 right-0 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
<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)}
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
>
<span>{preset.label}</span>
{#if preset.videoBitrate}
<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}
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
</button>
</div>
{/if}
</div>
<!-- Click outside to close -->
<!-- Quality picker dropdown (fixed position, viewport-clamped) -->
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
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>
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
<button
onclick={() => startDownload(key as QualityPreset)}
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
>
<span>{preset.label}</span>
{#if preset.videoBitrate}
<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}
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
</button>
</div>
{/if}