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

Merged
dtourolle merged 10 commits from chore/infra-hardening into master 2026-08-21 17:33:54 +00:00
199 changed files with 4698 additions and 3453 deletions
Showing only changes of commit ad48d89dfe - Show all commits
+1 -1
View File
@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
# Test service - runs tests only # Test service - runs tests only
+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>
+33 -13
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,8 +29,12 @@
<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">
@@ -36,24 +45,35 @@
<!-- 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}
+6 -1
View File
@@ -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>
+27 -16
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"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<svg class="w-8 h-8 text-amber-500" fill="none" 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>
+11 -7
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
+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>
+2 -6
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}
+30 -8
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>
@@ -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>
@@ -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
+4 -9
View File
@@ -10,22 +10,17 @@
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>
@@ -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
@@ -274,13 +291,17 @@
<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
class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"
>
{#each adjacentEpisodes() as ep (ep.id)} {#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)} {@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)} {@const epProgress = getProgress(ep)}
<button <button
onclick={() => !isCurrent && handleEpisodeClick(ep)} onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}" class="flex-shrink-0 w-64 text-left group/card {isCurrent
? 'ring-2 ring-yellow-400 rounded-lg'
: ''}"
disabled={isCurrent} disabled={isCurrent}
> >
<!-- Thumbnail --> <!-- Thumbnail -->
@@ -291,14 +312,20 @@
tag={ep.imageId} tag={ep.imageId}
maxWidth={400} maxWidth={400}
alt={ep.name} alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}" 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
class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center"
>
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity"> <div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"> <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"> <svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
@@ -309,7 +336,9 @@
<!-- 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"> <div
class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold"
>
Current Current
</div> </div>
{/if} {/if}
@@ -317,17 +346,18 @@
<!-- Progress bar --> <!-- Progress bar -->
{#if epProgress > 0} {#if epProgress > 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: {epProgress}%"></div>
class="h-full bg-[var(--color-jellyfin)]"
style="width: {epProgress}%"
></div>
</div> </div>
{/if} {/if}
<!-- Played indicator --> <!-- Played indicator -->
{#if ep.userData?.isPlayed} {#if ep.userData?.isPlayed}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<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>
</div> </div>
@@ -340,7 +370,11 @@
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap"> <span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)} {stripCardLabel(ep, episode)}
</span> </span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors"> <p
class="text-white font-medium truncate {isCurrent
? 'text-yellow-400'
: 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors"
>
{ep.name} {ep.name}
</p> </p>
</div> </div>
@@ -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 } };
}); });
+28 -19
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,9 +81,13 @@
/> />
<!-- 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>
@@ -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,7 +170,11 @@
{/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
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" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg> </svg>
{/if} {/if}
@@ -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">
@@ -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(
"lib123",
expect.objectContaining({
includeItemTypes: ["Audio"], includeItemTypes: ["Audio"],
sortBy: "SortName", sortBy: "SortName",
sortOrder: "Ascending", 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(
"lib123",
expect.objectContaining({
sortBy: "SortName", sortBy: "SortName",
sortOrder: "Ascending", 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(
"lib123",
expect.objectContaining({
includeItemTypes: ["Audio"], 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.
+6 -13
View File
@@ -11,12 +11,7 @@
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>
+23 -5
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,7 +48,9 @@
<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"
> >
@@ -48,7 +60,9 @@
</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"
> >
@@ -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,7 +104,9 @@
/> />
<!-- 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>
@@ -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,7 +179,11 @@
<!-- 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
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" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg> </svg>
{/if} {/if}
+133 -37
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,21 +272,33 @@
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>
@@ -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}
@@ -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}
@@ -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>
@@ -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();
+39 -21
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";
@@ -175,7 +170,12 @@
aria-label="Close player" aria-label="Close player"
> >
<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="M19 9l-7 7-7-7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
@@ -184,7 +184,9 @@
{#if $isRemoteMode && $selectedSession} {#if $isRemoteMode && $selectedSession}
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1"> <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"> <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" /> <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>
{$selectedSession.deviceName} {$selectedSession.deviceName}
</p> </p>
@@ -198,12 +200,19 @@
<!-- Queue Button --> <!-- Queue Button -->
<button <button
onclick={() => (showQueue = !showQueue)} onclick={() => (showQueue = !showQueue)}
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 {showQueue
? 'bg-white/10 text-[var(--color-jellyfin)]'
: ''}"
title="Queue" title="Queue"
aria-label="Open queue" 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="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg> </svg>
</button> </button>
@@ -214,11 +223,16 @@
aria-label="Sleep timer" aria-label="Sleep timer"
> >
<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" <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>
{#if $sleepTimerActive} {#if $sleepTimerActive}
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span> <span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"
></span>
{/if} {/if}
</button> </button>
@@ -229,7 +243,9 @@
<!-- Artwork --> <!-- Artwork -->
<div class="flex-1 flex items-center justify-center p-8 min-h-0"> <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"> <div
class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0"
>
{#if artworkItemId} {#if artworkItemId}
<CachedImage <CachedImage
itemId={artworkItemId} itemId={artworkItemId}
@@ -242,7 +258,9 @@
{:else} {:else}
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center"> <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"> <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" /> <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}
@@ -253,7 +271,9 @@
<div class="p-6 space-y-6 flex-shrink-0"> <div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist --> <!-- Title & Artist -->
<div class="text-center"> <div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1> <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"> <div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length} {#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i} {#each displayMedia?.artistItems as artist, i}
@@ -320,14 +340,12 @@
/> />
</div> </div>
</div> </div>
</div> <!-- Close content overlay --> </div>
<!-- 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(
options: {
paused?: boolean; paused?: boolean;
autoplay?: boolean; autoplay?: boolean;
currentTime?: number; currentTime?: number;
} = {}) { } = {},
) {
const listeners: Record<string, (() => void)[]> = {}; const listeners: Record<string, (() => void)[]> = {};
return { return {
@@ -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);
}); });
+283 -109
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 (
fatalNetworkErrorAction({
positionSeconds: currentTime, positionSeconds: currentTime,
knownDurationSeconds: knownDuration, knownDurationSeconds: knownDuration,
attempts: hlsFatalRecoveryAttempts, 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,7 +1752,9 @@
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
.getRepository()
.getAudioOnlyStreamUrlForVideo(
media.id, media.id,
mediaSourceId ?? undefined, mediaSourceId ?? undefined,
pos, pos,
@@ -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}
@@ -2283,7 +2361,7 @@
<!-- 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}
@@ -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,7 +2685,11 @@
{/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
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" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg> </svg>
{/if} {/if}
@@ -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,14 +2729,21 @@
{#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
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" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg> </svg>
{/if} {/if}
@@ -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,11 +2782,18 @@
<!-- 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
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" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg> </svg>
{/if} {/if}
@@ -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,7 +2822,11 @@
{/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
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" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg> </svg>
{/if} {/if}
@@ -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,7 +102,9 @@
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>
@@ -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>
@@ -31,11 +31,7 @@
} }
</script> </script>
<div <div class="flex gap-2 overflow-x-auto scrollbar-hide" role="radiogroup" aria-label="Search scope">
class="flex gap-2 overflow-x-auto scrollbar-hide"
role="radiogroup"
aria-label="Search scope"
>
{#each SEARCH_SCOPES as s, i (s)} {#each SEARCH_SCOPES as s, i (s)}
<button <button
bind:this={chipEls[i]} bind:this={chipEls[i]}
@@ -45,7 +41,8 @@
tabindex={scope === s ? 0 : -1} tabindex={scope === s ? 0 : -1}
onclick={() => select(s)} onclick={() => select(s)}
onkeydown={(e) => onKeyDown(e, i)} onkeydown={(e) => onKeyDown(e, i)}
class="px-4 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors border {scope === s class="px-4 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors border {scope ===
s
? 'bg-[var(--color-jellyfin)] border-[var(--color-jellyfin)] text-white' ? 'bg-[var(--color-jellyfin)] border-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] border-gray-700 text-gray-300 hover:text-white hover:border-gray-500'}" : 'bg-[var(--color-surface)] border-gray-700 text-gray-300 hover:text-white hover:border-gray-500'}"
> >
+11 -5
View File
@@ -68,18 +68,22 @@
title={isConnected title={isConnected
? `Casting to ${$selectedSession?.deviceName}` ? `Casting to ${$selectedSession?.deviceName}`
: sessionCount > 0 : sessionCount > 0
? `Cast to ${sessionCount} available device${sessionCount !== 1 ? 's' : ''}` ? `Cast to ${sessionCount} available device${sessionCount !== 1 ? "s" : ""}`
: 'No devices available'} : "No devices available"}
aria-label="Cast" aria-label="Cast"
> >
<!-- Cast Icon --> <!-- Cast Icon -->
<svg class={sizeClasses[size]} fill="currentColor" viewBox="0 0 24 24"> <svg class={sizeClasses[size]} fill="currentColor" viewBox="0 0 24 24">
{#if isConnected} {#if isConnected}
<!-- Connected cast icon --> <!-- Connected cast icon -->
<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" /> <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"
/>
{:else} {:else}
<!-- Standard cast icon --> <!-- Standard cast icon -->
<path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 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-11z" /> <path
d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 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-11z"
/>
{/if} {/if}
</svg> </svg>
@@ -94,7 +98,9 @@
<!-- Connected indicator --> <!-- Connected indicator -->
{#if isConnected} {#if isConnected}
<span class="absolute bottom-0 right-0 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full border-2 border-[var(--color-surface)]"></span> <span
class="absolute bottom-0 right-0 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full border-2 border-[var(--color-surface)]"
></span>
{/if} {/if}
</button> </button>

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