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
+12 -12
View File
@@ -28,18 +28,6 @@
<span class="text-xs">Home</span>
</button>
<!-- Library Button -->
<button
onclick={() => goto('/library')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
<svg class="w-6 h-6" 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-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
</svg>
<span class="text-xs">Library</span>
</button>
<!-- Search Button -->
<button
onclick={() => goto('/search')}
@@ -51,5 +39,17 @@
</svg>
<span class="text-xs">Search</span>
</button>
<!-- Library Button -->
<button
onclick={() => goto('/library')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
<svg class="w-6 h-6" 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-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
</svg>
<span class="text-xs">Library</span>
</button>
</div>
</nav>
+5 -4
View File
@@ -61,7 +61,6 @@
imageUrl = dataUrl;
error = false;
} catch (e) {
console.error(`Failed to load image ${itemId}:`, e);
error = true;
imageUrl = null;
} finally {
@@ -78,10 +77,12 @@
{#if loading}
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
{:else if error}
{:else if error || !imageUrl}
<div class="{className} bg-gray-800 flex items-center justify-center">
<span class="text-gray-500 text-xs">Failed to load</span>
<svg class="w-8 h-8 text-gray-600" 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>
{:else if imageUrl}
{:else}
<img src={imageUrl} {alt} class={className} />
{/if}
+41 -81
View File
@@ -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 {
items: MediaItem[];
@@ -13,7 +13,6 @@
let currentIndex = $state(0);
let intervalId: number | null = null;
let heroImageUrl = $state<string>("");
// Touch/swipe state
let touchStartX = $state(0);
@@ -22,82 +21,45 @@
const currentItem = $derived(items[currentIndex] ?? null);
// Load hero image URL asynchronously based on item priority
async function loadHeroImageUrl(): Promise<void> {
if (!currentItem) {
heroImageUrl = "";
return;
// Compute the best image source for the hero banner (no fetch, pure derivation)
const heroImageSource = $derived.by(() => {
if (!currentItem) return null;
// 1. Try backdrop image first (best for hero display)
if (currentItem.backdropImageTags?.[0]) {
return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] };
}
try {
const repo = auth.getRepository();
// 1. Try backdrop image first (best for hero display)
if (currentItem.backdropImageTags?.[0]) {
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", {
maxWidth: 1920,
tag: currentItem.backdropImageTags[0],
});
return;
// 2. For episodes, try series/season backdrops
if (currentItem.type === "Episode") {
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
}
// 2. For episodes, try to use series backdrop from parent
if (currentItem.type === "Episode") {
// First try parent backdrop tags (includes image tag for caching)
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
maxWidth: 1920,
tag: currentItem.parentBackdropImageTags[0],
});
return;
}
// Fallback: try series backdrop without tag (may not be cached optimally)
if (currentItem.seriesId) {
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
maxWidth: 1920,
});
return;
}
// Last resort for episodes: try season backdrop
if (currentItem.seasonId) {
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", {
maxWidth: 1920,
});
return;
}
if (currentItem.seriesId) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
}
// 3. For music tracks, try album backdrop first, then primary
if (currentItem.type === "Audio" && currentItem.albumId) {
// Try album backdrop first (more cinematic for hero)
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
maxWidth: 1920,
});
return;
if (currentItem.seasonId) {
return { itemId: currentItem.seasonId, imageType: "Backdrop" as const, tag: undefined };
}
// 4. Fall back to primary image (poster, album art, episode thumbnail)
if (currentItem.primaryImageTag) {
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", {
maxWidth: 1920,
tag: currentItem.primaryImageTag,
});
return;
}
// 5. Last resort for audio: try album primary image
if (currentItem.type === "Audio" && currentItem.albumId) {
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", {
maxWidth: 1920,
});
return;
}
heroImageUrl = "";
} catch {
heroImageUrl = "";
}
}
// 3. For music tracks, try album backdrop
if (currentItem.type === "Audio" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
}
// 4. Fall back to primary image
if (currentItem.primaryImageTag) {
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
}
// 5. Last resort for audio: album primary
if (currentItem.type === "Audio" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
}
return null;
});
function next() {
currentIndex = (currentIndex + 1) % items.length;
@@ -143,11 +105,6 @@
touchEndX = 0;
}
// Load hero image whenever current item changes
$effect(() => {
loadHeroImageUrl();
});
// Auto-rotate logic
$effect(() => {
if (autoRotate && items.length > 1) {
@@ -166,10 +123,13 @@
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
{#if heroImageUrl}
<img
src={heroImageUrl}
alt={currentItem?.name}
{#if heroImageSource}
<CachedImage
itemId={heroImageSource.itemId}
imageType={heroImageSource.imageType}
tag={heroImageSource.tag}
maxWidth={1920}
alt={currentItem?.name ?? ""}
class="absolute inset-0 w-full h-full object-cover"
/>
{:else}
@@ -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}
+7 -7
View File
@@ -97,9 +97,9 @@
await invoke("player_cycle_repeat");
}
// Use track's own ID for artwork (primaryImageTag corresponds to track ID)
// Album art is inherited from album, so all tracks show the same album cover
const artworkItemId = $derived(displayMedia?.id);
// Prefer album ID for artwork (all tracks in an album share the same cover)
// Falls back to track ID if no album ID available
const artworkItemId = $derived(displayMedia?.albumId || displayMedia?.id);
// Show optimistic position while seeking or waiting for backend confirmation
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
@@ -137,12 +137,12 @@
{#if displayMedia}
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
<!-- Background image (blurred) -->
{#if artworkItemId && displayMedia?.primaryImageTag}
{#if artworkItemId}
<div class="fixed inset-0 z-0">
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia.primaryImageTag}
tag={displayMedia?.primaryImageTag}
maxWidth={800}
alt=""
class="w-full h-full object-cover blur-3xl opacity-30"
@@ -217,11 +217,11 @@
<!-- Artwork -->
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
{#if artworkItemId && displayMedia?.primaryImageTag}
{#if artworkItemId}
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia.primaryImageTag}
tag={displayMedia?.primaryImageTag}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
+10 -11
View File
@@ -145,6 +145,8 @@
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchEndX = touchStartX;
touchEndY = touchStartY;
isSwiping = true;
}
@@ -293,15 +295,13 @@
>
<!-- Media info -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Artwork (clickable to expand) -->
<button
onclick={onExpand}
<!-- Artwork -->
<div
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
aria-label="Open full player"
>
{#if displayMedia?.primaryImageTag}
{#if displayMedia}
<CachedImage
itemId={displayMedia.id}
itemId={displayMedia.albumId || displayMedia.id}
imageType="Primary"
tag={displayMedia.primaryImageTag}
maxWidth={100}
@@ -315,16 +315,15 @@
</svg>
</div>
{/if}
</button>
</div>
<!-- Title & Artist -->
<div class="flex-1 min-w-0">
<button
onclick={onExpand}
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
<div
class="text-sm font-medium text-white truncate block w-full text-left"
>
{displayMedia?.name}
</button>
</div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
+4 -12
View File
@@ -372,22 +372,14 @@
// Call Rust backend to start playback
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
// Send minimal video data - no complex serialization to avoid Tauri Android issues
const response: any = await invoke("player_play_item", {
item: {
id: media.id,
title: media.name,
artist: null,
album: null,
duration: media.runTimeTicks ? media.runTimeTicks / 10000000 : null,
artworkUrl: null,
mediaType: "video",
streamUrl: currentStreamUrl,
jellyfinItemId: media.id,
title: media.name,
id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding,
videoWidth: null,
videoHeight: null,
subtitles: subtitleTracks,
needsTranscoding: needsTranscoding,
},
});
+9 -31
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Session } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
session: Session;
@@ -10,31 +10,6 @@
let { session, selected = false, onclick }: Props = $props();
let imageUrl = $state<string>("");
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
if (!session.nowPlayingItem) {
imageUrl = "";
return;
}
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
maxWidth: 80,
tag: session.nowPlayingItem.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when session changes
$effect(() => {
loadImageUrl();
});
function formatTime(ticks: number): string {
const seconds = Math.floor(ticks / 10000000);
const minutes = Math.floor(seconds / 60);
@@ -73,13 +48,16 @@
<!-- Now playing -->
{#if nowPlaying && playState}
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
{#if imageUrl}
<img
src={imageUrl}
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
<CachedImage
itemId={nowPlaying.id}
imageType="Primary"
tag={nowPlaying.primaryImageTag}
maxWidth={80}
alt={nowPlaying.name}
class="w-12 h-12 rounded object-cover flex-shrink-0"
class="w-full h-full object-cover"
/>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>