Infrastructure hardening: CI enforcement, supply chain, updater, diagnostics #14

Merged
dtourolle merged 10 commits from chore/infra-hardening into master 2026-08-21 17:33:54 +00:00
199 changed files with 4698 additions and 3453 deletions
Showing only changes of commit ad48d89dfe - Show all commits
+2 -2
View File
@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
# Test service - runs tests only
@@ -31,7 +31,7 @@ services:
depends_on:
- test
ports:
- "5172:5172" # In case you want to run dev server
- "5172:5172" # In case you want to run dev server
# Linux desktop packages - deb + rpm + pacman into ./dist
desktop-linux-build:
+7 -11
View File
@@ -140,9 +140,10 @@ describe("findDanglingIds", () => {
});
it("deduplicates and sorts, so one typo is reported once", () => {
expect(
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
).toEqual(["DR-189", "UR-999"]);
expect(findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)).toEqual([
"DR-189",
"UR-999",
]);
});
it("ignores IDs whose prefix is not a known trace type", () => {
@@ -158,7 +159,7 @@ describe("coverage threshold", () => {
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
const workflow = fs.readFileSync(
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
"utf-8"
"utf-8",
);
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull();
@@ -307,9 +308,7 @@ describe("generated matrix file links", () => {
it("keeps the #Lnn line anchor on the href", () => {
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
expect(link).toBe(
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
);
expect(link).toBe("[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)");
});
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
@@ -334,10 +333,7 @@ describe("live requirements.md", () => {
// row. Worse, the pins never guarded the actual defect — a stale denominator
// is caught by the sum-consistency check below, and the >100% ratio it
// produced is covered directly by the computeCoverage tests, on fixtures.
const md = fs.readFileSync(
path.resolve(HERE, "../docs/requirements.md"),
"utf-8"
);
const md = fs.readFileSync(path.resolve(HERE, "../docs/requirements.md"), "utf-8");
const defined = countDefinedRequirements(md);
// The parser found real rows of every type: a section silently failing to
+11 -30
View File
@@ -64,8 +64,7 @@ export const MIN_COVERAGE_PERCENT = 88;
// `import.meta.dir` is a Bun extension and is undefined when this module is
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
// import.meta.url — this file must stay importable for extract-traces.test.ts.
const SCRIPT_DIR =
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const SCRIPT_DIR = import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
@@ -283,8 +282,7 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
else ids.add(id);
}
const countOf = (type: string) =>
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
const countOf = (type: string) => [...ids].filter((id) => id.startsWith(`${type}-`)).length;
return {
UR: countOf("UR"),
@@ -311,16 +309,13 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
*
* TRACES: | DR-093
*/
export function findDanglingIds(
tracedIds: string[],
defined: DefinedRequirements
): string[] {
export function findDanglingIds(tracedIds: string[], defined: DefinedRequirements): string[] {
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
const dangling = new Set(
tracedIds
.filter((id) => KNOWN_TYPE.test(id))
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id))
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id)),
);
return [...dangling].sort();
@@ -336,10 +331,7 @@ export function findDanglingIds(
*
* TRACES: | DR-093
*/
export function computeCoverage(
tracedIds: string[],
defined: DefinedRequirements
): CoverageResult {
export function computeCoverage(tracedIds: string[], defined: DefinedRequirements): CoverageResult {
// Only the four *requirement* types participate in coverage. UT/IT are test
// identifiers defined in §4 of requirements.md — a different taxonomy, and
// flagging them as orphans would bury real typos in ~60 lines of noise.
@@ -352,10 +344,7 @@ export function computeCoverage(
return {
covered: covered.length,
total: defined.total,
percent:
defined.total === 0
? 0
: Math.round((covered.length / defined.total) * 100),
percent: defined.total === 0 ? 0 : Math.round((covered.length / defined.total) * 100),
orphaned,
};
}
@@ -488,16 +477,14 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
if (cov.orphaned.length > 0) {
console.log("");
console.log(
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
);
console.log(`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`);
console.log(" Fix the TRACES comment or add the requirement.");
}
if (data.dangling && data.dangling.length > 0) {
console.log("");
console.log(
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`,
);
}
@@ -538,9 +525,7 @@ function reportDangling(data: TracesData): number {
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
console.log("");
for (const id of dangling) {
const files = [
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
].sort();
const files = [...new Set((data.requirements[id] ?? []).map((e) => e.file))].sort();
console.log(` ${id}`);
for (const file of files) console.log(` ${file}`);
}
@@ -554,9 +539,7 @@ function reportDangling(data: TracesData): number {
// Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) {
const args = process.argv.slice(2);
const format = args.includes("--format")
? args[args.indexOf("--format") + 1]
: "markdown";
const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "markdown";
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
@@ -583,7 +566,5 @@ if (import.meta.main) {
console.log(generateMarkdown(data));
}
console.error(
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
);
console.error(`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`);
}
+1 -3
View File
@@ -55,9 +55,7 @@ function loadRequirementDescriptions(): Map<string, string> {
}
function changedFiles(range: string): string[] {
const cmd = range
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd)
.split("\n")
.filter((f) => f && existsSync(f));
+24 -7
View File
@@ -34,30 +34,47 @@ function seed(dir: string) {
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"),
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2),
);
// A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"),
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
[
"[package]",
'name = "jellytau"',
'version = "0.0.1"',
"",
"[dependencies]",
'serde = { version = "1.0.100" }',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"),
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
[
"[[package]]",
'name = "serde"',
'version = "1.0.100"',
"",
"[[package]]",
'name = "jellytau"',
'version = "0.0.1"',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n"
"tauri.android.versionCode=1\n",
);
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
fs.writeFileSync(
path.join(dir, "packaging", "arch", "PKGBUILD"),
['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n")
["pkgname=jellytau", "pkgver=0.0.1", "pkgrel=3", 'pkgdesc="x"', ""].join("\n"),
);
}
@@ -74,7 +91,7 @@ function read(rel: string): string {
function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match(
/^tauri\.android\.versionCode=(\d+)$/m
/^tauri\.android\.versionCode=(\d+)$/m,
);
return m ? Number(m[1]) : NaN;
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { readFileSync } from "fs";
import { resolve } from "path";
const config = JSON.parse(
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8")
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8"),
);
const security = config.app.security;
+6 -2
View File
@@ -46,7 +46,8 @@
}
/* Global styles */
html, body {
html,
body {
@apply h-full;
background-color: var(--color-background);
}
@@ -77,5 +78,8 @@ html[data-native-video="active"] [data-app-shell] {
body {
@apply text-white antialiased;
font-family: system-ui, -apple-system, sans-serif;
font-family:
system-ui,
-apple-system,
sans-serif;
}
+1 -4
View File
@@ -9,10 +9,7 @@
and the bottom nav renders under the Android navigation bar. See
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
-->
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>JellyTau</title>
%sveltekit.head%
</head>
+2 -6
View File
@@ -112,9 +112,7 @@ describe("autoplay API", () => {
await setAutoplaySettings(settings);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_set_autoplay_settings"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_autoplay_settings");
expect(call).toBeDefined();
expect(call![1]).toEqual({ userId: "user-1", settings });
});
@@ -155,9 +153,7 @@ describe("autoplay API", () => {
const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke);
const call = invokeSpy.mock.calls.find(
(c) => c[0] === "player_play_next_episode"
);
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_play_next_episode");
expect(call).toBeDefined();
expect(call![1]).toEqual({ item: mockItem });
});
+1 -3
View File
@@ -14,9 +14,7 @@ export async function getAutoplaySettings(): Promise<AutoplaySettings> {
return commands.playerGetAutoplaySettings();
}
export async function setAutoplaySettings(
settings: AutoplaySettings
): Promise<AutoplaySettings> {
export async function setAutoplaySettings(settings: AutoplaySettings): Promise<AutoplaySettings> {
return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings);
}
+14 -22
View File
@@ -77,7 +77,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
sortBy: sortField,
}),
})
}),
);
}
});
@@ -99,7 +99,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
sortOrder: "Descending",
}),
})
}),
);
});
@@ -148,7 +148,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
includeItemTypes: ["Audio", "MusicAlbum"],
}),
})
}),
);
});
@@ -168,7 +168,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
genres: ["Rock", "Jazz"],
}),
})
}),
);
});
@@ -195,7 +195,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
"repository_search",
expect.objectContaining({
query: "query",
})
}),
);
});
@@ -217,7 +217,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
startIndex: 100,
limit: 50,
}),
})
}),
);
});
});
@@ -238,7 +238,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
"repository_search",
expect.objectContaining({
query: "query",
})
}),
);
expect(result.items.length).toBe(2);
@@ -260,7 +260,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({
includeItemTypes: ["Audio"],
}),
})
}),
);
});
@@ -302,7 +302,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
expect.objectContaining({
itemId: "item123",
imageType: "Primary",
})
}),
);
});
@@ -327,23 +327,18 @@ describe("Backend Integration - Refactored Business Logic", () => {
const url = await client.getVideoStreamUrl("item123");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_video_stream_url",
expect.any(Object)
);
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", expect.any(Object));
});
it("should get subtitle URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
const backendUrl =
"https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getSubtitleUrl("item123", "source456", 0);
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_subtitle_url",
expect.any(Object)
);
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", expect.any(Object));
});
it("should get video download URLs from backend", async () => {
@@ -353,10 +348,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
const url = await client.getVideoDownloadUrl("item123", "medium");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_video_download_url",
expect.any(Object)
);
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", expect.any(Object));
});
it("should never expose access token in frontend code", async () => {
+22 -15
View File
@@ -96,7 +96,8 @@ describe("RepositoryClient", () => {
});
it("should pass multiple image options to backend", async () => {
const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
const mockUrl =
"https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
(invoke as any).mockResolvedValueOnce(mockUrl);
const options = {
@@ -133,9 +134,7 @@ describe("RepositoryClient", () => {
it("should throw error if not initialized before getImageUrl", async () => {
const newClient = new RepositoryClient();
await expect(newClient.getImageUrl("item123")).rejects.toThrow(
"Repository not initialized"
);
await expect(newClient.getImageUrl("item123")).rejects.toThrow("Repository not initialized");
});
});
@@ -243,7 +242,7 @@ describe("RepositoryClient", () => {
"repository_get_video_download_url",
expect.objectContaining({
quality,
})
}),
);
}
});
@@ -314,9 +313,7 @@ describe("RepositoryClient", () => {
it("should search with backend search command", async () => {
const mockResult = {
items: [
{ id: "item1", name: "Search Result 1", type: "Audio" },
],
items: [{ id: "item1", name: "Search Result 1", type: "Audio" }],
totalRecordCount: 1,
};
(invoke as any).mockResolvedValueOnce(mockResult);
@@ -353,7 +350,10 @@ describe("RepositoryClient", () => {
});
it("should get downloaded items with camelCase params", async () => {
const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 };
const mockResult = {
items: [{ id: "t1", name: "Track", type: "Audio" }],
totalRecordCount: 1,
};
(invoke as any).mockResolvedValueOnce(mockResult);
const result = await client.getDownloadedItems("album1", { limit: 50 });
@@ -367,7 +367,12 @@ describe("RepositoryClient", () => {
});
it("should get download disk usage from backend", async () => {
const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 };
const mockUsage = {
sizes: { t1: 1000 },
partialContainers: {},
deviceTotalBytes: 1000,
itemCount: 1,
};
(invoke as any).mockResolvedValueOnce(mockUsage);
const usage = await client.getDownloadDiskUsage();
@@ -589,7 +594,9 @@ describe("RepositoryClient", () => {
it("should throw error if not initialized before playlist operations", async () => {
const newClient = new RepositoryClient();
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized");
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow(
"Repository not initialized",
);
await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized");
await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized");
});
@@ -599,9 +606,9 @@ describe("RepositoryClient", () => {
it("should throw error if invoke fails", async () => {
(invoke as any).mockRejectedValueOnce(new Error("Network error"));
await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow(
"Network error"
);
await expect(
client.create("https://server.com", "user1", "token", "server1"),
).rejects.toThrow("Network error");
});
it("should handle missing optional parameters", async () => {
@@ -616,7 +623,7 @@ describe("RepositoryClient", () => {
"repository_get_image_url",
expect.objectContaining({
options: null,
})
}),
);
});
});
+35 -12
View File
@@ -40,7 +40,7 @@ export class RepositoryClient {
serverUrl: string,
userId: string,
accessToken: string,
serverId: string
serverId: string,
): Promise<string> {
log.debug("Creating Rust repository...");
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
@@ -137,7 +137,11 @@ export class RepositoryClient {
}
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
return commands.repositoryGetNextUpEpisodes(
this.ensureHandle(),
seriesId ?? null,
limit ?? null,
);
}
/**
@@ -181,7 +185,11 @@ export class RepositoryClient {
/** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
return commands.repositoryGetRediscoverAlbums(
this.ensureHandle(),
parentId ?? null,
limit ?? null,
);
}
async getGenres(parentId?: string): Promise<Genre[]> {
@@ -229,13 +237,13 @@ export class RepositoryClient {
async getVideoStreamUrl(
itemId: string,
mediaSourceId?: string,
audioStreamIndex?: number
audioStreamIndex?: number,
): Promise<string> {
return commands.repositoryGetVideoStreamUrl(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
audioStreamIndex ?? null
audioStreamIndex ?? null,
);
}
@@ -248,14 +256,14 @@ export class RepositoryClient {
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number
audioStreamIndex?: number,
): Promise<string> {
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null
audioStreamIndex ?? null,
);
}
@@ -282,7 +290,11 @@ export class RepositoryClient {
* Get image URL from backend
* The Rust backend constructs and returns the URL with proper credentials handling
*/
async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> {
async getImageUrl(
itemId: string,
imageType: ImageType = "Primary",
options?: ImageOptions,
): Promise<string> {
return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
}
@@ -294,9 +306,15 @@ export class RepositoryClient {
itemId: string,
mediaSourceId: string,
streamIndex: number,
format: string = "vtt"
format: string = "vtt",
): Promise<string> {
return commands.repositoryGetSubtitleUrl(this.ensureHandle(), itemId, mediaSourceId, streamIndex, format);
return commands.repositoryGetSubtitleUrl(
this.ensureHandle(),
itemId,
mediaSourceId,
streamIndex,
format,
);
}
/**
@@ -307,9 +325,14 @@ export class RepositoryClient {
async getVideoDownloadUrl(
itemId: string,
quality: QualityPreset = "original",
mediaSourceId?: string
mediaSourceId?: string,
): Promise<string> {
return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null);
return commands.repositoryGetVideoDownloadUrl(
this.ensureHandle(),
itemId,
quality,
mediaSourceId ?? null,
);
}
// ===== Favorite Methods (via Rust) =====
+1 -8
View File
@@ -81,11 +81,4 @@ export type PersonType =
| "Lyricist";
export type SessionCommand =
| "PlayPause"
| "Stop"
| "Pause"
| "Unpause"
| "NextTrack"
| "PreviousTrack"
| "Mute"
| "Unmute";
"PlayPause" | "Stop" | "Pause" | "Unpause" | "NextTrack" | "PreviousTrack" | "Mute" | "Unmute";
+27 -9
View File
@@ -19,36 +19,49 @@
const withSearch = $derived(showHeaderSearch({ pathname }));
</script>
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
<header
class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0"
>
<div class="px-4 py-3 flex items-center gap-4">
<!-- Logo -->
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
JellyTau
</a>
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]"> JellyTau </a>
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-1">
<a
href="/"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
'/'
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Home
</a>
<a
href="/library"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith(
'/library',
)
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Library
</a>
<a
href="/downloads"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
'/downloads'
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Downloads
</a>
<a
href="/settings"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
'/settings'
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: 'text-gray-400'}"
>
Settings
</a>
@@ -70,7 +83,12 @@
title="Downloads"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
+34 -14
View File
@@ -1,8 +1,8 @@
<!-- TRACES: UR-039 | DR-045 -->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { library } from '$lib/stores/library';
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { library } from "$lib/stores/library";
// When a className is supplied the parent positions this bar (e.g. inside a
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
@@ -11,9 +11,14 @@
// Determine if a route is active
function isActive(path: string): boolean {
const pathname = $page.url.pathname;
if (path === '/') {
if (path === "/") {
// Home is active only when exactly on / or /home, not /library or /search
return pathname === '/' || (pathname.startsWith('/home') && !pathname.startsWith('/library') && !pathname.startsWith('/search'));
return (
pathname === "/" ||
(pathname.startsWith("/home") &&
!pathname.startsWith("/library") &&
!pathname.startsWith("/search"))
);
}
return pathname.startsWith(path);
}
@@ -24,36 +29,51 @@
<div class="flex items-center justify-around px-4 py-2">
<!-- Home Button -->
<button
onclick={() => goto('/')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') && !isActive('/library') && !isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
onclick={() => goto("/")}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') &&
!isActive('/library') &&
!isActive('/search')
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
aria-label="Home"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
<span class="text-xs">Home</span>
</button>
<!-- Search Button -->
<button
onclick={() => goto('/search')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
onclick={() => goto("/search")}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search')
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
aria-label="Search"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<span class="text-xs">Search</span>
</button>
<!-- Library Button -->
<button
onclick={() => { library.setCurrentLibrary(null); 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'}"
onclick={() => {
library.setCurrentLibrary(null);
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"/>
<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>
+13 -9
View File
@@ -112,7 +112,9 @@
// Inline animation styles
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : "");
const svgStyle = $derived(isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "");
const svgStyle = $derived(
isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "",
);
</script>
<button
@@ -125,19 +127,20 @@
>
{#if isFavorite}
<!-- Filled heart with scale animation -->
<svg
class={svgClass}
style={svgStyle}
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class={svgClass} style={svgStyle} fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<!-- Outline heart -->
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -161,7 +164,8 @@
}
@keyframes bounce-once {
0%, 100% {
0%,
100% {
transform: translateY(0);
}
25% {
+2 -2
View File
@@ -10,7 +10,7 @@
* This escapes any overflow clipping boundaries
*/
function portal(node: HTMLElement) {
const container = document.createElement('div');
const container = document.createElement("div");
document.body.appendChild(container);
container.appendChild(node);
@@ -19,7 +19,7 @@
if (container.parentNode) {
document.body.removeChild(container);
}
}
},
};
}
</script>
+12 -2
View File
@@ -41,7 +41,12 @@
<form onsubmit={handleSubmit} class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
@@ -62,7 +67,12 @@
aria-label="Clear search"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
+7 -2
View File
@@ -44,7 +44,7 @@
<!-- Icon -->
<div class="flex-shrink-0 w-6 h-6 rounded-full {style.bg} flex items-center justify-center">
<svg class="w-4 h-4 {style.color}" fill="currentColor" viewBox="0 0 24 24">
<path d={style.path}/>
<path d={style.path} />
</svg>
</div>
@@ -60,7 +60,12 @@
aria-label="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
+33 -6
View File
@@ -82,7 +82,9 @@
<div
class="fixed inset-0 z-40"
onclick={() => close()}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") close();
}}
role="button"
tabindex="-1"
aria-label="Close account menu"
@@ -110,7 +112,12 @@
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Downloads
</a>
@@ -121,8 +128,18 @@
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</a>
@@ -133,7 +150,12 @@
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8"
/>
</svg>
Display
</a>
@@ -146,7 +168,12 @@
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Sign out
</button>
+28 -17
View File
@@ -15,7 +15,7 @@
let serverName = $state("Jellyfin Server");
// Load session info asynchronously
auth.getCurrentSession().then(session => {
auth.getCurrentSession().then((session) => {
if (session) {
username = session.username ?? "User";
serverName = session.serverName ?? "Jellyfin Server";
@@ -56,7 +56,9 @@
<div
class="fixed inset-0 bg-black/70 z-[100] flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
onkeydown={(e) => {
if (e.key === "Escape") handleBackdropClick();
}}
role="dialog"
aria-modal="true"
aria-labelledby="reauth-title"
@@ -70,13 +72,10 @@
<!-- Header -->
<div class="px-6 pt-6 pb-4 text-center">
<!-- Lock icon -->
<div class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4">
<svg
class="w-8 h-8 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<div
class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4"
>
<svg class="w-8 h-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -86,12 +85,10 @@
</svg>
</div>
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">
Session Expired
</h2>
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">Session Expired</h2>
<p class="text-sm text-gray-400">
Your session on <span class="text-white font-medium">{serverName}</span> has expired.
Please enter your password to continue.
Your session on <span class="text-white font-medium">{serverName}</span> has expired. Please
enter your password to continue.
</p>
</div>
@@ -102,7 +99,10 @@
<div class="block text-sm font-medium text-gray-400 mb-1" id="reauth-username-label">
Username
</div>
<div class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm" aria-labelledby="reauth-username-label">
<div
class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm"
aria-labelledby="reauth-username-label"
>
{username}
</div>
</div>
@@ -140,8 +140,19 @@
>
{#if $isAuthLoading}
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Authenticating...</span>
{:else}
+5 -1
View File
@@ -27,7 +27,11 @@
aria-label={label}
class={`text-gray-400 hover:text-white transition-colors ${className}`}
>
<svg class={`${sizeMap[size]} fill-none stroke-current`} stroke="currentColor" viewBox="0 0 24 24">
<svg
class={`${sizeMap[size]} fill-none stroke-current`}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
+8 -2
View File
@@ -86,11 +86,17 @@
</script>
{#if loading}
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
<div
class="{className} bg-gray-700 animate-pulse"
aria-busy="true"
aria-label="Loading image"
></div>
{:else if error || !imageUrl}
<div class="{className} bg-gray-800 flex items-center justify-center">
<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"/>
<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}
@@ -25,7 +25,9 @@
playlist: { singular: "playlist", plural: "playlists" },
};
const labels = $derived(itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` });
const labels = $derived(
itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` },
);
const label = $derived(count === 1 ? labels.singular : labels.plural);
</script>
+13 -9
View File
@@ -46,17 +46,17 @@
// Initialize scroll position
$effect(() => {
if (scrollContainer) {
const idx = Math.max(0, items.findIndex((i) => i.value === selectedValue));
const idx = Math.max(
0,
items.findIndex((i) => i.value === selectedValue),
);
scrollContainer.scrollTop = idx * itemHeight;
selectedIndex = idx;
}
});
</script>
<div
class="relative overflow-hidden rounded-lg"
style="height: {containerHeight}px"
>
<div class="relative overflow-hidden rounded-lg" style="height: {containerHeight}px">
<!-- Highlight band for center item -->
<div
class="absolute left-0 right-0 pointer-events-none z-10 border-y border-[var(--color-jellyfin)]/40 bg-[var(--color-jellyfin)]/5 rounded"
@@ -64,8 +64,12 @@
></div>
<!-- Fade gradients -->
<div class="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
<div
class="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[var(--color-surface)] to-transparent z-20 pointer-events-none"
></div>
<div
class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-[var(--color-surface)] to-transparent z-20 pointer-events-none"
></div>
<!-- Scrollable container -->
<div
@@ -85,8 +89,8 @@
onclick={() => scrollToIndex(i)}
class="w-full snap-center flex items-center justify-center transition-all duration-150
{i === selectedIndex
? 'text-white text-2xl font-bold'
: 'text-gray-500 text-lg font-medium opacity-60'}"
? 'text-white text-2xl font-bold'
: 'text-gray-500 text-lg font-medium opacity-60'}"
style="height: {itemHeight}px"
>
{item.label}
+1 -1
View File
@@ -146,7 +146,7 @@ describe("SearchBar", () => {
it("should handle special characters in value", () => {
render(SearchBar, {
props: {
value: '@$%^&*()',
value: "@$%^&*()",
placeholder: "Search...",
onInput: vi.fn(),
},
@@ -59,11 +59,11 @@
function getSourceBorderColor(): string {
// Green for user downloads, blue for auto-cached
return download.downloadSource === 'user' ? 'border-green-500/50' : 'border-blue-500/50';
return download.downloadSource === "user" ? "border-green-500/50" : "border-blue-500/50";
}
function getSourceLabel(): string {
return download.downloadSource === 'user' ? 'Downloaded' : 'Auto-Cached';
return download.downloadSource === "user" ? "Downloaded" : "Auto-Cached";
}
async function handlePause() {
@@ -107,7 +107,9 @@
}
</script>
<div class="bg-[var(--color-surface)] rounded-lg p-4 hover:bg-[var(--color-surface-hover)] transition-colors border-l-4 {getSourceBorderColor()}">
<div
class="bg-[var(--color-surface)] rounded-lg p-4 hover:bg-[var(--color-surface-hover)] transition-colors border-l-4 {getSourceBorderColor()}"
>
<div class="flex items-center gap-4">
<!-- Status Indicator -->
<div class="w-2 h-2 rounded-full {getStatusColor()} flex-shrink-0"></div>
@@ -115,12 +117,32 @@
<!-- Media Type Icon -->
<div class="flex-shrink-0 text-gray-500">
{#if download.mediaType === "video"}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" />
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5"
/>
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"
/>
</svg>
{/if}
</div>
@@ -135,10 +157,16 @@
<p class="text-xs text-gray-400 truncate">
{download.seriesName}
{#if download.seasonNumber !== undefined && download.episodeNumber !== undefined}
<span class="text-gray-500"> • S{String(download.seasonNumber).padStart(2, '0')}E{String(download.episodeNumber).padStart(2, '0')}</span>
<span class="text-gray-500">
• S{String(download.seasonNumber).padStart(2, "0")}E{String(
download.episodeNumber,
).padStart(2, "0")}</span
>
{/if}
{#if download.qualityPreset && download.qualityPreset !== "original"}
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase">{download.qualityPreset}</span>
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase"
>{download.qualityPreset}</span
>
{/if}
</p>
{:else if download.mediaType === "video"}
@@ -146,20 +174,27 @@
<p class="text-xs text-gray-400 truncate">
Movie
{#if download.qualityPreset && download.qualityPreset !== "original"}
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase">{download.qualityPreset}</span>
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase"
>{download.qualityPreset}</span
>
{/if}
</p>
{:else if download.artistName || download.albumName}
<!-- Audio: Show artist and album -->
<p class="text-xs text-gray-400 truncate">
{download.artistName}{download.artistName && download.albumName ? ' • ' : ''}{download.albumName}
{download.artistName}{download.artistName && download.albumName
? " • "
: ""}{download.albumName}
</p>
{/if}
</div>
<div class="flex items-center gap-2 ml-2 flex-shrink-0">
<span class="text-xs text-gray-400">{getStatusText()}</span>
{#if download.downloadSource === 'auto'}
<span class="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded uppercase font-semibold" title="Automatically cached">Auto</span>
{#if download.downloadSource === "auto"}
<span
class="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded uppercase font-semibold"
title="Automatically cached">Auto</span
>
{/if}
</div>
</div>
@@ -210,7 +245,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -231,7 +272,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -242,7 +289,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -253,7 +306,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Delete download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -268,7 +327,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Retry download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -282,7 +347,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Delete failed download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -115,8 +115,18 @@
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
>
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
<svg
class="h-5 w-5 text-[var(--color-jellyfin)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z"
/>
</svg>
<p class="text-sm text-gray-200">
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
@@ -131,10 +141,7 @@
{#if currentLibrary}
<!-- Inside a library: breadcrumb back to the library list. -->
<div class="flex items-center gap-2 text-sm">
<button
onclick={backToLibraries}
class="text-gray-400 hover:text-white transition-colors"
>
<button onclick={backToLibraries} class="text-gray-400 hover:text-white transition-colors">
Downloaded
</button>
<span class="text-gray-600">/</span>
@@ -163,8 +170,18 @@
{:else if $downloadedLibraries.length === 0}
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
<svg
class="mx-auto mb-4 h-14 w-14 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
/>
</svg>
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
<p class="mt-2 text-sm text-gray-500">
@@ -185,12 +202,26 @@
onclick={() => openLibrary(lib)}
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
>
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
<div
class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center"
>
<svg
class="h-10 w-10 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
</div>
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
<p
class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors"
>
{lib.name}
</p>
</button>
+4 -8
View File
@@ -21,8 +21,7 @@
if (!scrollContainer) return;
showLeftArrow = scrollContainer.scrollLeft > 0;
showRightArrow =
scrollContainer.scrollLeft <
scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
scrollContainer.scrollLeft < scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
}
function scrollLeft() {
@@ -39,10 +38,7 @@
<div class="flex items-center justify-between px-4">
<h2 class="text-2xl font-semibold text-white">{title}</h2>
{#if showAll}
<button
onclick={showAll}
class="text-sm text-gray-400 hover:text-white transition-colors"
>
<button onclick={showAll} class="text-sm text-gray-400 hover:text-white transition-colors">
See all
</button>
{/if}
@@ -74,7 +70,7 @@
aria-label="Scroll left"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
</button>
{/if}
@@ -86,7 +82,7 @@
aria-label="Scroll right"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
{/if}
+32 -10
View File
@@ -29,13 +29,21 @@
// 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] };
return {
itemId: currentItem.id,
imageType: "Backdrop" as const,
tag: currentItem.backdropImageTags[0],
};
}
// 2. For episodes, try series/season backdrops
if (currentItem.kind === "episode") {
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
return {
itemId: currentItem.seriesId,
imageType: "Backdrop" as const,
tag: currentItem.parentBackdropImageTags[0],
};
}
if (currentItem.seriesId) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
@@ -153,7 +161,9 @@
class="absolute inset-0 w-full h-full object-cover"
/>
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
<div
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"
></div>
{/if}
<!-- Gradient overlay -->
@@ -175,7 +185,9 @@
{#if currentItem.communityRating}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
{currentItem.communityRating.toFixed(1)}
</span>
@@ -200,7 +212,7 @@
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
Play
</button>
@@ -225,12 +237,17 @@
<!-- Navigation -->
{#if items.length > 1}
<!-- Indicators / Location Bar -->
<div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex gap-3 bg-black/40 backdrop-blur-sm px-4 py-2 rounded-full">
<div
class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex gap-3 bg-black/40 backdrop-blur-sm px-4 py-2 rounded-full"
>
{#each items as _, idx}
<button
onclick={() => goToIndex(idx)}
class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx === currentIndex ? 'bg-white w-12' : 'bg-white/50 w-8'}"
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ''}`}
class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx ===
currentIndex
? 'bg-white w-12'
: 'bg-white/50 w-8'}"
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ""}`}
></button>
{/each}
</div>
@@ -242,7 +259,12 @@
aria-label="Previous item"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
@@ -252,7 +274,7 @@
aria-label="Next item"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
{/if}
@@ -19,36 +19,23 @@
// Calculate download status for all tracks in album
const downloadStatuses = $derived(
tracks.map((track) =>
Object.values($downloads.downloads).find((d) => d.itemId === track.id)
)
tracks.map((track) => Object.values($downloads.downloads).find((d) => d.itemId === track.id)),
);
const completedCount = $derived(
downloadStatuses.filter((d) => d?.status === "completed").length
);
const completedCount = $derived(downloadStatuses.filter((d) => d?.status === "completed").length);
const downloadingCount = $derived(
downloadStatuses.filter(
(d) => d?.status === "downloading" || d?.status === "pending"
).length
downloadStatuses.filter((d) => d?.status === "downloading" || d?.status === "pending").length,
);
const failedCount = $derived(
downloadStatuses.filter((d) => d?.status === "failed").length
);
const failedCount = $derived(downloadStatuses.filter((d) => d?.status === "failed").length);
const totalProgress = $derived(() => {
if (tracks.length === 0) return 0;
const activeDownloads = downloadStatuses.filter(
(d) => d?.status === "downloading"
);
const activeDownloads = downloadStatuses.filter((d) => d?.status === "downloading");
if (activeDownloads.length === 0) return completedCount / tracks.length;
const downloadingProgress = activeDownloads.reduce(
(sum, d) => sum + (d?.progress || 0),
0
);
const downloadingProgress = activeDownloads.reduce((sum, d) => sum + (d?.progress || 0), 0);
return (completedCount + downloadingProgress) / tracks.length;
});
@@ -77,10 +64,7 @@
} else if (isDownloading) {
// Cancel all active downloads for this album
for (const status of downloadStatuses) {
if (
status?.id &&
(status.status === "downloading" || status.status === "pending")
) {
if (status?.id && (status.status === "downloading" || status.status === "pending")) {
await downloads.cancel(status.id);
}
}
@@ -184,13 +168,7 @@
</svg>
{:else if isFullyDownloaded}
<!-- Checkmark icon -->
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else if failedCount > 0}
@@ -210,13 +188,7 @@
</svg>
{:else}
<!-- Download icon -->
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -88,7 +88,7 @@
// x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic.
lastClientX,
clientY
clientY,
);
const letter = el?.getAttribute?.("data-letter");
return letter ?? null;
@@ -135,7 +135,9 @@
disabled={!enabled}
onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
{enabled
? 'text-gray-400 hover:text-[var(--color-jellyfin)]'
: 'text-gray-700 cursor-default'}
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`}
>
@@ -46,9 +46,9 @@
includeItemTypes: ["MusicAlbum"],
limit: 50,
sortBy: "DateCreated",
sortOrder: "Descending"
sortOrder: "Descending",
});
albums = albumsResult.items.filter(item => item.kind === "album");
albums = albumsResult.items.filter((item) => item.kind === "album");
} catch (e) {
log.warn("Failed to load albums:", e);
} finally {
@@ -61,9 +61,9 @@
includeItemTypes: ["Audio"],
limit: 10,
sortBy: "CommunityRating",
sortOrder: "Descending"
sortOrder: "Descending",
});
topTracks = tracksResult.items.filter(item => item.kind === "track");
topTracks = tracksResult.items.filter((item) => item.kind === "track");
} catch (e) {
log.warn("Failed to load tracks:", e);
} finally {
@@ -78,10 +78,10 @@
genres: artist.genres.slice(0, 2),
limit: 12,
sortBy: "CommunityRating",
sortOrder: "Descending"
sortOrder: "Descending",
});
relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.kind === "artist")
.filter((item) => item.id !== artist.id && item.kind === "artist")
.slice(0, 6);
}
} catch (e) {
@@ -110,7 +110,9 @@
maxWidth={1920}
class="w-full h-full object-cover opacity-40"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"
></div>
</div>
{/if}
@@ -168,11 +170,10 @@
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each albums as album (album.id)}
<a
href="/library/{album.id}"
class="group cursor-pointer"
>
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
<a href="/library/{album.id}" class="group cursor-pointer">
<div
class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity"
>
{#if album.imageId}
<CachedImage
itemId={album.id}
@@ -184,7 +185,9 @@
/>
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(album.name, 40)}
</p>
{#if album.productionYear}
@@ -226,11 +229,10 @@
{:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each relatedArtists as relatedArtist (relatedArtist.id)}
<a
href="/library/{relatedArtist.id}"
class="group text-center"
>
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
<a href="/library/{relatedArtist.id}" class="group text-center">
<div
class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity"
>
{#if relatedArtist.imageId}
<CachedImage
itemId={relatedArtist.id}
@@ -242,7 +244,9 @@
/>
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{relatedArtist.name}
</p>
</a>
@@ -29,9 +29,7 @@
onNavigate,
}: Props = $props();
const linkable = $derived(
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
);
const linkable = $derived((artistItems ?? []).filter((a) => a.id && a.id.trim() !== ""));
function handleClick(artistId: string, e: MouseEvent) {
e.preventDefault();
@@ -95,7 +95,9 @@
</div>
<!-- Name and role -->
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{person.name}
</p>
{#if person.role}
@@ -43,7 +43,7 @@
!confirm(
`Erase watch history for ${subject}?\n\n` +
"Every episode is marked unwatched and resume positions are cleared. " +
"This cannot be undone."
"This cannot be undone.",
)
) {
return;
@@ -55,9 +55,7 @@
onCleared?.();
} catch (e) {
log.error("Failed to clear watch history:", e);
alert(
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
);
alert(`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`);
} finally {
busy = false;
}
@@ -81,11 +79,7 @@
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else}
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class={size === "lg" ? "w-5 h-5" : "w-4 h-4"} fill="currentColor" viewBox="0 0 24 24">
<path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
+7 -12
View File
@@ -5,27 +5,22 @@
interface Props {
people: Person[];
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
label?: string; // e.g., "Directed by", "Written by"
maxShow?: number; // Default: 3
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
label?: string; // e.g., "Directed by", "Written by"
maxShow?: number; // Default: 3
}
let {
people,
roleFilter,
label,
maxShow = 3
}: Props = $props();
let { people, roleFilter, label, maxShow = 3 }: Props = $props();
// Filter and limit people by role
const filteredPeople = $derived(
people
.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
.slice(0, maxShow)
.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
.slice(0, maxShow),
);
const totalMatching = $derived(
people.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length
people.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length,
);
function handlePersonClick(personId: string, e: MouseEvent) {
@@ -24,13 +24,20 @@
className?: string;
}
let { itemId, itemName = "", artistName = "", albumName = "", size = "md", className = "" }: Props = $props();
let {
itemId,
itemName = "",
artistName = "",
albumName = "",
size = "md",
className = "",
}: Props = $props();
let isProcessing = $state(false);
// Find download for this item
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
);
const status = $derived(downloadInfo?.status || "not_downloaded");
@@ -130,5 +137,12 @@
</script>
<div class="p-2 rounded-full">
<DownloadButtonCore {size} state={buttonState} title={getTitle()} onClick={handleClick} {isProcessing} {className} />
<DownloadButtonCore
{size}
state={buttonState}
title={getTitle()}
onClick={handleClick}
{isProcessing}
{className}
/>
</div>
@@ -22,7 +22,14 @@
className?: string;
}
let { state, size = "md", title, onClick, isProcessing = false, className = "" }: Props = $props();
let {
state,
size = "md",
title,
onClick,
isProcessing = false,
className = "",
}: Props = $props();
const sizeMap = {
sm: { icon: "w-4 h-4", ring: "w-8 h-8" },
@@ -46,13 +53,21 @@
onclick={onClick}
disabled={isProcessing || state.status === "downloading"}
aria-label={title}
title={title}
{title}
class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`}
>
{#if state.status === "downloading"}
<!-- Progress Ring -->
<svg class="{sizeMap[size].ring} -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="2" class="opacity-20" />
<circle
cx="18"
cy="18"
r="15"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-20"
/>
<circle
cx="18"
cy="18"
@@ -67,7 +82,13 @@
style="transition: stroke-dashoffset 0.3s ease;"
/>
<!-- 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">
<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>
@@ -79,7 +100,9 @@
{:else if state.status === "failed"}
<!-- Error Icon -->
<svg class={sizeMap[size].icon} fill="currentColor" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
/>
</svg>
{:else if state.status === "pending"}
<!-- Pending Icon (clock) -->
@@ -90,7 +113,12 @@
{:else}
<!-- Download Icon -->
<svg class={sizeMap[size].icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{/if}
</button>
@@ -53,13 +53,21 @@
// 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] };
return {
itemId: episode.id,
imageType: "Backdrop" as const,
tag: episode.backdropImageTags[0],
};
}
if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
}
if (series?.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
return {
itemId: series.id,
imageType: "Backdrop" as const,
tag: series.backdropImageTags[0],
};
}
return null;
});
@@ -67,8 +75,8 @@
// Cast and genres are the episode's own when the server sent them, else the
// series' — a list-level episode fetch often carries neither, and an empty
// Cast row on an episode of a show with a known cast reads as broken.
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
const people = $derived(episode.people?.length ? episode.people : (series?.people ?? []));
const genres = $derived(episode.genres?.length ? episode.genres : (series?.genres ?? []));
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
// off the series rather than the episode.
@@ -77,7 +85,7 @@
const seasonHref = $derived(
series && episode.parentIndexNumber != null
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
: null
: null,
);
function formatDuration(ms?: number | null): string {
@@ -108,9 +116,7 @@
goto(`/library/${series.id}?episode=${ep.id}`);
}
const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode));
</script>
@@ -128,12 +134,16 @@
class="absolute inset-0 w-full h-full object-cover"
/>
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
<div
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"
></div>
{/if}
<!-- Gradient overlay -->
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"
></div>
<!-- Back button -->
{#if onBack}
@@ -143,7 +153,12 @@
title="Back to series"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
@@ -156,7 +171,10 @@
{#if seriesName}
<p class="text-lg">
{#if seriesHref}
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
<a
href={seriesHref}
class="text-gray-300 hover:text-white hover:underline transition-colors"
>
{seriesName}
</a>
{:else}
@@ -192,7 +210,9 @@
{#if episode.communityRating}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
{episode.communityRating.toFixed(1)}
</span>
@@ -200,7 +220,7 @@
{#if episode.userData?.isPlayed}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
Watched
</span>
@@ -218,10 +238,7 @@
{#if progress > 0 && progress < 95}
<div class="w-64">
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
></div>
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">
{Math.round(progress)}% watched
@@ -237,7 +254,7 @@
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
@@ -271,89 +288,106 @@
strip — continuation content comes before discovery content
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
{#if hasEpisodeStrip}
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
disabled={isCurrent}
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
/>
<div
class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"
>
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent
? 'ring-2 ring-yellow-400 rounded-lg'
: ''}"
disabled={isCurrent}
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<CachedImage
itemId={ep.id}
imageType="Primary"
tag={ep.imageId}
maxWidth={400}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent
? ''
: 'group-hover/card:scale-105'}"
/>
<!-- Hover overlay -->
{#if !isCurrent}
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<!-- Hover overlay -->
{#if !isCurrent}
<div
class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center"
>
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div
class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
>
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
</div>
{/if}
{/if}
<!-- Now Playing indicator -->
{#if isCurrent}
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
Current
</div>
{/if}
<!-- Progress bar -->
{#if epProgress > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<!-- Now Playing indicator -->
{#if isCurrent}
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {epProgress}%"
></div>
</div>
{/if}
class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold"
>
Current
</div>
{/if}
<!-- Played indicator -->
{#if ep.userData?.isPlayed}
<div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
{/if}
</div>
<!-- Progress bar -->
{#if epProgress > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {epProgress}%"></div>
</div>
{/if}
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
</p>
<!-- Played indicator -->
{#if ep.userData?.isPlayed}
<div class="absolute top-2 right-2">
<svg
class="w-5 h-5 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
{/if}
</div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2">
{ep.overview}
</p>
{/if}
</div>
</button>
{/each}
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p
class="text-white font-medium truncate {isCurrent
? 'text-yellow-400'
: 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors"
>
{ep.name}
</p>
</div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2">
{ep.overview}
</p>
{/if}
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
@@ -45,9 +45,8 @@ vi.mock("$lib/stores/downloads", () => ({
}));
vi.mock("$lib/stores/favorites", async () => {
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>(
"$lib/stores/favorites"
);
const actual =
await vi.importActual<typeof import("$lib/stores/favorites")>("$lib/stores/favorites");
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
});
+30 -21
View File
@@ -22,13 +22,7 @@
onWatchedChanged?: () => void;
}
let {
episode,
focused = false,
current = false,
onclick,
onWatchedChanged,
}: Props = $props();
let { episode, focused = false, current = false, onclick, onWatchedChanged }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
@@ -43,12 +37,12 @@
// Check if this episode is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === episode.id)
Object.values($downloads.downloads).find((d) => d.itemId === episode.id),
);
const isDownloaded = $derived(downloadInfo?.status === "completed");
const isDownloading = $derived(
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
@@ -74,7 +68,9 @@
{onclick}
>
<!-- Thumbnail -->
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<div
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
>
<CachedImage
itemId={episode.id}
imageType="Primary"
@@ -85,11 +81,15 @@
/>
<!-- Hover overlay with play icon -->
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
<div
class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center"
>
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
<div class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<div
class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
>
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
@@ -98,10 +98,7 @@
<!-- Progress bar -->
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress()}%"
></div>
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
@@ -110,7 +107,13 @@
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
{#if isDownloaded}
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
@@ -153,7 +156,9 @@
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
{episodeNumber}.
</span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
<h3
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
@@ -165,8 +170,12 @@
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</div>
@@ -72,9 +72,7 @@
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) {
const match = genres.find(
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
const match = genres.find((g) => g.name.toLowerCase() === requestedGenre.toLowerCase());
if (match) {
await loadGenreItems(match);
}
@@ -157,14 +155,21 @@
}
}
const aspectRatioClass = $derived(config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square");
const aspectRatioClass = $derived(
config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square",
);
const gridColsClass = $derived(
config.itemDisplayMode === "poster"
? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6",
);
const searchPlaceholder = $derived(
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
);
const noItemsMessage = $derived(
config.noItemsMessage ||
`No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`,
);
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
const noItemsMessage = $derived(config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`);
</script>
<div class="space-y-6">
@@ -188,7 +193,7 @@
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
{#if !loading && filteredGenres.length > 0}
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} />
<ResultsCounter count={filteredGenres.length} itemType="genre" {searchQuery} />
{/if}
<!-- Genres Grid -->
@@ -220,7 +225,9 @@
{@html config.genreIcon}
</svg>
</div>
<p class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{genre.name}
</p>
</button>
@@ -244,11 +251,16 @@
</div>
{:else}
<div>
<ResultsCounter count={genreItems.length} itemType={config.itemTypes[0]?.toLowerCase() || "item"} />
<ResultsCounter
count={genreItems.length}
itemType={config.itemTypes[0]?.toLowerCase() || "item"}
/>
<div class="grid {gridColsClass} gap-4 mt-4">
{#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">
<div
class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2"
>
<CachedImage
itemId={item.id}
imageType="Primary"
@@ -258,7 +270,9 @@
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">
<p
class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(item.name, 40)}
</p>
{#if item.productionYear}
@@ -98,9 +98,7 @@ describe("GenericMediaListPage — two-phase search", () => {
await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull());
// Cache-only phase: nothing to show yet (the results counter reads zero).
await waitFor(() =>
expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()
);
await waitFor(() => expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy());
// Phase 2: backend emits the merged cache+server union for this request.
expect(capturedRequestId).toBeTypeOf("number");
@@ -116,9 +114,7 @@ describe("GenericMediaListPage — two-phase search", () => {
// The server result must now be reflected in the list. Old code (no
// listener) never reached this state — the count stayed at zero.
await waitFor(() =>
expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()
);
await waitFor(() => expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy());
});
it("ignores a search-event whose requestId is stale", async () => {
@@ -135,7 +135,7 @@
includeItemTypes: [config.itemType],
limit: 10000,
},
requestId
requestId,
);
// Only apply if this is still the active query.
if (requestId === searchRequestId) {
@@ -204,7 +204,9 @@
navigateUp(config.backPath);
}
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
const searchPlaceholder = $derived(
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
);
function handleItemClick(item: MediaItem | Library) {
// Navigate to detail page for browseable items
@@ -230,10 +232,7 @@
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
const isAlphaSorted = $derived(sortBy === "SortName");
const showAlphaBar = $derived(
isAlphaSorted &&
!loading &&
!debouncedSearchQuery.trim() &&
items.length > 30
isAlphaSorted && !loading && !debouncedSearchQuery.trim() && items.length > 30,
);
const availableLetters = $derived.by(() => {
@@ -264,9 +263,7 @@
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived(
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
);
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
</script>
<div class="space-y-6">
@@ -295,8 +292,8 @@
aria-pressed={favoritesOnly}
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
{favoritesOnly
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
>
<svg
@@ -306,7 +303,11 @@
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
Favourites
</button>
@@ -320,7 +321,7 @@
<!-- Results Count -->
{#if !loading}
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} {searchQuery} />
{/if}
<!-- Items List/Grid -->
@@ -349,7 +350,13 @@
<div class="flex gap-2">
<div bind:this={gridWrapper} class="flex-1 min-w-0">
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
<LibraryGrid
{items}
onItemClick={handleItemClick}
musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(
config.itemType,
)}
/>
{:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
@@ -200,7 +200,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -231,7 +231,7 @@ describe("GenericMediaListPage", () => {
includeItemTypes: ["Audio"],
limit: 10000,
}),
expect.any(Number)
expect.any(Number),
);
});
});
@@ -248,7 +248,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -266,11 +266,14 @@ describe("GenericMediaListPage", () => {
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
includeItemTypes: ["Audio"],
sortBy: "SortName",
sortOrder: "Ascending",
}));
expect(mockGetItemsFn).toHaveBeenCalledWith(
"lib123",
expect.objectContaining({
includeItemTypes: ["Audio"],
sortBy: "SortName",
sortOrder: "Ascending",
}),
);
});
});
@@ -293,7 +296,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -342,7 +345,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -363,10 +366,13 @@ describe("GenericMediaListPage", () => {
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
sortBy: "SortName",
sortOrder: "Ascending",
}));
expect(mockGetItemsFn).toHaveBeenCalledWith(
"lib123",
expect.objectContaining({
sortBy: "SortName",
sortOrder: "Ascending",
}),
);
});
});
@@ -382,7 +388,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -428,7 +434,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -446,9 +452,12 @@ describe("GenericMediaListPage", () => {
});
await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
includeItemTypes: ["Audio"],
}));
expect(mockGetItemsFn).toHaveBeenCalledWith(
"lib123",
expect.objectContaining({
includeItemTypes: ["Audio"],
}),
);
});
});
@@ -469,7 +478,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -497,7 +506,7 @@ describe("GenericMediaListPage", () => {
expect.objectContaining({
includeItemTypes: ["MusicAlbum"],
}),
expect.any(Number)
expect.any(Number),
);
});
});
@@ -506,10 +515,10 @@ describe("GenericMediaListPage", () => {
describe("Loading State", () => {
it("should show loading indicator during data fetch", async () => {
const mockGetItemsFn = vi.fn(
() => new Promise((resolve) => setTimeout(
() => resolve({ items: [], totalRecordCount: 0 }),
100
))
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ items: [], totalRecordCount: 0 }), 100),
),
);
const mockRepository = {
@@ -518,7 +527,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -554,7 +563,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
const config = {
@@ -589,7 +598,7 @@ describe("GenericMediaListPage", () => {
};
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any
mockRepository as any,
);
// Deliver a null current library for this test only.
@@ -38,8 +38,8 @@
onclick={() => handleToggleGenre(genre.name)}
class="px-3 py-1 rounded-full text-sm transition-colors
{isSelected
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
>
{genre.name}
</button>
+9 -16
View File
@@ -6,17 +6,12 @@
interface Props {
genres: string[];
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemKind?: MediaKind; // Determines which genre browse page to open
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemKind?: MediaKind; // Determines which genre browse page to open
}
let {
genres,
maxShow,
clickable = true,
itemKind
}: Props = $props();
let { genres, maxShow, clickable = true, itemKind }: Props = $props();
// Map the item kind to its genre-browse surface. Video genres are a tab of
// the library page now, not a route of their own (DR-105); linking straight
@@ -39,13 +34,9 @@
}
}
const displayGenres = $derived(
maxShow ? genres.slice(0, maxShow) : genres
);
const displayGenres = $derived(maxShow ? genres.slice(0, maxShow) : genres);
const hiddenCount = $derived(
maxShow && genres.length > maxShow ? genres.length - maxShow : 0
);
const hiddenCount = $derived(maxShow && genres.length > maxShow ? genres.length - maxShow : 0);
function handleGenreClick(genre: string) {
if (clickable) {
@@ -60,7 +51,9 @@
<button
onclick={() => handleGenreClick(genre)}
disabled={!clickable}
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable ? 'hover:bg-[var(--color-surface-hover)] cursor-pointer' : 'cursor-default'}"
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable
? 'hover:bg-[var(--color-surface-hover)] cursor-pointer'
: 'cursor-default'}"
>
{genre}
</button>
+25 -7
View File
@@ -23,7 +23,17 @@
onItemRemove?: (item: MediaItem | Library) => void;
}
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
let {
items,
title,
loading = false,
showViewToggle = true,
musicContent = false,
onItemClick,
sizeLabelFor,
downloadedBadgeFor,
onItemRemove,
}: Props = $props();
</script>
<div class="space-y-4">
@@ -38,22 +48,26 @@
<div class="flex gap-1">
<button
onclick={() => library.setViewMode("grid")}
class="p-2 rounded transition-colors {$viewMode === 'grid' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
class="p-2 rounded transition-colors {$viewMode === 'grid'
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:bg-white/10 hover:text-white'}"
aria-label="Grid view"
title="Grid view"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/>
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
</svg>
</button>
<button
onclick={() => library.setViewMode("list")}
class="p-2 rounded transition-colors {$viewMode === 'list' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
class="p-2 rounded transition-colors {$viewMode === 'list'
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:bg-white/10 hover:text-white'}"
aria-label="List view"
title="List view"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/>
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z" />
</svg>
</button>
</div>
@@ -64,7 +78,11 @@
<div class="flex gap-4 overflow-hidden">
{#each Array(6) as _}
<div class="w-36 flex-shrink-0 animate-pulse">
<div class="{musicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"></div>
<div
class="{musicContent
? 'aspect-square'
: 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"
></div>
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
<div class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
</div>
@@ -75,7 +93,7 @@
<p>No items found</p>
</div>
{:else if $viewMode === "list"}
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
<LibraryListView {items} showProgress={true} {onItemClick} />
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each items as item, index (item.id)}
@@ -19,7 +19,11 @@
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
return "imageId" in item
? (item.imageId ?? undefined)
: "imageTag" in item
? (item.imageTag ?? undefined)
: undefined;
}
function getSubtitle(item: MediaItem | Library): string {
@@ -31,7 +35,9 @@
case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode":
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
return item.seriesName
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
: "";
case "Movie":
case "Series":
return item.productionYear?.toString() || "";
@@ -40,9 +46,14 @@
}
}
function getProgress(item: MediaItem | Library): number {
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
if (
!showProgress ||
!("userData" in item) ||
!item.userData ||
!("durationMs" in item) ||
!item.durationMs
) {
return 0;
}
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
@@ -65,7 +76,8 @@
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
{@const downloadInfo = getDownloadInfo(item.id)}
{@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
{@const isDownloading =
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
<button
type="button"
@@ -79,7 +91,9 @@
</span>
<!-- Thumbnail -->
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
<div
class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative"
>
<CachedImage
itemId={item.id}
imageType="Primary"
@@ -90,9 +104,11 @@
/>
<!-- 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">
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
@@ -106,7 +122,9 @@
<!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(item.name, 56)}
</p>
{#if subtitle}
@@ -118,19 +136,41 @@
{#if showDownloadStatus && (isDownloaded || isDownloading)}
{#if isDownloaded}
<span title="Downloaded">
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-label="Downloaded">
<svg
class="w-4 h-4 text-green-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
aria-label="Downloaded"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</span>
{:else if isDownloading}
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3" class="text-blue-500" />
<circle
cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
opacity="0.3"
class="text-blue-500"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))}
stroke-linecap="round" class="text-blue-500 transition-all duration-300"
stroke-linecap="round"
class="text-blue-500 transition-all duration-300"
/>
</svg>
</div>
@@ -139,8 +179,12 @@
<!-- Played indicator -->
{#if isPlayed}
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
@@ -35,8 +35,8 @@
aria-current={view === active ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{view === active
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{labels[view]}
</button>
+135 -39
View File
@@ -57,7 +57,19 @@
aspect?: "square" | "video" | "poster";
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
let {
item,
size = "medium",
showProgress = false,
showDownloadStatus = true,
sizeLabel,
downloadedBadge,
onRemove,
onclick,
onLongPress,
showFavorite = true,
aspect,
}: Props = $props();
// Long-press detection. We arm a timer on pointerdown; if it fires before the
// pointer is released (or moves too far), we treat it as a long press and set a
@@ -114,12 +126,12 @@
// Check if this item is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
Object.values($downloads.downloads).find((d) => d.itemId === item.id),
);
const isDownloaded = $derived(downloadInfo?.status === "completed");
const isDownloading = $derived(
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
@@ -135,14 +147,14 @@
// 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
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
);
// The heart is about an item, so libraries never get one, and a greyed
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
const isFavorited = $derived(
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false,
);
let queueError = $state<string | null>(null);
@@ -171,7 +183,7 @@
undefined,
media.name,
media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined
media.albumName ?? undefined,
);
} catch (err) {
log.error("Failed to queue download:", err);
@@ -186,7 +198,11 @@
};
const isMusicType = $derived(
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
"kind" in item &&
(item.kind === "track" ||
item.kind === "album" ||
item.kind === "artist" ||
item.kind === "playlist"),
);
const FIXED_ASPECT = {
@@ -201,11 +217,13 @@
return isMusicType ? "aspect-square" : "aspect-[2/3]";
}
// Library
return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video";
return "collectionType" in item && item.collectionType === "music"
? "aspect-square"
: "aspect-video";
});
const imageTag = $derived(
"imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
"imageId" in item ? item.imageId : "imageTag" in item ? item.imageTag : undefined,
);
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
@@ -226,7 +244,9 @@
case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode":
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
return item.seriesName
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
: "";
case "Movie":
return item.productionYear?.toString() || "";
case "Series":
@@ -241,7 +261,9 @@
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'}"
class="group/card flex flex-col text-left {sizeClasses[
size
]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined}
onclick={isServerOnly ? undefined : handleClick}
onpointerdown={isServerOnly ? undefined : handlePointerDown}
@@ -250,23 +272,35 @@
onpointercancel={isServerOnly ? undefined : handlePointerUp}
oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined}
>
<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">
<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
itemId={item.id}
imageType="Primary"
tag={imageTag}
maxWidth={maxWidth}
{maxWidth}
alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly ? 'opacity-40 grayscale' : ''}"
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 (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">
<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">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
@@ -275,10 +309,7 @@
<!-- Progress bar -->
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress()}%"
></div>
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
@@ -288,7 +319,7 @@
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
{#if "userData" in item && item.userData?.isPlayed}
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
{#if showHeart}
@@ -319,7 +350,13 @@
{#if isDownloaded}
<!-- Downloaded badge -->
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
@@ -348,7 +385,13 @@
class="transition-all duration-300"
/>
</svg>
<svg class="absolute inset-0 m-auto w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class="absolute inset-0 m-auto w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
@@ -360,13 +403,20 @@
{#if onRemove}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
onclick={(e) => {
e.stopPropagation();
onRemove?.();
}}
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
title="Remove from device"
aria-label="Remove {item.name} from device"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7"
/>
</svg>
</button>
{/if}
@@ -379,13 +429,28 @@
>
{#if downloadedBadge === "full"}
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
{:else}
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<div
class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg"
aria-label="Partially downloaded"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
@@ -398,13 +463,30 @@
{#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" />
<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>
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full"
>Queued</span
>
</div>
{:else}
<button
@@ -414,14 +496,26 @@
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
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">
<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}
@@ -429,7 +523,9 @@
</div>
<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">
<p
class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(item.name, 40)}
</p>
{#if subtitle()}
+4 -7
View File
@@ -16,12 +16,7 @@
<script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte";
import { onDestroy } from "svelte";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicTile,
} from "./mosaic";
import { layoutMosaic, layoutMosaicStrip, mosaicTargetHeight, type MosaicTile } from "./mosaic";
interface Props {
items: T[];
@@ -67,7 +62,9 @@
});
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
const sized = $derived(
items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })),
);
const rows = $derived(
layout === "strip"
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
+6 -2
View File
@@ -82,8 +82,12 @@
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
<div
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5"
>
<p
class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors"
>
{label}
</p>
</div>
@@ -34,8 +34,8 @@
});
// Separate movies and series
movies = result.items.filter(item => item.kind === "movie");
series = result.items.filter(item => item.kind === "series");
movies = result.items.filter((item) => item.kind === "movie");
series = result.items.filter((item) => item.kind === "series");
} catch (e) {
log.error("Failed to load filmography:", e);
} finally {
@@ -27,11 +27,9 @@
let showDeleteConfirm = $state(false);
// Extract MediaItem[] from PlaylistEntry[] for TrackList
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const tracks = $derived(entries.map((e) => ({ ...e }) as MediaItem));
const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
);
const totalDuration = $derived(entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0));
onMount(() => {
loadPlaylistItems();
@@ -53,7 +51,7 @@
async function handlePlayAll() {
if (entries.length === 0) return;
try {
const trackIds = entries.map(e => e.id);
const trackIds = entries.map((e) => e.id);
await playerController.playTracks({
trackIds,
startIndex: 0,
@@ -73,7 +71,7 @@
async function handleShufflePlay() {
if (entries.length === 0) return;
try {
const trackIds = entries.map(e => e.id);
const trackIds = entries.map((e) => e.id);
await playerController.playTracks({
trackIds,
startIndex: 0,
@@ -129,7 +127,7 @@
try {
const repo = auth.getRepository();
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
entries = entries.filter((e) => e.playlistItemId !== entry.playlistItemId);
toast.success("Track removed");
} catch (e) {
log.error("Failed to remove track:", e);
@@ -161,9 +159,13 @@
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">
<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="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
</div>
{/if}
@@ -183,7 +185,10 @@
{:else}
<button
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
onclick={() => { editingName = true; editName = playlist.name; }}
onclick={() => {
editingName = true;
editName = playlist.name;
}}
title="Click to rename"
>
{playlist.name}
@@ -205,7 +210,7 @@
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
Play All
</button>
@@ -215,7 +220,9 @@
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
<path
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
/>
</svg>
Shuffle
</button>
@@ -227,11 +234,13 @@
className="self-center"
/>
<button
onclick={() => showDeleteConfirm = true}
onclick={() => (showDeleteConfirm = true)}
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
<path
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
/>
</svg>
Delete
</button>
@@ -264,7 +273,7 @@
title="Remove from playlist"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z"/>
<path d="M19 13H5v-2h14v2z" />
</svg>
</button>
</div>
@@ -278,8 +287,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={() => showDeleteConfirm = false}
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }}
onclick={() => (showDeleteConfirm = false)}
onkeydown={(e) => {
if (e.key === "Escape") showDeleteConfirm = false;
}}
role="dialog"
aria-modal="true"
tabindex="-1"
@@ -296,7 +307,7 @@
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => showDeleteConfirm = false}
onclick={() => (showDeleteConfirm = false)}
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
@@ -23,7 +23,7 @@
genres = [],
people = [],
artistIds = [],
limit = 12
limit = 12,
}: Props = $props();
let relatedItems = $state<MediaItem[]>([]);
@@ -53,7 +53,7 @@
if (itemKind === "movie" || itemKind === "series") {
try {
const result = await repo.getSimilarItems(currentItemId, limit);
items = result.items.filter(item => item.id !== currentItemId);
items = result.items.filter((item) => item.id !== currentItemId);
if (items.length > 0) {
relatedItems = items.slice(0, limit);
@@ -72,14 +72,18 @@
// maps the neutral kind to the concrete Jellyfin item type it needs.
const searchTerm = genres[0];
const itemTypeForKind: Record<string, string> = {
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
movie: "Movie",
series: "Series",
album: "MusicAlbum",
track: "Audio",
artist: "MusicArtist",
};
const result = await repo.search(searchTerm, {
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
limit: limit * 2
limit: limit * 2,
});
items = result.items.filter(item => item.id !== currentItemId);
items = result.items.filter((item) => item.id !== currentItemId);
} catch (e) {
log.warn("Failed to load related items by genre:", e);
}
@@ -91,10 +95,10 @@
// Search for other albums by artist name from first artist
const result = await repo.search(artistIds[0], {
includeItemTypes: ["MusicAlbum"],
limit: limit * 2
limit: limit * 2,
});
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
const artistAlbums = result.items.filter((item) => item.id !== currentItemId);
items = [...items, ...artistAlbums];
} catch (e) {
log.warn("Failed to load albums by artist:", e);
@@ -102,9 +106,10 @@
}
// Remove duplicates and limit results
const uniqueItems = Array.from(
new Map(items.map(item => [item.id, item])).values()
).slice(0, limit);
const uniqueItems = Array.from(new Map(items.map((item) => [item.id, item])).values()).slice(
0,
limit,
);
relatedItems = uniqueItems;
} catch (e) {
@@ -144,7 +149,11 @@
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
{#each Array(6) as _}
<div class="animate-pulse">
<div class="{isMusicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"></div>
<div
class="{isMusicContent
? 'aspect-square'
: 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"
></div>
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
</div>
@@ -17,25 +17,28 @@
size?: "sm" | "md" | "lg";
}
let { seasonId, seriesName, seasonName, seasonNumber, episodeCount, className = "", size = "md" }: Props = $props();
let {
seasonId,
seriesName,
seasonName,
seasonNumber,
episodeCount,
className = "",
size = "md",
}: Props = $props();
let isProcessing = $state(false);
let showQualityPicker = $state(false);
// Count downloads for this season
const seasonDownloads = $derived(
$videoDownloads.filter((d) =>
d.seriesName === seriesName &&
d.seasonName === seasonName
)
$videoDownloads.filter((d) => d.seriesName === seriesName && d.seasonName === seasonName),
);
const completedCount = $derived(
seasonDownloads.filter((d) => d.status === "completed").length
);
const completedCount = $derived(seasonDownloads.filter((d) => d.status === "completed").length);
const inProgressCount = $derived(
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length,
);
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
@@ -67,7 +70,7 @@
seasonNumber,
userId,
basePath,
quality
quality,
);
log.debug(`✅ Queued ${downloadIds.length} episodes for download`);
@@ -102,7 +105,9 @@
return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`;
}
if (completedCount > 0) {
return size === "sm" ? `${completedCount}/${episodeCount}` : `Download (${completedCount}/${episodeCount})`;
return size === "sm"
? `${completedCount}/${episodeCount}`
: `Download (${completedCount}/${episodeCount})`;
}
return size === "sm" ? "⬇" : "Download Season";
}
@@ -118,39 +123,49 @@
}
const sizeClasses = $derived(
size === "sm" ? "px-2 py-1 text-xs" :
size === "lg" ? "px-6 py-3 text-base" :
"px-4 py-2 text-sm"
size === "sm"
? "px-2 py-1 text-xs"
: size === "lg"
? "px-6 py-3 text-base"
: "px-4 py-2 text-sm",
);
const iconSize = $derived(
size === "sm" ? "w-3 h-3" :
size === "lg" ? "w-6 h-6" :
"w-4 h-4"
);
const iconSize = $derived(size === "sm" ? "w-3 h-3" : size === "lg" ? "w-6 h-6" : "w-4 h-4");
</script>
<div class="relative {className}">
<button
onclick={handleClick}
disabled={isProcessing || allDownloaded}
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing ||
allDownloaded
? 'opacity-70 cursor-not-allowed'
: ''}"
>
{#if inProgressCount > 0}
<!-- Spinner -->
<svg class="{iconSize} animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if allDownloaded}
<!-- Checkmark -->
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else}
<!-- Download icon -->
<svg class="{iconSize}" 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-4m-9 7h10" />
<svg class={iconSize} 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-4m-9 7h10"
/>
</svg>
{/if}
{#if size !== "sm"}
@@ -162,7 +177,9 @@
<!-- Quality picker dropdown -->
{#if showQualityPicker}
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
<div
class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
>
<div class="p-3 border-b border-gray-700">
<div class="text-sm font-medium text-white">Download Quality</div>
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</div>
@@ -174,14 +191,16 @@
>
<span class="text-sm text-white">{preset.label}</span>
{#if preset.videoBitrate}
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
<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}
onclick={() => (showQualityPicker = false)}
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
@@ -194,7 +213,7 @@
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker"
></button>
{/if}
@@ -37,14 +37,14 @@
}: Props = $props();
const holdsCurrentEpisode = $derived(
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId),
);
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived(
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season"),
);
// Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list.
@@ -55,7 +55,9 @@
<!-- Season header -->
<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)]">
<div
class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]"
>
<CachedImage
itemId={season.id}
imageType="Primary"
@@ -135,7 +137,7 @@
<SeasonDownloadButton
seasonId={season.id}
seriesName={season.seriesName || ""}
seasonName={seasonName}
{seasonName}
seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
{episodeCount}
size="sm"
@@ -20,16 +20,12 @@
let showQualityPicker = $state(false);
// Count downloads for this series
const seriesDownloads = $derived(
$videoDownloads.filter((d) => d.seriesName === seriesName)
);
const seriesDownloads = $derived($videoDownloads.filter((d) => d.seriesName === seriesName));
const completedCount = $derived(
seriesDownloads.filter((d) => d.status === "completed").length
);
const completedCount = $derived(seriesDownloads.filter((d) => d.status === "completed").length);
const inProgressCount = $derived(
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length,
);
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
@@ -59,7 +55,7 @@
seriesName,
userId,
basePath,
quality
quality,
);
log.debug(` Queued ${downloadIds.length} episodes for download`);
@@ -113,13 +109,21 @@
<button
onclick={handleClick}
disabled={isProcessing || allDownloaded}
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing ||
allDownloaded
? 'opacity-70 cursor-not-allowed'
: ''}"
>
{#if inProgressCount > 0}
<!-- Spinner -->
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if allDownloaded}
<!-- Checkmark -->
@@ -129,7 +133,11 @@
{:else}
<!-- Download icon -->
<svg class="w-5 h-5" 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-4m-9 7h10" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
/>
</svg>
{/if}
<span>{getButtonText()}</span>
@@ -137,7 +145,9 @@
<!-- Quality picker dropdown -->
{#if showQualityPicker}
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
<div
class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
>
<div class="p-3 border-b border-gray-700">
<div class="text-sm font-medium text-white">Download Quality</div>
{#if episodeCount}
@@ -151,14 +161,16 @@
>
<span class="text-sm text-white">{preset.label}</span>
{#if preset.videoBitrate}
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
<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}
onclick={() => (showQualityPicker = false)}
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
>
Cancel
@@ -171,7 +183,7 @@
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker"
></button>
{/if}
@@ -84,7 +84,7 @@ describe("TrackList Logic Tests", () => {
mediaType: "Audio",
streamUrl: await repo.getAudioStreamUrl(t.id),
jellyfinItemId: t.id,
}))
})),
);
expect(queueItems).toHaveLength(2);
@@ -110,7 +110,7 @@ describe("TrackList Logic Tests", () => {
id: t.id,
streamUrl,
};
})
}),
);
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2);
@@ -279,7 +279,7 @@ describe("TrackList Logic Tests", () => {
startIndex: 0,
shuffle: false,
},
})
}),
).rejects.toThrow("Network error");
});
});
+61 -26
View File
@@ -40,7 +40,7 @@
showArtist = true,
showDownload = false,
context,
onTrackClick
onTrackClick,
}: Props = $props();
let isPlayingTrack = $state<string | null>(null);
@@ -93,7 +93,7 @@
// Queue will auto-update from Rust backend event
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
const errorMessage = e instanceof Error ? e.message : "Unknown error";
log.error("Failed to play track:", errorMessage);
toast.error(`Failed to play track: ${errorMessage}`, 5000);
} finally {
@@ -110,7 +110,6 @@
}
}
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
e.stopPropagation();
@@ -158,9 +157,7 @@
{#if loading}
<div class="space-y-2">
{#each Array(10) as _}
<div
class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4"
>
<div class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4">
<div class="w-12 h-12 bg-gray-700 rounded"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-700 rounded w-1/3"></div>
@@ -199,7 +196,13 @@
<!-- Track Rows -->
<div class="space-y-1">
{#each tracks as track, index (track.id)}
<div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<div
data-grid-index={index}
class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId ===
track.id
? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]'
: ''}"
>
<!-- Desktop View -->
<button
onclick={() => handleTrackClick(track, index)}
@@ -212,7 +215,9 @@
<!-- Index/Play Button -->
<div class="w-12 flex items-center justify-center">
{#if isPlayingTrack === track.id}
<div class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
<div
class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
{:else}
<span class="group-hover:hidden text-gray-400">{index + 1}</span>
<svg
@@ -230,12 +235,21 @@
{#if currentlyPlayingId === track.id}
<div class="flex flex-col items-center justify-center">
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"></div>
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 150ms"></div>
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 300ms"></div>
<div
class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"
style="animation-delay: 150ms"
></div>
<div
class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"
style="animation-delay: 300ms"
></div>
</div>
{/if}
<span
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId ===
track.id
? 'text-[var(--color-jellyfin)]'
: ''}"
>
{truncateMiddle(track.name, 48)}
</span>
@@ -250,7 +264,7 @@
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
>
{artist.name}
@@ -273,7 +287,7 @@
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
>
{track.albumName || "-"}
@@ -317,7 +331,9 @@
aria-label="More options"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
@@ -332,7 +348,9 @@
<!-- Track Number -->
<div class="w-8 flex-shrink-0 text-center">
{#if isPlayingTrack === track.id}
<div class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"></div>
<div
class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"
></div>
{:else}
<span class="group-hover:hidden text-gray-400 text-sm">{index + 1}</span>
<svg
@@ -346,7 +364,10 @@
</div>
<div class="flex-1 min-w-0">
<p
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId ===
track.id
? 'text-[var(--color-jellyfin)]'
: ''}"
>
{#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span>
@@ -361,7 +382,7 @@
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{artist.name}
@@ -379,7 +400,7 @@
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{track.albumName || "-"}
@@ -394,7 +415,7 @@
role="button"
tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{artist.name}
@@ -412,7 +433,7 @@
role="button"
tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
>
{track.albumName || "-"}
@@ -447,7 +468,9 @@
aria-label="More options"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</div>
@@ -459,7 +482,7 @@
<!-- Portal Menu (rendered at document.body to avoid overflow clipping) -->
{#if openMenuId && menuPosition}
{@const selectedTrack = tracks.find(t => t.id === openMenuId)}
{@const selectedTrack = tracks.find((t) => t.id === openMenuId)}
{#if selectedTrack}
<Portal>
<div
@@ -472,7 +495,12 @@
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Play Next
</button>
@@ -482,7 +510,12 @@
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
Add to Queue
</button>
@@ -496,7 +529,9 @@
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
Add to Playlist
</button>
@@ -508,7 +543,7 @@
<!-- Add to Playlist Modal -->
<AddToPlaylistModal
isOpen={addToPlaylistTrackId !== null}
onClose={() => addToPlaylistTrackId = null}
onClose={() => (addToPlaylistTrackId = null)}
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
/>
+56 -36
View File
@@ -28,7 +28,12 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(),
addToQueue: vi.fn(),
},
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
currentQueueItem: {
subscribe: vi.fn((fn: any) => {
fn(null);
return () => {};
}),
},
}));
vi.mock("./DownloadButton.svelte", () => ({
@@ -42,10 +47,30 @@ vi.mock("$lib/stores/library", () => ({
loadItem: vi.fn(),
setCurrentLibrary: vi.fn(),
},
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) },
libraries: {
subscribe: vi.fn((fn: any) => {
fn([]);
return () => {};
}),
},
libraryItems: {
subscribe: vi.fn((fn: any) => {
fn([]);
return () => {};
}),
},
currentLibrary: {
subscribe: vi.fn((fn: any) => {
fn(null);
return () => {};
}),
},
isLibraryLoading: {
subscribe: vi.fn((fn: any) => {
fn(false);
return () => {};
}),
},
}));
// Now import the modules after mocks are set up
@@ -120,7 +145,9 @@ describe("TrackList", () => {
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
expect(getAllByText("Song 2").length).toBeGreaterThan(0);
// Long names are abbreviated in the middle via truncateMiddle(name, 48).
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(0);
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(
0,
);
});
it("shows loading skeleton when loading=true", () => {
@@ -234,7 +261,7 @@ describe("TrackList", () => {
// Find and click the first track button
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
expect(firstTrackButton).toBeTruthy();
@@ -250,7 +277,7 @@ describe("TrackList", () => {
startIndex: 0,
shuffle: false,
}),
})
}),
);
});
});
@@ -261,7 +288,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2")
btn.textContent?.includes("Song 2"),
);
await fireEvent.click(secondTrackButton!);
@@ -278,7 +305,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const thirdTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3")
btn.textContent?.includes("Song 3"),
);
await fireEvent.click(thirdTrackButton!);
@@ -297,7 +324,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -305,7 +332,7 @@ describe("TrackList", () => {
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
expect.anything(),
);
});
@@ -322,7 +349,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -330,7 +357,7 @@ describe("TrackList", () => {
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
expect.anything(),
);
});
@@ -339,7 +366,6 @@ describe("TrackList", () => {
// Restore mock for other tests
(auth.getRepository as any).mockReturnValue(mockRepository as any);
});
});
describe("Custom Callback Tests", () => {
@@ -351,7 +377,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -363,7 +389,7 @@ describe("TrackList", () => {
it("does not call player_play_queue when custom callback provided", async () => {
const onTrackClick = vi.fn();
const invokeMock = (invoke as any);
const invokeMock = invoke as any;
const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick },
@@ -371,7 +397,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -391,7 +417,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2")
btn.textContent?.includes("Song 2"),
);
await fireEvent.click(secondTrackButton!);
@@ -409,7 +435,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -429,7 +455,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -454,9 +480,7 @@ describe("TrackList", () => {
const { container } = render(TrackList, { props: { tracks: singleTrack } });
const buttons = container.querySelectorAll("button");
const trackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
);
const trackButton = Array.from(buttons).find((btn) => btn.textContent?.includes("Song 1"));
await fireEvent.click(trackButton!);
@@ -473,7 +497,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
await fireEvent.click(firstTrackButton!);
@@ -490,7 +514,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button");
const lastTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3")
btn.textContent?.includes("Song 3"),
);
await fireEvent.click(lastTrackButton!);
@@ -505,15 +529,13 @@ describe("TrackList", () => {
describe("Loading State", () => {
it("shows loading spinner when track is clicked", async () => {
// Make invoke slow to capture loading state
(invoke as any).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
(invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
fireEvent.click(firstTrackButton!);
@@ -526,15 +548,13 @@ describe("TrackList", () => {
});
it("disables track buttons during loading", async () => {
(invoke as any).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
(invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1")
btn.textContent?.includes("Song 1"),
);
fireEvent.click(firstTrackButton!);
@@ -542,8 +562,8 @@ describe("TrackList", () => {
// Track selection buttons should be disabled during loading
await waitFor(() => {
// Find track buttons (ones containing song names)
const trackButtons = Array.from(container.querySelectorAll("button")).filter(
(btn) => btn.textContent?.includes("Song")
const trackButtons = Array.from(container.querySelectorAll("button")).filter((btn) =>
btn.textContent?.includes("Song"),
);
expect(trackButtons.length).toBeGreaterThan(0);
trackButtons.forEach((btn) => {
@@ -30,7 +30,7 @@
episodeNumber,
seasonNumber,
size = "md",
className = ""
className = "",
}: Props = $props();
const sizeClasses = {
@@ -46,7 +46,7 @@
// Find download for this item
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
);
const status = $derived(downloadInfo?.status || "not_downloaded");
@@ -83,7 +83,7 @@
filePath = `videos/movies/${safeName}.mp4`;
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_");
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}_${safeName}.mp4`;
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}_${safeName}.mp4`;
} else {
filePath = `videos/${safeName}.mp4`;
}
@@ -96,13 +96,13 @@
userId,
filePath,
"video/mp4",
isMovie ? 500 : (1000 - (episodeNumber || 0)), // Movies have medium priority, episodes ordered by number
isMovie ? 500 : 1000 - (episodeNumber || 0), // Movies have medium priority, episodes ordered by number
itemName || undefined,
quality,
seriesName,
seasonName,
episodeNumber,
seasonNumber
seasonNumber,
);
log.debug(" Download queued with ID:", downloadId);
@@ -232,27 +232,59 @@
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
{:else if status === "completed"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else if status === "pending"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" />
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z"
/>
</svg>
{:else if status === "failed"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<svg
class={sizeClasses[size]}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{:else}
<svg class={sizeClasses[size]} 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-4m-9 7h10" />
<svg
class={sizeClasses[size]}
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-4m-9 7h10"
/>
</svg>
{/if}
</div>
@@ -263,16 +295,14 @@
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
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>
<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)}
@@ -280,14 +310,16 @@
>
<span>{preset.label}</span>
{#if preset.videoBitrate}
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
<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}
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
@@ -32,14 +32,7 @@
onChanged?: (watched: boolean) => void;
}
let {
itemId,
watched,
scope,
size = "lg",
showLabel = false,
onChanged,
}: Props = $props();
let { itemId, watched, scope, size = "lg", showLabel = false, onChanged }: Props = $props();
let busy = $state(false);
@@ -60,7 +53,7 @@
});
const subject = $derived(
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
scope === "series" ? "series" : scope === "season" ? "season" : "episode",
);
const label = $derived(isWatched ? "Watched" : "Mark watched");
const title = $derived(
@@ -68,7 +61,7 @@
? `Mark this ${subject} unwatched`
: scope === "episode"
? "Mark this episode watched"
: `Mark every episode in this ${subject} watched`
: `Mark every episode in this ${subject} watched`,
);
async function handleClick() {
@@ -106,7 +99,13 @@
{isWatched
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
{showLabel
? size === 'lg'
? 'px-6 py-2'
: 'px-3 py-1.5 text-sm'
: size === 'lg'
? 'p-2'
: 'p-1.5'}"
>
{#if busy}
<div
+17 -11
View File
@@ -1,13 +1,14 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
import {
isCurrentEpisode,
adjacentEpisodes,
compareSeriesOrder,
stripCardLabel,
} from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
id: string,
season: number | null,
number: number | null,
): MediaItem {
function ep(id: string, season: number | null, number: number | null): MediaItem {
return {
id,
name: `S${season}E${number}`,
@@ -79,8 +80,15 @@ describe("adjacentEpisodes", () => {
const current = eps[4]; // S1E5 — the season finale
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e2", "s1e3", "s1e4", "s1e5",
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
"s1e2",
"s1e3",
"s1e4",
"s1e5",
"s2e1",
"s2e2",
"s2e3",
"s2e4",
"s2e5",
]);
});
@@ -96,9 +104,7 @@ describe("adjacentEpisodes", () => {
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
const current = eps[3]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
]);
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3"]);
});
it("sorts specials (season 0) after the numbered seasons", () => {
+5 -4
View File
@@ -26,14 +26,15 @@ const SPECIALS_SEASON = 0;
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
ep.indexNumber == null ||
current.indexNumber == null ||
ep.parentIndexNumber == null ||
current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
ep.parentIndexNumber === current.parentIndexNumber && ep.indexNumber === current.indexNumber
);
}
@@ -42,7 +42,11 @@ describe("buildLibraryMosaic", () => {
it("links a category tile to that category's favourites tab", () => {
const entries = buildLibraryMosaic([SHOWS]);
const tile = entries.find((e) => e.label === "Favourite Shows");
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
expect(tile).toMatchObject({
kind: "favorites",
scope: "tv",
href: "/library/favorites?scope=tv",
});
});
it("offers a category's favourites once, however many libraries share it", () => {
+8 -3
View File
@@ -31,8 +31,7 @@ export type LibraryMosaicEntry = {
ratio: number;
label: string;
} & (
| { kind: "library"; library: Library }
| { kind: "favorites"; scope: FavoritesScope; href: string }
{ kind: "library"; library: Library } | { kind: "favorites"; scope: FavoritesScope; href: string }
);
/**
@@ -68,7 +67,13 @@ export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
for (const lib of libraries) {
const ratio = assumedLibraryRatio(lib);
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
entries.push({
key: `library:${lib.id}`,
kind: "library",
library: lib,
ratio,
label: lib.name,
});
const scope = asFavoritesScope(lib.favoritesScope);
if (!scope || seenScopes.has(scope)) continue;
@@ -88,9 +88,7 @@ describe("seasonRedirectTarget", () => {
});
it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
`/library/${SERIES}#${seasonAnchorId(2)}`
);
expect(seasonRedirectTarget(seasonHeader(2))).toBe(`/library/${SERIES}#${seasonAnchorId(2)}`);
});
});
@@ -130,7 +128,7 @@ describe("groupEpisodesBySeason", () => {
it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)],
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)],
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
});
@@ -156,7 +154,7 @@ describe("groupEpisodesBySeason", () => {
it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)]
[ep("s2e1", 2, 1)],
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
});
@@ -171,12 +169,7 @@ describe("groupEpisodesBySeason", () => {
describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[
ep("s1e1", 1, 1),
ep("s2e1", 2, 1),
ep("s2e2", 2, 2),
ep("s3e1", 3, 1),
]
[ep("s1e1", 1, 1), ep("s2e1", 2, 1), ep("s2e2", 2, 2), ep("s3e1", 3, 1)],
);
it("expands only the season holding the current episode", () => {
@@ -114,10 +114,7 @@ export function seriesPlayLabel(current: MediaItem | null): string {
* server did not return one for (a flat series, or a season fetch that failed).
* Seasons with no episodes are dropped an empty accordion row is noise.
*/
export function groupEpisodesBySeason(
seasons: MediaItem[],
episodes: MediaItem[]
): SeasonData[] {
export function groupEpisodesBySeason(seasons: MediaItem[], episodes: MediaItem[]): SeasonData[] {
const headerFor = new Map<number, MediaItem>();
for (const season of seasons) {
const number = season.indexNumber ?? season.parentIndexNumber;
@@ -164,7 +161,7 @@ export function groupEpisodesBySeason(
export function initialExpandedSeasons(
seasons: SeasonData[],
currentEpisodeId: string | null | undefined,
focusedEpisodeId?: string | null
focusedEpisodeId?: string | null,
): Set<string> {
if (seasons.length === 0) return new Set();
+161 -143
View File
@@ -6,12 +6,7 @@
import type { MediaItem } from "$lib/api/types";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
import {
mergedMedia,
mergedIsPlaying,
mergedPosition,
mergedDuration
} from "$lib/stores/player";
import { mergedMedia, mergedIsPlaying, mergedPosition, mergedDuration } from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession } from "$lib/stores/sessions";
import { formatTime } from "$lib/utils/playbackUnits";
@@ -167,167 +162,190 @@
style:padding-left="var(--safe-left)"
style:padding-right="var(--safe-right)"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 flex-shrink-0">
<button
onclick={onClose}
class="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close player"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="flex flex-col items-center">
<p class="text-sm text-gray-400">Now Playing</p>
{#if $isRemoteMode && $selectedSession}
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
{$selectedSession.deviceName}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Cast Button -->
<CastButton size="md" />
<!-- Queue Button -->
<!-- Header -->
<div class="flex items-center justify-between p-4 flex-shrink-0">
<button
onclick={() => (showQueue = !showQueue)}
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue ? 'bg-white/10 text-[var(--color-jellyfin)]' : ''}"
title="Queue"
aria-label="Open queue"
onclick={onClose}
class="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close player"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<button
onclick={() => (showSleepTimerModal = true)}
class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
title="Sleep timer"
aria-label="Sleep timer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{#if $sleepTimerActive}
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
<div class="flex flex-col items-center">
<p class="text-sm text-gray-400">Now Playing</p>
{#if $isRemoteMode && $selectedSession}
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path
d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
/>
</svg>
{$selectedSession.deviceName}
</p>
{/if}
</button>
</div>
<!-- Volume Control (Linux only) -->
<VolumeControl size="md" />
</div>
</div>
<div class="flex items-center gap-2">
<!-- Cast Button -->
<CastButton size="md" />
<!-- 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}
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.imageId}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
<svg class="w-32 h-32 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" />
<!-- Queue Button -->
<button
onclick={() => (showQueue = !showQueue)}
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue
? 'bg-white/10 text-[var(--color-jellyfin)]'
: ''}"
title="Queue"
aria-label="Open queue"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
</div>
{/if}
</div>
</div>
</button>
<!-- Info & Controls -->
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist -->
<div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
<button
onclick={() => (showSleepTimerModal = true)}
class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
title="Sleep timer"
aria-label="Sleep timer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{#if $sleepTimerActive}
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"
></span>
{/if}
</button>
<!-- Volume Control (Linux only) -->
<VolumeControl size="md" />
</div>
</div>
<!-- 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}
<CachedImage
itemId={artworkItemId}
imageType="Primary"
tag={displayMedia?.imageId}
maxWidth={500}
alt={displayMedia?.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
<svg class="w-32 h-32 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>
{/if}
</div>
</div>
<!-- Info & Controls -->
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist -->
<div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">
{truncateMiddle(displayMedia?.name, 48)}
</h1>
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
<button
onclick={() => navigateToArtist(artist.id)}
class="hover:text-white hover:underline transition-colors"
>
{artist.name}
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
{/each}
{:else if displayMedia?.artists?.length}
<span>{displayMedia?.artists.join(", ")}</span>
{/if}
{#if displayMedia?.albumId && displayMedia?.albumName}
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
<span class="text-gray-500"></span>
{/if}
<button
onclick={() => navigateToArtist(artist.id)}
onclick={navigateToAlbum}
class="hover:text-white hover:underline transition-colors"
>
{artist.name}
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
{/each}
{:else if displayMedia?.artists?.length}
<span>{displayMedia?.artists.join(", ")}</span>
{/if}
{#if displayMedia?.albumId && displayMedia?.albumName}
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
<span class="text-gray-500"></span>
{displayMedia?.albumName}
</button>
{:else if displayMedia?.albumName}
<span>{displayMedia?.albumName}</span>
{/if}
<button
onclick={navigateToAlbum}
class="hover:text-white hover:underline transition-colors"
>
{displayMedia?.albumName}
</button>
{:else if displayMedia?.albumName}
<span>{displayMedia?.albumName}</span>
{/if}
</div>
</div>
</div>
<!-- Progress bar -->
<div class="space-y-2">
<input
type="range"
min="0"
max={displayDuration}
value={displayPosition}
oninput={handleSeekInput}
onmousedown={handleSeekStart}
ontouchstart={handleSeekStart}
onmouseup={handleSeekEnd}
ontouchend={handleSeekEnd}
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>{formatTime(displayPosition)}</span>
<span>{formatTime(displayDuration)}</span>
<!-- Progress bar -->
<div class="space-y-2">
<input
type="range"
min="0"
max={displayDuration}
value={displayPosition}
oninput={handleSeekInput}
onmousedown={handleSeekStart}
ontouchstart={handleSeekStart}
onmouseup={handleSeekEnd}
ontouchend={handleSeekEnd}
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>{formatTime(displayPosition)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
</div>
<!-- Controls -->
<div class="flex justify-center">
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
onSleepTimerClick={() => (showSleepTimerModal = true)}
/>
<!-- Controls -->
<div class="flex justify-center">
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
onSleepTimerClick={() => (showSleepTimerModal = true)}
/>
</div>
</div>
</div>
</div> <!-- Close content overlay -->
<!-- Close content overlay -->
</div>
{/if}
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => (showSleepTimerModal = false)}
/>
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => (showSleepTimerModal = false)} />
<!-- Queue Panel (slide up from bottom) -->
{#if showQueue}
+12 -4
View File
@@ -90,8 +90,12 @@
aria-label="Sleep timer"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
</button>
{:else}
@@ -113,7 +117,9 @@
title="Shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
<path
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
/>
</svg>
</button>
@@ -192,7 +198,9 @@
>
{#if repeat === "one"}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z" />
<path
d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"
/>
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
+57 -29
View File
@@ -25,7 +25,7 @@
mergedIsPlaying,
mergedPosition,
mergedDuration,
shouldShowAudioMiniPlayer
shouldShowAudioMiniPlayer,
} from "$lib/stores/player";
import { currentQueueItem } from "$lib/stores/queue";
import { isRemoteMode } from "$lib/stores/playbackMode";
@@ -101,9 +101,7 @@
// State machine gated visibility - only show when player is playing/paused AND media is audio
const shouldShow = $derived($shouldShowAudioMiniPlayer);
const progress = $derived(
calculateProgress(displayPosition, displayDuration)
);
const progress = $derived(calculateProgress(displayPosition, displayDuration));
function navigateToArtist(event: MouseEvent, artistId: string) {
event.stopPropagation();
@@ -284,12 +282,23 @@
</script>
{#if shouldShow && displayMedia}
<div class="{className || 'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]">
<div
class="{className ||
'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]"
>
<!-- Remote Mode Indicator -->
{#if $isRemoteMode && $selectedSession}
<div class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
<div
class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2"
>
<svg
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
/>
</svg>
<span class="text-xs text-[var(--color-jellyfin)] font-medium">
Playing on {$selectedSession.deviceName}
@@ -308,7 +317,9 @@
style="width: {progress}%"
></div>
<!-- Hover indicator -->
<div class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div
class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"
></div>
</button>
<div
@@ -317,14 +328,14 @@
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
style="transform: translateX({swipeTransform}px); transition: {isSwiping
? 'none'
: 'transform 0.3s ease-out'}"
>
<!-- Row 1: Media info, like, cast, overflow -->
<div class="flex items-center gap-3">
<!-- Artwork -->
<div
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
>
<div class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
{#if displayMedia}
<CachedImage
itemId={displayMedia.albumId || displayMedia.id}
@@ -337,7 +348,9 @@
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-6 h-6" 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" />
<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}
@@ -345,9 +358,7 @@
<!-- Title & Artist -->
<div class="flex-1 min-w-0">
<div
class="text-sm font-medium text-white truncate block w-full text-left"
>
<div class="text-sm font-medium text-white truncate block w-full text-left">
{truncateMiddle(displayMedia?.name, 40)}
</div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
@@ -381,11 +392,7 @@
<!-- Like Button -->
{#if displayMedia}
<FavoriteButton
itemId={displayMedia?.id ?? ""}
bind:isFavorite
size="sm"
/>
<FavoriteButton itemId={displayMedia?.id ?? ""} bind:isFavorite size="sm" />
{/if}
<!-- Cast Button -->
@@ -402,7 +409,9 @@
aria-label="More options"
>
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
@@ -417,7 +426,12 @@
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
View Queue
</button>
@@ -428,7 +442,9 @@
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"
/>
</svg>
Go to Album
</button>
@@ -440,7 +456,9 @@
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" 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"/>
<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>
Go to Artist
</button>
@@ -451,7 +469,12 @@
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add to Playlist
</button>
@@ -461,7 +484,12 @@
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
Share
</button>
@@ -508,7 +536,7 @@
{#if showOverflowMenu}
<button
class="fixed inset-0 z-[65]"
onclick={() => showOverflowMenu = false}
onclick={() => (showOverflowMenu = false)}
aria-label="Close menu"
></button>
{/if}
@@ -7,15 +7,14 @@
initialCountdownSeconds,
isCountdownActive,
} from "$lib/stores/nextEpisode";
import {
cancelAutoPlay,
watchNextManually,
} from "$lib/services/nextEpisodeService";
import { cancelAutoPlay, watchNextManually } from "$lib/services/nextEpisodeService";
import { auth } from "$lib/stores/auth";
import CachedImage from "../common/CachedImage.svelte";
// Use series primary image for better visual consistency
const imageId = $derived($nextEpisodeItem ? ($nextEpisodeItem.seriesId || $nextEpisodeItem.id) : null);
const imageId = $derived(
$nextEpisodeItem ? $nextEpisodeItem.seriesId || $nextEpisodeItem.id : null,
);
// Format episode info (S1:E5)
const episodeInfo = $derived.by(() => {
@@ -75,9 +74,7 @@
<!-- Episode Card -->
<div class="flex gap-4 p-4">
<!-- Thumbnail -->
<div
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
>
<div class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800">
{#if imageId && $nextEpisodeItem.imageId}
<CachedImage
itemId={imageId}
@@ -90,14 +87,8 @@
{/if}
<!-- Play icon overlay -->
<div
class="absolute inset-0 flex items-center justify-center bg-black/30"
>
<svg
class="w-8 h-8 text-white"
fill="currentColor"
viewBox="0 0 24 24"
>
<div class="absolute inset-0 flex items-center justify-center bg-black/30">
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
+36 -16
View File
@@ -17,12 +17,7 @@
onClose?: () => void;
}
let {
items,
currentIndex = null,
onItemClick,
onClose,
}: Props = $props();
let { items, currentIndex = null, onItemClick, onClose }: Props = $props();
// Add unique IDs for dnd-zone (required)
interface DndItem extends MediaItem {
@@ -33,7 +28,7 @@
items.map((item, index) => ({
...item,
dndId: `${item.id}-${index}`,
}))
})),
);
let dragDisabled = $state(true);
@@ -47,7 +42,9 @@
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) {
function handleConsider(
e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>,
) {
const { items: newItems, info } = e.detail;
// Update local state during drag
if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) {
@@ -59,8 +56,8 @@
const { items: newItems, info } = e.detail;
// Find the moved item by comparing old and new positions
const oldIds = dndItems.map(i => i.dndId);
const newIds = newItems.map(i => i.dndId);
const oldIds = dndItems.map((i) => i.dndId);
const newIds = newItems.map((i) => i.dndId);
// Find indices that changed
let fromIndex = -1;
@@ -126,7 +123,12 @@
aria-label="Close queue"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -151,7 +153,10 @@
{#each dndItems as item, index (item.dndId)}
<li class="outline-none">
<div
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex === index ? 'bg-white/10' : ''}"
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex ===
index
? 'bg-white/10'
: ''}"
>
<!-- Drag handle -->
<button
@@ -163,7 +168,9 @@
onkeydown={handleKeyDown}
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"/>
<path
d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"
/>
</svg>
</button>
@@ -177,7 +184,11 @@
<!-- Index or playing indicator -->
<div class="w-6 text-center flex-shrink-0">
{#if currentIndex === index}
<svg class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
{:else}
@@ -201,7 +212,11 @@
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
<p
class="text-sm font-medium truncate {currentIndex === index
? 'text-[var(--color-jellyfin)]'
: 'text-white'}"
>
{truncateMiddle(item.name, 48)}
</p>
{#if item.artists?.length}
@@ -225,7 +240,12 @@
aria-label="Remove from queue"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -1,10 +1,6 @@
<!-- TRACES: UR-026 | DR-029, DR-050 -->
<script lang="ts">
import {
sleepTimer,
sleepTimerMode,
sleepTimerActive,
} from "$lib/stores/sleepTimer";
import { sleepTimer, sleepTimerMode, sleepTimerActive } from "$lib/stores/sleepTimer";
import { currentQueueItem } from "$lib/stores/queue";
import ScrollPicker from "$lib/components/common/ScrollPicker.svelte";
@@ -96,7 +92,9 @@
<div
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog"
aria-modal="true"
aria-labelledby="sleep-timer-title"
@@ -108,23 +106,14 @@
role="none"
>
<!-- Header -->
<div
class="px-6 py-4 border-b border-gray-800 flex items-center justify-between"
>
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">
Sleep Timer
</h2>
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">Sleep Timer</h2>
<button
onclick={onClose}
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -176,7 +165,9 @@
selectedValue={selectedMinutes}
visibleCount={3}
itemHeight={56}
onSelect={(val) => { selectedMinutes = val as number; }}
onSelect={(val) => {
selectedMinutes = val as number;
}}
/>
<button
onclick={handleSetTimer}
@@ -194,11 +185,7 @@
onclick={handleEndOfTrack}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
>
<svg
class="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
<span class="text-white">{getEndOfTrackLabel()}</span>
@@ -208,27 +195,19 @@
<!-- Episode countdown (only for TV episodes) -->
{#if isEpisode}
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">
Stop after episodes
</h3>
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after episodes</h3>
<div class="space-y-2">
{#each episodePresets as count}
<button
onclick={() => handleEpisodePreset(count)}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
>
<svg
class="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
/>
</svg>
<span class="text-white"
>{count} more episode{count !== 1 ? "s" : ""}</span
>
<span class="text-white">{count} more episode{count !== 1 ? "s" : ""}</span>
</button>
{/each}
</div>
@@ -1,11 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock video element for testing seek behavior
function createMockVideoElement(options: {
paused?: boolean;
autoplay?: boolean;
currentTime?: number;
} = {}) {
function createMockVideoElement(
options: {
paused?: boolean;
autoplay?: boolean;
currentTime?: number;
} = {},
) {
const listeners: Record<string, (() => void)[]> = {};
return {
@@ -13,11 +15,11 @@ function createMockVideoElement(options: {
autoplay: options.autoplay ?? true,
currentTime: options.currentTime ?? 0,
pause: vi.fn(function(this: any) {
pause: vi.fn(function (this: any) {
this.paused = true;
}),
play: vi.fn(function(this: any) {
play: vi.fn(function (this: any) {
this.paused = false;
return Promise.resolve();
}),
@@ -29,13 +31,13 @@ function createMockVideoElement(options: {
removeEventListener: vi.fn((event: string, handler: () => void) => {
if (listeners[event]) {
listeners[event] = listeners[event].filter(h => h !== handler);
listeners[event] = listeners[event].filter((h) => h !== handler);
}
}),
// Helper to trigger events in tests
_triggerEvent: (event: string) => {
listeners[event]?.forEach(h => h());
listeners[event]?.forEach((h) => h());
},
_getListeners: () => listeners,
@@ -283,7 +285,13 @@ describe("VideoPlayer Resume Logic", () => {
// Simulate new position
const newPosition = 120;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
if (
newPosition &&
newPosition > 0 &&
isMediaReady &&
videoElement &&
hasPerformedInitialSeek
) {
hasPerformedInitialSeek = false;
videoElement.currentTime = newPosition;
currentTime = newPosition;
@@ -301,7 +309,13 @@ describe("VideoPlayer Resume Logic", () => {
const newPosition = 120;
let seekTriggered = false;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
if (
newPosition &&
newPosition > 0 &&
isMediaReady &&
videoElement &&
hasPerformedInitialSeek
) {
seekTriggered = true;
}
@@ -315,7 +329,13 @@ describe("VideoPlayer Resume Logic", () => {
const newPosition = 120;
let seekTriggered = false;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
if (
newPosition &&
newPosition > 0 &&
isMediaReady &&
videoElement &&
hasPerformedInitialSeek
) {
seekTriggered = true;
}
@@ -355,8 +375,10 @@ describe("VideoPlayer Resume Logic", () => {
let errorCaught = false;
// Simulate a video element that throws on currentTime set
Object.defineProperty(videoElement, 'currentTime', {
set: () => { throw new Error('Seek not allowed'); },
Object.defineProperty(videoElement, "currentTime", {
set: () => {
throw new Error("Seek not allowed");
},
get: () => 0,
});
@@ -371,7 +393,7 @@ describe("VideoPlayer Resume Logic", () => {
it("should handle play() rejection gracefully", async () => {
const videoElement = createMockVideoElement();
videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
videoElement.play = vi.fn().mockRejectedValue(new Error("Autoplay blocked"));
let errorCaught = false;
try {
@@ -146,9 +146,7 @@ async function mountNativePlayer() {
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
// The native path must NOT be overridden to HTML5 and must NOT be stopped —
// if it were, these tests would be guarding the HTML5 path by accident.
await waitFor(() =>
expect(utils.container.querySelector("video")).toBeNull()
);
await waitFor(() => expect(utils.container.querySelector("video")).toBeNull());
expect(playerStop).not.toHaveBeenCalled();
return utils;
}
@@ -168,11 +166,7 @@ function poster(container: HTMLElement): HTMLElement | null {
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
* the store is what the component must read.
*/
async function backendReports(
kind: "playing" | "paused" | "error",
position = 0,
duration = 0
) {
async function backendReports(kind: "playing" | "paused" | "error", position = 0, duration = 0) {
const media = makeEpisode();
if (kind === "playing") player.setPlaying(media, position, duration);
else if (kind === "paused") player.setPaused(media, position, duration);
@@ -219,7 +213,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
await backendReports("paused", 5, 1440);
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull(),
);
await backendReports("playing", 6, 1440);
@@ -228,25 +222,21 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
// both dims and covers the ExoPlayer surface while it plays. Before the
// mirror, nothing after init could take it down, because the only other
// writer was the never-emitted `player://state-changed` channel.
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
});
it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
const { container } = await mountNativePlayer();
await backendReports("playing", 5, 1440);
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
await backendReports("paused", 6, 1440);
// The mirror has to work in both directions, or pausing leaves no affordance
// to resume.
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull(),
);
});
@@ -284,14 +274,18 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
// defeated the first attempt: a one-shot timer armed on entry fired here,
// declined, and was never re-armed.
await vi.advanceTimersByTimeAsync(3500);
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain("opacity-0");
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain(
"opacity-0",
);
// Playback starts late; the countdown has to restart on its own.
player.setPlaying(makeEpisode(), 5, 1440);
await vi.advanceTimersByTimeAsync(3500);
await vi.waitFor(() =>
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain("opacity-0")
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain(
"opacity-0",
),
);
} finally {
vi.useRealTimers();
@@ -150,9 +150,7 @@ async function mountAndroidPlayer() {
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector(
'input[type="range"]'
) as HTMLInputElement;
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull();
expect(video).not.toBeNull();
@@ -160,11 +158,7 @@ async function mountAndroidPlayer() {
}
/** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(
slider: HTMLInputElement,
video: HTMLVideoElement,
target: number
) {
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
await fireEvent.mouseDown(slider);
slider.value = String(target);
await fireEvent.input(slider);
@@ -201,8 +195,8 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
600,
"src-1",
null,
true // HTML5 path: the webview owns playback after the override
)
true, // HTML5 path: the webview owns playback after the override
),
);
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
+326 -152
View File
@@ -100,7 +100,22 @@
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
}
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
let {
media,
streamUrl,
mediaSourceId,
initialPosition,
needsTranscoding = false,
onClose,
onSeek,
onReportProgress,
onReportStart,
onReportStop,
onEnded,
onNext,
hasNext = false,
isLive = false,
}: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
@@ -140,12 +155,7 @@
setHtml5VideoState(false, 0, 0, false);
return;
}
setHtml5VideoState(
true,
videoElement.videoWidth,
videoElement.videoHeight,
isPlaying
);
setHtml5VideoState(true, videoElement.videoWidth, videoElement.videoHeight, isPlaying);
}
let isFullscreen = $state(false);
let showControls = $state(true);
@@ -242,8 +252,12 @@
const adapterBridge: Html5ElementBridge = {
getElement: () => videoElement,
getSeekOffset: () => seekOffset,
setSeekOffset: (o) => { seekOffset = o; },
setStreamUrl: (u) => { currentStreamUrl = u; },
setSeekOffset: (o) => {
seekOffset = o;
},
setStreamUrl: (u) => {
currentStreamUrl = u;
},
destroyHls: tearDownHls,
getMediaSourceId: () => mediaSourceId ?? null,
};
@@ -278,7 +292,6 @@
return videoDuration;
});
// The audio tracks available for this item, as the server described them.
//
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
@@ -293,19 +306,22 @@
log.debug("No media or mediaStreams available");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
const tracks = media.mediaStreams.filter((stream) => stream.kind === "audio");
log.debug("Found audio tracks:", tracks.length, tracks);
return tracks;
});
// Function to find best matching audio track based on preference
function findBestAudioTrack(preference: { audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null }) {
function findBestAudioTrack(preference: {
audioTrackDisplayTitle?: string | null;
audioTrackLanguage?: string | null;
}) {
const tracks = audioTracks();
if (tracks.length === 0) return null;
// Try to match by display title first
if (preference.audioTrackDisplayTitle) {
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
const match = tracks.find((t) => t.displayTitle === preference.audioTrackDisplayTitle);
if (match) {
log.debug("Matched audio track by display title:", match.displayTitle);
return match.index;
@@ -314,7 +330,7 @@
// Try to match by language
if (preference.audioTrackLanguage) {
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
const match = tracks.find((t) => t.language === preference.audioTrackLanguage);
if (match) {
log.debug("Matched audio track by language:", match.language);
return match.index;
@@ -322,8 +338,11 @@
}
// Fall back to default track
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
const defaultTrack = tracks.find((t) => t.isDefault) || tracks[0];
log.debug(
"Using default/first audio track:",
defaultTrack.displayTitle || defaultTrack.language,
);
return defaultTrack.index;
}
@@ -388,7 +407,7 @@
// Cross-origin <track> fetches use the media element's CORS setting; see
// videoCrossOriginMode for why this is opt-in and same-origin-only.
const videoCrossOrigin = $derived(
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length),
);
$effect(() => {
@@ -408,7 +427,10 @@
renderedSubtitleTracks = tracks;
// Keep the menu's checkmark and the element's text tracks in agreement:
// a selection that no longer resolves collapses to "Off".
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
const selected = reconcileSelectedSubtitle(
tracks,
untrack(() => selectedSubtitleIndex),
);
selectedSubtitleIndex = selected;
// The <track> children were just (re)created, so re-apply the selection to
// the new TextTrack objects — otherwise a surviving selection shows nothing.
@@ -439,7 +461,6 @@
}
});
// Sleep-timer expiry pause is now driven by the backend through the player
// adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
// pause() (see handleControlCommand / the sleep_timer_expired case). This
@@ -545,12 +566,12 @@
return;
}
const isHlsStream = currentStreamUrl.includes('.m3u8');
const isHlsStream = currentStreamUrl.includes(".m3u8");
if (isHlsStream && Hls.isSupported()) {
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
if (hls) {
log.debug('Cleaning up existing HLS instance');
log.debug("Cleaning up existing HLS instance");
// Detach from media element first to stop all audio/video
hls.detachMedia();
// Stop loading and flush buffers
@@ -564,7 +585,7 @@
// This is critical to prevent dual audio streams
if (videoElement.src) {
videoElement.pause(); // Ensure playback is stopped
videoElement.removeAttribute('src');
videoElement.removeAttribute("src");
videoElement.load(); // Reset the media element and clear all buffers
videoElement.currentTime = 0;
}
@@ -574,7 +595,7 @@
setTimeout(() => {
if (!videoElement) return;
log.debug('Creating new HLS instance for:', currentStreamUrl);
log.debug("Creating new HLS instance for:", currentStreamUrl);
// Create new HLS instance
hls = new Hls({
@@ -602,14 +623,14 @@
// Listen for media attached event
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
log.debug('HLS.js attached to video element');
log.debug("HLS.js attached to video element");
// Load the HLS stream
hls!.loadSource(currentStreamUrl);
});
// Listen for manifest parsed event
hls.on(Hls.Events.MANIFEST_PARSED, () => {
log.debug('HLS manifest parsed, ready to play');
log.debug("HLS manifest parsed, ready to play");
});
// On the Android WebView the element's own `canplay` may not fire for
@@ -626,7 +647,11 @@
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
log.warn(
"HLS canplay fallback - revealing video (readyState:",
videoElement.readyState,
")",
);
markMediaReady();
}
}, 5000);
@@ -636,7 +661,7 @@
// Handle errors
hls.on(Hls.Events.ERROR, (event, data) => {
log.error('HLS error:', data);
log.error("HLS error:", data);
if (data.fatal) {
// Is this the stream ending or the stream breaking? Jellyfin's
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
@@ -647,31 +672,37 @@
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hlsFatalRecoveryAttempts++;
switch (fatalNetworkErrorAction({
positionSeconds: currentTime,
knownDurationSeconds: knownDuration,
attempts: hlsFatalRecoveryAttempts,
})) {
case 'ended':
log.debug('Fatal network error near end of stream - treating as ended');
switch (
fatalNetworkErrorAction({
positionSeconds: currentTime,
knownDurationSeconds: knownDuration,
attempts: hlsFatalRecoveryAttempts,
})
) {
case "ended":
log.debug("Fatal network error near end of stream - treating as ended");
notifyEnded();
break;
case 'retry':
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
case "retry":
log.error(
"Fatal network error, trying to recover (attempt",
hlsFatalRecoveryAttempts,
")",
);
hls!.startLoad();
break;
case 'giveUp':
log.error('Fatal network error, max recovery attempts reached');
case "giveUp":
log.error("Fatal network error, max recovery attempts reached");
hls!.destroy();
break;
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
log.error('Fatal media error, trying to recover');
log.error("Fatal media error, trying to recover");
hls!.recoverMediaError();
break;
default:
log.error('Unrecoverable HLS error');
log.error("Unrecoverable HLS error");
hls!.destroy();
break;
}
@@ -681,7 +712,7 @@
// Cleanup on effect re-run
return () => {
log.debug('Effect cleanup: destroying HLS instance');
log.debug("Effect cleanup: destroying HLS instance");
if (hls) {
hls.detachMedia();
hls.stopLoad();
@@ -692,13 +723,13 @@
videoElement.pause();
}
};
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS support (Safari)
log.debug('Using native HLS support');
log.debug("Using native HLS support");
videoElement.src = currentStreamUrl;
} else {
// Not an HLS stream, use regular video element
log.debug('Using regular video element for non-HLS stream');
log.debug("Using regular video element for non-HLS stream");
}
});
@@ -707,7 +738,12 @@
if (videoElement) {
videoElement.muted = false;
videoElement.volume = 1.0;
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
log.debug(
"Video element configured: muted=",
videoElement.muted,
"volume=",
videoElement.volume,
);
// DIAGNOSTIC: Check if video has audio tracks
if ((videoElement as any).audioTracks) {
@@ -715,7 +751,7 @@
// Set initial audio track (prefer default track)
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
const defaultTrack = audioTracks().find(t => t.isDefault);
const defaultTrack = audioTracks().find((t) => t.isDefault);
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
log.debug("Selected default audio track:", selectedAudioTrackIndex);
}
@@ -724,7 +760,10 @@
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
}
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
log.debug(
"webkitAudioDecodedByteCount:",
(videoElement as any).webkitAudioDecodedByteCount,
);
}
}
});
@@ -768,10 +807,7 @@
//
// TRACES: UR-074 | DR-162
onMount(() => {
Promise.all([
commands.playerGetStreamingQualities(),
commands.playerGetVideoSettings(),
])
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
.then(([qualities, settings]) => {
streamingQualities = qualities;
// Optional on the wire (serde default) — absent means uncapped.
@@ -883,14 +919,18 @@
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
try {
log.debug("Using HTML5 for direct stream - stopping backend player to prevent dual audio");
log.debug(
"Using HTML5 for direct stream - stopping backend player to prevent dual audio",
);
await commands.playerStop();
didStopBackendEarly = true; // Track that we stopped the backend
} catch (err) {
log.warn("Failed to stop backend player:", err);
}
} else if (useHtml5Element && needsTranscoding) {
log.debug("Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
log.debug(
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
);
// Backend is kept running but should not play audio since HTML5 element handles playback
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
@@ -902,7 +942,9 @@
{
const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
onStreamUrlChanged: (u) => {
currentStreamUrl = u;
},
});
playerAdapter = createAdapter({
backendKind: useHtml5Element ? "html5" : "native",
@@ -966,12 +1008,12 @@
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
currentTime = event.payload.position;
}
})
}),
);
nativeUnlisteners.push(
await listen("player://state-changed", (event: any) => {
isPlaying = event.payload.state === "playing";
})
}),
);
}
} catch (err) {
@@ -1053,7 +1095,7 @@
` paused=${videoElement.paused}` +
` seeking=${videoElement.seeking}` +
` rate=${videoElement.playbackRate}` +
` buffered=${bufferedRanges.join(", ")}`
` buffered=${bufferedRanges.join(", ")}`,
);
}
}, 1000);
@@ -1124,7 +1166,7 @@
// Stop video element playback
if (videoElement) {
videoElement.pause();
videoElement.src = '';
videoElement.src = "";
videoElement.load();
}
@@ -1199,7 +1241,12 @@
log.debug("Needs transcoding:", needsTranscoding);
// For direct streams without runTimeTicks, use video element's duration
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
if (
videoElement &&
videoElement.duration &&
!isNaN(videoElement.duration) &&
videoElement.duration !== Infinity
) {
const newDuration = videoElement.duration;
log.debug("Setting videoDuration to:", newDuration);
videoDuration = newDuration;
@@ -1262,8 +1309,17 @@
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
await doSeek();
} else {
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
log.debug(
"Deferring foreground seek until loadedmetadata:",
(seekOffset + seekTo).toFixed(1),
);
el.addEventListener(
"loadedmetadata",
() => {
void doSeek();
},
{ once: true },
);
}
return true;
}
@@ -1368,7 +1424,13 @@
log.error("Network state:", networkStates[video.networkState] || video.networkState);
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
const readyStates = [
"HAVE_NOTHING",
"HAVE_METADATA",
"HAVE_CURRENT_DATA",
"HAVE_FUTURE_DATA",
"HAVE_ENOUGH_DATA",
];
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
}
@@ -1399,10 +1461,16 @@
canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement) {
log.warn("canplay event did not fire within 5 seconds");
log.debug("Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
log.debug(
"Fallback check - readyState:",
videoElement.readyState,
"networkState:",
videoElement.networkState,
);
// Check if video is actually ready despite event not firing
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
if (videoElement.readyState >= 3) {
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
markMediaReady();
}
@@ -1519,7 +1587,7 @@
` ended=${el?.ended}` +
` isSeeking=${isSeeking}` +
` isBuffering=${isBuffering}` +
` handoff=${handoffState.active}`
` handoff=${handoffState.active}`,
);
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused
@@ -1612,7 +1680,7 @@
await playerController.seekVideo(
targetTime,
mediaSourceId ?? null,
selectedAudioTrackIndex ?? null
selectedAudioTrackIndex ?? null,
);
// Resume smooth updates if still playing after the seek settled.
@@ -1684,12 +1752,14 @@
if (!media) return;
// Ask the server for an audio-only stream of this video item (no video
// decode), carrying the selected audio track and resume position.
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
media.id,
mediaSourceId ?? undefined,
pos,
selectedAudioTrackIndex ?? undefined,
);
const audioUrl = await auth
.getRepository()
.getAudioOnlyStreamUrlForVideo(
media.id,
mediaSourceId ?? undefined,
pos,
selectedAudioTrackIndex ?? undefined,
);
await commands.playerEnterBackgroundAudio(
{
id: media.id,
@@ -2110,7 +2180,7 @@
streamIndex,
arrayIndex,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null
mediaSourceId ?? null,
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
@@ -2125,7 +2195,7 @@
if (!userId) return;
// Find the selected track info
const selectedTrack = audioTracks().find(t => t.index === streamIndex);
const selectedTrack = audioTracks().find((t) => t.index === streamIndex);
if (selectedTrack) {
await commands.storageSaveSeriesAudioPreference(
userId,
@@ -2133,9 +2203,12 @@
media.serverId ?? "",
selectedTrack.displayTitle || null,
selectedTrack.language || null,
streamIndex
streamIndex,
);
log.debug(
"Saved series audio preference:",
selectedTrack.displayTitle || selectedTrack.language,
);
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
}
} catch (err) {
log.warn("Failed to save series audio preference:", err);
@@ -2175,7 +2248,7 @@
quality,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null,
selectedAudioTrackIndex
selectedAudioTrackIndex,
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
@@ -2245,7 +2318,12 @@
try {
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
await commands.playerSetSubtitleTrack(indexToUse);
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
log.debug(
"Native backend subtitle track changed - streamIndex:",
streamIndex,
"position:",
indexToUse,
);
} catch (error) {
log.error("Failed to set subtitle track:", error);
}
@@ -2269,7 +2347,7 @@
<div
class="fixed inset-0 flex flex-col z-50"
class:bg-black={useHtml5Element}
style:background-color={!useHtml5Element ? 'transparent' : ''}
style:background-color={!useHtml5Element ? "transparent" : ""}
onmousemove={handleMouseMove}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
@@ -2282,44 +2360,44 @@
{#if !!useHtml5Element}
<!-- HTML5 video for desktop/non-Android platforms -->
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
autoplay
muted={false}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
oncanplay={handleCanPlay}
onplay={handlePlay}
onpause={handlePause}
onended={handleEnded}
onerror={handleError}
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleSurfaceClick}
>
<!--
bind:this={videoElement}
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
autoplay
muted={false}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata}
oncanplay={handleCanPlay}
onplay={handlePlay}
onpause={handlePause}
onended={handleEnded}
onerror={handleError}
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleSurfaceClick}
>
<!--
Subtitles for the HTML5 path. `src` is a resolved string (see
renderedSubtitleTracks); `data-stream-index` is what
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
attribute: a default track auto-shows, which would contradict the
menu opening on "Off".
-->
{#each renderedSubtitleTracks as track (track.streamIndex)}
<track
kind="subtitles"
src={track.url}
srclang={track.srclang}
label={track.label}
data-stream-index={track.streamIndex}
/>
{/each}
</video>
{#each renderedSubtitleTracks as track (track.streamIndex)}
<track
kind="subtitles"
src={track.url}
srclang={track.srclang}
label={track.label}
data-stream-index={track.streamIndex}
/>
{/each}
</video>
{:else}
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
<!-- Leave this area transparent so video shows through -->
@@ -2350,7 +2428,9 @@
<!-- Loading spinner overlay -->
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
<div
class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
</div>
{/if}
@@ -2360,8 +2440,12 @@
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
/>
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
>{SEEK_BACKWARD_SECONDS}</text
>
</svg>
</div>
</div>
@@ -2371,8 +2455,12 @@
<div class="absolute right-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
/>
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
>+{SEEK_FORWARD_SECONDS}</text
>
</svg>
</div>
</div>
@@ -2383,12 +2471,17 @@
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none">
<div class="bg-black/60 rounded-lg px-4 py-3 backdrop-blur-sm flex items-center gap-3">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" />
<path
d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"
/>
</svg>
<div class="flex flex-col">
<span class="text-white text-xs font-medium">Brightness</span>
<div class="w-24 h-1 bg-white/30 rounded-full mt-1">
<div class="h-full bg-white rounded-full" style="width: {((brightness - 0.3) / 1.4) * 100}%"></div>
<div
class="h-full bg-white rounded-full"
style="width: {((brightness - 0.3) / 1.4) * 100}%"
></div>
</div>
</div>
</div>
@@ -2398,7 +2491,9 @@
<!-- Loading overlay for seeking -->
{#if isSeeking}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
<div
class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if !isPlaying}
<!-- Play overlay. Visually this IS the video surface, so it is marked
@@ -2454,7 +2549,9 @@
{:else}
<span class="flex items-center gap-2 text-white/80 text-sm">
<!-- No resolved Jellyfin id → no headshot available. -->
<span class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400">
<span
class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400"
>
{actor.name.slice(0, 1)}
</span>
<span>{actor.name}</span>
@@ -2506,9 +2603,9 @@
value={currentTime}
oninput={handleSeekBarInput}
onchange={handleSeekBarRelease}
onmousedown={() => isDraggingSeekBar = true}
onmousedown={() => (isDraggingSeekBar = true)}
onmouseup={handleSeekBarRelease}
ontouchstart={() => isDraggingSeekBar = true}
ontouchstart={() => (isDraggingSeekBar = true)}
ontouchend={handleSeekBarRelease}
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
@@ -2522,7 +2619,11 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<button onclick={togglePlayPause} class="text-white hover:text-gray-300" aria-label={isPlaying ? "Pause" : "Play"}>
<button
onclick={togglePlayPause}
class="text-white hover:text-gray-300"
aria-label={isPlaying ? "Pause" : "Play"}
>
{#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
@@ -2554,13 +2655,17 @@
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
<!-- Audio Track Menu -->
{#if showAudioTrackMenu}
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track
@@ -2568,7 +2673,10 @@
{#each audioTracks() as track, i}
<button
onclick={() => selectAudioTrack(track.index, i)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex === track.index ? 'bg-white/20' : ''}"
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
track.index
? 'bg-white/20'
: ''}"
>
<span class="text-sm">
{track.displayTitle || track.language || `Track ${i + 1}`}
@@ -2577,8 +2685,12 @@
{/if}
</span>
{#if selectedAudioTrackIndex === track.index}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
@@ -2600,12 +2712,16 @@
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"/>
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
{#if showQualityMenu}
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto">
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Quality
@@ -2613,15 +2729,22 @@
{#each streamingQualities as [quality, label, detail]}
<button
onclick={() => selectQuality(quality)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
quality
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">{label}</span>
<span class="text-xs text-gray-400">{detail}</span>
</div>
{#if selectedQuality === quality}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
@@ -2641,13 +2764,17 @@
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"/>
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
<!-- Subtitle Menu -->
{#if showSubtitleMenu}
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles
@@ -2655,12 +2782,19 @@
<!-- Off option -->
<button
onclick={() => selectSubtitle(null)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === null ? 'bg-white/20' : ''}"
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
null
? 'bg-white/20'
: ''}"
>
<span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
@@ -2668,7 +2802,10 @@
{#each subtitleTracks() as track}
<button
onclick={() => selectSubtitle(track.index)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
track.index
? 'bg-white/20'
: ''}"
>
<div class="flex flex-col">
<span class="text-sm">
@@ -2685,8 +2822,12 @@
{/if}
</div>
{#if selectedSubtitleIndex === track.index}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</button>
@@ -2699,15 +2840,23 @@
<!-- Sleep Timer -->
{#if $sleepTimerActive}
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
<SleepTimerIndicator
onClick={() => {
showSleepTimerModal = true;
}}
/>
{:else}
<button
onclick={() => { showSleepTimerModal = true; }}
onclick={() => {
showSleepTimerModal = true;
}}
class="text-white hover:text-gray-300"
aria-label="Sleep timer"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
/>
</svg>
</button>
{/if}
@@ -2723,7 +2872,9 @@
aria-label="Picture in picture"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
<path
d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"
/>
</svg>
</button>
{/if}
@@ -2733,23 +2884,35 @@
{#if backgroundAudioSupported}
<button
onclick={toggleBackgroundAudio}
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
class={backgroundAudioOn
? "text-blue-400 hover:text-blue-300"
: "text-white hover:text-gray-300"}
aria-label="Background audio"
aria-pressed={backgroundAudioOn}
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
<path
d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"
/>
</svg>
</button>
{/if}
<!-- Fullscreen -->
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
<button
onclick={toggleFullscreen}
class="text-white hover:text-gray-300"
aria-label="Toggle fullscreen"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
{#if isFullscreen}
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
<path
d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
/>
{:else}
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
<path
d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
/>
{/if}
</svg>
</button>
@@ -2757,7 +2920,12 @@
<!-- Close -->
<button onclick={onClose} class="text-white hover:text-gray-300" aria-label="Close">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -2765,7 +2933,13 @@
</div>
</div>
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
<SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => {
showSleepTimerModal = false;
}}
mediaType={media?.type}
/>
<style>
@keyframes fade-out {
@@ -116,7 +116,7 @@ function touchAt(el: Element, x: number) {
bubbles: true,
cancelable: true,
touches: [touch] as unknown as Touch[],
})
}),
);
}
@@ -139,9 +139,7 @@ async function mountAndroidPlayer() {
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector(
'input[type="range"]'
) as HTMLInputElement;
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull();
return { ...utils, slider, video };
@@ -157,11 +155,7 @@ function touch(x: number, y: number) {
* A real drag along the bar moves the finger far enough that the container's
* swipe detector (50px) would trigger if it were still listening.
*/
async function touchScrubTo(
slider: HTMLInputElement,
video: HTMLVideoElement,
target: number
) {
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
// Finger travels across the bar. Small vertical wander is normal for a thumb
// drag; the horizontal travel is what matters.
@@ -187,7 +181,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
await touchScrubTo(slider, video, 600);
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
@@ -216,7 +210,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
await tick();
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
});
@@ -155,7 +155,7 @@
{#if showSlider}
<button
class="fixed inset-0 z-[65]"
onclick={() => showSlider = false}
onclick={() => (showSlider = false)}
aria-label="Close volume"
></button>
{/if}
@@ -42,7 +42,7 @@ export const initialHandoffState: BackgroundAudioState = {
*/
export function shouldEnterBackgroundAudio(
toggleOn: boolean,
state: BackgroundAudioState
state: BackgroundAudioState,
): boolean {
return toggleOn && !state.active;
}
@@ -70,7 +70,7 @@ export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean
*/
export function shouldResumeOnForeground(
wasPlaying: boolean,
nativeStateKind: string | undefined
nativeStateKind: string | undefined,
): boolean {
return wasPlaying && nativeStateKind !== "paused";
}
+1 -4
View File
@@ -37,10 +37,7 @@ export interface FatalNetworkErrorInput {
}
/** Whether a failure at this position should be read as the stream ending. */
export function isNearEndOfStream(
positionSeconds: number,
knownDurationSeconds: number
): boolean {
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
}
+5 -13
View File
@@ -16,34 +16,26 @@ describe("nativeSignalRevealsVideo", () => {
"leaves the poster up on state %s",
(state) => {
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
}
},
);
it("reveals on a position tick that carries a duration", () => {
expect(
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
).toBe(true);
expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })).toBe(true);
});
it("reveals on a position tick that has advanced, even with no duration", () => {
// Live streams report no duration; an advancing position is still proof
// that the surface has content.
expect(
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
).toBe(true);
expect(nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })).toBe(true);
});
it("leaves the poster up on an empty position tick", () => {
// A tick before anything is loaded proves nothing, and revealing here would
// show a transparent hole through the app.
expect(
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
).toBe(false);
expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })).toBe(false);
});
it("does not treat a negative position as progress", () => {
expect(
nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })
).toBe(false);
expect(nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })).toBe(false);
});
});
+1 -2
View File
@@ -19,8 +19,7 @@
/** A player event that might mean "the surface has a picture on it". */
export type NativeRevealSignal =
| { kind: "state"; state: string }
| { kind: "position"; position: number; duration: number };
{ kind: "state"; state: string } | { kind: "position"; position: number; duration: number };
/**
* Whether `signal` proves the native backend is rendering, and the poster card
@@ -24,7 +24,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "track-1",
isVideo: false,
forceRestart: false,
})
}),
).toBe(true);
});
@@ -37,7 +37,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "episode-1",
isVideo: true,
forceRestart: false,
})
}),
).toBe(false);
});
@@ -48,7 +48,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "track-1",
isVideo: false,
forceRestart: false,
})
}),
).toBe(false);
});
@@ -59,7 +59,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: null,
isVideo: false,
forceRestart: false,
})
}),
).toBe(false);
});
@@ -71,7 +71,7 @@ describe("shouldReuseActivePlayback", () => {
isVideo: false,
startPosition: 42,
forceRestart: false,
})
}),
).toBe(false);
});
@@ -82,7 +82,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "episode-2",
isVideo: true,
forceRestart: true,
})
}),
).toBe(false);
});
});
@@ -90,7 +90,7 @@ describe("shouldReuseActivePlayback", () => {
describe("resolvePlayerSurface", () => {
it("renders the video surface for video with a stream URL", () => {
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe(
"video"
"video",
);
});
@@ -68,8 +68,18 @@ describe("subtitleStreamsOf", () => {
*/
it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
const streams: SubtitleStreamLike[] = [
{ index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false },
{ index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true },
{
index: 2,
kind: "subtitle",
displayTitle: "English PGS SDH",
supportsExternalDelivery: false,
},
{
index: 3,
kind: "subtitle",
displayTitle: "English Text SDH",
supportsExternalDelivery: true,
},
];
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
@@ -119,7 +129,9 @@ describe("subtitleStreamsOf", () => {
describe("subtitleTrackLabel", () => {
it("prefers the display title, then language, then the index", () => {
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe(
"English (SRT)",
);
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
});
@@ -309,10 +321,7 @@ describe("nativeSubtitleArrayIndex", () => {
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(
resolve(__dirname, "VideoPlayer.svelte"),
"utf-8",
);
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
+1 -3
View File
@@ -209,9 +209,7 @@ export function videoCrossOriginMode(
*
* TRACES: UR-020 | IR-016, JA-008 | UT-147
*/
export function nativeSubtitleTracks(
tracks: readonly RenderableSubtitleTrack[],
): SubtitleTrack[] {
export function nativeSubtitleTracks(tracks: readonly RenderableSubtitleTrack[]): SubtitleTrack[] {
return tracks.map((track) => ({
index: track.streamIndex,
url: track.url,
@@ -151,7 +151,7 @@ describe("seek target resolution", () => {
// that starts at/after the media end, which the server never produces —
// the fetch times out and the gap-controller stalls in a pause loop.
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
DURATION - END_SEEK_MARGIN_SECONDS
DURATION - END_SEEK_MARGIN_SECONDS,
);
});
@@ -214,9 +214,9 @@ describe("control-surface touches are not gestures", () => {
});
it("treats anything inside the controls bar as a control", () => {
expect(
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
).toBe(true);
expect(isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])).toBe(
true,
);
});
it("lets a tap on the bare video surface through as a gesture", () => {
+5 -3
View File
@@ -45,7 +45,7 @@ export const TOUCH_CLICK_SUPPRESS_MS = 700;
* testable without a DOM.
*/
export function isControlSurfaceTouch(
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>,
): boolean {
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
for (const node of ancestors) {
@@ -75,7 +75,7 @@ export function isControlSurfaceTouch(
export function isSynthesizedTouchClick(
detail: number,
now: number,
lastTouchTapAt: number
lastTouchTapAt: number,
): boolean {
if (detail === 0) return true;
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
@@ -219,7 +219,9 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
const { delta, reportedPosition, duration, pendingTarget } = input;
const base =
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
pendingTarget != null &&
Math.abs(pendingTarget - reportedPosition) > 0.5 &&
pendingTarget > reportedPosition
? pendingTarget
: reportedPosition;
+1 -4
View File
@@ -37,10 +37,7 @@ export function fittedVideoSize(
return { width: 0, height: 0 };
}
const scale = Math.min(
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
return {
width: intrinsicWidth * scale,
@@ -32,9 +32,12 @@
try {
const repo = auth.getRepository();
// Find music library for playlist browsing
const musicLib = $libraries.find(lib => lib.collectionType === "music");
const musicLib = $libraries.find((lib) => lib.collectionType === "music");
if (musicLib) {
const result = await repo.getItems(musicLib.id, { includeItemTypes: ["Playlist"], limit: 100 });
const result = await repo.getItems(musicLib.id, {
includeItemTypes: ["Playlist"],
limit: 100,
});
playlists = result.items;
} else {
// Try searching for playlists without a parent
@@ -79,7 +82,9 @@
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog"
aria-modal="true"
tabindex="-1"
@@ -97,9 +102,11 @@
onclick={handleNewPlaylist}
class="w-full flex items-center gap-3 p-3 bg-[var(--color-background)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
>
<div class="w-10 h-10 bg-[var(--color-jellyfin)] rounded flex items-center justify-center flex-shrink-0">
<div
class="w-10 h-10 bg-[var(--color-jellyfin)] rounded flex items-center justify-center flex-shrink-0"
>
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
</div>
<span class="text-white font-medium">New Playlist</span>
@@ -136,7 +143,9 @@
{:else}
<div class="w-full h-full bg-gray-700 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
</div>
{/if}
@@ -24,7 +24,10 @@
creating = true;
try {
const repo = auth.getRepository();
const result = await repo.createPlaylist(trimmed, initialItemIds.length > 0 ? initialItemIds : undefined);
const result = await repo.createPlaylist(
trimmed,
initialItemIds.length > 0 ? initialItemIds : undefined,
);
toast.success(`Playlist "${trimmed}" created`);
name = "";
onClose?.();
@@ -48,7 +51,9 @@
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog"
aria-modal="true"
tabindex="-1"
@@ -34,7 +34,7 @@
let scope = $state<SearchScope>(
isSearchRoute($page.url.pathname)
? parseSearchScope($page.url.searchParams.get("scope"))
: resolveSearchScope($page.url.pathname)
: resolveSearchScope($page.url.pathname),
);
$effect(() => {
@@ -80,9 +80,4 @@
}
</script>
<Search
bind:value
bind:inputEl
placeholder="Search your library..."
onSearch={handleSearch}
/>
<Search bind:value bind:inputEl placeholder="Search your library..." onSearch={handleSearch} />
@@ -104,7 +104,7 @@ describe("on /search", () => {
expect(goto).toHaveBeenCalledWith(
"/search?q=jazzy&scope=tv",
expect.objectContaining({ replaceState: true })
expect.objectContaining({ replaceState: true }),
);
});
});
@@ -34,7 +34,9 @@
{:else if groups.length === 0}
<div class="text-center py-12 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
/>
</svg>
<p>No results found</p>
</div>

Some files were not shown because too many files have changed in this diff Show More