chore(format): run prettier over src/ and scripts/

Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
This commit is contained in:
2026-08-21 17:41:44 +02:00
parent d095e1f410
commit ad48d89dfe
199 changed files with 4698 additions and 3453 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
# Test service - runs tests only # Test service - runs tests only
@@ -31,7 +31,7 @@ services:
depends_on: depends_on:
- test - test
ports: 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 # Linux desktop packages - deb + rpm + pacman into ./dist
desktop-linux-build: desktop-linux-build:
+7 -11
View File
@@ -140,9 +140,10 @@ describe("findDanglingIds", () => {
}); });
it("deduplicates and sorts, so one typo is reported once", () => { it("deduplicates and sorts, so one typo is reported once", () => {
expect( expect(findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)).toEqual([
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined) "DR-189",
).toEqual(["DR-189", "UR-999"]); "UR-999",
]);
}); });
it("ignores IDs whose prefix is not a known trace type", () => { 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. // passes, which is how the 50%-while-actually-86% slack went unnoticed.
const workflow = fs.readFileSync( const workflow = fs.readFileSync(
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"), path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
"utf-8" "utf-8",
); );
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m); const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull(); expect(match).not.toBeNull();
@@ -307,9 +308,7 @@ describe("generated matrix file links", () => {
it("keeps the #Lnn line anchor on the href", () => { it("keeps the #Lnn line anchor on the href", () => {
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427); const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
expect(link).toBe( expect(link).toBe("[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)");
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
);
}); });
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => { 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 // 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 // is caught by the sum-consistency check below, and the >100% ratio it
// produced is covered directly by the computeCoverage tests, on fixtures. // produced is covered directly by the computeCoverage tests, on fixtures.
const md = fs.readFileSync( const md = fs.readFileSync(path.resolve(HERE, "../docs/requirements.md"), "utf-8");
path.resolve(HERE, "../docs/requirements.md"),
"utf-8"
);
const defined = countDefinedRequirements(md); const defined = countDefinedRequirements(md);
// The parser found real rows of every type: a section silently failing to // 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 // `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 // 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. // import.meta.url — this file must stay importable for extract-traces.test.ts.
const SCRIPT_DIR = const SCRIPT_DIR = import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const BASE_DIR = path.resolve(SCRIPT_DIR, ".."); const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi; const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
@@ -283,8 +282,7 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
else ids.add(id); else ids.add(id);
} }
const countOf = (type: string) => const countOf = (type: string) => [...ids].filter((id) => id.startsWith(`${type}-`)).length;
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
return { return {
UR: countOf("UR"), UR: countOf("UR"),
@@ -311,16 +309,13 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
* *
* TRACES: | DR-093 * TRACES: | DR-093
*/ */
export function findDanglingIds( export function findDanglingIds(tracedIds: string[], defined: DefinedRequirements): string[] {
tracedIds: string[],
defined: DefinedRequirements
): string[] {
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/; const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
const dangling = new Set( const dangling = new Set(
tracedIds tracedIds
.filter((id) => KNOWN_TYPE.test(id)) .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(); return [...dangling].sort();
@@ -336,10 +331,7 @@ export function findDanglingIds(
* *
* TRACES: | DR-093 * TRACES: | DR-093
*/ */
export function computeCoverage( export function computeCoverage(tracedIds: string[], defined: DefinedRequirements): CoverageResult {
tracedIds: string[],
defined: DefinedRequirements
): CoverageResult {
// Only the four *requirement* types participate in coverage. UT/IT are test // Only the four *requirement* types participate in coverage. UT/IT are test
// identifiers defined in §4 of requirements.md — a different taxonomy, and // identifiers defined in §4 of requirements.md — a different taxonomy, and
// flagging them as orphans would bury real typos in ~60 lines of noise. // flagging them as orphans would bury real typos in ~60 lines of noise.
@@ -352,10 +344,7 @@ export function computeCoverage(
return { return {
covered: covered.length, covered: covered.length,
total: defined.total, total: defined.total,
percent: percent: defined.total === 0 ? 0 : Math.round((covered.length / defined.total) * 100),
defined.total === 0
? 0
: Math.round((covered.length / defined.total) * 100),
orphaned, orphaned,
}; };
} }
@@ -488,16 +477,14 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
if (cov.orphaned.length > 0) { if (cov.orphaned.length > 0) {
console.log(""); console.log("");
console.log( console.log(`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`);
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
);
console.log(" Fix the TRACES comment or add the requirement."); console.log(" Fix the TRACES comment or add the requirement.");
} }
if (data.dangling && data.dangling.length > 0) { if (data.dangling && data.dangling.length > 0) {
console.log(""); console.log("");
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("❌ TRACES reference IDs that docs/requirements.md does not define:");
console.log(""); console.log("");
for (const id of dangling) { for (const id of dangling) {
const files = [ const files = [...new Set((data.requirements[id] ?? []).map((e) => e.file))].sort();
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
].sort();
console.log(` ${id}`); console.log(` ${id}`);
for (const file of files) console.log(` ${file}`); 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. // Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) { if (import.meta.main) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const format = args.includes("--format") const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "markdown";
? args[args.indexOf("--format") + 1]
: "markdown";
console.error("🔍 Extracting TRACES from codebase..."); console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces(); const data = extractTraces();
@@ -583,7 +566,5 @@ if (import.meta.main) {
console.log(generateMarkdown(data)); console.log(generateMarkdown(data));
} }
console.error( console.error(`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`);
`\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[] { function changedFiles(range: string): string[] {
const cmd = range const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd) return sh(cmd)
.split("\n") .split("\n")
.filter((f) => f && existsSync(f)); .filter((f) => f && existsSync(f));
+24 -7
View File
@@ -34,30 +34,47 @@ function seed(dir: string) {
fs.writeFileSync( fs.writeFileSync(
path.join(dir, "package.json"), 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( fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"), 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 // A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate. // rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync( fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"), 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( fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"), 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( fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"), 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.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(dir, "packaging", "arch", "PKGBUILD"), 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 { function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match( 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; return m ? Number(m[1]) : NaN;
} }
+1 -1
View File
@@ -16,7 +16,7 @@ import { readFileSync } from "fs";
import { resolve } from "path"; import { resolve } from "path";
const config = JSON.parse( 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; const security = config.app.security;
+6 -2
View File
@@ -46,7 +46,8 @@
} }
/* Global styles */ /* Global styles */
html, body { html,
body {
@apply h-full; @apply h-full;
background-color: var(--color-background); background-color: var(--color-background);
} }
@@ -77,5 +78,8 @@ html[data-native-video="active"] [data-app-shell] {
body { body {
@apply text-white antialiased; @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 and the bottom nav renders under the Android navigation bar. See
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars). $lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
--> -->
<meta <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>JellyTau</title> <title>JellyTau</title>
%sveltekit.head% %sveltekit.head%
</head> </head>
+2 -6
View File
@@ -112,9 +112,7 @@ describe("autoplay API", () => {
await setAutoplaySettings(settings); await setAutoplaySettings(settings);
const call = invokeSpy.mock.calls.find( const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_autoplay_settings");
(c) => c[0] === "player_set_autoplay_settings"
);
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toEqual({ userId: "user-1", settings }); expect(call![1]).toEqual({ userId: "user-1", settings });
}); });
@@ -155,9 +153,7 @@ describe("autoplay API", () => {
const { invoke } = await import("@tauri-apps/api/core"); const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke); const invokeSpy = vi.mocked(invoke);
const call = invokeSpy.mock.calls.find( const call = invokeSpy.mock.calls.find((c) => c[0] === "player_play_next_episode");
(c) => c[0] === "player_play_next_episode"
);
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toEqual({ item: mockItem }); expect(call![1]).toEqual({ item: mockItem });
}); });
+1 -3
View File
@@ -14,9 +14,7 @@ export async function getAutoplaySettings(): Promise<AutoplaySettings> {
return commands.playerGetAutoplaySettings(); return commands.playerGetAutoplaySettings();
} }
export async function setAutoplaySettings( export async function setAutoplaySettings(settings: AutoplaySettings): Promise<AutoplaySettings> {
settings: AutoplaySettings
): Promise<AutoplaySettings> {
return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings); return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings);
} }
+14 -22
View File
@@ -77,7 +77,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({ options: expect.objectContaining({
sortBy: sortField, sortBy: sortField,
}), }),
}) }),
); );
} }
}); });
@@ -99,7 +99,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({ options: expect.objectContaining({
sortOrder: "Descending", sortOrder: "Descending",
}), }),
}) }),
); );
}); });
@@ -148,7 +148,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({ options: expect.objectContaining({
includeItemTypes: ["Audio", "MusicAlbum"], includeItemTypes: ["Audio", "MusicAlbum"],
}), }),
}) }),
); );
}); });
@@ -168,7 +168,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({ options: expect.objectContaining({
genres: ["Rock", "Jazz"], genres: ["Rock", "Jazz"],
}), }),
}) }),
); );
}); });
@@ -195,7 +195,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
"repository_search", "repository_search",
expect.objectContaining({ expect.objectContaining({
query: "query", query: "query",
}) }),
); );
}); });
@@ -217,7 +217,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
startIndex: 100, startIndex: 100,
limit: 50, limit: 50,
}), }),
}) }),
); );
}); });
}); });
@@ -238,7 +238,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
"repository_search", "repository_search",
expect.objectContaining({ expect.objectContaining({
query: "query", query: "query",
}) }),
); );
expect(result.items.length).toBe(2); expect(result.items.length).toBe(2);
@@ -260,7 +260,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
options: expect.objectContaining({ options: expect.objectContaining({
includeItemTypes: ["Audio"], includeItemTypes: ["Audio"],
}), }),
}) }),
); );
}); });
@@ -302,7 +302,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
expect.objectContaining({ expect.objectContaining({
itemId: "item123", itemId: "item123",
imageType: "Primary", imageType: "Primary",
}) }),
); );
}); });
@@ -327,23 +327,18 @@ describe("Backend Integration - Refactored Business Logic", () => {
const url = await client.getVideoStreamUrl("item123"); const url = await client.getVideoStreamUrl("item123");
expect(url).toBe(backendUrl); expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith( expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", expect.any(Object));
"repository_get_video_stream_url",
expect.any(Object)
);
}); });
it("should get subtitle URLs from backend", async () => { 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); (invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getSubtitleUrl("item123", "source456", 0); const url = await client.getSubtitleUrl("item123", "source456", 0);
expect(url).toBe(backendUrl); expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith( expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", expect.any(Object));
"repository_get_subtitle_url",
expect.any(Object)
);
}); });
it("should get video download URLs from backend", async () => { 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"); const url = await client.getVideoDownloadUrl("item123", "medium");
expect(url).toBe(backendUrl); expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith( expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", expect.any(Object));
"repository_get_video_download_url",
expect.any(Object)
);
}); });
it("should never expose access token in frontend code", async () => { 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 () => { 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); (invoke as any).mockResolvedValueOnce(mockUrl);
const options = { const options = {
@@ -133,9 +134,7 @@ describe("RepositoryClient", () => {
it("should throw error if not initialized before getImageUrl", async () => { it("should throw error if not initialized before getImageUrl", async () => {
const newClient = new RepositoryClient(); const newClient = new RepositoryClient();
await expect(newClient.getImageUrl("item123")).rejects.toThrow( await expect(newClient.getImageUrl("item123")).rejects.toThrow("Repository not initialized");
"Repository not initialized"
);
}); });
}); });
@@ -243,7 +242,7 @@ describe("RepositoryClient", () => {
"repository_get_video_download_url", "repository_get_video_download_url",
expect.objectContaining({ expect.objectContaining({
quality, quality,
}) }),
); );
} }
}); });
@@ -314,9 +313,7 @@ describe("RepositoryClient", () => {
it("should search with backend search command", async () => { it("should search with backend search command", async () => {
const mockResult = { const mockResult = {
items: [ items: [{ id: "item1", name: "Search Result 1", type: "Audio" }],
{ id: "item1", name: "Search Result 1", type: "Audio" },
],
totalRecordCount: 1, totalRecordCount: 1,
}; };
(invoke as any).mockResolvedValueOnce(mockResult); (invoke as any).mockResolvedValueOnce(mockResult);
@@ -353,7 +350,10 @@ describe("RepositoryClient", () => {
}); });
it("should get downloaded items with camelCase params", async () => { 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); (invoke as any).mockResolvedValueOnce(mockResult);
const result = await client.getDownloadedItems("album1", { limit: 50 }); const result = await client.getDownloadedItems("album1", { limit: 50 });
@@ -367,7 +367,12 @@ describe("RepositoryClient", () => {
}); });
it("should get download disk usage from backend", async () => { 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); (invoke as any).mockResolvedValueOnce(mockUsage);
const usage = await client.getDownloadDiskUsage(); const usage = await client.getDownloadDiskUsage();
@@ -589,7 +594,9 @@ describe("RepositoryClient", () => {
it("should throw error if not initialized before playlist operations", async () => { it("should throw error if not initialized before playlist operations", async () => {
const newClient = new RepositoryClient(); 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.createPlaylist("test")).rejects.toThrow("Repository not initialized");
await expect(newClient.deletePlaylist("pl-1")).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 () => { it("should throw error if invoke fails", async () => {
(invoke as any).mockRejectedValueOnce(new Error("Network error")); (invoke as any).mockRejectedValueOnce(new Error("Network error"));
await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow( await expect(
"Network error" client.create("https://server.com", "user1", "token", "server1"),
); ).rejects.toThrow("Network error");
}); });
it("should handle missing optional parameters", async () => { it("should handle missing optional parameters", async () => {
@@ -616,7 +623,7 @@ describe("RepositoryClient", () => {
"repository_get_image_url", "repository_get_image_url",
expect.objectContaining({ expect.objectContaining({
options: null, options: null,
}) }),
); );
}); });
}); });
+35 -12
View File
@@ -40,7 +40,7 @@ export class RepositoryClient {
serverUrl: string, serverUrl: string,
userId: string, userId: string,
accessToken: string, accessToken: string,
serverId: string serverId: string,
): Promise<string> { ): Promise<string> {
log.debug("Creating Rust repository..."); log.debug("Creating Rust repository...");
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId); this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
@@ -137,7 +137,11 @@ export class RepositoryClient {
} }
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> { 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"). */ /** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> { 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[]> { async getGenres(parentId?: string): Promise<Genre[]> {
@@ -229,13 +237,13 @@ export class RepositoryClient {
async getVideoStreamUrl( async getVideoStreamUrl(
itemId: string, itemId: string,
mediaSourceId?: string, mediaSourceId?: string,
audioStreamIndex?: number audioStreamIndex?: number,
): Promise<string> { ): Promise<string> {
return commands.repositoryGetVideoStreamUrl( return commands.repositoryGetVideoStreamUrl(
this.ensureHandle(), this.ensureHandle(),
itemId, itemId,
mediaSourceId ?? null, mediaSourceId ?? null,
audioStreamIndex ?? null audioStreamIndex ?? null,
); );
} }
@@ -248,14 +256,14 @@ export class RepositoryClient {
itemId: string, itemId: string,
mediaSourceId?: string, mediaSourceId?: string,
startTimeSeconds?: number, startTimeSeconds?: number,
audioStreamIndex?: number audioStreamIndex?: number,
): Promise<string> { ): Promise<string> {
return commands.repositoryGetAudioOnlyStreamUrlForVideo( return commands.repositoryGetAudioOnlyStreamUrlForVideo(
this.ensureHandle(), this.ensureHandle(),
itemId, itemId,
mediaSourceId ?? null, mediaSourceId ?? null,
startTimeSeconds ?? null, startTimeSeconds ?? null,
audioStreamIndex ?? null audioStreamIndex ?? null,
); );
} }
@@ -282,7 +290,11 @@ export class RepositoryClient {
* Get image URL from backend * Get image URL from backend
* The Rust backend constructs and returns the URL with proper credentials handling * 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); return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
} }
@@ -294,9 +306,15 @@ export class RepositoryClient {
itemId: string, itemId: string,
mediaSourceId: string, mediaSourceId: string,
streamIndex: number, streamIndex: number,
format: string = "vtt" format: string = "vtt",
): Promise<string> { ): 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( async getVideoDownloadUrl(
itemId: string, itemId: string,
quality: QualityPreset = "original", quality: QualityPreset = "original",
mediaSourceId?: string mediaSourceId?: string,
): Promise<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) ===== // ===== Favorite Methods (via Rust) =====
+1 -8
View File
@@ -81,11 +81,4 @@ export type PersonType =
| "Lyricist"; | "Lyricist";
export type SessionCommand = export type SessionCommand =
| "PlayPause" "PlayPause" | "Stop" | "Pause" | "Unpause" | "NextTrack" | "PreviousTrack" | "Mute" | "Unmute";
| "Stop"
| "Pause"
| "Unpause"
| "NextTrack"
| "PreviousTrack"
| "Mute"
| "Unmute";
+27 -9
View File
@@ -19,36 +19,49 @@
const withSearch = $derived(showHeaderSearch({ pathname })); const withSearch = $derived(showHeaderSearch({ pathname }));
</script> </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"> <div class="px-4 py-3 flex items-center gap-4">
<!-- Logo --> <!-- Logo -->
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]"> <a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]"> JellyTau </a>
JellyTau
</a>
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-1"> <nav class="hidden md:flex items-center gap-1">
<a <a
href="/" 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 Home
</a> </a>
<a <a
href="/library" 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 Library
</a> </a>
<a <a
href="/downloads" 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 Downloads
</a> </a>
<a <a
href="/settings" 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 Settings
</a> </a>
@@ -70,7 +83,12 @@
title="Downloads" title="Downloads"
> >
<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" 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> </svg>
</a> </a>
+34 -14
View File
@@ -1,8 +1,8 @@
<!-- TRACES: UR-039 | DR-045 --> <!-- TRACES: UR-039 | DR-045 -->
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from "$app/stores";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { library } from '$lib/stores/library'; import { library } from "$lib/stores/library";
// When a className is supplied the parent positions this bar (e.g. inside a // 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. // measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
@@ -11,9 +11,14 @@
// Determine if a route is active // Determine if a route is active
function isActive(path: string): boolean { function isActive(path: string): boolean {
const pathname = $page.url.pathname; const pathname = $page.url.pathname;
if (path === '/') { if (path === "/") {
// Home is active only when exactly on / or /home, not /library or /search // 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); return pathname.startsWith(path);
} }
@@ -24,36 +29,51 @@
<div class="flex items-center justify-around px-4 py-2"> <div class="flex items-center justify-around px-4 py-2">
<!-- Home Button --> <!-- Home Button -->
<button <button
onclick={() => goto('/')} 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'}" 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" aria-label="Home"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
<span class="text-xs">Home</span> <span class="text-xs">Home</span>
</button> </button>
<!-- Search Button --> <!-- Search Button -->
<button <button
onclick={() => goto('/search')} 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'}" 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" aria-label="Search"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
<span class="text-xs">Search</span> <span class="text-xs">Search</span>
</button> </button>
<!-- Library Button --> <!-- Library Button -->
<button <button
onclick={() => { library.setCurrentLibrary(null); goto('/library'); }} onclick={() => {
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'}" 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" aria-label="Library"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
<span class="text-xs">Library</span> <span class="text-xs">Library</span>
</button> </button>
+13 -9
View File
@@ -112,7 +112,9 @@
// Inline animation styles // Inline animation styles
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : ""); 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> </script>
<button <button
@@ -125,19 +127,20 @@
> >
{#if isFavorite} {#if isFavorite}
<!-- Filled heart with scale animation --> <!-- Filled heart with scale animation -->
<svg <svg class={svgClass} style={svgStyle} fill="currentColor" viewBox="0 0 24 24">
class={svgClass}
style={svgStyle}
fill="currentColor"
viewBox="0 0 24 24"
>
<path <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" 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> </svg>
{:else} {:else}
<!-- Outline heart --> <!-- 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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@@ -161,7 +164,8 @@
} }
@keyframes bounce-once { @keyframes bounce-once {
0%, 100% { 0%,
100% {
transform: translateY(0); transform: translateY(0);
} }
25% { 25% {
+2 -2
View File
@@ -10,7 +10,7 @@
* This escapes any overflow clipping boundaries * This escapes any overflow clipping boundaries
*/ */
function portal(node: HTMLElement) { function portal(node: HTMLElement) {
const container = document.createElement('div'); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
container.appendChild(node); container.appendChild(node);
@@ -19,7 +19,7 @@
if (container.parentNode) { if (container.parentNode) {
document.body.removeChild(container); document.body.removeChild(container);
} }
} },
}; };
} }
</script> </script>
+12 -2
View File
@@ -41,7 +41,12 @@
<form onsubmit={handleSubmit} class="relative"> <form onsubmit={handleSubmit} class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"> <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"> <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> </svg>
</div> </div>
@@ -62,7 +67,12 @@
aria-label="Clear search" aria-label="Clear search"
> >
<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" 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> </svg>
</button> </button>
{/if} {/if}
+7 -2
View File
@@ -44,7 +44,7 @@
<!-- Icon --> <!-- Icon -->
<div class="flex-shrink-0 w-6 h-6 rounded-full {style.bg} flex items-center justify-center"> <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"> <svg class="w-4 h-4 {style.color}" fill="currentColor" viewBox="0 0 24 24">
<path d={style.path}/> <path d={style.path} />
</svg> </svg>
</div> </div>
@@ -60,7 +60,12 @@
aria-label="Dismiss" aria-label="Dismiss"
> >
<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" 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> </svg>
</button> </button>
</div> </div>
+33 -6
View File
@@ -82,7 +82,9 @@
<div <div
class="fixed inset-0 z-40" class="fixed inset-0 z-40"
onclick={() => close()} onclick={() => close()}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }} onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") close();
}}
role="button" role="button"
tabindex="-1" tabindex="-1"
aria-label="Close account menu" aria-label="Close account menu"
@@ -110,7 +112,12 @@
onclick={() => close(false)} onclick={() => close(false)}
> >
<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" 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> </svg>
Downloads Downloads
</a> </a>
@@ -121,8 +128,18 @@
onclick={() => close(false)} onclick={() => close(false)}
> >
<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" 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
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> 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> </svg>
Settings Settings
</a> </a>
@@ -133,7 +150,12 @@
onclick={() => close(false)} onclick={() => close(false)}
> >
<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" 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> </svg>
Display Display
</a> </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" 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"> <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> </svg>
Sign out Sign out
</button> </button>
+28 -17
View File
@@ -15,7 +15,7 @@
let serverName = $state("Jellyfin Server"); let serverName = $state("Jellyfin Server");
// Load session info asynchronously // Load session info asynchronously
auth.getCurrentSession().then(session => { auth.getCurrentSession().then((session) => {
if (session) { if (session) {
username = session.username ?? "User"; username = session.username ?? "User";
serverName = session.serverName ?? "Jellyfin Server"; serverName = session.serverName ?? "Jellyfin Server";
@@ -56,7 +56,9 @@
<div <div
class="fixed inset-0 bg-black/70 z-[100] flex items-center justify-center p-4" class="fixed inset-0 bg-black/70 z-[100] flex items-center justify-center p-4"
onclick={handleBackdropClick} onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }} onkeydown={(e) => {
if (e.key === "Escape") handleBackdropClick();
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="reauth-title" aria-labelledby="reauth-title"
@@ -70,13 +72,10 @@
<!-- Header --> <!-- Header -->
<div class="px-6 pt-6 pb-4 text-center"> <div class="px-6 pt-6 pb-4 text-center">
<!-- Lock icon --> <!-- Lock icon -->
<div class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4"> <div
<svg class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4"
class="w-8 h-8 text-amber-500" >
fill="none" <svg class="w-8 h-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@@ -86,12 +85,10 @@
</svg> </svg>
</div> </div>
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2"> <h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">Session Expired</h2>
Session Expired
</h2>
<p class="text-sm text-gray-400"> <p class="text-sm text-gray-400">
Your session on <span class="text-white font-medium">{serverName}</span> has expired. Your session on <span class="text-white font-medium">{serverName}</span> has expired. Please
Please enter your password to continue. enter your password to continue.
</p> </p>
</div> </div>
@@ -102,7 +99,10 @@
<div class="block text-sm font-medium text-gray-400 mb-1" id="reauth-username-label"> <div class="block text-sm font-medium text-gray-400 mb-1" id="reauth-username-label">
Username Username
</div> </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} {username}
</div> </div>
</div> </div>
@@ -140,8 +140,19 @@
> >
{#if $isAuthLoading} {#if $isAuthLoading}
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24"> <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> <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> 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> </svg>
<span>Authenticating...</span> <span>Authenticating...</span>
{:else} {:else}
+5 -1
View File
@@ -27,7 +27,11 @@
aria-label={label} aria-label={label}
class={`text-gray-400 hover:text-white transition-colors ${className}`} 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
+8 -2
View File
@@ -86,11 +86,17 @@
</script> </script>
{#if loading} {#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} {:else if error || !imageUrl}
<div class="{className} bg-gray-800 flex items-center justify-center"> <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"> <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> </svg>
</div> </div>
{:else} {:else}
@@ -25,7 +25,9 @@
playlist: { singular: "playlist", plural: "playlists" }, 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); const label = $derived(count === 1 ? labels.singular : labels.plural);
</script> </script>
+13 -9
View File
@@ -46,17 +46,17 @@
// Initialize scroll position // Initialize scroll position
$effect(() => { $effect(() => {
if (scrollContainer) { 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; scrollContainer.scrollTop = idx * itemHeight;
selectedIndex = idx; selectedIndex = idx;
} }
}); });
</script> </script>
<div <div class="relative overflow-hidden rounded-lg" style="height: {containerHeight}px">
class="relative overflow-hidden rounded-lg"
style="height: {containerHeight}px"
>
<!-- Highlight band for center item --> <!-- Highlight band for center item -->
<div <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" 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> ></div>
<!-- Fade gradients --> <!-- 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
<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> 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 --> <!-- Scrollable container -->
<div <div
@@ -85,8 +89,8 @@
onclick={() => scrollToIndex(i)} onclick={() => scrollToIndex(i)}
class="w-full snap-center flex items-center justify-center transition-all duration-150 class="w-full snap-center flex items-center justify-center transition-all duration-150
{i === selectedIndex {i === selectedIndex
? 'text-white text-2xl font-bold' ? 'text-white text-2xl font-bold'
: 'text-gray-500 text-lg font-medium opacity-60'}" : 'text-gray-500 text-lg font-medium opacity-60'}"
style="height: {itemHeight}px" style="height: {itemHeight}px"
> >
{item.label} {item.label}
+1 -1
View File
@@ -146,7 +146,7 @@ describe("SearchBar", () => {
it("should handle special characters in value", () => { it("should handle special characters in value", () => {
render(SearchBar, { render(SearchBar, {
props: { props: {
value: '@$%^&*()', value: "@$%^&*()",
placeholder: "Search...", placeholder: "Search...",
onInput: vi.fn(), onInput: vi.fn(),
}, },
@@ -59,11 +59,11 @@
function getSourceBorderColor(): string { function getSourceBorderColor(): string {
// Green for user downloads, blue for auto-cached // 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 { function getSourceLabel(): string {
return download.downloadSource === 'user' ? 'Downloaded' : 'Auto-Cached'; return download.downloadSource === "user" ? "Downloaded" : "Auto-Cached";
} }
async function handlePause() { async function handlePause() {
@@ -107,7 +107,9 @@
} }
</script> </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"> <div class="flex items-center gap-4">
<!-- Status Indicator --> <!-- Status Indicator -->
<div class="w-2 h-2 rounded-full {getStatusColor()} flex-shrink-0"></div> <div class="w-2 h-2 rounded-full {getStatusColor()} flex-shrink-0"></div>
@@ -115,12 +117,32 @@
<!-- Media Type Icon --> <!-- Media Type Icon -->
<div class="flex-shrink-0 text-gray-500"> <div class="flex-shrink-0 text-gray-500">
{#if download.mediaType === "video"} {#if download.mediaType === "video"}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<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" /> 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> </svg>
{:else} {:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<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" /> 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> </svg>
{/if} {/if}
</div> </div>
@@ -135,10 +157,16 @@
<p class="text-xs text-gray-400 truncate"> <p class="text-xs text-gray-400 truncate">
{download.seriesName} {download.seriesName}
{#if download.seasonNumber !== undefined && download.episodeNumber !== undefined} {#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}
{#if download.qualityPreset && download.qualityPreset !== "original"} {#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} {/if}
</p> </p>
{:else if download.mediaType === "video"} {:else if download.mediaType === "video"}
@@ -146,20 +174,27 @@
<p class="text-xs text-gray-400 truncate"> <p class="text-xs text-gray-400 truncate">
Movie Movie
{#if download.qualityPreset && download.qualityPreset !== "original"} {#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} {/if}
</p> </p>
{:else if download.artistName || download.albumName} {:else if download.artistName || download.albumName}
<!-- Audio: Show artist and album --> <!-- Audio: Show artist and album -->
<p class="text-xs text-gray-400 truncate"> <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> </p>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2 ml-2 flex-shrink-0"> <div class="flex items-center gap-2 ml-2 flex-shrink-0">
<span class="text-xs text-gray-400">{getStatusText()}</span> <span class="text-xs text-gray-400">{getStatusText()}</span>
{#if download.downloadSource === 'auto'} {#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> <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} {/if}
</div> </div>
</div> </div>
@@ -210,7 +245,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download" 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -231,7 +272,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download" 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -242,7 +289,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Cancel download" 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -253,7 +306,13 @@
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors" class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Delete download" 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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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" class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Retry download" 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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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" class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
title="Delete failed download" 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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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" 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"> <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"> <svg
<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" /> 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> </svg>
<p class="text-sm text-gray-200"> <p class="text-sm text-gray-200">
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span> <span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
@@ -131,10 +141,7 @@
{#if currentLibrary} {#if currentLibrary}
<!-- Inside a library: breadcrumb back to the library list. --> <!-- Inside a library: breadcrumb back to the library list. -->
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<button <button onclick={backToLibraries} class="text-gray-400 hover:text-white transition-colors">
onclick={backToLibraries}
class="text-gray-400 hover:text-white transition-colors"
>
Downloaded Downloaded
</button> </button>
<span class="text-gray-600">/</span> <span class="text-gray-600">/</span>
@@ -163,8 +170,18 @@
{:else if $downloadedLibraries.length === 0} {:else if $downloadedLibraries.length === 0}
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. --> <!-- 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"> <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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" /> 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> </svg>
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p> <p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500">
@@ -185,12 +202,26 @@
onclick={() => openLibrary(lib)} onclick={() => openLibrary(lib)}
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105" 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"> <div
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4"> class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center"
<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
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> </svg>
</div> </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} {lib.name}
</p> </p>
</button> </button>
+4 -8
View File
@@ -21,8 +21,7 @@
if (!scrollContainer) return; if (!scrollContainer) return;
showLeftArrow = scrollContainer.scrollLeft > 0; showLeftArrow = scrollContainer.scrollLeft > 0;
showRightArrow = showRightArrow =
scrollContainer.scrollLeft < scrollContainer.scrollLeft < scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
} }
function scrollLeft() { function scrollLeft() {
@@ -39,10 +38,7 @@
<div class="flex items-center justify-between px-4"> <div class="flex items-center justify-between px-4">
<h2 class="text-2xl font-semibold text-white">{title}</h2> <h2 class="text-2xl font-semibold text-white">{title}</h2>
{#if showAll} {#if showAll}
<button <button onclick={showAll} class="text-sm text-gray-400 hover:text-white transition-colors">
onclick={showAll}
class="text-sm text-gray-400 hover:text-white transition-colors"
>
See all See all
</button> </button>
{/if} {/if}
@@ -74,7 +70,7 @@
aria-label="Scroll left" aria-label="Scroll left"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
@@ -86,7 +82,7 @@
aria-label="Scroll right" aria-label="Scroll right"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
+32 -10
View File
@@ -29,13 +29,21 @@
// 1. Try backdrop image first (best for hero display) // 1. Try backdrop image first (best for hero display)
if (currentItem.backdropImageTags?.[0]) { 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 // 2. For episodes, try series/season backdrops
if (currentItem.kind === "episode") { if (currentItem.kind === "episode") {
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) { 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) { if (currentItem.seriesId) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined }; return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
@@ -153,7 +161,9 @@
class="absolute inset-0 w-full h-full object-cover" class="absolute inset-0 w-full h-full object-cover"
/> />
{:else} {: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} {/if}
<!-- Gradient overlay --> <!-- Gradient overlay -->
@@ -175,7 +185,9 @@
{#if currentItem.communityRating} {#if currentItem.communityRating}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
{currentItem.communityRating.toFixed(1)} {currentItem.communityRating.toFixed(1)}
</span> </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" 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"> <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z" />
</svg> </svg>
Play Play
</button> </button>
@@ -225,12 +237,17 @@
<!-- Navigation --> <!-- Navigation -->
{#if items.length > 1} {#if items.length > 1}
<!-- Indicators / Location Bar --> <!-- 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} {#each items as _, idx}
<button <button
onclick={() => goToIndex(idx)} 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'}" class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx ===
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ''}`} currentIndex
? 'bg-white w-12'
: 'bg-white/50 w-8'}"
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ""}`}
></button> ></button>
{/each} {/each}
</div> </div>
@@ -242,7 +259,12 @@
aria-label="Previous item" aria-label="Previous item"
> >
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
@@ -252,7 +274,7 @@
aria-label="Next item" aria-label="Next item"
> >
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
@@ -19,36 +19,23 @@
// Calculate download status for all tracks in album // Calculate download status for all tracks in album
const downloadStatuses = $derived( const downloadStatuses = $derived(
tracks.map((track) => tracks.map((track) => Object.values($downloads.downloads).find((d) => d.itemId === track.id)),
Object.values($downloads.downloads).find((d) => d.itemId === track.id)
)
); );
const completedCount = $derived( const completedCount = $derived(downloadStatuses.filter((d) => d?.status === "completed").length);
downloadStatuses.filter((d) => d?.status === "completed").length
);
const downloadingCount = $derived( const downloadingCount = $derived(
downloadStatuses.filter( downloadStatuses.filter((d) => d?.status === "downloading" || d?.status === "pending").length,
(d) => d?.status === "downloading" || d?.status === "pending"
).length
); );
const failedCount = $derived( const failedCount = $derived(downloadStatuses.filter((d) => d?.status === "failed").length);
downloadStatuses.filter((d) => d?.status === "failed").length
);
const totalProgress = $derived(() => { const totalProgress = $derived(() => {
if (tracks.length === 0) return 0; if (tracks.length === 0) return 0;
const activeDownloads = downloadStatuses.filter( const activeDownloads = downloadStatuses.filter((d) => d?.status === "downloading");
(d) => d?.status === "downloading"
);
if (activeDownloads.length === 0) return completedCount / tracks.length; if (activeDownloads.length === 0) return completedCount / tracks.length;
const downloadingProgress = activeDownloads.reduce( const downloadingProgress = activeDownloads.reduce((sum, d) => sum + (d?.progress || 0), 0);
(sum, d) => sum + (d?.progress || 0),
0
);
return (completedCount + downloadingProgress) / tracks.length; return (completedCount + downloadingProgress) / tracks.length;
}); });
@@ -77,10 +64,7 @@
} else if (isDownloading) { } else if (isDownloading) {
// Cancel all active downloads for this album // Cancel all active downloads for this album
for (const status of downloadStatuses) { for (const status of downloadStatuses) {
if ( if (status?.id && (status.status === "downloading" || status.status === "pending")) {
status?.id &&
(status.status === "downloading" || status.status === "pending")
) {
await downloads.cancel(status.id); await downloads.cancel(status.id);
} }
} }
@@ -184,13 +168,7 @@
</svg> </svg>
{:else if isFullyDownloaded} {:else if isFullyDownloaded}
<!-- Checkmark icon --> <!-- Checkmark icon -->
<svg <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
{:else if failedCount > 0} {:else if failedCount > 0}
@@ -210,13 +188,7 @@
</svg> </svg>
{:else} {:else}
<!-- Download icon --> <!-- Download icon -->
<svg <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@@ -88,7 +88,7 @@
// x is supplied by the caller via the bound container; we read from the // x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic. // element under the pointer instead to stay layout-agnostic.
lastClientX, lastClientX,
clientY clientY,
); );
const letter = el?.getAttribute?.("data-letter"); const letter = el?.getAttribute?.("data-letter");
return letter ?? null; return letter ?? null;
@@ -135,7 +135,9 @@
disabled={!enabled} disabled={!enabled}
onclick={() => jumpTo(letter)} onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors 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' : ''}" {activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`} aria-label={`Jump to ${letter}`}
> >
@@ -46,9 +46,9 @@
includeItemTypes: ["MusicAlbum"], includeItemTypes: ["MusicAlbum"],
limit: 50, limit: 50,
sortBy: "DateCreated", sortBy: "DateCreated",
sortOrder: "Descending" sortOrder: "Descending",
}); });
albums = albumsResult.items.filter(item => item.kind === "album"); albums = albumsResult.items.filter((item) => item.kind === "album");
} catch (e) { } catch (e) {
log.warn("Failed to load albums:", e); log.warn("Failed to load albums:", e);
} finally { } finally {
@@ -61,9 +61,9 @@
includeItemTypes: ["Audio"], includeItemTypes: ["Audio"],
limit: 10, limit: 10,
sortBy: "CommunityRating", sortBy: "CommunityRating",
sortOrder: "Descending" sortOrder: "Descending",
}); });
topTracks = tracksResult.items.filter(item => item.kind === "track"); topTracks = tracksResult.items.filter((item) => item.kind === "track");
} catch (e) { } catch (e) {
log.warn("Failed to load tracks:", e); log.warn("Failed to load tracks:", e);
} finally { } finally {
@@ -78,10 +78,10 @@
genres: artist.genres.slice(0, 2), genres: artist.genres.slice(0, 2),
limit: 12, limit: 12,
sortBy: "CommunityRating", sortBy: "CommunityRating",
sortOrder: "Descending" sortOrder: "Descending",
}); });
relatedArtists = relatedResult.items relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.kind === "artist") .filter((item) => item.id !== artist.id && item.kind === "artist")
.slice(0, 6); .slice(0, 6);
} }
} catch (e) { } catch (e) {
@@ -110,7 +110,9 @@
maxWidth={1920} maxWidth={1920}
class="w-full h-full object-cover opacity-40" 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> </div>
{/if} {/if}
@@ -168,11 +170,10 @@
{:else} {:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each albums as album (album.id)} {#each albums as album (album.id)}
<a <a href="/library/{album.id}" class="group cursor-pointer">
href="/library/{album.id}" <div
class="group cursor-pointer" class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity"
> >
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
{#if album.imageId} {#if album.imageId}
<CachedImage <CachedImage
itemId={album.id} itemId={album.id}
@@ -184,7 +185,9 @@
/> />
{/if} {/if}
</div> </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)} {truncateMiddle(album.name, 40)}
</p> </p>
{#if album.productionYear} {#if album.productionYear}
@@ -226,11 +229,10 @@
{:else} {:else}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each relatedArtists as relatedArtist (relatedArtist.id)} {#each relatedArtists as relatedArtist (relatedArtist.id)}
<a <a href="/library/{relatedArtist.id}" class="group text-center">
href="/library/{relatedArtist.id}" <div
class="group text-center" class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity"
> >
<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} {#if relatedArtist.imageId}
<CachedImage <CachedImage
itemId={relatedArtist.id} itemId={relatedArtist.id}
@@ -242,7 +244,9 @@
/> />
{/if} {/if}
</div> </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} {relatedArtist.name}
</p> </p>
</a> </a>
@@ -29,9 +29,7 @@
onNavigate, onNavigate,
}: Props = $props(); }: Props = $props();
const linkable = $derived( const linkable = $derived((artistItems ?? []).filter((a) => a.id && a.id.trim() !== ""));
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
);
function handleClick(artistId: string, e: MouseEvent) { function handleClick(artistId: string, e: MouseEvent) {
e.preventDefault(); e.preventDefault();
@@ -95,7 +95,9 @@
</div> </div>
<!-- Name and role --> <!-- 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} {person.name}
</p> </p>
{#if person.role} {#if person.role}
@@ -43,7 +43,7 @@
!confirm( !confirm(
`Erase watch history for ${subject}?\n\n` + `Erase watch history for ${subject}?\n\n` +
"Every episode is marked unwatched and resume positions are cleared. " + "Every episode is marked unwatched and resume positions are cleared. " +
"This cannot be undone." "This cannot be undone.",
) )
) { ) {
return; return;
@@ -55,9 +55,7 @@
onCleared?.(); onCleared?.();
} catch (e) { } catch (e) {
log.error("Failed to clear watch history:", e); log.error("Failed to clear watch history:", e);
alert( alert(`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`);
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
);
} finally { } finally {
busy = false; busy = false;
} }
@@ -81,11 +79,7 @@
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}" {size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div> ></div>
{:else} {:else}
<svg <svg class={size === "lg" ? "w-5 h-5" : "w-4 h-4"} fill="currentColor" viewBox="0 0 24 24">
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
>
<path <path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93 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 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 { interface Props {
people: Person[]; people: Person[];
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"] roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
label?: string; // e.g., "Directed by", "Written by" label?: string; // e.g., "Directed by", "Written by"
maxShow?: number; // Default: 3 maxShow?: number; // Default: 3
} }
let { let { people, roleFilter, label, maxShow = 3 }: Props = $props();
people,
roleFilter,
label,
maxShow = 3
}: Props = $props();
// Filter and limit people by role // Filter and limit people by role
const filteredPeople = $derived( const filteredPeople = $derived(
people people
.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "") .filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
.slice(0, maxShow) .slice(0, maxShow),
); );
const totalMatching = $derived( 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) { function handlePersonClick(personId: string, e: MouseEvent) {
@@ -24,13 +24,20 @@
className?: string; 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); let isProcessing = $state(false);
// Find download for this item // Find download for this item
const downloadInfo = $derived( 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"); const status = $derived(downloadInfo?.status || "not_downloaded");
@@ -130,5 +137,12 @@
</script> </script>
<div class="p-2 rounded-full"> <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> </div>
@@ -22,7 +22,14 @@
className?: string; 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 = { const sizeMap = {
sm: { icon: "w-4 h-4", ring: "w-8 h-8" }, sm: { icon: "w-4 h-4", ring: "w-8 h-8" },
@@ -46,13 +53,21 @@
onclick={onClick} onclick={onClick}
disabled={isProcessing || state.status === "downloading"} disabled={isProcessing || state.status === "downloading"}
aria-label={title} aria-label={title}
title={title} {title}
class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`} class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`}
> >
{#if state.status === "downloading"} {#if state.status === "downloading"}
<!-- Progress Ring --> <!-- Progress Ring -->
<svg class="{sizeMap[size].ring} -rotate-90" viewBox="0 0 36 36"> <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 <circle
cx="18" cx="18"
cy="18" cy="18"
@@ -67,7 +82,13 @@
style="transition: stroke-dashoffset 0.3s ease;" style="transition: stroke-dashoffset 0.3s ease;"
/> />
<!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) --> <!-- 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)}% {Math.round(state.progress * 100)}%
</text> </text>
</svg> </svg>
@@ -79,7 +100,9 @@
{:else if state.status === "failed"} {:else if state.status === "failed"}
<!-- Error Icon --> <!-- Error Icon -->
<svg class={sizeMap[size].icon} fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
{:else if state.status === "pending"} {:else if state.status === "pending"}
<!-- Pending Icon (clock) --> <!-- Pending Icon (clock) -->
@@ -90,7 +113,12 @@
{:else} {:else}
<!-- Download Icon --> <!-- Download Icon -->
<svg class={sizeMap[size].icon} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
{/if} {/if}
</button> </button>
@@ -53,13 +53,21 @@
// Compute best backdrop source (no fetch, pure derivation) // Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => { const backdropSource = $derived.by(() => {
if (episode.backdropImageTags?.[0]) { 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) { if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId }; return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
} }
if (series?.backdropImageTags?.[0]) { 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; return null;
}); });
@@ -67,8 +75,8 @@
// Cast and genres are the episode's own when the server sent them, else the // 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 // 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. // 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 people = $derived(episode.people?.length ? episode.people : (series?.people ?? []));
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []); const genres = $derived(episode.genres?.length ? episode.genres : (series?.genres ?? []));
// "More Like This" on an episode means similar *shows* (UR-048), so it keys // "More Like This" on an episode means similar *shows* (UR-048), so it keys
// off the series rather than the episode. // off the series rather than the episode.
@@ -77,7 +85,7 @@
const seasonHref = $derived( const seasonHref = $derived(
series && episode.parentIndexNumber != null series && episode.parentIndexNumber != null
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}` ? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
: null : null,
); );
function formatDuration(ms?: number | null): string { function formatDuration(ms?: number | null): string {
@@ -108,9 +116,7 @@
goto(`/library/${series.id}?episode=${ep.id}`); goto(`/library/${series.id}?episode=${ep.id}`);
} }
const episodeLabel = $derived( const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
const duration = $derived(formatDuration(episode.durationMs)); const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode)); const progress = $derived(getProgress(episode));
</script> </script>
@@ -128,12 +134,16 @@
class="absolute inset-0 w-full h-full object-cover" class="absolute inset-0 w-full h-full object-cover"
/> />
{:else} {: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} {/if}
<!-- Gradient overlay --> <!-- 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-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 --> <!-- Back button -->
{#if onBack} {#if onBack}
@@ -143,7 +153,12 @@
title="Back to series" title="Back to series"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
@@ -156,7 +171,10 @@
{#if seriesName} {#if seriesName}
<p class="text-lg"> <p class="text-lg">
{#if seriesHref} {#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} {seriesName}
</a> </a>
{:else} {:else}
@@ -192,7 +210,9 @@
{#if episode.communityRating} {#if episode.communityRating}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
{episode.communityRating.toFixed(1)} {episode.communityRating.toFixed(1)}
</span> </span>
@@ -200,7 +220,7 @@
{#if episode.userData?.isPlayed} {#if episode.userData?.isPlayed}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]"> <span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
Watched Watched
</span> </span>
@@ -218,10 +238,7 @@
{#if progress > 0 && progress < 95} {#if progress > 0 && progress < 95}
<div class="w-64"> <div class="w-64">
<div class="h-1 bg-gray-700 rounded-full overflow-hidden"> <div class="h-1 bg-gray-700 rounded-full overflow-hidden">
<div <div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
></div>
</div> </div>
<p class="text-xs text-gray-400 mt-1"> <p class="text-xs text-gray-400 mt-1">
{Math.round(progress)}% watched {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" 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"> <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z" />
</svg> </svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"} {progress > 0 && progress < 95 ? "Resume" : "Play"}
</button> </button>
@@ -271,89 +288,106 @@
strip — continuation content comes before discovery content strip — continuation content comes before discovery content
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 --> (ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
{#if hasEpisodeStrip} {#if hasEpisodeStrip}
<div class="space-y-4"> <div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2> <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"> <div
{#each adjacentEpisodes() as ep (ep.id)} class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"
{@const isCurrent = isCurrentEpisode(ep)} >
{@const epProgress = getProgress(ep)} {#each adjacentEpisodes() as ep (ep.id)}
<button {@const isCurrent = isCurrentEpisode(ep)}
onclick={() => !isCurrent && handleEpisodeClick(ep)} {@const epProgress = getProgress(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}" <button
disabled={isCurrent} onclick={() => !isCurrent && handleEpisodeClick(ep)}
> class="flex-shrink-0 w-64 text-left group/card {isCurrent
<!-- Thumbnail --> ? 'ring-2 ring-yellow-400 rounded-lg'
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"> : ''}"
<CachedImage disabled={isCurrent}
itemId={ep.id} >
imageType="Primary" <!-- Thumbnail -->
tag={ep.imageId} <div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
maxWidth={400} <CachedImage
alt={ep.name} itemId={ep.id}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}" 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 --> <!-- Hover overlay -->
{#if !isCurrent} {#if !isCurrent}
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center"> <div
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity"> class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center"
<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"> <div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<path d="M8 5v14l11-7z"/> <div
</svg> 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> </div>
</div> {/if}
{/if}
<!-- Now Playing indicator --> <!-- Now Playing indicator -->
{#if isCurrent} {#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">
<div <div
class="h-full bg-[var(--color-jellyfin)]" class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold"
style="width: {epProgress}%" >
></div> Current
</div> </div>
{/if} {/if}
<!-- Played indicator --> <!-- Progress bar -->
{#if ep.userData?.isPlayed} {#if epProgress > 0}
<div class="absolute top-2 right-2"> <div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <div class="h-full bg-[var(--color-jellyfin)]" style="width: {epProgress}%"></div>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> </div>
</svg> {/if}
</div>
{/if}
</div>
<!-- Episode info --> <!-- Played indicator -->
<div class="mt-2 space-y-1"> {#if ep.userData?.isPlayed}
<div class="flex items-center gap-2"> <div class="absolute top-2 right-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap"> <svg
{stripCardLabel(ep, episode)} class="w-5 h-5 text-[var(--color-jellyfin)]"
</span> fill="currentColor"
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors"> viewBox="0 0 24 24"
{ep.name} >
</p> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
{/if}
</div> </div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2"> <!-- Episode info -->
{ep.overview} <div class="mt-2 space-y-1">
</p> <div class="flex items-center gap-2">
{/if} <span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
</div> {stripCardLabel(ep, episode)}
</button> </span>
{/each} <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>
</div>
{/if} {/if}
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2: <!-- 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 () => { vi.mock("$lib/stores/favorites", async () => {
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>( const actual =
"$lib/stores/favorites" await vi.importActual<typeof import("$lib/stores/favorites")>("$lib/stores/favorites");
);
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } }; return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
}); });
+30 -21
View File
@@ -22,13 +22,7 @@
onWatchedChanged?: () => void; onWatchedChanged?: () => void;
} }
let { let { episode, focused = false, current = false, onclick, onWatchedChanged }: Props = $props();
episode,
focused = false,
current = false,
onclick,
onWatchedChanged,
}: Props = $props();
let buttonRef: HTMLButtonElement | null = null; let buttonRef: HTMLButtonElement | null = null;
@@ -43,12 +37,12 @@
// Check if this episode is downloaded // Check if this episode is downloaded
const downloadInfo = $derived( 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 isDownloaded = $derived(downloadInfo?.status === "completed");
const isDownloading = $derived( const isDownloading = $derived(
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending" downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
); );
const downloadProgress = $derived(downloadInfo?.progress || 0); const downloadProgress = $derived(downloadInfo?.progress || 0);
@@ -74,7 +68,9 @@
{onclick} {onclick}
> >
<!-- Thumbnail --> <!-- 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 <CachedImage
itemId={episode.id} itemId={episode.id}
imageType="Primary" imageType="Primary"
@@ -85,11 +81,15 @@
/> />
<!-- Hover overlay with play icon --> <!-- 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="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"> <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> </svg>
</div> </div>
</div> </div>
@@ -98,10 +98,7 @@
<!-- Progress bar --> <!-- Progress bar -->
{#if progress() > 0} {#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800"> <div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div <div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress()}%"
></div>
</div> </div>
{/if} {/if}
@@ -110,7 +107,13 @@
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}> <div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
{#if isDownloaded} {#if isDownloaded}
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg> </svg>
</div> </div>
@@ -153,7 +156,9 @@
<span class="text-[var(--color-jellyfin)] font-semibold text-sm"> <span class="text-[var(--color-jellyfin)] font-semibold text-sm">
{episodeNumber}. {episodeNumber}.
</span> </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)} {truncateMiddle(episode.name, 56)}
</h3> </h3>
{#if current} {#if current}
@@ -165,8 +170,12 @@
{/if} {/if}
<!-- Played indicator --> <!-- Played indicator -->
{#if episode.userData?.isPlayed} {#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> 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> </svg>
{/if} {/if}
</div> </div>
@@ -72,9 +72,7 @@
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag) // Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre"); const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) { if (requestedGenre) {
const match = genres.find( const match = genres.find((g) => g.name.toLowerCase() === requestedGenre.toLowerCase());
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
if (match) { if (match) {
await loadGenreItems(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( const gridColsClass = $derived(
config.itemDisplayMode === "poster" 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-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> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -188,7 +193,7 @@
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} /> <SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
{#if !loading && filteredGenres.length > 0} {#if !loading && filteredGenres.length > 0}
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} /> <ResultsCounter count={filteredGenres.length} itemType="genre" {searchQuery} />
{/if} {/if}
<!-- Genres Grid --> <!-- Genres Grid -->
@@ -220,7 +225,9 @@
{@html config.genreIcon} {@html config.genreIcon}
</svg> </svg>
</div> </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} {genre.name}
</p> </p>
</button> </button>
@@ -244,11 +251,16 @@
</div> </div>
{:else} {:else}
<div> <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"> <div class="grid {gridColsClass} gap-4 mt-4">
{#each genreItems as item (item.id)} {#each genreItems as item (item.id)}
<button onclick={() => handleItemClick(item)} class="group text-left"> <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 <CachedImage
itemId={item.id} itemId={item.id}
imageType="Primary" imageType="Primary"
@@ -258,7 +270,9 @@
class="w-full h-full object-cover group-hover:scale-105 transition-transform" class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/> />
</div> </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)} {truncateMiddle(item.name, 40)}
</p> </p>
{#if item.productionYear} {#if item.productionYear}
@@ -98,9 +98,7 @@ describe("GenericMediaListPage — two-phase search", () => {
await waitFor(() => expect(search).toHaveBeenCalled()); await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull()); await waitFor(() => expect(searchEventHandler).not.toBeNull());
// Cache-only phase: nothing to show yet (the results counter reads zero). // Cache-only phase: nothing to show yet (the results counter reads zero).
await waitFor(() => await waitFor(() => expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy());
expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()
);
// Phase 2: backend emits the merged cache+server union for this request. // Phase 2: backend emits the merged cache+server union for this request.
expect(capturedRequestId).toBeTypeOf("number"); 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 // The server result must now be reflected in the list. Old code (no
// listener) never reached this state — the count stayed at zero. // listener) never reached this state — the count stayed at zero.
await waitFor(() => await waitFor(() => expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy());
expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()
);
}); });
it("ignores a search-event whose requestId is stale", async () => { it("ignores a search-event whose requestId is stale", async () => {
@@ -135,7 +135,7 @@
includeItemTypes: [config.itemType], includeItemTypes: [config.itemType],
limit: 10000, limit: 10000,
}, },
requestId requestId,
); );
// Only apply if this is still the active query. // Only apply if this is still the active query.
if (requestId === searchRequestId) { if (requestId === searchRequestId) {
@@ -204,7 +204,9 @@
navigateUp(config.backPath); 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) { function handleItemClick(item: MediaItem | Library) {
// Navigate to detail page for browseable items // Navigate to detail page for browseable items
@@ -230,10 +232,7 @@
// Only meaningful when the list is sorted alphabetically and long enough to scroll. // Only meaningful when the list is sorted alphabetically and long enough to scroll.
const isAlphaSorted = $derived(sortBy === "SortName"); const isAlphaSorted = $derived(sortBy === "SortName");
const showAlphaBar = $derived( const showAlphaBar = $derived(
isAlphaSorted && isAlphaSorted && !loading && !debouncedSearchQuery.trim() && items.length > 30,
!loading &&
!debouncedSearchQuery.trim() &&
items.length > 30
); );
const availableLetters = $derived.by(() => { const availableLetters = $derived.by(() => {
@@ -264,9 +263,7 @@
// Bottom space the layout's <main> reserves for the nav / mini-player bars. // 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 // Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible. // whichever bars are visible.
const bottomGap = $derived( const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
);
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -295,8 +292,8 @@
aria-pressed={favoritesOnly} aria-pressed={favoritesOnly}
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
{favoritesOnly {favoritesOnly
? 'bg-[var(--color-jellyfin)] text-white' ? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}" : 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"} title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
> >
<svg <svg
@@ -306,7 +303,11 @@
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="2" 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> </svg>
Favourites Favourites
</button> </button>
@@ -320,7 +321,7 @@
<!-- Results Count --> <!-- Results Count -->
{#if !loading} {#if !loading}
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} /> <ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} {searchQuery} />
{/if} {/if}
<!-- Items List/Grid --> <!-- Items List/Grid -->
@@ -349,7 +350,13 @@
<div class="flex gap-2"> <div class="flex gap-2">
<div bind:this={gridWrapper} class="flex-1 min-w-0"> <div bind:this={gridWrapper} class="flex-1 min-w-0">
{#if config.displayComponent === "grid"} {#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"} {:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} /> <TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if} {/if}
@@ -200,7 +200,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -231,7 +231,7 @@ describe("GenericMediaListPage", () => {
includeItemTypes: ["Audio"], includeItemTypes: ["Audio"],
limit: 10000, limit: 10000,
}), }),
expect.any(Number) expect.any(Number),
); );
}); });
}); });
@@ -248,7 +248,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -266,11 +266,14 @@ describe("GenericMediaListPage", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({ expect(mockGetItemsFn).toHaveBeenCalledWith(
includeItemTypes: ["Audio"], "lib123",
sortBy: "SortName", expect.objectContaining({
sortOrder: "Ascending", includeItemTypes: ["Audio"],
})); sortBy: "SortName",
sortOrder: "Ascending",
}),
);
}); });
}); });
@@ -293,7 +296,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -342,7 +345,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -363,10 +366,13 @@ describe("GenericMediaListPage", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({ expect(mockGetItemsFn).toHaveBeenCalledWith(
sortBy: "SortName", "lib123",
sortOrder: "Ascending", expect.objectContaining({
})); sortBy: "SortName",
sortOrder: "Ascending",
}),
);
}); });
}); });
@@ -382,7 +388,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -428,7 +434,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -446,9 +452,12 @@ describe("GenericMediaListPage", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({ expect(mockGetItemsFn).toHaveBeenCalledWith(
includeItemTypes: ["Audio"], "lib123",
})); expect.objectContaining({
includeItemTypes: ["Audio"],
}),
);
}); });
}); });
@@ -469,7 +478,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -497,7 +506,7 @@ describe("GenericMediaListPage", () => {
expect.objectContaining({ expect.objectContaining({
includeItemTypes: ["MusicAlbum"], includeItemTypes: ["MusicAlbum"],
}), }),
expect.any(Number) expect.any(Number),
); );
}); });
}); });
@@ -506,10 +515,10 @@ describe("GenericMediaListPage", () => {
describe("Loading State", () => { describe("Loading State", () => {
it("should show loading indicator during data fetch", async () => { it("should show loading indicator during data fetch", async () => {
const mockGetItemsFn = vi.fn( const mockGetItemsFn = vi.fn(
() => new Promise((resolve) => setTimeout( () =>
() => resolve({ items: [], totalRecordCount: 0 }), new Promise((resolve) =>
100 setTimeout(() => resolve({ items: [], totalRecordCount: 0 }), 100),
)) ),
); );
const mockRepository = { const mockRepository = {
@@ -518,7 +527,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -554,7 +563,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
mockRepository as any mockRepository as any,
); );
const config = { const config = {
@@ -589,7 +598,7 @@ describe("GenericMediaListPage", () => {
}; };
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( 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. // Deliver a null current library for this test only.
@@ -38,8 +38,8 @@
onclick={() => handleToggleGenre(genre.name)} onclick={() => handleToggleGenre(genre.name)}
class="px-3 py-1 rounded-full text-sm transition-colors class="px-3 py-1 rounded-full text-sm transition-colors
{isSelected {isSelected
? 'bg-[var(--color-jellyfin)] text-white' ? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}" : 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
> >
{genre.name} {genre.name}
</button> </button>
+9 -16
View File
@@ -6,17 +6,12 @@
interface Props { interface Props {
genres: string[]; genres: string[];
maxShow?: number; // Default: unlimited maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true clickable?: boolean; // Default: true
itemKind?: MediaKind; // Determines which genre browse page to open itemKind?: MediaKind; // Determines which genre browse page to open
} }
let { let { genres, maxShow, clickable = true, itemKind }: Props = $props();
genres,
maxShow,
clickable = true,
itemKind
}: Props = $props();
// Map the item kind to its genre-browse surface. Video genres are a tab of // 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 // the library page now, not a route of their own (DR-105); linking straight
@@ -39,13 +34,9 @@
} }
} }
const displayGenres = $derived( const displayGenres = $derived(maxShow ? genres.slice(0, maxShow) : genres);
maxShow ? genres.slice(0, maxShow) : genres
);
const hiddenCount = $derived( const hiddenCount = $derived(maxShow && genres.length > maxShow ? genres.length - maxShow : 0);
maxShow && genres.length > maxShow ? genres.length - maxShow : 0
);
function handleGenreClick(genre: string) { function handleGenreClick(genre: string) {
if (clickable) { if (clickable) {
@@ -60,7 +51,9 @@
<button <button
onclick={() => handleGenreClick(genre)} onclick={() => handleGenreClick(genre)}
disabled={!clickable} 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} {genre}
</button> </button>
+25 -7
View File
@@ -23,7 +23,17 @@
onItemRemove?: (item: MediaItem | Library) => void; 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> </script>
<div class="space-y-4"> <div class="space-y-4">
@@ -38,22 +48,26 @@
<div class="flex gap-1"> <div class="flex gap-1">
<button <button
onclick={() => library.setViewMode("grid")} 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" aria-label="Grid view"
title="Grid view" title="Grid view"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
<button <button
onclick={() => library.setViewMode("list")} 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" aria-label="List view"
title="List view" title="List view"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
</div> </div>
@@ -64,7 +78,11 @@
<div class="flex gap-4 overflow-hidden"> <div class="flex gap-4 overflow-hidden">
{#each Array(6) as _} {#each Array(6) as _}
<div class="w-36 flex-shrink-0 animate-pulse"> <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-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 class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
</div> </div>
@@ -75,7 +93,7 @@
<p>No items found</p> <p>No items found</p>
</div> </div>
{:else if $viewMode === "list"} {:else if $viewMode === "list"}
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} /> <LibraryListView {items} showProgress={true} {onItemClick} />
{:else} {: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"> <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)} {#each items as item, index (item.id)}
@@ -19,7 +19,11 @@
} }
function getImageTag(item: MediaItem | Library): string | undefined { 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 { function getSubtitle(item: MediaItem | Library): string {
@@ -31,7 +35,9 @@
case "MusicAlbum": case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || ""; return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode": 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 "Movie":
case "Series": case "Series":
return item.productionYear?.toString() || ""; return item.productionYear?.toString() || "";
@@ -40,9 +46,14 @@
} }
} }
function getProgress(item: MediaItem | Library): number { 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 0;
} }
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100; return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
@@ -65,7 +76,8 @@
{@const isPlayed = "userData" in item && item.userData?.isPlayed} {@const isPlayed = "userData" in item && item.userData?.isPlayed}
{@const downloadInfo = getDownloadInfo(item.id)} {@const downloadInfo = getDownloadInfo(item.id)}
{@const isDownloaded = downloadInfo?.status === "completed"} {@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"} {@const isDownloading =
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
<button <button
type="button" type="button"
@@ -79,7 +91,9 @@
</span> </span>
<!-- Thumbnail --> <!-- 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 <CachedImage
itemId={item.id} itemId={item.id}
imageType="Primary" imageType="Primary"
@@ -90,9 +104,11 @@
/> />
<!-- Play overlay on hover --> <!-- 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"> <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> </svg>
</div> </div>
@@ -106,7 +122,9 @@
<!-- Title & Subtitle --> <!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left"> <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)} {truncateMiddle(item.name, 56)}
</p> </p>
{#if subtitle} {#if subtitle}
@@ -118,19 +136,41 @@
{#if showDownloadStatus && (isDownloaded || isDownloading)} {#if showDownloadStatus && (isDownloaded || isDownloading)}
{#if isDownloaded} {#if isDownloaded}
<span title="Downloaded"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg> </svg>
</span> </span>
{:else if isDownloading} {:else if isDownloading}
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading..."> <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"> <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 <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-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))} 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> </svg>
</div> </div>
@@ -139,8 +179,12 @@
<!-- Played indicator --> <!-- Played indicator -->
{#if isPlayed} {#if isPlayed}
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> <svg
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> 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> </svg>
{/if} {/if}
@@ -35,8 +35,8 @@
aria-current={view === active ? "page" : undefined} aria-current={view === active ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{view === active {view === active
? 'bg-[var(--color-jellyfin)] text-white' ? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}" : 'text-gray-400 hover:text-white hover:bg-white/10'}"
> >
{labels[view]} {labels[view]}
</button> </button>
+135 -39
View File
@@ -57,7 +57,19 @@
aspect?: "square" | "video" | "poster"; 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 // 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 // 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 // Check if this item is downloaded
const downloadInfo = $derived( 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 isDownloaded = $derived(downloadInfo?.status === "completed");
const isDownloading = $derived( const isDownloading = $derived(
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending" downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
); );
const downloadProgress = $derived(downloadInfo?.progress || 0); const downloadProgress = $derived(downloadInfo?.progress || 0);
@@ -135,14 +147,14 @@
// transferring. A `pending` (queued-for-reconnect) item stays server-only so // transferring. A `pending` (queued-for-reconnect) item stays server-only so
// it can show the Queued badge in place of the queue button. // it can show the Queued badge in place of the queue button.
const isServerOnly = $derived( 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 // 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 // server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly); const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
const isFavorited = $derived( const isFavorited = $derived(
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false,
); );
let queueError = $state<string | null>(null); let queueError = $state<string | null>(null);
@@ -171,7 +183,7 @@
undefined, undefined,
media.name, media.name,
media.artists?.join(", ") ?? undefined, media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined media.albumName ?? undefined,
); );
} catch (err) { } catch (err) {
log.error("Failed to queue download:", err); log.error("Failed to queue download:", err);
@@ -186,7 +198,11 @@
}; };
const isMusicType = $derived( 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 = { const FIXED_ASPECT = {
@@ -201,11 +217,13 @@
return isMusicType ? "aspect-square" : "aspect-[2/3]"; return isMusicType ? "aspect-square" : "aspect-[2/3]";
} }
// Library // 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( 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); const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
@@ -226,7 +244,9 @@
case "MusicAlbum": case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || ""; return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode": 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 "Movie":
return item.productionYear?.toString() || ""; return item.productionYear?.toString() || "";
case "Series": case "Series":
@@ -241,7 +261,9 @@
this={isServerOnly ? "div" : "button"} this={isServerOnly ? "div" : "button"}
type={isServerOnly ? undefined : "button"} type={isServerOnly ? undefined : "button"}
role={isServerOnly ? "group" : undefined} 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} style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined}
onclick={isServerOnly ? undefined : handleClick} onclick={isServerOnly ? undefined : handleClick}
onpointerdown={isServerOnly ? undefined : handlePointerDown} onpointerdown={isServerOnly ? undefined : handlePointerDown}
@@ -250,23 +272,35 @@
onpointercancel={isServerOnly ? undefined : handlePointerUp} onpointercancel={isServerOnly ? undefined : handlePointerUp}
oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined} 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 <CachedImage
itemId={item.id} itemId={item.id}
imageType="Primary" imageType="Primary"
tag={imageTag} tag={imageTag}
maxWidth={maxWidth} {maxWidth}
alt={item.name} 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 <!-- Hover overlay with smooth gradient (play affordance; hidden for
server-only cards, which can't be played offline) --> 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
<div class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300"> class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 {isServerOnly
<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"> ? ''
: '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"> <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> </svg>
</div> </div>
</div> </div>
@@ -275,10 +309,7 @@
<!-- Progress bar --> <!-- Progress bar -->
{#if progress() > 0} {#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800"> <div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div <div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress()}%"
></div>
</div> </div>
{/if} {/if}
@@ -288,7 +319,7 @@
<div class="absolute top-2 right-2 flex flex-col items-end gap-1"> <div class="absolute top-2 right-2 flex flex-col items-end gap-1">
{#if "userData" in item && item.userData?.isPlayed} {#if "userData" in item && item.userData?.isPlayed}
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
{/if} {/if}
{#if showHeart} {#if showHeart}
@@ -319,7 +350,13 @@
{#if isDownloaded} {#if isDownloaded}
<!-- Downloaded badge --> <!-- Downloaded badge -->
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg> </svg>
</div> </div>
@@ -348,7 +385,13 @@
class="transition-all duration-300" class="transition-all duration-300"
/> />
</svg> </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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg> </svg>
</div> </div>
@@ -360,13 +403,20 @@
{#if onRemove} {#if onRemove}
<button <button
type="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" 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" title="Remove from device"
aria-label="Remove {item.name} 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"> <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> </svg>
</button> </button>
{/if} {/if}
@@ -379,13 +429,28 @@
> >
{#if downloadedBadge === "full"} {#if downloadedBadge === "full"}
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
{:else} {:else}
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded"> <div
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"> 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg> </svg>
</div> </div>
@@ -398,13 +463,30 @@
{#if isServerOnly} {#if isServerOnly}
<div class="absolute inset-0 flex items-center justify-center"> <div class="absolute inset-0 flex items-center justify-center">
{#if isQueued} {#if isQueued}
<div class="flex flex-col items-center gap-1 text-white" title="Queued — will download on reconnect"> <div
<div class="w-11 h-11 rounded-full bg-black/60 flex items-center justify-center shadow-lg"> class="flex flex-col items-center gap-1 text-white"
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> title="Queued — will download on reconnect"
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> >
<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> </svg>
</div> </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> </div>
{:else} {:else}
<button <button
@@ -414,14 +496,26 @@
title="Queue download for next connection" title="Queue download for next connection"
aria-label="Queue download for {item.name}" 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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" /> 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> </svg>
</button> </button>
{/if} {/if}
</div> </div>
{#if queueError} {#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} {queueError}
</div> </div>
{/if} {/if}
@@ -429,7 +523,9 @@
</div> </div>
<div class="mt-2 space-y-0.5 {isServerOnly ? 'opacity-60' : ''}"> <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)} {truncateMiddle(item.name, 40)}
</p> </p>
{#if subtitle()} {#if subtitle()}
+4 -7
View File
@@ -16,12 +16,7 @@
<script lang="ts" generics="T extends { key: string; ratio: number }"> <script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { import { layoutMosaic, layoutMosaicStrip, mosaicTargetHeight, type MosaicTile } from "./mosaic";
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicTile,
} from "./mosaic";
interface Props { interface Props {
items: T[]; items: T[];
@@ -67,7 +62,9 @@
}); });
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth)); 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( const rows = $derived(
layout === "strip" layout === "strip"
? [{ height, tiles: layoutMosaicStrip(sized, height) }] ? [{ 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 <!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. --> 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"> <div
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors"> 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} {label}
</p> </p>
</div> </div>
@@ -34,8 +34,8 @@
}); });
// Separate movies and series // Separate movies and series
movies = result.items.filter(item => item.kind === "movie"); movies = result.items.filter((item) => item.kind === "movie");
series = result.items.filter(item => item.kind === "series"); series = result.items.filter((item) => item.kind === "series");
} catch (e) { } catch (e) {
log.error("Failed to load filmography:", e); log.error("Failed to load filmography:", e);
} finally { } finally {
@@ -27,11 +27,9 @@
let showDeleteConfirm = $state(false); let showDeleteConfirm = $state(false);
// Extract MediaItem[] from PlaylistEntry[] for TrackList // 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( const totalDuration = $derived(entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0));
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
);
onMount(() => { onMount(() => {
loadPlaylistItems(); loadPlaylistItems();
@@ -53,7 +51,7 @@
async function handlePlayAll() { async function handlePlayAll() {
if (entries.length === 0) return; if (entries.length === 0) return;
try { try {
const trackIds = entries.map(e => e.id); const trackIds = entries.map((e) => e.id);
await playerController.playTracks({ await playerController.playTracks({
trackIds, trackIds,
startIndex: 0, startIndex: 0,
@@ -73,7 +71,7 @@
async function handleShufflePlay() { async function handleShufflePlay() {
if (entries.length === 0) return; if (entries.length === 0) return;
try { try {
const trackIds = entries.map(e => e.id); const trackIds = entries.map((e) => e.id);
await playerController.playTracks({ await playerController.playTracks({
trackIds, trackIds,
startIndex: 0, startIndex: 0,
@@ -129,7 +127,7 @@
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]); 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"); toast.success("Track removed");
} catch (e) { } catch (e) {
log.error("Failed to remove track:", e); log.error("Failed to remove track:", e);
@@ -161,9 +159,13 @@
class="w-full rounded-lg shadow-lg" class="w-full rounded-lg shadow-lg"
/> />
{:else} {: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"> <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> </svg>
</div> </div>
{/if} {/if}
@@ -183,7 +185,10 @@
{:else} {:else}
<button <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" 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" title="Click to rename"
> >
{playlist.name} {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" 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"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z" />
</svg> </svg>
Play All Play All
</button> </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" 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"> <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> </svg>
Shuffle Shuffle
</button> </button>
@@ -227,11 +234,13 @@
className="self-center" className="self-center"
/> />
<button <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" 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"> <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> </svg>
Delete Delete
</button> </button>
@@ -264,7 +273,7 @@
title="Remove from playlist" title="Remove from playlist"
> >
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z"/> <path d="M19 13H5v-2h14v2z" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -278,8 +287,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={() => showDeleteConfirm = false} onclick={() => (showDeleteConfirm = false)}
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }} onkeydown={(e) => {
if (e.key === "Escape") showDeleteConfirm = false;
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
@@ -296,7 +307,7 @@
</p> </p>
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<button <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" class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
> >
Cancel Cancel
@@ -23,7 +23,7 @@
genres = [], genres = [],
people = [], people = [],
artistIds = [], artistIds = [],
limit = 12 limit = 12,
}: Props = $props(); }: Props = $props();
let relatedItems = $state<MediaItem[]>([]); let relatedItems = $state<MediaItem[]>([]);
@@ -53,7 +53,7 @@
if (itemKind === "movie" || itemKind === "series") { if (itemKind === "movie" || itemKind === "series") {
try { try {
const result = await repo.getSimilarItems(currentItemId, limit); 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) { if (items.length > 0) {
relatedItems = items.slice(0, limit); relatedItems = items.slice(0, limit);
@@ -72,14 +72,18 @@
// maps the neutral kind to the concrete Jellyfin item type it needs. // maps the neutral kind to the concrete Jellyfin item type it needs.
const searchTerm = genres[0]; const searchTerm = genres[0];
const itemTypeForKind: Record<string, string> = { 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, { const result = await repo.search(searchTerm, {
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"], 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) { } catch (e) {
log.warn("Failed to load related items by genre:", 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 // Search for other albums by artist name from first artist
const result = await repo.search(artistIds[0], { const result = await repo.search(artistIds[0], {
includeItemTypes: ["MusicAlbum"], 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]; items = [...items, ...artistAlbums];
} catch (e) { } catch (e) {
log.warn("Failed to load albums by artist:", e); log.warn("Failed to load albums by artist:", e);
@@ -102,9 +106,10 @@
} }
// Remove duplicates and limit results // Remove duplicates and limit results
const uniqueItems = Array.from( const uniqueItems = Array.from(new Map(items.map((item) => [item.id, item])).values()).slice(
new Map(items.map(item => [item.id, item])).values() 0,
).slice(0, limit); limit,
);
relatedItems = uniqueItems; relatedItems = uniqueItems;
} catch (e) { } catch (e) {
@@ -144,7 +149,11 @@
<div class="grid grid-cols-2 md:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-6 gap-4">
{#each Array(6) as _} {#each Array(6) as _}
<div class="animate-pulse"> <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-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 class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
</div> </div>
@@ -17,25 +17,28 @@
size?: "sm" | "md" | "lg"; 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 isProcessing = $state(false);
let showQualityPicker = $state(false); let showQualityPicker = $state(false);
// Count downloads for this season // Count downloads for this season
const seasonDownloads = $derived( const seasonDownloads = $derived(
$videoDownloads.filter((d) => $videoDownloads.filter((d) => d.seriesName === seriesName && d.seasonName === seasonName),
d.seriesName === seriesName &&
d.seasonName === seasonName
)
); );
const completedCount = $derived( const completedCount = $derived(seasonDownloads.filter((d) => d.status === "completed").length);
seasonDownloads.filter((d) => d.status === "completed").length
);
const inProgressCount = $derived( 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); const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
@@ -67,7 +70,7 @@
seasonNumber, seasonNumber,
userId, userId,
basePath, basePath,
quality quality,
); );
log.debug(`✅ Queued ${downloadIds.length} episodes for download`); log.debug(`✅ Queued ${downloadIds.length} episodes for download`);
@@ -102,7 +105,9 @@
return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`; return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`;
} }
if (completedCount > 0) { if (completedCount > 0) {
return size === "sm" ? `${completedCount}/${episodeCount}` : `Download (${completedCount}/${episodeCount})`; return size === "sm"
? `${completedCount}/${episodeCount}`
: `Download (${completedCount}/${episodeCount})`;
} }
return size === "sm" ? "⬇" : "Download Season"; return size === "sm" ? "⬇" : "Download Season";
} }
@@ -118,39 +123,49 @@
} }
const sizeClasses = $derived( const sizeClasses = $derived(
size === "sm" ? "px-2 py-1 text-xs" : size === "sm"
size === "lg" ? "px-6 py-3 text-base" : ? "px-2 py-1 text-xs"
"px-4 py-2 text-sm" : size === "lg"
? "px-6 py-3 text-base"
: "px-4 py-2 text-sm",
); );
const iconSize = $derived( const iconSize = $derived(size === "sm" ? "w-3 h-3" : size === "lg" ? "w-6 h-6" : "w-4 h-4");
size === "sm" ? "w-3 h-3" :
size === "lg" ? "w-6 h-6" :
"w-4 h-4"
);
</script> </script>
<div class="relative {className}"> <div class="relative {className}">
<button <button
onclick={handleClick} onclick={handleClick}
disabled={isProcessing || allDownloaded} 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} {#if inProgressCount > 0}
<!-- Spinner --> <!-- Spinner -->
<svg class="{iconSize} animate-spin" fill="none" viewBox="0 0 24 24"> <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> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
<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>
<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> </svg>
{:else if allDownloaded} {:else if allDownloaded}
<!-- Checkmark --> <!-- 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
{:else} {:else}
<!-- Download icon --> <!-- Download icon -->
<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="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> </svg>
{/if} {/if}
{#if size !== "sm"} {#if size !== "sm"}
@@ -162,7 +177,9 @@
<!-- Quality picker dropdown --> <!-- Quality picker dropdown -->
{#if showQualityPicker} {#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="p-3 border-b border-gray-700">
<div class="text-sm font-medium text-white">Download Quality</div> <div class="text-sm font-medium text-white">Download Quality</div>
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</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> <span class="text-sm text-white">{preset.label}</span>
{#if preset.videoBitrate} {#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} {:else}
<span class="text-xs text-gray-500">Direct</span> <span class="text-xs text-gray-500">Direct</span>
{/if} {/if}
</button> </button>
{/each} {/each}
<button <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" class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
> >
Cancel Cancel
@@ -194,7 +213,7 @@
{#if showQualityPicker} {#if showQualityPicker}
<button <button
class="fixed inset-0 z-40" class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false} onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker" aria-label="Close quality picker"
></button> ></button>
{/if} {/if}
@@ -37,14 +37,14 @@
}: Props = $props(); }: Props = $props();
const holdsCurrentEpisode = $derived( 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 watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length); const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber); const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived( 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 // Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list. // inside the series' single continuous episode list.
@@ -55,7 +55,9 @@
<!-- Season header --> <!-- Season header -->
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl"> <div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster --> <!-- 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 <CachedImage
itemId={season.id} itemId={season.id}
imageType="Primary" imageType="Primary"
@@ -135,7 +137,7 @@
<SeasonDownloadButton <SeasonDownloadButton
seasonId={season.id} seasonId={season.id}
seriesName={season.seriesName || ""} seriesName={season.seriesName || ""}
seasonName={seasonName} {seasonName}
seasonNumber={season.indexNumber || season.parentIndexNumber || 0} seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
{episodeCount} {episodeCount}
size="sm" size="sm"
@@ -20,16 +20,12 @@
let showQualityPicker = $state(false); let showQualityPicker = $state(false);
// Count downloads for this series // Count downloads for this series
const seriesDownloads = $derived( const seriesDownloads = $derived($videoDownloads.filter((d) => d.seriesName === seriesName));
$videoDownloads.filter((d) => d.seriesName === seriesName)
);
const completedCount = $derived( const completedCount = $derived(seriesDownloads.filter((d) => d.status === "completed").length);
seriesDownloads.filter((d) => d.status === "completed").length
);
const inProgressCount = $derived( 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); const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
@@ -59,7 +55,7 @@
seriesName, seriesName,
userId, userId,
basePath, basePath,
quality quality,
); );
log.debug(` Queued ${downloadIds.length} episodes for download`); log.debug(` Queued ${downloadIds.length} episodes for download`);
@@ -113,13 +109,21 @@
<button <button
onclick={handleClick} onclick={handleClick}
disabled={isProcessing || allDownloaded} 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} {#if inProgressCount > 0}
<!-- Spinner --> <!-- Spinner -->
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> <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> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
<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>
<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> </svg>
{:else if allDownloaded} {:else if allDownloaded}
<!-- Checkmark --> <!-- Checkmark -->
@@ -129,7 +133,11 @@
{:else} {:else}
<!-- Download icon --> <!-- 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" 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> </svg>
{/if} {/if}
<span>{getButtonText()}</span> <span>{getButtonText()}</span>
@@ -137,7 +145,9 @@
<!-- Quality picker dropdown --> <!-- Quality picker dropdown -->
{#if showQualityPicker} {#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="p-3 border-b border-gray-700">
<div class="text-sm font-medium text-white">Download Quality</div> <div class="text-sm font-medium text-white">Download Quality</div>
{#if episodeCount} {#if episodeCount}
@@ -151,14 +161,16 @@
> >
<span class="text-sm text-white">{preset.label}</span> <span class="text-sm text-white">{preset.label}</span>
{#if preset.videoBitrate} {#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} {:else}
<span class="text-xs text-gray-500">Direct</span> <span class="text-xs text-gray-500">Direct</span>
{/if} {/if}
</button> </button>
{/each} {/each}
<button <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" class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
> >
Cancel Cancel
@@ -171,7 +183,7 @@
{#if showQualityPicker} {#if showQualityPicker}
<button <button
class="fixed inset-0 z-40" class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false} onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker" aria-label="Close quality picker"
></button> ></button>
{/if} {/if}
@@ -84,7 +84,7 @@ describe("TrackList Logic Tests", () => {
mediaType: "Audio", mediaType: "Audio",
streamUrl: await repo.getAudioStreamUrl(t.id), streamUrl: await repo.getAudioStreamUrl(t.id),
jellyfinItemId: t.id, jellyfinItemId: t.id,
})) })),
); );
expect(queueItems).toHaveLength(2); expect(queueItems).toHaveLength(2);
@@ -110,7 +110,7 @@ describe("TrackList Logic Tests", () => {
id: t.id, id: t.id,
streamUrl, streamUrl,
}; };
}) }),
); );
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2); expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2);
@@ -279,7 +279,7 @@ describe("TrackList Logic Tests", () => {
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
}, },
}) }),
).rejects.toThrow("Network error"); ).rejects.toThrow("Network error");
}); });
}); });
+61 -26
View File
@@ -40,7 +40,7 @@
showArtist = true, showArtist = true,
showDownload = false, showDownload = false,
context, context,
onTrackClick onTrackClick,
}: Props = $props(); }: Props = $props();
let isPlayingTrack = $state<string | null>(null); let isPlayingTrack = $state<string | null>(null);
@@ -93,7 +93,7 @@
// Queue will auto-update from Rust backend event // Queue will auto-update from Rust backend event
} catch (e) { } 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); log.error("Failed to play track:", errorMessage);
toast.error(`Failed to play track: ${errorMessage}`, 5000); toast.error(`Failed to play track: ${errorMessage}`, 5000);
} finally { } finally {
@@ -110,7 +110,6 @@
} }
} }
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) { function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
e.stopPropagation(); e.stopPropagation();
@@ -158,9 +157,7 @@
{#if loading} {#if loading}
<div class="space-y-2"> <div class="space-y-2">
{#each Array(10) as _} {#each Array(10) as _}
<div <div class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4">
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="w-12 h-12 bg-gray-700 rounded"></div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="h-4 bg-gray-700 rounded w-1/3"></div> <div class="h-4 bg-gray-700 rounded w-1/3"></div>
@@ -199,7 +196,13 @@
<!-- Track Rows --> <!-- Track Rows -->
<div class="space-y-1"> <div class="space-y-1">
{#each tracks as track, index (track.id)} {#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 --> <!-- Desktop View -->
<button <button
onclick={() => handleTrackClick(track, index)} onclick={() => handleTrackClick(track, index)}
@@ -212,7 +215,9 @@
<!-- Index/Play Button --> <!-- Index/Play Button -->
<div class="w-12 flex items-center justify-center"> <div class="w-12 flex items-center justify-center">
{#if isPlayingTrack === track.id} {#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} {:else}
<span class="group-hover:hidden text-gray-400">{index + 1}</span> <span class="group-hover:hidden text-gray-400">{index + 1}</span>
<svg <svg
@@ -230,12 +235,21 @@
{#if currentlyPlayingId === track.id} {#if currentlyPlayingId === track.id}
<div class="flex flex-col items-center justify-center"> <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"></div>
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 150ms"></div> <div
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 300ms"></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> </div>
{/if} {/if}
<span <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)} {truncateMiddle(track.name, 48)}
</span> </span>
@@ -250,7 +264,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)} 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" class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
> >
{artist.name} {artist.name}
@@ -273,7 +287,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)} 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" class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
> >
{track.albumName || "-"} {track.albumName || "-"}
@@ -317,7 +331,9 @@
aria-label="More options" aria-label="More options"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
</div> </div>
@@ -332,7 +348,9 @@
<!-- Track Number --> <!-- Track Number -->
<div class="w-8 flex-shrink-0 text-center"> <div class="w-8 flex-shrink-0 text-center">
{#if isPlayingTrack === track.id} {#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} {:else}
<span class="group-hover:hidden text-gray-400 text-sm">{index + 1}</span> <span class="group-hover:hidden text-gray-400 text-sm">{index + 1}</span>
<svg <svg
@@ -346,7 +364,10 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p <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} {#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span> <span class="inline-block mr-1"></span>
@@ -361,7 +382,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)} 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" class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
> >
{artist.name} {artist.name}
@@ -379,7 +400,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)} 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" class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
> >
{track.albumName || "-"} {track.albumName || "-"}
@@ -394,7 +415,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => handleArtistClick(artist.id, e)} 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" class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
> >
{artist.name} {artist.name}
@@ -412,7 +433,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => handleAlbumClick(track.albumId, e)} 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" class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
> >
{track.albumName || "-"} {track.albumName || "-"}
@@ -447,7 +468,9 @@
aria-label="More options" aria-label="More options"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
</div> </div>
@@ -459,7 +482,7 @@
<!-- Portal Menu (rendered at document.body to avoid overflow clipping) --> <!-- Portal Menu (rendered at document.body to avoid overflow clipping) -->
{#if openMenuId && menuPosition} {#if openMenuId && menuPosition}
{@const selectedTrack = tracks.find(t => t.id === openMenuId)} {@const selectedTrack = tracks.find((t) => t.id === openMenuId)}
{#if selectedTrack} {#if selectedTrack}
<Portal> <Portal>
<div <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" 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"> <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> </svg>
Play Next Play Next
</button> </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" 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"> <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> </svg>
Add to Queue Add to Queue
</button> </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" 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"> <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> </svg>
Add to Playlist Add to Playlist
</button> </button>
@@ -508,7 +543,7 @@
<!-- Add to Playlist Modal --> <!-- Add to Playlist Modal -->
<AddToPlaylistModal <AddToPlaylistModal
isOpen={addToPlaylistTrackId !== null} isOpen={addToPlaylistTrackId !== null}
onClose={() => addToPlaylistTrackId = null} onClose={() => (addToPlaylistTrackId = null)}
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []} trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
/> />
+56 -36
View File
@@ -28,7 +28,12 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(), setQueue: vi.fn(),
addToQueue: 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", () => ({ vi.mock("./DownloadButton.svelte", () => ({
@@ -42,10 +47,30 @@ vi.mock("$lib/stores/library", () => ({
loadItem: vi.fn(), loadItem: vi.fn(),
setCurrentLibrary: vi.fn(), setCurrentLibrary: vi.fn(),
}, },
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) }, libraries: {
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) }, subscribe: vi.fn((fn: any) => {
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) }, fn([]);
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) }, 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 // 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 1").length).toBeGreaterThan(0);
expect(getAllByText("Song 2").length).toBeGreaterThan(0); expect(getAllByText("Song 2").length).toBeGreaterThan(0);
// Long names are abbreviated in the middle via truncateMiddle(name, 48). // 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", () => { it("shows loading skeleton when loading=true", () => {
@@ -234,7 +261,7 @@ describe("TrackList", () => {
// Find and click the first track button // Find and click the first track button
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
expect(firstTrackButton).toBeTruthy(); expect(firstTrackButton).toBeTruthy();
@@ -250,7 +277,7 @@ describe("TrackList", () => {
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
}), }),
}) }),
); );
}); });
}); });
@@ -261,7 +288,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) => const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2") btn.textContent?.includes("Song 2"),
); );
await fireEvent.click(secondTrackButton!); await fireEvent.click(secondTrackButton!);
@@ -278,7 +305,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const thirdTrackButton = Array.from(buttons).find((btn) => const thirdTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3") btn.textContent?.includes("Song 3"),
); );
await fireEvent.click(thirdTrackButton!); await fireEvent.click(thirdTrackButton!);
@@ -297,7 +324,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -305,7 +332,7 @@ describe("TrackList", () => {
await waitFor(() => { await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith( expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"), expect.stringContaining("Failed to play track"),
expect.anything() expect.anything(),
); );
}); });
@@ -322,7 +349,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -330,7 +357,7 @@ describe("TrackList", () => {
await waitFor(() => { await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith( expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"), expect.stringContaining("Failed to play track"),
expect.anything() expect.anything(),
); );
}); });
@@ -339,7 +366,6 @@ describe("TrackList", () => {
// Restore mock for other tests // Restore mock for other tests
(auth.getRepository as any).mockReturnValue(mockRepository as any); (auth.getRepository as any).mockReturnValue(mockRepository as any);
}); });
}); });
describe("Custom Callback Tests", () => { describe("Custom Callback Tests", () => {
@@ -351,7 +377,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -363,7 +389,7 @@ describe("TrackList", () => {
it("does not call player_play_queue when custom callback provided", async () => { it("does not call player_play_queue when custom callback provided", async () => {
const onTrackClick = vi.fn(); const onTrackClick = vi.fn();
const invokeMock = (invoke as any); const invokeMock = invoke as any;
const { container } = render(TrackList, { const { container } = render(TrackList, {
props: { tracks: mockTracks, onTrackClick }, props: { tracks: mockTracks, onTrackClick },
@@ -371,7 +397,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -391,7 +417,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const secondTrackButton = Array.from(buttons).find((btn) => const secondTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 2") btn.textContent?.includes("Song 2"),
); );
await fireEvent.click(secondTrackButton!); await fireEvent.click(secondTrackButton!);
@@ -409,7 +435,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -429,7 +455,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -454,9 +480,7 @@ describe("TrackList", () => {
const { container } = render(TrackList, { props: { tracks: singleTrack } }); const { container } = render(TrackList, { props: { tracks: singleTrack } });
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const trackButton = Array.from(buttons).find((btn) => const trackButton = Array.from(buttons).find((btn) => btn.textContent?.includes("Song 1"));
btn.textContent?.includes("Song 1")
);
await fireEvent.click(trackButton!); await fireEvent.click(trackButton!);
@@ -473,7 +497,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
await fireEvent.click(firstTrackButton!); await fireEvent.click(firstTrackButton!);
@@ -490,7 +514,7 @@ describe("TrackList", () => {
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const lastTrackButton = Array.from(buttons).find((btn) => const lastTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 3") btn.textContent?.includes("Song 3"),
); );
await fireEvent.click(lastTrackButton!); await fireEvent.click(lastTrackButton!);
@@ -505,15 +529,13 @@ describe("TrackList", () => {
describe("Loading State", () => { describe("Loading State", () => {
it("shows loading spinner when track is clicked", async () => { it("shows loading spinner when track is clicked", async () => {
// Make invoke slow to capture loading state // Make invoke slow to capture loading state
(invoke as any).mockImplementation( (invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
() => new Promise((resolve) => setTimeout(resolve, 100))
);
const { container } = render(TrackList, { props: { tracks: mockTracks } }); const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
fireEvent.click(firstTrackButton!); fireEvent.click(firstTrackButton!);
@@ -526,15 +548,13 @@ describe("TrackList", () => {
}); });
it("disables track buttons during loading", async () => { it("disables track buttons during loading", async () => {
(invoke as any).mockImplementation( (invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
() => new Promise((resolve) => setTimeout(resolve, 100))
);
const { container } = render(TrackList, { props: { tracks: mockTracks } }); const { container } = render(TrackList, { props: { tracks: mockTracks } });
const buttons = container.querySelectorAll("button"); const buttons = container.querySelectorAll("button");
const firstTrackButton = Array.from(buttons).find((btn) => const firstTrackButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("Song 1") btn.textContent?.includes("Song 1"),
); );
fireEvent.click(firstTrackButton!); fireEvent.click(firstTrackButton!);
@@ -542,8 +562,8 @@ describe("TrackList", () => {
// Track selection buttons should be disabled during loading // Track selection buttons should be disabled during loading
await waitFor(() => { await waitFor(() => {
// Find track buttons (ones containing song names) // Find track buttons (ones containing song names)
const trackButtons = Array.from(container.querySelectorAll("button")).filter( const trackButtons = Array.from(container.querySelectorAll("button")).filter((btn) =>
(btn) => btn.textContent?.includes("Song") btn.textContent?.includes("Song"),
); );
expect(trackButtons.length).toBeGreaterThan(0); expect(trackButtons.length).toBeGreaterThan(0);
trackButtons.forEach((btn) => { trackButtons.forEach((btn) => {
@@ -30,7 +30,7 @@
episodeNumber, episodeNumber,
seasonNumber, seasonNumber,
size = "md", size = "md",
className = "" className = "",
}: Props = $props(); }: Props = $props();
const sizeClasses = { const sizeClasses = {
@@ -46,7 +46,7 @@
// Find download for this item // Find download for this item
const downloadInfo = $derived( 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"); const status = $derived(downloadInfo?.status || "not_downloaded");
@@ -83,7 +83,7 @@
filePath = `videos/movies/${safeName}.mp4`; filePath = `videos/movies/${safeName}.mp4`;
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) { } else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_"); 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 { } else {
filePath = `videos/${safeName}.mp4`; filePath = `videos/${safeName}.mp4`;
} }
@@ -96,13 +96,13 @@
userId, userId,
filePath, filePath,
"video/mp4", "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, itemName || undefined,
quality, quality,
seriesName, seriesName,
seasonName, seasonName,
episodeNumber, episodeNumber,
seasonNumber seasonNumber,
); );
log.debug(" Download queued with ID:", downloadId); log.debug(" Download queued with ID:", downloadId);
@@ -232,27 +232,59 @@
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="2.5" stroke-width="2.5"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4"
/>
</svg> </svg>
{:else if status === "completed"} {: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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
{:else if status === "pending"} {:else if status === "pending"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" /> 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> </svg>
{:else if status === "failed"} {:else if status === "failed"}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg
<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" /> 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> </svg>
{:else} {:else}
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" /> 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> </svg>
{/if} {/if}
</div> </div>
@@ -263,16 +295,14 @@
{#if showQualityPicker} {#if showQualityPicker}
<button <button
class="fixed inset-0 z-40" class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false} onclick={() => (showQualityPicker = false)}
aria-label="Close quality picker" aria-label="Close quality picker"
></button> ></button>
<div <div
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden" 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;" style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
> >
<div class="p-2 text-xs text-gray-400 border-b border-gray-700"> <div class="p-2 text-xs text-gray-400 border-b border-gray-700">Select Quality</div>
Select Quality
</div>
{#each Object.entries(QUALITY_PRESETS) as [key, preset]} {#each Object.entries(QUALITY_PRESETS) as [key, preset]}
<button <button
onclick={() => startDownload(key as QualityPreset)} onclick={() => startDownload(key as QualityPreset)}
@@ -280,14 +310,16 @@
> >
<span>{preset.label}</span> <span>{preset.label}</span>
{#if preset.videoBitrate} {#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} {:else}
<span class="text-xs text-gray-500">Direct</span> <span class="text-xs text-gray-500">Direct</span>
{/if} {/if}
</button> </button>
{/each} {/each}
<button <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" class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
> >
Cancel Cancel
@@ -32,14 +32,7 @@
onChanged?: (watched: boolean) => void; onChanged?: (watched: boolean) => void;
} }
let { let { itemId, watched, scope, size = "lg", showLabel = false, onChanged }: Props = $props();
itemId,
watched,
scope,
size = "lg",
showLabel = false,
onChanged,
}: Props = $props();
let busy = $state(false); let busy = $state(false);
@@ -60,7 +53,7 @@
}); });
const subject = $derived( 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 label = $derived(isWatched ? "Watched" : "Mark watched");
const title = $derived( const title = $derived(
@@ -68,7 +61,7 @@
? `Mark this ${subject} unwatched` ? `Mark this ${subject} unwatched`
: scope === "episode" : scope === "episode"
? "Mark this episode watched" ? "Mark this episode watched"
: `Mark every episode in this ${subject} watched` : `Mark every episode in this ${subject} watched`,
); );
async function handleClick() { async function handleClick() {
@@ -106,7 +99,13 @@
{isWatched {isWatched
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25' ? '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'} : '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} {#if busy}
<div <div
+17 -11
View File
@@ -1,13 +1,14 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types"; 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. // Minimal episode factory — only the fields the strip logic reads.
function ep( function ep(id: string, season: number | null, number: number | null): MediaItem {
id: string,
season: number | null,
number: number | null,
): MediaItem {
return { return {
id, id,
name: `S${season}E${number}`, name: `S${season}E${number}`,
@@ -79,8 +80,15 @@ describe("adjacentEpisodes", () => {
const current = eps[4]; // S1E5 — the season finale const current = eps[4]; // S1E5 — the season finale
const strip = adjacentEpisodes(current, eps); const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([ expect(strip.map((e) => e.id)).toEqual([
"s1e2", "s1e3", "s1e4", "s1e5", "s1e2",
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5", "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 eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
const current = eps[3]; // S1E1 const current = eps[3]; // S1E1
const strip = adjacentEpisodes(current, eps); const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([ expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3"]);
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
]);
}); });
it("sorts specials (season 0) after the numbered seasons", () => { 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 { export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true; if (ep.id === current.id) return true;
if ( if (
ep.indexNumber == null || current.indexNumber == null || ep.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null current.indexNumber == null ||
ep.parentIndexNumber == null ||
current.parentIndexNumber == null
) { ) {
return false; return false;
} }
return ( return (
ep.parentIndexNumber === current.parentIndexNumber && ep.parentIndexNumber === current.parentIndexNumber && ep.indexNumber === current.indexNumber
ep.indexNumber === current.indexNumber
); );
} }
@@ -42,7 +42,11 @@ describe("buildLibraryMosaic", () => {
it("links a category tile to that category's favourites tab", () => { it("links a category tile to that category's favourites tab", () => {
const entries = buildLibraryMosaic([SHOWS]); const entries = buildLibraryMosaic([SHOWS]);
const tile = entries.find((e) => e.label === "Favourite 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", () => { 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; ratio: number;
label: string; label: string;
} & ( } & (
| { kind: "library"; library: Library } { kind: "library"; library: Library } | { kind: "favorites"; scope: FavoritesScope; href: string }
| { kind: "favorites"; scope: FavoritesScope; href: string }
); );
/** /**
@@ -68,7 +67,13 @@ export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
for (const lib of libraries) { for (const lib of libraries) {
const ratio = assumedLibraryRatio(lib); 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); const scope = asFavoritesScope(lib.favoritesScope);
if (!scope || seenScopes.has(scope)) continue; if (!scope || seenScopes.has(scope)) continue;
@@ -88,9 +88,7 @@ describe("seasonRedirectTarget", () => {
}); });
it("matches the anchor the season section renders", () => { it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe( expect(seasonRedirectTarget(seasonHeader(2))).toBe(`/library/${SERIES}#${seasonAnchorId(2)}`);
`/library/${SERIES}#${seasonAnchorId(2)}`
);
}); });
}); });
@@ -130,7 +128,7 @@ describe("groupEpisodesBySeason", () => {
it("puts specials after the numbered seasons", () => { it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason( const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)], [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]); expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
}); });
@@ -156,7 +154,7 @@ describe("groupEpisodesBySeason", () => {
it("drops seasons that have no episodes", () => { it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason( const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)], [seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)] [ep("s2e1", 2, 1)],
); );
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]); expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
}); });
@@ -171,12 +169,7 @@ describe("groupEpisodesBySeason", () => {
describe("initialExpandedSeasons", () => { describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason( const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)], [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", () => { 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). * 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. * Seasons with no episodes are dropped an empty accordion row is noise.
*/ */
export function groupEpisodesBySeason( export function groupEpisodesBySeason(seasons: MediaItem[], episodes: MediaItem[]): SeasonData[] {
seasons: MediaItem[],
episodes: MediaItem[]
): SeasonData[] {
const headerFor = new Map<number, MediaItem>(); const headerFor = new Map<number, MediaItem>();
for (const season of seasons) { for (const season of seasons) {
const number = season.indexNumber ?? season.parentIndexNumber; const number = season.indexNumber ?? season.parentIndexNumber;
@@ -164,7 +161,7 @@ export function groupEpisodesBySeason(
export function initialExpandedSeasons( export function initialExpandedSeasons(
seasons: SeasonData[], seasons: SeasonData[],
currentEpisodeId: string | null | undefined, currentEpisodeId: string | null | undefined,
focusedEpisodeId?: string | null focusedEpisodeId?: string | null,
): Set<string> { ): Set<string> {
if (seasons.length === 0) return new Set(); if (seasons.length === 0) return new Set();
+161 -143
View File
@@ -6,12 +6,7 @@
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { sleepTimerActive } from "$lib/stores/sleepTimer"; import { sleepTimerActive } from "$lib/stores/sleepTimer";
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue"; import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
import { import { mergedMedia, mergedIsPlaying, mergedPosition, mergedDuration } from "$lib/stores/player";
mergedMedia,
mergedIsPlaying,
mergedPosition,
mergedDuration
} from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode"; import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession } from "$lib/stores/sessions"; import { selectedSession } from "$lib/stores/sessions";
import { formatTime } from "$lib/utils/playbackUnits"; import { formatTime } from "$lib/utils/playbackUnits";
@@ -167,167 +162,190 @@
style:padding-left="var(--safe-left)" style:padding-left="var(--safe-left)"
style:padding-right="var(--safe-right)" style:padding-right="var(--safe-right)"
> >
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between p-4 flex-shrink-0"> <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 -->
<button <button
onclick={() => (showQueue = !showQueue)} onclick={onClose}
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue ? 'bg-white/10 text-[var(--color-jellyfin)]' : ''}" class="p-2 rounded-full hover:bg-white/10 transition-colors"
title="Queue" aria-label="Close player"
aria-label="Open queue"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
<button <div class="flex flex-col items-center">
onclick={() => (showSleepTimerModal = true)} <p class="text-sm text-gray-400">Now Playing</p>
class="p-2 rounded-full hover:bg-white/10 transition-colors relative" {#if $isRemoteMode && $selectedSession}
title="Sleep timer" <p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
aria-label="Sleep timer" <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
> <path
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 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"
<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>
</svg> {$selectedSession.deviceName}
{#if $sleepTimerActive} </p>
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
{/if} {/if}
</button> </div>
<!-- Volume Control (Linux only) --> <div class="flex items-center gap-2">
<VolumeControl size="md" /> <!-- Cast Button -->
</div> <CastButton size="md" />
</div>
<!-- Artwork --> <!-- Queue Button -->
<div class="flex-1 flex items-center justify-center p-8 min-h-0"> <button
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0"> onclick={() => (showQueue = !showQueue)}
{#if artworkItemId} class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue
<CachedImage ? 'bg-white/10 text-[var(--color-jellyfin)]'
itemId={artworkItemId} : ''}"
imageType="Primary" title="Queue"
tag={displayMedia?.imageId} aria-label="Open queue"
maxWidth={500} >
alt={displayMedia?.name} <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="w-full h-full object-cover" <path
/> stroke-linecap="round"
{:else} stroke-linejoin="round"
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center"> stroke-width="2"
<svg class="w-32 h-32 text-gray-600" fill="currentColor" viewBox="0 0 24 24"> d="M4 6h16M4 10h16M4 14h16M4 18h16"
<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> </svg>
</div> </button>
{/if}
</div>
</div>
<!-- Info & Controls --> <button
<div class="p-6 space-y-6 flex-shrink-0"> onclick={() => (showSleepTimerModal = true)}
<!-- Title & Artist --> class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
<div class="text-center"> title="Sleep timer"
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1> aria-label="Sleep timer"
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap"> >
{#if displayMedia?.artistItems?.length} <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#each displayMedia?.artistItems as artist, i} <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 <button
onclick={() => navigateToArtist(artist.id)} onclick={navigateToAlbum}
class="hover:text-white hover:underline transition-colors" class="hover:text-white hover:underline transition-colors"
> >
{artist.name} {displayMedia?.albumName}
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if} </button>
{/each} {:else if displayMedia?.albumName}
{:else if displayMedia?.artists?.length} <span>{displayMedia?.albumName}</span>
<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} {/if}
<button </div>
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 --> <!-- Progress bar -->
<div class="space-y-2"> <div class="space-y-2">
<input <input
type="range" type="range"
min="0" min="0"
max={displayDuration} max={displayDuration}
value={displayPosition} value={displayPosition}
oninput={handleSeekInput} oninput={handleSeekInput}
onmousedown={handleSeekStart} onmousedown={handleSeekStart}
ontouchstart={handleSeekStart} ontouchstart={handleSeekStart}
onmouseup={handleSeekEnd} onmouseup={handleSeekEnd}
ontouchend={handleSeekEnd} ontouchend={handleSeekEnd}
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer" class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
/> />
<div class="flex justify-between text-xs text-gray-400"> <div class="flex justify-between text-xs text-gray-400">
<span>{formatTime(displayPosition)}</span> <span>{formatTime(displayPosition)}</span>
<span>{formatTime(displayDuration)}</span> <span>{formatTime(displayDuration)}</span>
</div>
</div> </div>
</div>
<!-- Controls --> <!-- Controls -->
<div class="flex justify-center"> <div class="flex justify-center">
<Controls <Controls
isPlaying={displayIsPlaying} isPlaying={displayIsPlaying}
{hasPrevious} {hasPrevious}
{hasNext} {hasNext}
{shuffle} {shuffle}
{repeat} {repeat}
onPlayPause={handlePlayPause} onPlayPause={handlePlayPause}
onPrevious={handlePrevious} onPrevious={handlePrevious}
onNext={handleNext} onNext={handleNext}
onToggleShuffle={handleToggleShuffle} onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat} onCycleRepeat={handleCycleRepeat}
onSleepTimerClick={() => (showSleepTimerModal = true)} onSleepTimerClick={() => (showSleepTimerModal = true)}
/> />
</div>
</div> </div>
</div> </div>
</div> <!-- Close content overlay --> <!-- Close content overlay -->
</div> </div>
{/if} {/if}
<SleepTimerModal <SleepTimerModal isOpen={showSleepTimerModal} onClose={() => (showSleepTimerModal = false)} />
isOpen={showSleepTimerModal}
onClose={() => (showSleepTimerModal = false)}
/>
<!-- Queue Panel (slide up from bottom) --> <!-- Queue Panel (slide up from bottom) -->
{#if showQueue} {#if showQueue}
+12 -4
View File
@@ -90,8 +90,12 @@
aria-label="Sleep timer" aria-label="Sleep timer"
> >
<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" stroke-width="2" <path
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" /> 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> </svg>
</button> </button>
{:else} {:else}
@@ -113,7 +117,9 @@
title="Shuffle" title="Shuffle"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
@@ -192,7 +198,9 @@
> >
{#if repeat === "one"} {#if repeat === "one"}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
{:else} {:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
+57 -29
View File
@@ -25,7 +25,7 @@
mergedIsPlaying, mergedIsPlaying,
mergedPosition, mergedPosition,
mergedDuration, mergedDuration,
shouldShowAudioMiniPlayer shouldShowAudioMiniPlayer,
} from "$lib/stores/player"; } from "$lib/stores/player";
import { currentQueueItem } from "$lib/stores/queue"; import { currentQueueItem } from "$lib/stores/queue";
import { isRemoteMode } from "$lib/stores/playbackMode"; 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 // State machine gated visibility - only show when player is playing/paused AND media is audio
const shouldShow = $derived($shouldShowAudioMiniPlayer); const shouldShow = $derived($shouldShowAudioMiniPlayer);
const progress = $derived( const progress = $derived(calculateProgress(displayPosition, displayDuration));
calculateProgress(displayPosition, displayDuration)
);
function navigateToArtist(event: MouseEvent, artistId: string) { function navigateToArtist(event: MouseEvent, artistId: string) {
event.stopPropagation(); event.stopPropagation();
@@ -284,12 +282,23 @@
</script> </script>
{#if shouldShow && displayMedia} {#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 --> <!-- Remote Mode Indicator -->
{#if $isRemoteMode && $selectedSession} {#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"> <div
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2"
<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
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> </svg>
<span class="text-xs text-[var(--color-jellyfin)] font-medium"> <span class="text-xs text-[var(--color-jellyfin)] font-medium">
Playing on {$selectedSession.deviceName} Playing on {$selectedSession.deviceName}
@@ -308,7 +317,9 @@
style="width: {progress}%" style="width: {progress}%"
></div> ></div>
<!-- Hover indicator --> <!-- 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> </button>
<div <div
@@ -317,14 +328,14 @@
ontouchstart={handleTouchStart} ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove} ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd} 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 --> <!-- Row 1: Media info, like, cast, overflow -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Artwork --> <!-- Artwork -->
<div <div class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
>
{#if displayMedia} {#if displayMedia}
<CachedImage <CachedImage
itemId={displayMedia.albumId || displayMedia.id} itemId={displayMedia.albumId || displayMedia.id}
@@ -337,7 +348,9 @@
{:else} {:else}
<div class="w-full h-full flex items-center justify-center text-gray-600"> <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"> <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> </svg>
</div> </div>
{/if} {/if}
@@ -345,9 +358,7 @@
<!-- Title & Artist --> <!-- Title & Artist -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div <div class="text-sm font-medium text-white truncate block w-full text-left">
class="text-sm font-medium text-white truncate block w-full text-left"
>
{truncateMiddle(displayMedia?.name, 40)} {truncateMiddle(displayMedia?.name, 40)}
</div> </div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1"> <div class="text-xs text-gray-400 truncate flex items-center gap-1">
@@ -381,11 +392,7 @@
<!-- Like Button --> <!-- Like Button -->
{#if displayMedia} {#if displayMedia}
<FavoriteButton <FavoriteButton itemId={displayMedia?.id ?? ""} bind:isFavorite size="sm" />
itemId={displayMedia?.id ?? ""}
bind:isFavorite
size="sm"
/>
{/if} {/if}
<!-- Cast Button --> <!-- Cast Button -->
@@ -402,7 +409,9 @@
aria-label="More options" aria-label="More options"
> >
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </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" 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"> <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> </svg>
View Queue View Queue
</button> </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" 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"> <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> </svg>
Go to Album Go to Album
</button> </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" 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"> <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> </svg>
Go to Artist Go to Artist
</button> </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" 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"> <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> </svg>
Add to Playlist Add to Playlist
</button> </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" 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"> <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> </svg>
Share Share
</button> </button>
@@ -508,7 +536,7 @@
{#if showOverflowMenu} {#if showOverflowMenu}
<button <button
class="fixed inset-0 z-[65]" class="fixed inset-0 z-[65]"
onclick={() => showOverflowMenu = false} onclick={() => (showOverflowMenu = false)}
aria-label="Close menu" aria-label="Close menu"
></button> ></button>
{/if} {/if}
@@ -7,15 +7,14 @@
initialCountdownSeconds, initialCountdownSeconds,
isCountdownActive, isCountdownActive,
} from "$lib/stores/nextEpisode"; } from "$lib/stores/nextEpisode";
import { import { cancelAutoPlay, watchNextManually } from "$lib/services/nextEpisodeService";
cancelAutoPlay,
watchNextManually,
} from "$lib/services/nextEpisodeService";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
// Use series primary image for better visual consistency // 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) // Format episode info (S1:E5)
const episodeInfo = $derived.by(() => { const episodeInfo = $derived.by(() => {
@@ -75,9 +74,7 @@
<!-- Episode Card --> <!-- Episode Card -->
<div class="flex gap-4 p-4"> <div class="flex gap-4 p-4">
<!-- Thumbnail --> <!-- Thumbnail -->
<div <div class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800">
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
>
{#if imageId && $nextEpisodeItem.imageId} {#if imageId && $nextEpisodeItem.imageId}
<CachedImage <CachedImage
itemId={imageId} itemId={imageId}
@@ -90,14 +87,8 @@
{/if} {/if}
<!-- Play icon overlay --> <!-- Play icon overlay -->
<div <div class="absolute inset-0 flex items-center justify-center bg-black/30">
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">
>
<svg
class="w-8 h-8 text-white"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
</div> </div>
+36 -16
View File
@@ -17,12 +17,7 @@
onClose?: () => void; onClose?: () => void;
} }
let { let { items, currentIndex = null, onItemClick, onClose }: Props = $props();
items,
currentIndex = null,
onItemClick,
onClose,
}: Props = $props();
// Add unique IDs for dnd-zone (required) // Add unique IDs for dnd-zone (required)
interface DndItem extends MediaItem { interface DndItem extends MediaItem {
@@ -33,7 +28,7 @@
items.map((item, index) => ({ items.map((item, index) => ({
...item, ...item,
dndId: `${item.id}-${index}`, dndId: `${item.id}-${index}`,
})) })),
); );
let dragDisabled = $state(true); let dragDisabled = $state(true);
@@ -47,7 +42,9 @@
return `${mins}:${secs.toString().padStart(2, "0")}`; 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; const { items: newItems, info } = e.detail;
// Update local state during drag // Update local state during drag
if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) { if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) {
@@ -59,8 +56,8 @@
const { items: newItems, info } = e.detail; const { items: newItems, info } = e.detail;
// Find the moved item by comparing old and new positions // Find the moved item by comparing old and new positions
const oldIds = dndItems.map(i => i.dndId); const oldIds = dndItems.map((i) => i.dndId);
const newIds = newItems.map(i => i.dndId); const newIds = newItems.map((i) => i.dndId);
// Find indices that changed // Find indices that changed
let fromIndex = -1; let fromIndex = -1;
@@ -126,7 +123,12 @@
aria-label="Close queue" aria-label="Close queue"
> >
<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" 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> </svg>
</button> </button>
</div> </div>
@@ -151,7 +153,10 @@
{#each dndItems as item, index (item.dndId)} {#each dndItems as item, index (item.dndId)}
<li class="outline-none"> <li class="outline-none">
<div <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 --> <!-- Drag handle -->
<button <button
@@ -163,7 +168,9 @@
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
> >
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
@@ -177,7 +184,11 @@
<!-- Index or playing indicator --> <!-- Index or playing indicator -->
<div class="w-6 text-center flex-shrink-0"> <div class="w-6 text-center flex-shrink-0">
{#if currentIndex === index} {#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" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
{:else} {:else}
@@ -201,7 +212,11 @@
<!-- Info --> <!-- Info -->
<div class="flex-1 min-w-0"> <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)} {truncateMiddle(item.name, 48)}
</p> </p>
{#if item.artists?.length} {#if item.artists?.length}
@@ -225,7 +240,12 @@
aria-label="Remove from queue" aria-label="Remove from queue"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
</div> </div>
@@ -1,10 +1,6 @@
<!-- TRACES: UR-026 | DR-029, DR-050 --> <!-- TRACES: UR-026 | DR-029, DR-050 -->
<script lang="ts"> <script lang="ts">
import { import { sleepTimer, sleepTimerMode, sleepTimerActive } from "$lib/stores/sleepTimer";
sleepTimer,
sleepTimerMode,
sleepTimerActive,
} from "$lib/stores/sleepTimer";
import { currentQueueItem } from "$lib/stores/queue"; import { currentQueueItem } from "$lib/stores/queue";
import ScrollPicker from "$lib/components/common/ScrollPicker.svelte"; import ScrollPicker from "$lib/components/common/ScrollPicker.svelte";
@@ -96,7 +92,9 @@
<div <div
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4" class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
onclick={handleBackdropClick} onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }} onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="sleep-timer-title" aria-labelledby="sleep-timer-title"
@@ -108,23 +106,14 @@
role="none" role="none"
> >
<!-- Header --> <!-- Header -->
<div <div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
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>
>
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">
Sleep Timer
</h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors" class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@@ -176,7 +165,9 @@
selectedValue={selectedMinutes} selectedValue={selectedMinutes}
visibleCount={3} visibleCount={3}
itemHeight={56} itemHeight={56}
onSelect={(val) => { selectedMinutes = val as number; }} onSelect={(val) => {
selectedMinutes = val as number;
}}
/> />
<button <button
onclick={handleSetTimer} onclick={handleSetTimer}
@@ -194,11 +185,7 @@
onclick={handleEndOfTrack} 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" 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 <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
class="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" /> <path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg> </svg>
<span class="text-white">{getEndOfTrackLabel()}</span> <span class="text-white">{getEndOfTrackLabel()}</span>
@@ -208,27 +195,19 @@
<!-- Episode countdown (only for TV episodes) --> <!-- Episode countdown (only for TV episodes) -->
{#if isEpisode} {#if isEpisode}
<div> <div>
<h3 class="text-sm font-medium text-gray-400 mb-3"> <h3 class="text-sm font-medium text-gray-400 mb-3">Stop after episodes</h3>
Stop after episodes
</h3>
<div class="space-y-2"> <div class="space-y-2">
{#each episodePresets as count} {#each episodePresets as count}
<button <button
onclick={() => handleEpisodePreset(count)} 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" 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 <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
class="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path <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" 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> </svg>
<span class="text-white" <span class="text-white">{count} more episode{count !== 1 ? "s" : ""}</span>
>{count} more episode{count !== 1 ? "s" : ""}</span
>
</button> </button>
{/each} {/each}
</div> </div>
@@ -1,11 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock video element for testing seek behavior // Mock video element for testing seek behavior
function createMockVideoElement(options: { function createMockVideoElement(
paused?: boolean; options: {
autoplay?: boolean; paused?: boolean;
currentTime?: number; autoplay?: boolean;
} = {}) { currentTime?: number;
} = {},
) {
const listeners: Record<string, (() => void)[]> = {}; const listeners: Record<string, (() => void)[]> = {};
return { return {
@@ -13,11 +15,11 @@ function createMockVideoElement(options: {
autoplay: options.autoplay ?? true, autoplay: options.autoplay ?? true,
currentTime: options.currentTime ?? 0, currentTime: options.currentTime ?? 0,
pause: vi.fn(function(this: any) { pause: vi.fn(function (this: any) {
this.paused = true; this.paused = true;
}), }),
play: vi.fn(function(this: any) { play: vi.fn(function (this: any) {
this.paused = false; this.paused = false;
return Promise.resolve(); return Promise.resolve();
}), }),
@@ -29,13 +31,13 @@ function createMockVideoElement(options: {
removeEventListener: vi.fn((event: string, handler: () => void) => { removeEventListener: vi.fn((event: string, handler: () => void) => {
if (listeners[event]) { 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 // Helper to trigger events in tests
_triggerEvent: (event: string) => { _triggerEvent: (event: string) => {
listeners[event]?.forEach(h => h()); listeners[event]?.forEach((h) => h());
}, },
_getListeners: () => listeners, _getListeners: () => listeners,
@@ -283,7 +285,13 @@ describe("VideoPlayer Resume Logic", () => {
// Simulate new position // Simulate new position
const newPosition = 120; const newPosition = 120;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { if (
newPosition &&
newPosition > 0 &&
isMediaReady &&
videoElement &&
hasPerformedInitialSeek
) {
hasPerformedInitialSeek = false; hasPerformedInitialSeek = false;
videoElement.currentTime = newPosition; videoElement.currentTime = newPosition;
currentTime = newPosition; currentTime = newPosition;
@@ -301,7 +309,13 @@ describe("VideoPlayer Resume Logic", () => {
const newPosition = 120; const newPosition = 120;
let seekTriggered = false; let seekTriggered = false;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { if (
newPosition &&
newPosition > 0 &&
isMediaReady &&
videoElement &&
hasPerformedInitialSeek
) {
seekTriggered = true; seekTriggered = true;
} }
@@ -315,7 +329,13 @@ describe("VideoPlayer Resume Logic", () => {
const newPosition = 120; const newPosition = 120;
let seekTriggered = false; let seekTriggered = false;
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) { if (
newPosition &&
newPosition > 0 &&
isMediaReady &&
videoElement &&
hasPerformedInitialSeek
) {
seekTriggered = true; seekTriggered = true;
} }
@@ -355,8 +375,10 @@ describe("VideoPlayer Resume Logic", () => {
let errorCaught = false; let errorCaught = false;
// Simulate a video element that throws on currentTime set // Simulate a video element that throws on currentTime set
Object.defineProperty(videoElement, 'currentTime', { Object.defineProperty(videoElement, "currentTime", {
set: () => { throw new Error('Seek not allowed'); }, set: () => {
throw new Error("Seek not allowed");
},
get: () => 0, get: () => 0,
}); });
@@ -371,7 +393,7 @@ describe("VideoPlayer Resume Logic", () => {
it("should handle play() rejection gracefully", async () => { it("should handle play() rejection gracefully", async () => {
const videoElement = createMockVideoElement(); const videoElement = createMockVideoElement();
videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked')); videoElement.play = vi.fn().mockRejectedValue(new Error("Autoplay blocked"));
let errorCaught = false; let errorCaught = false;
try { try {
@@ -146,9 +146,7 @@ async function mountNativePlayer() {
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled()); await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
// The native path must NOT be overridden to HTML5 and must NOT be stopped — // 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. // if it were, these tests would be guarding the HTML5 path by accident.
await waitFor(() => await waitFor(() => expect(utils.container.querySelector("video")).toBeNull());
expect(utils.container.querySelector("video")).toBeNull()
);
expect(playerStop).not.toHaveBeenCalled(); expect(playerStop).not.toHaveBeenCalled();
return utils; return utils;
} }
@@ -168,11 +166,7 @@ function poster(container: HTMLElement): HTMLElement | null {
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and * ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
* the store is what the component must read. * the store is what the component must read.
*/ */
async function backendReports( async function backendReports(kind: "playing" | "paused" | "error", position = 0, duration = 0) {
kind: "playing" | "paused" | "error",
position = 0,
duration = 0
) {
const media = makeEpisode(); const media = makeEpisode();
if (kind === "playing") player.setPlaying(media, position, duration); if (kind === "playing") player.setPlaying(media, position, duration);
else if (kind === "paused") player.setPaused(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 backendReports("paused", 5, 1440);
await waitFor(() => 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); 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 // both dims and covers the ExoPlayer surface while it plays. Before the
// mirror, nothing after init could take it down, because the only other // mirror, nothing after init could take it down, because the only other
// writer was the never-emitted `player://state-changed` channel. // writer was the never-emitted `player://state-changed` channel.
await waitFor(() => await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
}); });
it("raises the play overlay again when the backend reports paused (DR-186)", async () => { it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
const { container } = await mountNativePlayer(); const { container } = await mountNativePlayer();
await backendReports("playing", 5, 1440); await backendReports("playing", 5, 1440);
await waitFor(() => await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
await backendReports("paused", 6, 1440); await backendReports("paused", 6, 1440);
// The mirror has to work in both directions, or pausing leaves no affordance // The mirror has to work in both directions, or pausing leaves no affordance
// to resume. // to resume.
await waitFor(() => 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, // defeated the first attempt: a one-shot timer armed on entry fired here,
// declined, and was never re-armed. // declined, and was never re-armed.
await vi.advanceTimersByTimeAsync(3500); 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. // Playback starts late; the countdown has to restart on its own.
player.setPlaying(makeEpisode(), 5, 1440); player.setPlaying(makeEpisode(), 5, 1440);
await vi.advanceTimersByTimeAsync(3500); await vi.advanceTimersByTimeAsync(3500);
await vi.waitFor(() => 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 { } finally {
vi.useRealTimers(); vi.useRealTimers();
@@ -150,9 +150,7 @@ async function mountAndroidPlayer() {
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled()); await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled()); await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector( const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
'input[type="range"]'
) as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement; const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull(); expect(slider).not.toBeNull();
expect(video).not.toBeNull(); expect(video).not.toBeNull();
@@ -160,11 +158,7 @@ async function mountAndroidPlayer() {
} }
/** Scrub the seek bar to `target` seconds like a user drag. */ /** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo( async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
slider: HTMLInputElement,
video: HTMLVideoElement,
target: number
) {
await fireEvent.mouseDown(slider); await fireEvent.mouseDown(slider);
slider.value = String(target); slider.value = String(target);
await fireEvent.input(slider); await fireEvent.input(slider);
@@ -201,8 +195,8 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
600, 600,
"src-1", "src-1",
null, 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); 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 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 // The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay // media prop so a late reportStop (e.g. from onDestroy during autoplay
@@ -140,12 +155,7 @@
setHtml5VideoState(false, 0, 0, false); setHtml5VideoState(false, 0, 0, false);
return; return;
} }
setHtml5VideoState( setHtml5VideoState(true, videoElement.videoWidth, videoElement.videoHeight, isPlaying);
true,
videoElement.videoWidth,
videoElement.videoHeight,
isPlaying
);
} }
let isFullscreen = $state(false); let isFullscreen = $state(false);
let showControls = $state(true); let showControls = $state(true);
@@ -242,8 +252,12 @@
const adapterBridge: Html5ElementBridge = { const adapterBridge: Html5ElementBridge = {
getElement: () => videoElement, getElement: () => videoElement,
getSeekOffset: () => seekOffset, getSeekOffset: () => seekOffset,
setSeekOffset: (o) => { seekOffset = o; }, setSeekOffset: (o) => {
setStreamUrl: (u) => { currentStreamUrl = u; }, seekOffset = o;
},
setStreamUrl: (u) => {
currentStreamUrl = u;
},
destroyHls: tearDownHls, destroyHls: tearDownHls,
getMediaSourceId: () => mediaSourceId ?? null, getMediaSourceId: () => mediaSourceId ?? null,
}; };
@@ -278,7 +292,6 @@
return videoDuration; return videoDuration;
}); });
// The audio tracks available for this item, as the server described them. // The audio tracks available for this item, as the server described them.
// //
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the // Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
@@ -293,19 +306,22 @@
log.debug("No media or mediaStreams available"); log.debug("No media or mediaStreams available");
return []; 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); log.debug("Found audio tracks:", tracks.length, tracks);
return tracks; return tracks;
}); });
// Function to find best matching audio track based on preference // 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(); const tracks = audioTracks();
if (tracks.length === 0) return null; if (tracks.length === 0) return null;
// Try to match by display title first // Try to match by display title first
if (preference.audioTrackDisplayTitle) { if (preference.audioTrackDisplayTitle) {
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle); const match = tracks.find((t) => t.displayTitle === preference.audioTrackDisplayTitle);
if (match) { if (match) {
log.debug("Matched audio track by display title:", match.displayTitle); log.debug("Matched audio track by display title:", match.displayTitle);
return match.index; return match.index;
@@ -314,7 +330,7 @@
// Try to match by language // Try to match by language
if (preference.audioTrackLanguage) { if (preference.audioTrackLanguage) {
const match = tracks.find(t => t.language === preference.audioTrackLanguage); const match = tracks.find((t) => t.language === preference.audioTrackLanguage);
if (match) { if (match) {
log.debug("Matched audio track by language:", match.language); log.debug("Matched audio track by language:", match.language);
return match.index; return match.index;
@@ -322,8 +338,11 @@
} }
// Fall back to default track // Fall back to default track
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0]; const defaultTrack = tracks.find((t) => t.isDefault) || tracks[0];
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language); log.debug(
"Using default/first audio track:",
defaultTrack.displayTitle || defaultTrack.language,
);
return defaultTrack.index; return defaultTrack.index;
} }
@@ -388,7 +407,7 @@
// Cross-origin <track> fetches use the media element's CORS setting; see // Cross-origin <track> fetches use the media element's CORS setting; see
// videoCrossOriginMode for why this is opt-in and same-origin-only. // videoCrossOriginMode for why this is opt-in and same-origin-only.
const videoCrossOrigin = $derived( const videoCrossOrigin = $derived(
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length) videoCrossOriginMode(currentStreamUrl, subtitleTracks().length),
); );
$effect(() => { $effect(() => {
@@ -408,7 +427,10 @@
renderedSubtitleTracks = tracks; renderedSubtitleTracks = tracks;
// Keep the menu's checkmark and the element's text tracks in agreement: // Keep the menu's checkmark and the element's text tracks in agreement:
// a selection that no longer resolves collapses to "Off". // a selection that no longer resolves collapses to "Off".
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex)); const selected = reconcileSelectedSubtitle(
tracks,
untrack(() => selectedSubtitleIndex),
);
selectedSubtitleIndex = selected; selectedSubtitleIndex = selected;
// The <track> children were just (re)created, so re-apply the selection to // The <track> children were just (re)created, so re-apply the selection to
// the new TextTrack objects — otherwise a surviving selection shows nothing. // 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 // 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 // adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
// pause() (see handleControlCommand / the sleep_timer_expired case). This // pause() (see handleControlCommand / the sleep_timer_expired case). This
@@ -545,12 +566,12 @@
return; return;
} }
const isHlsStream = currentStreamUrl.includes('.m3u8'); const isHlsStream = currentStreamUrl.includes(".m3u8");
if (isHlsStream && Hls.isSupported()) { if (isHlsStream && Hls.isSupported()) {
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio // Clean up existing HLS instance if any - CRITICAL for preventing dual audio
if (hls) { 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 // Detach from media element first to stop all audio/video
hls.detachMedia(); hls.detachMedia();
// Stop loading and flush buffers // Stop loading and flush buffers
@@ -564,7 +585,7 @@
// This is critical to prevent dual audio streams // This is critical to prevent dual audio streams
if (videoElement.src) { if (videoElement.src) {
videoElement.pause(); // Ensure playback is stopped videoElement.pause(); // Ensure playback is stopped
videoElement.removeAttribute('src'); videoElement.removeAttribute("src");
videoElement.load(); // Reset the media element and clear all buffers videoElement.load(); // Reset the media element and clear all buffers
videoElement.currentTime = 0; videoElement.currentTime = 0;
} }
@@ -574,7 +595,7 @@
setTimeout(() => { setTimeout(() => {
if (!videoElement) return; if (!videoElement) return;
log.debug('Creating new HLS instance for:', currentStreamUrl); log.debug("Creating new HLS instance for:", currentStreamUrl);
// Create new HLS instance // Create new HLS instance
hls = new Hls({ hls = new Hls({
@@ -602,14 +623,14 @@
// Listen for media attached event // Listen for media attached event
hls.on(Hls.Events.MEDIA_ATTACHED, () => { 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 // Load the HLS stream
hls!.loadSource(currentStreamUrl); hls!.loadSource(currentStreamUrl);
}); });
// Listen for manifest parsed event // Listen for manifest parsed event
hls.on(Hls.Events.MANIFEST_PARSED, () => { 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 // On the Android WebView the element's own `canplay` may not fire for
@@ -626,7 +647,11 @@
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout); if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
canplayFallbackTimeout = setTimeout(() => { canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement && videoElement.readyState >= 2) { 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(); markMediaReady();
} }
}, 5000); }, 5000);
@@ -636,7 +661,7 @@
// Handle errors // Handle errors
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
log.error('HLS error:', data); log.error("HLS error:", data);
if (data.fatal) { if (data.fatal) {
// Is this the stream ending or the stream breaking? Jellyfin's // Is this the stream ending or the stream breaking? Jellyfin's
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive // transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
@@ -647,31 +672,37 @@
switch (data.type) { switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR: case Hls.ErrorTypes.NETWORK_ERROR:
hlsFatalRecoveryAttempts++; hlsFatalRecoveryAttempts++;
switch (fatalNetworkErrorAction({ switch (
positionSeconds: currentTime, fatalNetworkErrorAction({
knownDurationSeconds: knownDuration, positionSeconds: currentTime,
attempts: hlsFatalRecoveryAttempts, knownDurationSeconds: knownDuration,
})) { attempts: hlsFatalRecoveryAttempts,
case 'ended': })
log.debug('Fatal network error near end of stream - treating as ended'); ) {
case "ended":
log.debug("Fatal network error near end of stream - treating as ended");
notifyEnded(); notifyEnded();
break; break;
case 'retry': case "retry":
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')'); log.error(
"Fatal network error, trying to recover (attempt",
hlsFatalRecoveryAttempts,
")",
);
hls!.startLoad(); hls!.startLoad();
break; break;
case 'giveUp': case "giveUp":
log.error('Fatal network error, max recovery attempts reached'); log.error("Fatal network error, max recovery attempts reached");
hls!.destroy(); hls!.destroy();
break; break;
} }
break; break;
case Hls.ErrorTypes.MEDIA_ERROR: case Hls.ErrorTypes.MEDIA_ERROR:
log.error('Fatal media error, trying to recover'); log.error("Fatal media error, trying to recover");
hls!.recoverMediaError(); hls!.recoverMediaError();
break; break;
default: default:
log.error('Unrecoverable HLS error'); log.error("Unrecoverable HLS error");
hls!.destroy(); hls!.destroy();
break; break;
} }
@@ -681,7 +712,7 @@
// Cleanup on effect re-run // Cleanup on effect re-run
return () => { return () => {
log.debug('Effect cleanup: destroying HLS instance'); log.debug("Effect cleanup: destroying HLS instance");
if (hls) { if (hls) {
hls.detachMedia(); hls.detachMedia();
hls.stopLoad(); hls.stopLoad();
@@ -692,13 +723,13 @@
videoElement.pause(); videoElement.pause();
} }
}; };
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) { } else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS support (Safari) // Native HLS support (Safari)
log.debug('Using native HLS support'); log.debug("Using native HLS support");
videoElement.src = currentStreamUrl; videoElement.src = currentStreamUrl;
} else { } else {
// Not an HLS stream, use regular video element // 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) { if (videoElement) {
videoElement.muted = false; videoElement.muted = false;
videoElement.volume = 1.0; 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 // DIAGNOSTIC: Check if video has audio tracks
if ((videoElement as any).audioTracks) { if ((videoElement as any).audioTracks) {
@@ -715,7 +751,7 @@
// Set initial audio track (prefer default track) // Set initial audio track (prefer default track)
if (selectedAudioTrackIndex === null && audioTracks().length > 0) { 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; selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
log.debug("Selected default audio track:", selectedAudioTrackIndex); log.debug("Selected default audio track:", selectedAudioTrackIndex);
} }
@@ -724,7 +760,10 @@
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio); log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
} }
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) { 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 // TRACES: UR-074 | DR-162
onMount(() => { onMount(() => {
Promise.all([ Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
commands.playerGetStreamingQualities(),
commands.playerGetVideoSettings(),
])
.then(([qualities, settings]) => { .then(([qualities, settings]) => {
streamingQualities = qualities; streamingQualities = qualities;
// Optional on the wire (serde default) — absent means uncapped. // 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 // For transcoded content, we need to keep the backend running to handle seeking/audio track switching
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) { if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
try { 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(); await commands.playerStop();
didStopBackendEarly = true; // Track that we stopped the backend didStopBackendEarly = true; // Track that we stopped the backend
} catch (err) { } catch (err) {
log.warn("Failed to stop backend player:", err); log.warn("Failed to stop backend player:", err);
} }
} else if (useHtml5Element && needsTranscoding) { } 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 // 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 didStartNativePlayback = true; // Track that we need to stop backend on unmount
} }
@@ -902,7 +942,9 @@
{ {
const host = createRustReportHost(media.id, { const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(), onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; }, onStreamUrlChanged: (u) => {
currentStreamUrl = u;
},
}); });
playerAdapter = createAdapter({ playerAdapter = createAdapter({
backendKind: useHtml5Element ? "html5" : "native", backendKind: useHtml5Element ? "html5" : "native",
@@ -966,12 +1008,12 @@
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) { if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
currentTime = event.payload.position; currentTime = event.payload.position;
} }
}) }),
); );
nativeUnlisteners.push( nativeUnlisteners.push(
await listen("player://state-changed", (event: any) => { await listen("player://state-changed", (event: any) => {
isPlaying = event.payload.state === "playing"; isPlaying = event.payload.state === "playing";
}) }),
); );
} }
} catch (err) { } catch (err) {
@@ -1053,7 +1095,7 @@
` paused=${videoElement.paused}` + ` paused=${videoElement.paused}` +
` seeking=${videoElement.seeking}` + ` seeking=${videoElement.seeking}` +
` rate=${videoElement.playbackRate}` + ` rate=${videoElement.playbackRate}` +
` buffered=${bufferedRanges.join(", ")}` ` buffered=${bufferedRanges.join(", ")}`,
); );
} }
}, 1000); }, 1000);
@@ -1124,7 +1166,7 @@
// Stop video element playback // Stop video element playback
if (videoElement) { if (videoElement) {
videoElement.pause(); videoElement.pause();
videoElement.src = ''; videoElement.src = "";
videoElement.load(); videoElement.load();
} }
@@ -1199,7 +1241,12 @@
log.debug("Needs transcoding:", needsTranscoding); log.debug("Needs transcoding:", needsTranscoding);
// For direct streams without runTimeTicks, use video element's duration // 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; const newDuration = videoElement.duration;
log.debug("Setting videoDuration to:", newDuration); log.debug("Setting videoDuration to:", newDuration);
videoDuration = newDuration; videoDuration = newDuration;
@@ -1262,8 +1309,17 @@
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1)); log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
await doSeek(); await doSeek();
} else { } else {
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1)); log.debug(
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true }); "Deferring foreground seek until loadedmetadata:",
(seekOffset + seekTo).toFixed(1),
);
el.addEventListener(
"loadedmetadata",
() => {
void doSeek();
},
{ once: true },
);
} }
return true; return true;
} }
@@ -1368,7 +1424,13 @@
log.error("Network state:", networkStates[video.networkState] || video.networkState); 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 // 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); log.error("Ready state:", readyStates[video.readyState] || video.readyState);
} }
@@ -1399,10 +1461,16 @@
canplayFallbackTimeout = setTimeout(() => { canplayFallbackTimeout = setTimeout(() => {
if (!isMediaReady && videoElement) { if (!isMediaReady && videoElement) {
log.warn("canplay event did not fire within 5 seconds"); 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 // 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"); log.debug("Video appears ready (readyState >= 3), forcing media ready state");
markMediaReady(); markMediaReady();
} }
@@ -1519,7 +1587,7 @@
` ended=${el?.ended}` + ` ended=${el?.ended}` +
` isSeeking=${isSeeking}` + ` isSeeking=${isSeeking}` +
` isBuffering=${isBuffering}` + ` isBuffering=${isBuffering}` +
` handoff=${handoffState.active}` ` handoff=${handoffState.active}`,
); );
isPlaying = false; isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused stopTimeUpdates(); // Stop RAF loop when paused
@@ -1612,7 +1680,7 @@
await playerController.seekVideo( await playerController.seekVideo(
targetTime, targetTime,
mediaSourceId ?? null, mediaSourceId ?? null,
selectedAudioTrackIndex ?? null selectedAudioTrackIndex ?? null,
); );
// Resume smooth updates if still playing after the seek settled. // Resume smooth updates if still playing after the seek settled.
@@ -1684,12 +1752,14 @@
if (!media) return; if (!media) return;
// Ask the server for an audio-only stream of this video item (no video // Ask the server for an audio-only stream of this video item (no video
// decode), carrying the selected audio track and resume position. // decode), carrying the selected audio track and resume position.
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo( const audioUrl = await auth
media.id, .getRepository()
mediaSourceId ?? undefined, .getAudioOnlyStreamUrlForVideo(
pos, media.id,
selectedAudioTrackIndex ?? undefined, mediaSourceId ?? undefined,
); pos,
selectedAudioTrackIndex ?? undefined,
);
await commands.playerEnterBackgroundAudio( await commands.playerEnterBackgroundAudio(
{ {
id: media.id, id: media.id,
@@ -2110,7 +2180,7 @@
streamIndex, streamIndex,
arrayIndex, arrayIndex,
videoElement ? videoElement.currentTime + seekOffset : null, videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null mediaSourceId ?? null,
); );
if (videoElement && !videoElement.paused) { if (videoElement && !videoElement.paused) {
startTimeUpdates(); startTimeUpdates();
@@ -2125,7 +2195,7 @@
if (!userId) return; if (!userId) return;
// Find the selected track info // Find the selected track info
const selectedTrack = audioTracks().find(t => t.index === streamIndex); const selectedTrack = audioTracks().find((t) => t.index === streamIndex);
if (selectedTrack) { if (selectedTrack) {
await commands.storageSaveSeriesAudioPreference( await commands.storageSaveSeriesAudioPreference(
userId, userId,
@@ -2133,9 +2203,12 @@
media.serverId ?? "", media.serverId ?? "",
selectedTrack.displayTitle || null, selectedTrack.displayTitle || null,
selectedTrack.language || 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) { } catch (err) {
log.warn("Failed to save series audio preference:", err); log.warn("Failed to save series audio preference:", err);
@@ -2175,7 +2248,7 @@
quality, quality,
videoElement ? videoElement.currentTime + seekOffset : null, videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null, mediaSourceId ?? null,
selectedAudioTrackIndex selectedAudioTrackIndex,
); );
if (videoElement && !videoElement.paused) { if (videoElement && !videoElement.paused) {
startTimeUpdates(); startTimeUpdates();
@@ -2245,7 +2318,12 @@
try { try {
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex); const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
await commands.playerSetSubtitleTrack(indexToUse); 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) { } catch (error) {
log.error("Failed to set subtitle track:", error); log.error("Failed to set subtitle track:", error);
} }
@@ -2269,7 +2347,7 @@
<div <div
class="fixed inset-0 flex flex-col z-50" class="fixed inset-0 flex flex-col z-50"
class:bg-black={useHtml5Element} class:bg-black={useHtml5Element}
style:background-color={!useHtml5Element ? 'transparent' : ''} style:background-color={!useHtml5Element ? "transparent" : ""}
onmousemove={handleMouseMove} onmousemove={handleMouseMove}
ontouchstart={handleTouchStart} ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove} ontouchmove={handleTouchMove}
@@ -2282,44 +2360,44 @@
{#if !!useHtml5Element} {#if !!useHtml5Element}
<!-- HTML5 video for desktop/non-Android platforms --> <!-- HTML5 video for desktop/non-Android platforms -->
<video <video
bind:this={videoElement} bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl} src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
crossorigin={videoCrossOrigin} crossorigin={videoCrossOrigin}
class={videoFitClass()} class={videoFitClass()}
class:invisible={!isMediaReady} class:invisible={!isMediaReady}
style="filter: brightness({brightness})" style="filter: brightness({brightness})"
playsinline playsinline
autoplay autoplay
muted={false} muted={false}
ontimeupdate={handleTimeUpdate} ontimeupdate={handleTimeUpdate}
onloadedmetadata={handleLoadedMetadata} onloadedmetadata={handleLoadedMetadata}
oncanplay={handleCanPlay} oncanplay={handleCanPlay}
onplay={handlePlay} onplay={handlePlay}
onpause={handlePause} onpause={handlePause}
onended={handleEnded} onended={handleEnded}
onerror={handleError} onerror={handleError}
onwaiting={handleWaiting} onwaiting={handleWaiting}
onplaying={handlePlaying} onplaying={handlePlaying}
onloadstart={handleLoadStart} onloadstart={handleLoadStart}
onclick={handleSurfaceClick} onclick={handleSurfaceClick}
> >
<!-- <!--
Subtitles for the HTML5 path. `src` is a resolved string (see Subtitles for the HTML5 path. `src` is a resolved string (see
renderedSubtitleTracks); `data-stream-index` is what renderedSubtitleTracks); `data-stream-index` is what
Html5PlayerAdapter.selectSubtitle() matches on. No `default` Html5PlayerAdapter.selectSubtitle() matches on. No `default`
attribute: a default track auto-shows, which would contradict the attribute: a default track auto-shows, which would contradict the
menu opening on "Off". menu opening on "Off".
--> -->
{#each renderedSubtitleTracks as track (track.streamIndex)} {#each renderedSubtitleTracks as track (track.streamIndex)}
<track <track
kind="subtitles" kind="subtitles"
src={track.url} src={track.url}
srclang={track.srclang} srclang={track.srclang}
label={track.label} label={track.label}
data-stream-index={track.streamIndex} data-stream-index={track.streamIndex}
/> />
{/each} {/each}
</video> </video>
{:else} {:else}
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView --> <!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
<!-- Leave this area transparent so video shows through --> <!-- Leave this area transparent so video shows through -->
@@ -2350,7 +2428,9 @@
<!-- Loading spinner overlay --> <!-- Loading spinner overlay -->
<div class="absolute inset-0 flex items-center justify-center bg-black/50"> <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>
</div> </div>
{/if} {/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="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"> <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"> <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" /> <path
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text> 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> </svg>
</div> </div>
</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="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"> <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"> <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" /> <path
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text> 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> </svg>
</div> </div>
</div> </div>
@@ -2383,12 +2471,17 @@
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none"> <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"> <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"> <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> </svg>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-white text-xs font-medium">Brightness</span> <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="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> </div>
</div> </div>
@@ -2398,7 +2491,9 @@
<!-- Loading overlay for seeking --> <!-- Loading overlay for seeking -->
{#if isSeeking} {#if isSeeking}
<div class="absolute inset-0 flex items-center justify-center bg-black/50"> <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> </div>
{:else if !isPlaying} {:else if !isPlaying}
<!-- Play overlay. Visually this IS the video surface, so it is marked <!-- Play overlay. Visually this IS the video surface, so it is marked
@@ -2454,7 +2549,9 @@
{:else} {:else}
<span class="flex items-center gap-2 text-white/80 text-sm"> <span class="flex items-center gap-2 text-white/80 text-sm">
<!-- No resolved Jellyfin id → no headshot available. --> <!-- 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)} {actor.name.slice(0, 1)}
</span> </span>
<span>{actor.name}</span> <span>{actor.name}</span>
@@ -2506,9 +2603,9 @@
value={currentTime} value={currentTime}
oninput={handleSeekBarInput} oninput={handleSeekBarInput}
onchange={handleSeekBarRelease} onchange={handleSeekBarRelease}
onmousedown={() => isDraggingSeekBar = true} onmousedown={() => (isDraggingSeekBar = true)}
onmouseup={handleSeekBarRelease} onmouseup={handleSeekBarRelease}
ontouchstart={() => isDraggingSeekBar = true} ontouchstart={() => (isDraggingSeekBar = true)}
ontouchend={handleSeekBarRelease} ontouchend={handleSeekBarRelease}
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer 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 [&::-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 justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Play/Pause --> <!-- 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} {#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
@@ -2554,13 +2655,17 @@
aria-label="Select audio track" aria-label="Select audio track"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
<!-- Audio Track Menu --> <!-- Audio Track Menu -->
{#if showAudioTrackMenu} {#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="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20"> <div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track Audio Track
@@ -2568,7 +2673,10 @@
{#each audioTracks() as track, i} {#each audioTracks() as track, i}
<button <button
onclick={() => selectAudioTrack(track.index, i)} 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"> <span class="text-sm">
{track.displayTitle || track.language || `Track ${i + 1}`} {track.displayTitle || track.language || `Track ${i + 1}`}
@@ -2577,8 +2685,12 @@
{/if} {/if}
</span> </span>
{#if selectedAudioTrackIndex === track.index} {#if selectedAudioTrackIndex === track.index}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> 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> </svg>
{/if} {/if}
</button> </button>
@@ -2600,12 +2712,16 @@
> >
<!-- Speedometer: bitrate ceiling --> <!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{#if showQualityMenu} {#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="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20"> <div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Quality Quality
@@ -2613,15 +2729,22 @@
{#each streamingQualities as [quality, label, detail]} {#each streamingQualities as [quality, label, detail]}
<button <button
onclick={() => selectQuality(quality)} 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"> <div class="flex flex-col">
<span class="text-sm">{label}</span> <span class="text-sm">{label}</span>
<span class="text-xs text-gray-400">{detail}</span> <span class="text-xs text-gray-400">{detail}</span>
</div> </div>
{#if selectedQuality === quality} {#if selectedQuality === quality}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> 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> </svg>
{/if} {/if}
</button> </button>
@@ -2641,13 +2764,17 @@
aria-label="Select subtitles" aria-label="Select subtitles"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
<!-- Subtitle Menu --> <!-- Subtitle Menu -->
{#if showSubtitleMenu} {#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="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20"> <div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles Subtitles
@@ -2655,12 +2782,19 @@
<!-- Off option --> <!-- Off option -->
<button <button
onclick={() => selectSubtitle(null)} 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> <span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null} {#if selectedSubtitleIndex === null}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> 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> </svg>
{/if} {/if}
</button> </button>
@@ -2668,7 +2802,10 @@
{#each subtitleTracks() as track} {#each subtitleTracks() as track}
<button <button
onclick={() => selectSubtitle(track.index)} 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"> <div class="flex flex-col">
<span class="text-sm"> <span class="text-sm">
@@ -2685,8 +2822,12 @@
{/if} {/if}
</div> </div>
{#if selectedSubtitleIndex === track.index} {#if selectedSubtitleIndex === track.index}
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> 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> </svg>
{/if} {/if}
</button> </button>
@@ -2699,15 +2840,23 @@
<!-- Sleep Timer --> <!-- Sleep Timer -->
{#if $sleepTimerActive} {#if $sleepTimerActive}
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} /> <SleepTimerIndicator
onClick={() => {
showSleepTimerModal = true;
}}
/>
{:else} {:else}
<button <button
onclick={() => { showSleepTimerModal = true; }} onclick={() => {
showSleepTimerModal = true;
}}
class="text-white hover:text-gray-300" class="text-white hover:text-gray-300"
aria-label="Sleep timer" aria-label="Sleep timer"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
@@ -2723,7 +2872,9 @@
aria-label="Picture in picture" aria-label="Picture in picture"
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
@@ -2733,23 +2884,35 @@
{#if backgroundAudioSupported} {#if backgroundAudioSupported}
<button <button
onclick={toggleBackgroundAudio} 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-label="Background audio"
aria-pressed={backgroundAudioOn} aria-pressed={backgroundAudioOn}
> >
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
</button> </button>
{/if} {/if}
<!-- Fullscreen --> <!-- 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"> <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
{#if isFullscreen} {#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} {: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} {/if}
</svg> </svg>
</button> </button>
@@ -2757,7 +2920,12 @@
<!-- Close --> <!-- Close -->
<button onclick={onClose} class="text-white hover:text-gray-300" aria-label="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"> <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> </svg>
</button> </button>
</div> </div>
@@ -2765,7 +2933,13 @@
</div> </div>
</div> </div>
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} /> <SleepTimerModal
isOpen={showSleepTimerModal}
onClose={() => {
showSleepTimerModal = false;
}}
mediaType={media?.type}
/>
<style> <style>
@keyframes fade-out { @keyframes fade-out {
@@ -116,7 +116,7 @@ function touchAt(el: Element, x: number) {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
touches: [touch] as unknown as Touch[], touches: [touch] as unknown as Touch[],
}) }),
); );
} }
@@ -139,9 +139,7 @@ async function mountAndroidPlayer() {
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled()); await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled()); await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector( const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
'input[type="range"]'
) as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement; const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull(); expect(slider).not.toBeNull();
return { ...utils, slider, video }; 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 * 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. * swipe detector (50px) would trigger if it were still listening.
*/ */
async function touchScrubTo( async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
slider: HTMLInputElement,
video: HTMLVideoElement,
target: number
) {
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] }); await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
// Finger travels across the bar. Small vertical wander is normal for a thumb // Finger travels across the bar. Small vertical wander is normal for a thumb
// drag; the horizontal travel is what matters. // drag; the horizontal travel is what matters.
@@ -187,7 +181,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
await touchScrubTo(slider, video, 600); await touchScrubTo(slider, video, 600);
await waitFor(() => 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); expect(parseFloat(slider.value)).toBeCloseTo(600);
}); });
@@ -216,7 +210,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
await tick(); await tick();
await waitFor(() => 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} {#if showSlider}
<button <button
class="fixed inset-0 z-[65]" class="fixed inset-0 z-[65]"
onclick={() => showSlider = false} onclick={() => (showSlider = false)}
aria-label="Close volume" aria-label="Close volume"
></button> ></button>
{/if} {/if}
@@ -42,7 +42,7 @@ export const initialHandoffState: BackgroundAudioState = {
*/ */
export function shouldEnterBackgroundAudio( export function shouldEnterBackgroundAudio(
toggleOn: boolean, toggleOn: boolean,
state: BackgroundAudioState state: BackgroundAudioState,
): boolean { ): boolean {
return toggleOn && !state.active; return toggleOn && !state.active;
} }
@@ -70,7 +70,7 @@ export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean
*/ */
export function shouldResumeOnForeground( export function shouldResumeOnForeground(
wasPlaying: boolean, wasPlaying: boolean,
nativeStateKind: string | undefined nativeStateKind: string | undefined,
): boolean { ): boolean {
return wasPlaying && nativeStateKind !== "paused"; 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. */ /** Whether a failure at this position should be read as the stream ending. */
export function isNearEndOfStream( export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
positionSeconds: number,
knownDurationSeconds: number
): boolean {
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false; if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION; return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
} }
+5 -13
View File
@@ -16,34 +16,26 @@ describe("nativeSignalRevealsVideo", () => {
"leaves the poster up on state %s", "leaves the poster up on state %s",
(state) => { (state) => {
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false); expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
} },
); );
it("reveals on a position tick that carries a duration", () => { it("reveals on a position tick that carries a duration", () => {
expect( expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })).toBe(true);
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
).toBe(true);
}); });
it("reveals on a position tick that has advanced, even with no duration", () => { it("reveals on a position tick that has advanced, even with no duration", () => {
// Live streams report no duration; an advancing position is still proof // Live streams report no duration; an advancing position is still proof
// that the surface has content. // that the surface has content.
expect( expect(nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })).toBe(true);
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
).toBe(true);
}); });
it("leaves the poster up on an empty position tick", () => { it("leaves the poster up on an empty position tick", () => {
// A tick before anything is loaded proves nothing, and revealing here would // A tick before anything is loaded proves nothing, and revealing here would
// show a transparent hole through the app. // show a transparent hole through the app.
expect( expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })).toBe(false);
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
).toBe(false);
}); });
it("does not treat a negative position as progress", () => { it("does not treat a negative position as progress", () => {
expect( expect(nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })).toBe(false);
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". */ /** A player event that might mean "the surface has a picture on it". */
export type NativeRevealSignal = export type NativeRevealSignal =
| { kind: "state"; state: string } { kind: "state"; state: string } | { kind: "position"; position: number; duration: number };
| { kind: "position"; position: number; duration: number };
/** /**
* Whether `signal` proves the native backend is rendering, and the poster card * Whether `signal` proves the native backend is rendering, and the poster card
@@ -24,7 +24,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "track-1", activeMediaId: "track-1",
isVideo: false, isVideo: false,
forceRestart: false, forceRestart: false,
}) }),
).toBe(true); ).toBe(true);
}); });
@@ -37,7 +37,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "episode-1", activeMediaId: "episode-1",
isVideo: true, isVideo: true,
forceRestart: false, forceRestart: false,
}) }),
).toBe(false); ).toBe(false);
}); });
@@ -48,7 +48,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "track-1", activeMediaId: "track-1",
isVideo: false, isVideo: false,
forceRestart: false, forceRestart: false,
}) }),
).toBe(false); ).toBe(false);
}); });
@@ -59,7 +59,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: null, activeMediaId: null,
isVideo: false, isVideo: false,
forceRestart: false, forceRestart: false,
}) }),
).toBe(false); ).toBe(false);
}); });
@@ -71,7 +71,7 @@ describe("shouldReuseActivePlayback", () => {
isVideo: false, isVideo: false,
startPosition: 42, startPosition: 42,
forceRestart: false, forceRestart: false,
}) }),
).toBe(false); ).toBe(false);
}); });
@@ -82,7 +82,7 @@ describe("shouldReuseActivePlayback", () => {
activeMediaId: "episode-2", activeMediaId: "episode-2",
isVideo: true, isVideo: true,
forceRestart: true, forceRestart: true,
}) }),
).toBe(false); ).toBe(false);
}); });
}); });
@@ -90,7 +90,7 @@ describe("shouldReuseActivePlayback", () => {
describe("resolvePlayerSurface", () => { describe("resolvePlayerSurface", () => {
it("renders the video surface for video with a stream URL", () => { it("renders the video surface for video with a stream URL", () => {
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe( 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", () => { it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
const streams: SubtitleStreamLike[] = [ 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]); expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
@@ -119,7 +129,9 @@ describe("subtitleStreamsOf", () => {
describe("subtitleTrackLabel", () => { describe("subtitleTrackLabel", () => {
it("prefers the display title, then language, then the index", () => { 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, displayTitle: null, language: "eng" })).toBe("eng");
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2"); expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
}); });
@@ -309,10 +321,7 @@ describe("nativeSubtitleArrayIndex", () => {
}); });
describe("VideoPlayer markup (the regression that made the menu inert)", () => { describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync( const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
resolve(__dirname, "VideoPlayer.svelte"),
"utf-8",
);
it("renders <track> elements instead of leaving them commented out", () => { it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues"); 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 * TRACES: UR-020 | IR-016, JA-008 | UT-147
*/ */
export function nativeSubtitleTracks( export function nativeSubtitleTracks(tracks: readonly RenderableSubtitleTrack[]): SubtitleTrack[] {
tracks: readonly RenderableSubtitleTrack[],
): SubtitleTrack[] {
return tracks.map((track) => ({ return tracks.map((track) => ({
index: track.streamIndex, index: track.streamIndex,
url: track.url, url: track.url,
@@ -151,7 +151,7 @@ describe("seek target resolution", () => {
// that starts at/after the media end, which the server never produces — // 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. // the fetch times out and the gap-controller stalls in a pause loop.
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe( 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", () => { it("treats anything inside the controls bar as a control", () => {
expect( expect(isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])).toBe(
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }]) true,
).toBe(true); );
}); });
it("lets a tap on the bare video surface through as a gesture", () => { 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. * testable without a DOM.
*/ */
export function isControlSurfaceTouch( export function isControlSurfaceTouch(
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }> ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>,
): boolean { ): boolean {
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]); const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
for (const node of ancestors) { for (const node of ancestors) {
@@ -75,7 +75,7 @@ export function isControlSurfaceTouch(
export function isSynthesizedTouchClick( export function isSynthesizedTouchClick(
detail: number, detail: number,
now: number, now: number,
lastTouchTapAt: number lastTouchTapAt: number,
): boolean { ): boolean {
if (detail === 0) return true; if (detail === 0) return true;
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS; return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
@@ -219,7 +219,9 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
const { delta, reportedPosition, duration, pendingTarget } = input; const { delta, reportedPosition, duration, pendingTarget } = input;
const base = const base =
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition pendingTarget != null &&
Math.abs(pendingTarget - reportedPosition) > 0.5 &&
pendingTarget > reportedPosition
? pendingTarget ? pendingTarget
: reportedPosition; : reportedPosition;
+1 -4
View File
@@ -37,10 +37,7 @@ export function fittedVideoSize(
return { width: 0, height: 0 }; return { width: 0, height: 0 };
} }
const scale = Math.min( const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
return { return {
width: intrinsicWidth * scale, width: intrinsicWidth * scale,
@@ -32,9 +32,12 @@
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
// Find music library for playlist browsing // Find music library for playlist browsing
const musicLib = $libraries.find(lib => lib.collectionType === "music"); const musicLib = $libraries.find((lib) => lib.collectionType === "music");
if (musicLib) { 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; playlists = result.items;
} else { } else {
// Try searching for playlists without a parent // Try searching for playlists without a parent
@@ -79,7 +82,9 @@
<div <div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose} onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }} onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
@@ -97,9 +102,11 @@
onclick={handleNewPlaylist} 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" 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"> <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> </svg>
</div> </div>
<span class="text-white font-medium">New Playlist</span> <span class="text-white font-medium">New Playlist</span>
@@ -136,7 +143,9 @@
{:else} {:else}
<div class="w-full h-full bg-gray-700 flex items-center justify-center"> <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"> <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> </svg>
</div> </div>
{/if} {/if}
@@ -24,7 +24,10 @@
creating = true; creating = true;
try { try {
const repo = auth.getRepository(); 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`); toast.success(`Playlist "${trimmed}" created`);
name = ""; name = "";
onClose?.(); onClose?.();
@@ -48,7 +51,9 @@
<div <div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose} onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }} onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
@@ -34,7 +34,7 @@
let scope = $state<SearchScope>( let scope = $state<SearchScope>(
isSearchRoute($page.url.pathname) isSearchRoute($page.url.pathname)
? parseSearchScope($page.url.searchParams.get("scope")) ? parseSearchScope($page.url.searchParams.get("scope"))
: resolveSearchScope($page.url.pathname) : resolveSearchScope($page.url.pathname),
); );
$effect(() => { $effect(() => {
@@ -80,9 +80,4 @@
} }
</script> </script>
<Search <Search bind:value bind:inputEl placeholder="Search your library..." onSearch={handleSearch} />
bind:value
bind:inputEl
placeholder="Search your library..."
onSearch={handleSearch}
/>
@@ -104,7 +104,7 @@ describe("on /search", () => {
expect(goto).toHaveBeenCalledWith( expect(goto).toHaveBeenCalledWith(
"/search?q=jazzy&scope=tv", "/search?q=jazzy&scope=tv",
expect.objectContaining({ replaceState: true }) expect.objectContaining({ replaceState: true }),
); );
}); });
}); });
@@ -34,7 +34,9 @@
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="text-center py-12 text-gray-400"> <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"> <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> </svg>
<p>No results found</p> <p>No results found</p>
</div> </div>

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