Many improvemtns and fixes related to decoupling of svelte and rust on android.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user