fix offline mode and layout bugs
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m32s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Build & Release / Build Linux (push) Successful in 17m31s
Build & Release / Build Android (push) Successful in 22m5s
Build & Release / Create Release (push) Successful in 16s

This commit is contained in:
2026-07-06 20:24:46 +02:00
parent 68c8602230
commit acb7e5f221
14 changed files with 1008 additions and 28 deletions
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
// Controllable stores for the offline "server only" branch. Declared via
// vi.hoisted so they exist when the hoisted vi.mock factories run. A tiny
// writable shim avoids importing svelte inside the hoisted block.
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
};
}
return {
isConnectedStore: shim(true),
showServerCatalogStore: shim(false),
downloadsStore: shim({ downloads: {} as Record<string, any> }),
downloadItem: vi.fn(async () => 1),
getUserId: vi.fn(() => "user-1"),
};
});
const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h;
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
}));
vi.mock("$lib/services/offlineCatalog", () => ({
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
}));
vi.mock("$lib/stores/downloads", () => ({
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
}));
vi.mock("$lib/stores/auth", () => ({
auth: { getUserId: h.getUserId },
}));
// CachedImage does async repo/image work irrelevant to these tests.
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
default: (await import("./__mocks__/StubImage.svelte")).default,
}));
import MediaCard from "./MediaCard.svelte";
const track = {
id: "track-1",
name: "Some Song",
type: "Audio" as const,
serverId: "server-1",
artists: ["Artist A"],
albumName: "Album X",
};
describe("MediaCard server-only (offline browse & queue)", () => {
beforeEach(() => {
vi.clearAllMocks();
isConnectedStore.set(true);
showServerCatalogStore.set(false);
downloadsStore.set({ downloads: {} });
});
it("shows no queue button while online", () => {
render(MediaCard, { props: { item: track } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("shows no queue button when offline but the reveal toggle is off", () => {
isConnectedStore.set(false);
render(MediaCard, { props: { item: track } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("reveals a queue button when offline and the reveal toggle is on", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
render(MediaCard, { props: { item: track } });
expect(screen.getByLabelText(/Queue download for Some Song/i)).toBeTruthy();
});
it("queues the item for download with its metadata on click", async () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
render(MediaCard, { props: { item: track } });
await fireEvent.click(screen.getByLabelText(/Queue download for Some Song/i));
expect(downloadItem).toHaveBeenCalledTimes(1);
const args = downloadItem.mock.calls[0] as unknown as any[];
expect(args[0]).toBe("track-1"); // itemId
expect(args[1]).toBe("user-1"); // userId
expect(args[5]).toBe("Some Song"); // itemName
expect(args[6]).toBe("Artist A"); // artistName
expect(args[7]).toBe("Album X"); // albumName
});
it("shows a Queued badge (not the button) for a pending download", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
downloadsStore.set({
downloads: { "track-1": { itemId: "track-1", status: "pending", progress: 0 } },
});
render(MediaCard, { props: { item: track } });
expect(screen.getByText(/Queued/i)).toBeTruthy();
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("does not grey out a completed download", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
downloadsStore.set({
downloads: { "track-1": { itemId: "track-1", status: "completed", progress: 1 } },
});
render(MediaCard, { props: { item: track } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
});
+98 -9
View File
@@ -2,6 +2,9 @@
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import { isConnected } from "$lib/stores/connectivity";
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { auth } from "$lib/stores/auth";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
@@ -25,6 +28,55 @@
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
// A media item (not a library) is "server only" when it exists in the cache
// but isn't downloaded/downloading — and we're offline with the reveal toggle
// on. Such cards render greyed out with a "queue for download" button and are
// inert to tap (nothing to play offline).
const isMediaItem = $derived("type" in item);
const isQueued = $derived(downloadInfo?.status === "pending");
// Actively transferring (as opposed to merely queued/pending for reconnect).
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
// "Server only" = offline, reveal on, and not already downloaded or actively
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
// it can show the Queued badge in place of the queue button.
const isServerOnly = $derived(
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
);
let queueError = $state<string | null>(null);
// Queue this item for download on next reconnect. Offline, this just persists
// a `pending` downloads row (no stream_url); the reconnect handler resolves
// the URL and the pump starts it. See offlineCatalog service.
async function queueForDownload(e: Event) {
e.stopPropagation();
if (!isMediaItem) return;
const media = item as MediaItem;
const userId = auth.getUserId();
if (!userId) {
queueError = "Not signed in";
return;
}
try {
queueError = null;
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
const filePath = `downloads/${media.id}`;
await downloads.downloadItem(
media.id,
userId,
filePath,
undefined,
undefined,
media.name,
media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined
);
} catch (err) {
console.error("[MediaCard] Failed to queue download:", err);
queueError = "Failed to queue";
}
}
const sizeClasses = {
small: "w-24",
medium: "w-36",
@@ -76,10 +128,12 @@
});
</script>
<button
type="button"
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 hover:scale-105"
{onclick}
<svelte:element
this={isServerOnly ? "div" : "button"}
type={isServerOnly ? undefined : "button"}
role={isServerOnly ? "group" : undefined}
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
onclick={isServerOnly ? undefined : 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">
<CachedImage
@@ -88,11 +142,12 @@
tag={imageTag}
maxWidth={maxWidth}
alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly ? 'opacity-40 grayscale' : ''}"
/>
<!-- 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">
<!-- Hover overlay with smooth gradient (play affordance; hidden for
server-only cards, which can't be played offline) -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 {isServerOnly ? '' : 'group-hover/card:opacity-100'} transition-opacity duration-300 flex items-center justify-center">
<div class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300">
<div class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl">
<svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
@@ -163,9 +218,43 @@
{/if}
</div>
{/if}
<!-- Server-only: queue-for-download control (kept at full opacity over the
greyed artwork). Queued items show a "queued" badge instead. -->
{#if isServerOnly}
<div class="absolute inset-0 flex items-center justify-center">
{#if isQueued}
<div class="flex flex-col items-center gap-1 text-white" title="Queued — will download on reconnect">
<div class="w-11 h-11 rounded-full bg-black/60 flex items-center justify-center shadow-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full">Queued</span>
</div>
{:else}
<button
type="button"
onclick={queueForDownload}
class="w-11 h-11 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-lg transition-colors"
title="Queue download for next connection"
aria-label="Queue download for {item.name}"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
</button>
{/if}
</div>
{#if queueError}
<div class="absolute bottom-1 left-1 right-1 text-center text-[10px] text-red-200 bg-black/70 rounded px-1 py-0.5">
{queueError}
</div>
{/if}
{/if}
</div>
<div class="mt-2 space-y-0.5">
<div class="mt-2 space-y-0.5 {isServerOnly ? 'opacity-60' : ''}">
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{truncateMiddle(item.name, 40)}
</p>
@@ -173,4 +262,4 @@
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
{/if}
</div>
</button>
</svelte:element>
@@ -0,0 +1,7 @@
<script lang="ts">
// Minimal stand-in for CachedImage in unit tests: renders nothing meaningful,
// just accepts the same props so MediaCard renders without hitting the repo.
let { alt = "" }: { alt?: string; [key: string]: unknown } = $props();
</script>
<div data-testid="stub-image" aria-label={alt}></div>