Infrastructure hardening: CI enforcement, supply chain, updater, diagnostics #14
+2
-2
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# Test service - runs tests only
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
depends_on:
|
||||
- test
|
||||
ports:
|
||||
- "5172:5172" # In case you want to run dev server
|
||||
- "5172:5172" # In case you want to run dev server
|
||||
|
||||
# Linux desktop packages - deb + rpm + pacman into ./dist
|
||||
desktop-linux-build:
|
||||
|
||||
@@ -140,9 +140,10 @@ describe("findDanglingIds", () => {
|
||||
});
|
||||
|
||||
it("deduplicates and sorts, so one typo is reported once", () => {
|
||||
expect(
|
||||
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
|
||||
).toEqual(["DR-189", "UR-999"]);
|
||||
expect(findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)).toEqual([
|
||||
"DR-189",
|
||||
"UR-999",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores IDs whose prefix is not a known trace type", () => {
|
||||
@@ -158,7 +159,7 @@ describe("coverage threshold", () => {
|
||||
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
|
||||
const workflow = fs.readFileSync(
|
||||
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
@@ -307,9 +308,7 @@ describe("generated matrix file links", () => {
|
||||
|
||||
it("keeps the #Lnn line anchor on the href", () => {
|
||||
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
|
||||
expect(link).toBe(
|
||||
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
|
||||
);
|
||||
expect(link).toBe("[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)");
|
||||
});
|
||||
|
||||
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
|
||||
@@ -334,10 +333,7 @@ describe("live requirements.md", () => {
|
||||
// row. Worse, the pins never guarded the actual defect — a stale denominator
|
||||
// is caught by the sum-consistency check below, and the >100% ratio it
|
||||
// produced is covered directly by the computeCoverage tests, on fixtures.
|
||||
const md = fs.readFileSync(
|
||||
path.resolve(HERE, "../docs/requirements.md"),
|
||||
"utf-8"
|
||||
);
|
||||
const md = fs.readFileSync(path.resolve(HERE, "../docs/requirements.md"), "utf-8");
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
// The parser found real rows of every type: a section silently failing to
|
||||
|
||||
+11
-30
@@ -64,8 +64,7 @@ export const MIN_COVERAGE_PERCENT = 88;
|
||||
// `import.meta.dir` is a Bun extension and is undefined when this module is
|
||||
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
|
||||
// import.meta.url — this file must stay importable for extract-traces.test.ts.
|
||||
const SCRIPT_DIR =
|
||||
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
|
||||
const SCRIPT_DIR = import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
|
||||
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
|
||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
||||
@@ -283,8 +282,7 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
|
||||
else ids.add(id);
|
||||
}
|
||||
|
||||
const countOf = (type: string) =>
|
||||
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
|
||||
const countOf = (type: string) => [...ids].filter((id) => id.startsWith(`${type}-`)).length;
|
||||
|
||||
return {
|
||||
UR: countOf("UR"),
|
||||
@@ -311,16 +309,13 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function findDanglingIds(
|
||||
tracedIds: string[],
|
||||
defined: DefinedRequirements
|
||||
): string[] {
|
||||
export function findDanglingIds(tracedIds: string[], defined: DefinedRequirements): string[] {
|
||||
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
|
||||
|
||||
const dangling = new Set(
|
||||
tracedIds
|
||||
.filter((id) => KNOWN_TYPE.test(id))
|
||||
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id))
|
||||
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id)),
|
||||
);
|
||||
|
||||
return [...dangling].sort();
|
||||
@@ -336,10 +331,7 @@ export function findDanglingIds(
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function computeCoverage(
|
||||
tracedIds: string[],
|
||||
defined: DefinedRequirements
|
||||
): CoverageResult {
|
||||
export function computeCoverage(tracedIds: string[], defined: DefinedRequirements): CoverageResult {
|
||||
// Only the four *requirement* types participate in coverage. UT/IT are test
|
||||
// identifiers defined in §4 of requirements.md — a different taxonomy, and
|
||||
// flagging them as orphans would bury real typos in ~60 lines of noise.
|
||||
@@ -352,10 +344,7 @@ export function computeCoverage(
|
||||
return {
|
||||
covered: covered.length,
|
||||
total: defined.total,
|
||||
percent:
|
||||
defined.total === 0
|
||||
? 0
|
||||
: Math.round((covered.length / defined.total) * 100),
|
||||
percent: defined.total === 0 ? 0 : Math.round((covered.length / defined.total) * 100),
|
||||
orphaned,
|
||||
};
|
||||
}
|
||||
@@ -488,16 +477,14 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
|
||||
|
||||
if (cov.orphaned.length > 0) {
|
||||
console.log("");
|
||||
console.log(
|
||||
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
|
||||
);
|
||||
console.log(`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`);
|
||||
console.log(" Fix the TRACES comment or add the requirement.");
|
||||
}
|
||||
|
||||
if (data.dangling && data.dangling.length > 0) {
|
||||
console.log("");
|
||||
console.log(
|
||||
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`
|
||||
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -538,9 +525,7 @@ function reportDangling(data: TracesData): number {
|
||||
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
|
||||
console.log("");
|
||||
for (const id of dangling) {
|
||||
const files = [
|
||||
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
|
||||
].sort();
|
||||
const files = [...new Set((data.requirements[id] ?? []).map((e) => e.file))].sort();
|
||||
console.log(` ${id}`);
|
||||
for (const file of files) console.log(` ${file}`);
|
||||
}
|
||||
@@ -554,9 +539,7 @@ function reportDangling(data: TracesData): number {
|
||||
// Main — guarded so this module stays importable from extract-traces.test.ts.
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const format = args.includes("--format")
|
||||
? args[args.indexOf("--format") + 1]
|
||||
: "markdown";
|
||||
const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "markdown";
|
||||
|
||||
console.error("🔍 Extracting TRACES from codebase...");
|
||||
const data = extractTraces();
|
||||
@@ -583,7 +566,5 @@ if (import.meta.main) {
|
||||
console.log(generateMarkdown(data));
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
||||
);
|
||||
console.error(`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`);
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ function loadRequirementDescriptions(): Map<string, string> {
|
||||
}
|
||||
|
||||
function changedFiles(range: string): string[] {
|
||||
const cmd = range
|
||||
? `git diff --name-only ${range}`
|
||||
: "git ls-files"; // untagged repo: describe everything currently traced
|
||||
const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
|
||||
return sh(cmd)
|
||||
.split("\n")
|
||||
.filter((f) => f && existsSync(f));
|
||||
|
||||
@@ -34,30 +34,47 @@ function seed(dir: string) {
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
|
||||
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "tauri.conf.json"),
|
||||
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
|
||||
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2),
|
||||
);
|
||||
// A dependency carrying its own `version =` is the trap: a greedy regex
|
||||
// rewrites it too and the build then resolves the wrong crate.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "Cargo.toml"),
|
||||
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
|
||||
[
|
||||
"[package]",
|
||||
'name = "jellytau"',
|
||||
'version = "0.0.1"',
|
||||
"",
|
||||
"[dependencies]",
|
||||
'serde = { version = "1.0.100" }',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "Cargo.lock"),
|
||||
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
|
||||
[
|
||||
"[[package]]",
|
||||
'name = "serde"',
|
||||
'version = "1.0.100"',
|
||||
"",
|
||||
"[[package]]",
|
||||
'name = "jellytau"',
|
||||
'version = "0.0.1"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||
"tauri.android.versionCode=1\n"
|
||||
"tauri.android.versionCode=1\n",
|
||||
);
|
||||
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "packaging", "arch", "PKGBUILD"),
|
||||
['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n")
|
||||
["pkgname=jellytau", "pkgver=0.0.1", "pkgrel=3", 'pkgdesc="x"', ""].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +91,7 @@ function read(rel: string): string {
|
||||
|
||||
function versionCode(): number {
|
||||
const m = read("src-tauri/gen/android/app/tauri.properties").match(
|
||||
/^tauri\.android\.versionCode=(\d+)$/m
|
||||
/^tauri\.android\.versionCode=(\d+)$/m,
|
||||
);
|
||||
return m ? Number(m[1]) : NaN;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8")
|
||||
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8"),
|
||||
);
|
||||
|
||||
const security = config.app.security;
|
||||
|
||||
+6
-2
@@ -46,7 +46,8 @@
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
@apply h-full;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
@@ -77,5 +78,8 @@ html[data-native-video="active"] [data-app-shell] {
|
||||
|
||||
body {
|
||||
@apply text-white antialiased;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
+1
-4
@@ -9,10 +9,7 @@
|
||||
and the bottom nav renders under the Android navigation bar. See
|
||||
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
|
||||
-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>JellyTau</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -112,9 +112,7 @@ describe("autoplay API", () => {
|
||||
|
||||
await setAutoplaySettings(settings);
|
||||
|
||||
const call = invokeSpy.mock.calls.find(
|
||||
(c) => c[0] === "player_set_autoplay_settings"
|
||||
);
|
||||
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_autoplay_settings");
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toEqual({ userId: "user-1", settings });
|
||||
});
|
||||
@@ -155,9 +153,7 @@ describe("autoplay API", () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const invokeSpy = vi.mocked(invoke);
|
||||
|
||||
const call = invokeSpy.mock.calls.find(
|
||||
(c) => c[0] === "player_play_next_episode"
|
||||
);
|
||||
const call = invokeSpy.mock.calls.find((c) => c[0] === "player_play_next_episode");
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toEqual({ item: mockItem });
|
||||
});
|
||||
|
||||
@@ -14,9 +14,7 @@ export async function getAutoplaySettings(): Promise<AutoplaySettings> {
|
||||
return commands.playerGetAutoplaySettings();
|
||||
}
|
||||
|
||||
export async function setAutoplaySettings(
|
||||
settings: AutoplaySettings
|
||||
): Promise<AutoplaySettings> {
|
||||
export async function setAutoplaySettings(settings: AutoplaySettings): Promise<AutoplaySettings> {
|
||||
return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
options: expect.objectContaining({
|
||||
sortBy: sortField,
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -99,7 +99,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
options: expect.objectContaining({
|
||||
sortOrder: "Descending",
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
options: expect.objectContaining({
|
||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
options: expect.objectContaining({
|
||||
genres: ["Rock", "Jazz"],
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
query: "query",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
startIndex: 100,
|
||||
limit: 50,
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
query: "query",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.items.length).toBe(2);
|
||||
@@ -260,7 +260,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
options: expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
expect.objectContaining({
|
||||
itemId: "item123",
|
||||
imageType: "Primary",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -327,23 +327,18 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
const url = await client.getVideoStreamUrl("item123");
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_stream_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", expect.any(Object));
|
||||
});
|
||||
|
||||
it("should get subtitle URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
||||
const backendUrl =
|
||||
"https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getSubtitleUrl("item123", "source456", 0);
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_subtitle_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", expect.any(Object));
|
||||
});
|
||||
|
||||
it("should get video download URLs from backend", async () => {
|
||||
@@ -353,10 +348,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
||||
const url = await client.getVideoDownloadUrl("item123", "medium");
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_download_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", expect.any(Object));
|
||||
});
|
||||
|
||||
it("should never expose access token in frontend code", async () => {
|
||||
|
||||
@@ -96,7 +96,8 @@ describe("RepositoryClient", () => {
|
||||
});
|
||||
|
||||
it("should pass multiple image options to backend", async () => {
|
||||
const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
|
||||
const mockUrl =
|
||||
"https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||
|
||||
const options = {
|
||||
@@ -133,9 +134,7 @@ describe("RepositoryClient", () => {
|
||||
it("should throw error if not initialized before getImageUrl", async () => {
|
||||
const newClient = new RepositoryClient();
|
||||
|
||||
await expect(newClient.getImageUrl("item123")).rejects.toThrow(
|
||||
"Repository not initialized"
|
||||
);
|
||||
await expect(newClient.getImageUrl("item123")).rejects.toThrow("Repository not initialized");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,7 +242,7 @@ describe("RepositoryClient", () => {
|
||||
"repository_get_video_download_url",
|
||||
expect.objectContaining({
|
||||
quality,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -314,9 +313,7 @@ describe("RepositoryClient", () => {
|
||||
|
||||
it("should search with backend search command", async () => {
|
||||
const mockResult = {
|
||||
items: [
|
||||
{ id: "item1", name: "Search Result 1", type: "Audio" },
|
||||
],
|
||||
items: [{ id: "item1", name: "Search Result 1", type: "Audio" }],
|
||||
totalRecordCount: 1,
|
||||
};
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
@@ -353,7 +350,10 @@ describe("RepositoryClient", () => {
|
||||
});
|
||||
|
||||
it("should get downloaded items with camelCase params", async () => {
|
||||
const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 };
|
||||
const mockResult = {
|
||||
items: [{ id: "t1", name: "Track", type: "Audio" }],
|
||||
totalRecordCount: 1,
|
||||
};
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await client.getDownloadedItems("album1", { limit: 50 });
|
||||
@@ -367,7 +367,12 @@ describe("RepositoryClient", () => {
|
||||
});
|
||||
|
||||
it("should get download disk usage from backend", async () => {
|
||||
const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 };
|
||||
const mockUsage = {
|
||||
sizes: { t1: 1000 },
|
||||
partialContainers: {},
|
||||
deviceTotalBytes: 1000,
|
||||
itemCount: 1,
|
||||
};
|
||||
(invoke as any).mockResolvedValueOnce(mockUsage);
|
||||
|
||||
const usage = await client.getDownloadDiskUsage();
|
||||
@@ -589,7 +594,9 @@ describe("RepositoryClient", () => {
|
||||
|
||||
it("should throw error if not initialized before playlist operations", async () => {
|
||||
const newClient = new RepositoryClient();
|
||||
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized");
|
||||
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow(
|
||||
"Repository not initialized",
|
||||
);
|
||||
await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized");
|
||||
await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized");
|
||||
});
|
||||
@@ -599,9 +606,9 @@ describe("RepositoryClient", () => {
|
||||
it("should throw error if invoke fails", async () => {
|
||||
(invoke as any).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow(
|
||||
"Network error"
|
||||
);
|
||||
await expect(
|
||||
client.create("https://server.com", "user1", "token", "server1"),
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("should handle missing optional parameters", async () => {
|
||||
@@ -616,7 +623,7 @@ describe("RepositoryClient", () => {
|
||||
"repository_get_image_url",
|
||||
expect.objectContaining({
|
||||
options: null,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export class RepositoryClient {
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
serverId: string
|
||||
serverId: string,
|
||||
): Promise<string> {
|
||||
log.debug("Creating Rust repository...");
|
||||
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
|
||||
@@ -137,7 +137,11 @@ export class RepositoryClient {
|
||||
}
|
||||
|
||||
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
|
||||
return commands.repositoryGetNextUpEpisodes(
|
||||
this.ensureHandle(),
|
||||
seriesId ?? null,
|
||||
limit ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +185,11 @@ export class RepositoryClient {
|
||||
|
||||
/** Albums the user has played but not listened to recently ("rediscover"). */
|
||||
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
|
||||
return commands.repositoryGetRediscoverAlbums(
|
||||
this.ensureHandle(),
|
||||
parentId ?? null,
|
||||
limit ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async getGenres(parentId?: string): Promise<Genre[]> {
|
||||
@@ -229,13 +237,13 @@ export class RepositoryClient {
|
||||
async getVideoStreamUrl(
|
||||
itemId: string,
|
||||
mediaSourceId?: string,
|
||||
audioStreamIndex?: number
|
||||
audioStreamIndex?: number,
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetVideoStreamUrl(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId ?? null,
|
||||
audioStreamIndex ?? null
|
||||
audioStreamIndex ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,14 +256,14 @@ export class RepositoryClient {
|
||||
itemId: string,
|
||||
mediaSourceId?: string,
|
||||
startTimeSeconds?: number,
|
||||
audioStreamIndex?: number
|
||||
audioStreamIndex?: number,
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId ?? null,
|
||||
startTimeSeconds ?? null,
|
||||
audioStreamIndex ?? null
|
||||
audioStreamIndex ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,7 +290,11 @@ export class RepositoryClient {
|
||||
* Get image URL from backend
|
||||
* The Rust backend constructs and returns the URL with proper credentials handling
|
||||
*/
|
||||
async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> {
|
||||
async getImageUrl(
|
||||
itemId: string,
|
||||
imageType: ImageType = "Primary",
|
||||
options?: ImageOptions,
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
|
||||
}
|
||||
|
||||
@@ -294,9 +306,15 @@ export class RepositoryClient {
|
||||
itemId: string,
|
||||
mediaSourceId: string,
|
||||
streamIndex: number,
|
||||
format: string = "vtt"
|
||||
format: string = "vtt",
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetSubtitleUrl(this.ensureHandle(), itemId, mediaSourceId, streamIndex, format);
|
||||
return commands.repositoryGetSubtitleUrl(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
streamIndex,
|
||||
format,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,9 +325,14 @@ export class RepositoryClient {
|
||||
async getVideoDownloadUrl(
|
||||
itemId: string,
|
||||
quality: QualityPreset = "original",
|
||||
mediaSourceId?: string
|
||||
mediaSourceId?: string,
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null);
|
||||
return commands.repositoryGetVideoDownloadUrl(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
quality,
|
||||
mediaSourceId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Favorite Methods (via Rust) =====
|
||||
|
||||
@@ -81,11 +81,4 @@ export type PersonType =
|
||||
| "Lyricist";
|
||||
|
||||
export type SessionCommand =
|
||||
| "PlayPause"
|
||||
| "Stop"
|
||||
| "Pause"
|
||||
| "Unpause"
|
||||
| "NextTrack"
|
||||
| "PreviousTrack"
|
||||
| "Mute"
|
||||
| "Unmute";
|
||||
"PlayPause" | "Stop" | "Pause" | "Unpause" | "NextTrack" | "PreviousTrack" | "Mute" | "Unmute";
|
||||
|
||||
@@ -19,36 +19,49 @@
|
||||
const withSearch = $derived(showHeaderSearch({ pathname }));
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
||||
<header
|
||||
class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0"
|
||||
>
|
||||
<div class="px-4 py-3 flex items-center gap-4">
|
||||
<!-- Logo -->
|
||||
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
|
||||
JellyTau
|
||||
</a>
|
||||
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]"> JellyTau </a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<a
|
||||
href="/"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
|
||||
'/'
|
||||
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
<a
|
||||
href="/library"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith(
|
||||
'/library',
|
||||
)
|
||||
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/downloads"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
|
||||
'/downloads'
|
||||
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname ===
|
||||
'/settings'
|
||||
? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
@@ -70,7 +83,12 @@
|
||||
title="Downloads"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- TRACES: UR-039 | DR-045 -->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { library } from '$lib/stores/library';
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { library } from "$lib/stores/library";
|
||||
|
||||
// When a className is supplied the parent positions this bar (e.g. inside a
|
||||
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
|
||||
@@ -11,9 +11,14 @@
|
||||
// Determine if a route is active
|
||||
function isActive(path: string): boolean {
|
||||
const pathname = $page.url.pathname;
|
||||
if (path === '/') {
|
||||
if (path === "/") {
|
||||
// Home is active only when exactly on / or /home, not /library or /search
|
||||
return pathname === '/' || (pathname.startsWith('/home') && !pathname.startsWith('/library') && !pathname.startsWith('/search'));
|
||||
return (
|
||||
pathname === "/" ||
|
||||
(pathname.startsWith("/home") &&
|
||||
!pathname.startsWith("/library") &&
|
||||
!pathname.startsWith("/search"))
|
||||
);
|
||||
}
|
||||
return pathname.startsWith(path);
|
||||
}
|
||||
@@ -24,36 +29,51 @@
|
||||
<div class="flex items-center justify-around px-4 py-2">
|
||||
<!-- Home Button -->
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') && !isActive('/library') && !isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
||||
onclick={() => goto("/")}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/') &&
|
||||
!isActive('/library') &&
|
||||
!isActive('/search')
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
aria-label="Home"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg>
|
||||
<span class="text-xs">Home</span>
|
||||
</button>
|
||||
|
||||
<!-- Search Button -->
|
||||
<button
|
||||
onclick={() => goto('/search')}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
||||
onclick={() => goto("/search")}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/search')
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
<path
|
||||
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">Search</span>
|
||||
</button>
|
||||
|
||||
<!-- Library Button -->
|
||||
<button
|
||||
onclick={() => { library.setCurrentLibrary(null); goto('/library'); }}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
||||
onclick={() => {
|
||||
library.setCurrentLibrary(null);
|
||||
goto("/library");
|
||||
}}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library')
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
aria-label="Library"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
|
||||
<path
|
||||
d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">Library</span>
|
||||
</button>
|
||||
|
||||
@@ -112,7 +112,9 @@
|
||||
|
||||
// Inline animation styles
|
||||
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : "");
|
||||
const svgStyle = $derived(isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "");
|
||||
const svgStyle = $derived(
|
||||
isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "",
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -125,19 +127,20 @@
|
||||
>
|
||||
{#if isFavorite}
|
||||
<!-- Filled heart with scale animation -->
|
||||
<svg
|
||||
class={svgClass}
|
||||
style={svgStyle}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg class={svgClass} style={svgStyle} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Outline heart -->
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class={sizeClasses[size]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -161,7 +164,8 @@
|
||||
}
|
||||
|
||||
@keyframes bounce-once {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
25% {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* This escapes any overflow clipping boundaries
|
||||
*/
|
||||
function portal(node: HTMLElement) {
|
||||
const container = document.createElement('div');
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
container.appendChild(node);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
if (container.parentNode) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
<form onsubmit={handleSubmit} class="relative">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +67,12 @@
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full {style.bg} flex items-center justify-center">
|
||||
<svg class="w-4 h-4 {style.color}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={style.path}/>
|
||||
<path d={style.path} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,12 @@
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,9 @@
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => close()}
|
||||
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") close();
|
||||
}}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close account menu"
|
||||
@@ -110,7 +112,12 @@
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Downloads
|
||||
</a>
|
||||
@@ -121,8 +128,18 @@
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
@@ -133,7 +150,12 @@
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8"
|
||||
/>
|
||||
</svg>
|
||||
Display
|
||||
</a>
|
||||
@@ -146,7 +168,12 @@
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
let serverName = $state("Jellyfin Server");
|
||||
|
||||
// Load session info asynchronously
|
||||
auth.getCurrentSession().then(session => {
|
||||
auth.getCurrentSession().then((session) => {
|
||||
if (session) {
|
||||
username = session.username ?? "User";
|
||||
serverName = session.serverName ?? "Jellyfin Server";
|
||||
@@ -56,7 +56,9 @@
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 z-[100] flex items-center justify-center p-4"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") handleBackdropClick();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reauth-title"
|
||||
@@ -70,13 +72,10 @@
|
||||
<!-- Header -->
|
||||
<div class="px-6 pt-6 pb-4 text-center">
|
||||
<!-- Lock icon -->
|
||||
<div class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4">
|
||||
<svg
|
||||
class="w-8 h-8 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<div
|
||||
class="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4"
|
||||
>
|
||||
<svg class="w-8 h-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -86,12 +85,10 @@
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">
|
||||
Session Expired
|
||||
</h2>
|
||||
<h2 id="reauth-title" class="text-xl font-semibold text-white mb-2">Session Expired</h2>
|
||||
<p class="text-sm text-gray-400">
|
||||
Your session on <span class="text-white font-medium">{serverName}</span> has expired.
|
||||
Please enter your password to continue.
|
||||
Your session on <span class="text-white font-medium">{serverName}</span> has expired. Please
|
||||
enter your password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +99,10 @@
|
||||
<div class="block text-sm font-medium text-gray-400 mb-1" id="reauth-username-label">
|
||||
Username
|
||||
</div>
|
||||
<div class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm" aria-labelledby="reauth-username-label">
|
||||
<div
|
||||
class="px-4 py-3 rounded-lg bg-gray-800/50 text-gray-300 text-sm"
|
||||
aria-labelledby="reauth-username-label"
|
||||
>
|
||||
{username}
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,8 +140,19 @@
|
||||
>
|
||||
{#if $isAuthLoading}
|
||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Authenticating...</span>
|
||||
{:else}
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
aria-label={label}
|
||||
class={`text-gray-400 hover:text-white transition-colors ${className}`}
|
||||
>
|
||||
<svg class={`${sizeMap[size]} fill-none stroke-current`} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class={`${sizeMap[size]} fill-none stroke-current`}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -86,11 +86,17 @@
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
|
||||
<div
|
||||
class="{className} bg-gray-700 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading image"
|
||||
></div>
|
||||
{:else if error || !imageUrl}
|
||||
<div class="{className} bg-gray-800 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
<path
|
||||
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
playlist: { singular: "playlist", plural: "playlists" },
|
||||
};
|
||||
|
||||
const labels = $derived(itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` });
|
||||
const labels = $derived(
|
||||
itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` },
|
||||
);
|
||||
const label = $derived(count === 1 ? labels.singular : labels.plural);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -46,17 +46,17 @@
|
||||
// Initialize scroll position
|
||||
$effect(() => {
|
||||
if (scrollContainer) {
|
||||
const idx = Math.max(0, items.findIndex((i) => i.value === selectedValue));
|
||||
const idx = Math.max(
|
||||
0,
|
||||
items.findIndex((i) => i.value === selectedValue),
|
||||
);
|
||||
scrollContainer.scrollTop = idx * itemHeight;
|
||||
selectedIndex = idx;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden rounded-lg"
|
||||
style="height: {containerHeight}px"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-lg" style="height: {containerHeight}px">
|
||||
<!-- Highlight band for center item -->
|
||||
<div
|
||||
class="absolute left-0 right-0 pointer-events-none z-10 border-y border-[var(--color-jellyfin)]/40 bg-[var(--color-jellyfin)]/5 rounded"
|
||||
@@ -64,8 +64,12 @@
|
||||
></div>
|
||||
|
||||
<!-- Fade gradients -->
|
||||
<div class="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[var(--color-surface)] to-transparent z-20 pointer-events-none"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-[var(--color-surface)] to-transparent z-20 pointer-events-none"
|
||||
></div>
|
||||
|
||||
<!-- Scrollable container -->
|
||||
<div
|
||||
@@ -85,8 +89,8 @@
|
||||
onclick={() => scrollToIndex(i)}
|
||||
class="w-full snap-center flex items-center justify-center transition-all duration-150
|
||||
{i === selectedIndex
|
||||
? 'text-white text-2xl font-bold'
|
||||
: 'text-gray-500 text-lg font-medium opacity-60'}"
|
||||
? 'text-white text-2xl font-bold'
|
||||
: 'text-gray-500 text-lg font-medium opacity-60'}"
|
||||
style="height: {itemHeight}px"
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("SearchBar", () => {
|
||||
it("should handle special characters in value", () => {
|
||||
render(SearchBar, {
|
||||
props: {
|
||||
value: '@$%^&*()',
|
||||
value: "@$%^&*()",
|
||||
placeholder: "Search...",
|
||||
onInput: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
|
||||
function getSourceBorderColor(): string {
|
||||
// Green for user downloads, blue for auto-cached
|
||||
return download.downloadSource === 'user' ? 'border-green-500/50' : 'border-blue-500/50';
|
||||
return download.downloadSource === "user" ? "border-green-500/50" : "border-blue-500/50";
|
||||
}
|
||||
|
||||
function getSourceLabel(): string {
|
||||
return download.downloadSource === 'user' ? 'Downloaded' : 'Auto-Cached';
|
||||
return download.downloadSource === "user" ? "Downloaded" : "Auto-Cached";
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
@@ -107,7 +107,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-4 hover:bg-[var(--color-surface-hover)] transition-colors border-l-4 {getSourceBorderColor()}">
|
||||
<div
|
||||
class="bg-[var(--color-surface)] rounded-lg p-4 hover:bg-[var(--color-surface-hover)] transition-colors border-l-4 {getSourceBorderColor()}"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Status Indicator -->
|
||||
<div class="w-2 h-2 rounded-full {getStatusColor()} flex-shrink-0"></div>
|
||||
@@ -115,12 +117,32 @@
|
||||
<!-- Media Type Icon -->
|
||||
<div class="flex-shrink-0 text-gray-500">
|
||||
{#if download.mediaType === "video"}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" />
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -135,10 +157,16 @@
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{download.seriesName}
|
||||
{#if download.seasonNumber !== undefined && download.episodeNumber !== undefined}
|
||||
<span class="text-gray-500"> • S{String(download.seasonNumber).padStart(2, '0')}E{String(download.episodeNumber).padStart(2, '0')}</span>
|
||||
<span class="text-gray-500">
|
||||
• S{String(download.seasonNumber).padStart(2, "0")}E{String(
|
||||
download.episodeNumber,
|
||||
).padStart(2, "0")}</span
|
||||
>
|
||||
{/if}
|
||||
{#if download.qualityPreset && download.qualityPreset !== "original"}
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase">{download.qualityPreset}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase"
|
||||
>{download.qualityPreset}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{:else if download.mediaType === "video"}
|
||||
@@ -146,20 +174,27 @@
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
Movie
|
||||
{#if download.qualityPreset && download.qualityPreset !== "original"}
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase">{download.qualityPreset}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-[10px] uppercase"
|
||||
>{download.qualityPreset}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{:else if download.artistName || download.albumName}
|
||||
<!-- Audio: Show artist and album -->
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{download.artistName}{download.artistName && download.albumName ? ' • ' : ''}{download.albumName}
|
||||
{download.artistName}{download.artistName && download.albumName
|
||||
? " • "
|
||||
: ""}{download.albumName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-400">{getStatusText()}</span>
|
||||
{#if download.downloadSource === 'auto'}
|
||||
<span class="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded uppercase font-semibold" title="Automatically cached">Auto</span>
|
||||
{#if download.downloadSource === "auto"}
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded uppercase font-semibold"
|
||||
title="Automatically cached">Auto</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +245,13 @@
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Cancel download"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -231,7 +272,13 @@
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Cancel download"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -242,7 +289,13 @@
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Cancel download"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -253,7 +306,13 @@
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Delete download"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -268,7 +327,13 @@
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Retry download"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -282,7 +347,13 @@
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Delete failed download"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
||||
@@ -115,8 +115,18 @@
|
||||
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
|
||||
<svg
|
||||
class="h-5 w-5 text-[var(--color-jellyfin)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-200">
|
||||
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
|
||||
@@ -131,10 +141,7 @@
|
||||
{#if currentLibrary}
|
||||
<!-- Inside a library: breadcrumb back to the library list. -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onclick={backToLibraries}
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<button onclick={backToLibraries} class="text-gray-400 hover:text-white transition-colors">
|
||||
Downloaded
|
||||
</button>
|
||||
<span class="text-gray-600">/</span>
|
||||
@@ -163,8 +170,18 @@
|
||||
{:else if $downloadedLibraries.length === 0}
|
||||
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
|
||||
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
|
||||
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
<svg
|
||||
class="mx-auto mb-4 h-14 w-14 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
@@ -185,12 +202,26 @@
|
||||
onclick={() => openLibrary(lib)}
|
||||
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
|
||||
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
||||
<div
|
||||
class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="h-10 w-10 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{lib.name}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
if (!scrollContainer) return;
|
||||
showLeftArrow = scrollContainer.scrollLeft > 0;
|
||||
showRightArrow =
|
||||
scrollContainer.scrollLeft <
|
||||
scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
|
||||
scrollContainer.scrollLeft < scrollContainer.scrollWidth - scrollContainer.clientWidth - 10;
|
||||
}
|
||||
|
||||
function scrollLeft() {
|
||||
@@ -39,10 +38,7 @@
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h2 class="text-2xl font-semibold text-white">{title}</h2>
|
||||
{#if showAll}
|
||||
<button
|
||||
onclick={showAll}
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<button onclick={showAll} class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
See all
|
||||
</button>
|
||||
{/if}
|
||||
@@ -74,7 +70,7 @@
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -86,7 +82,7 @@
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -29,13 +29,21 @@
|
||||
|
||||
// 1. Try backdrop image first (best for hero display)
|
||||
if (currentItem.backdropImageTags?.[0]) {
|
||||
return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] };
|
||||
return {
|
||||
itemId: currentItem.id,
|
||||
imageType: "Backdrop" as const,
|
||||
tag: currentItem.backdropImageTags[0],
|
||||
};
|
||||
}
|
||||
|
||||
// 2. For episodes, try series/season backdrops
|
||||
if (currentItem.kind === "episode") {
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
|
||||
return {
|
||||
itemId: currentItem.seriesId,
|
||||
imageType: "Backdrop" as const,
|
||||
tag: currentItem.parentBackdropImageTags[0],
|
||||
};
|
||||
}
|
||||
if (currentItem.seriesId) {
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
|
||||
@@ -153,7 +161,9 @@
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
@@ -175,7 +185,9 @@
|
||||
{#if currentItem.communityRating}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
{currentItem.communityRating.toFixed(1)}
|
||||
</span>
|
||||
@@ -200,7 +212,7 @@
|
||||
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
@@ -225,12 +237,17 @@
|
||||
<!-- Navigation -->
|
||||
{#if items.length > 1}
|
||||
<!-- Indicators / Location Bar -->
|
||||
<div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex gap-3 bg-black/40 backdrop-blur-sm px-4 py-2 rounded-full">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex gap-3 bg-black/40 backdrop-blur-sm px-4 py-2 rounded-full"
|
||||
>
|
||||
{#each items as _, idx}
|
||||
<button
|
||||
onclick={() => goToIndex(idx)}
|
||||
class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx === currentIndex ? 'bg-white w-12' : 'bg-white/50 w-8'}"
|
||||
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ''}`}
|
||||
class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx ===
|
||||
currentIndex
|
||||
? 'bg-white w-12'
|
||||
: 'bg-white/50 w-8'}"
|
||||
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ""}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -242,7 +259,12 @@
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -252,7 +274,7 @@
|
||||
aria-label="Next item"
|
||||
>
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -19,36 +19,23 @@
|
||||
|
||||
// Calculate download status for all tracks in album
|
||||
const downloadStatuses = $derived(
|
||||
tracks.map((track) =>
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === track.id)
|
||||
)
|
||||
tracks.map((track) => Object.values($downloads.downloads).find((d) => d.itemId === track.id)),
|
||||
);
|
||||
|
||||
const completedCount = $derived(
|
||||
downloadStatuses.filter((d) => d?.status === "completed").length
|
||||
);
|
||||
const completedCount = $derived(downloadStatuses.filter((d) => d?.status === "completed").length);
|
||||
|
||||
const downloadingCount = $derived(
|
||||
downloadStatuses.filter(
|
||||
(d) => d?.status === "downloading" || d?.status === "pending"
|
||||
).length
|
||||
downloadStatuses.filter((d) => d?.status === "downloading" || d?.status === "pending").length,
|
||||
);
|
||||
|
||||
const failedCount = $derived(
|
||||
downloadStatuses.filter((d) => d?.status === "failed").length
|
||||
);
|
||||
const failedCount = $derived(downloadStatuses.filter((d) => d?.status === "failed").length);
|
||||
|
||||
const totalProgress = $derived(() => {
|
||||
if (tracks.length === 0) return 0;
|
||||
const activeDownloads = downloadStatuses.filter(
|
||||
(d) => d?.status === "downloading"
|
||||
);
|
||||
const activeDownloads = downloadStatuses.filter((d) => d?.status === "downloading");
|
||||
if (activeDownloads.length === 0) return completedCount / tracks.length;
|
||||
|
||||
const downloadingProgress = activeDownloads.reduce(
|
||||
(sum, d) => sum + (d?.progress || 0),
|
||||
0
|
||||
);
|
||||
const downloadingProgress = activeDownloads.reduce((sum, d) => sum + (d?.progress || 0), 0);
|
||||
return (completedCount + downloadingProgress) / tracks.length;
|
||||
});
|
||||
|
||||
@@ -77,10 +64,7 @@
|
||||
} else if (isDownloading) {
|
||||
// Cancel all active downloads for this album
|
||||
for (const status of downloadStatuses) {
|
||||
if (
|
||||
status?.id &&
|
||||
(status.status === "downloading" || status.status === "pending")
|
||||
) {
|
||||
if (status?.id && (status.status === "downloading" || status.status === "pending")) {
|
||||
await downloads.cancel(status.id);
|
||||
}
|
||||
}
|
||||
@@ -184,13 +168,7 @@
|
||||
</svg>
|
||||
{:else if isFullyDownloaded}
|
||||
<!-- Checkmark icon -->
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else if failedCount > 0}
|
||||
@@ -210,13 +188,7 @@
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Download icon -->
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
// x is supplied by the caller via the bound container; we read from the
|
||||
// element under the pointer instead to stay layout-agnostic.
|
||||
lastClientX,
|
||||
clientY
|
||||
clientY,
|
||||
);
|
||||
const letter = el?.getAttribute?.("data-letter");
|
||||
return letter ?? null;
|
||||
@@ -135,7 +135,9 @@
|
||||
disabled={!enabled}
|
||||
onclick={() => jumpTo(letter)}
|
||||
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
|
||||
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
|
||||
{enabled
|
||||
? 'text-gray-400 hover:text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-700 cursor-default'}
|
||||
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
|
||||
aria-label={`Jump to ${letter}`}
|
||||
>
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
limit: 50,
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending"
|
||||
sortOrder: "Descending",
|
||||
});
|
||||
albums = albumsResult.items.filter(item => item.kind === "album");
|
||||
albums = albumsResult.items.filter((item) => item.kind === "album");
|
||||
} catch (e) {
|
||||
log.warn("Failed to load albums:", e);
|
||||
} finally {
|
||||
@@ -61,9 +61,9 @@
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 10,
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending"
|
||||
sortOrder: "Descending",
|
||||
});
|
||||
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
||||
topTracks = tracksResult.items.filter((item) => item.kind === "track");
|
||||
} catch (e) {
|
||||
log.warn("Failed to load tracks:", e);
|
||||
} finally {
|
||||
@@ -78,10 +78,10 @@
|
||||
genres: artist.genres.slice(0, 2),
|
||||
limit: 12,
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending"
|
||||
sortOrder: "Descending",
|
||||
});
|
||||
relatedArtists = relatedResult.items
|
||||
.filter(item => item.id !== artist.id && item.kind === "artist")
|
||||
.filter((item) => item.id !== artist.id && item.kind === "artist")
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -110,7 +110,9 @@
|
||||
maxWidth={1920}
|
||||
class="w-full h-full object-cover opacity-40"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -168,11 +170,10 @@
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each albums as album (album.id)}
|
||||
<a
|
||||
href="/library/{album.id}"
|
||||
class="group cursor-pointer"
|
||||
>
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
|
||||
<a href="/library/{album.id}" class="group cursor-pointer">
|
||||
<div
|
||||
class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{#if album.imageId}
|
||||
<CachedImage
|
||||
itemId={album.id}
|
||||
@@ -184,7 +185,9 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{truncateMiddle(album.name, 40)}
|
||||
</p>
|
||||
{#if album.productionYear}
|
||||
@@ -226,11 +229,10 @@
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each relatedArtists as relatedArtist (relatedArtist.id)}
|
||||
<a
|
||||
href="/library/{relatedArtist.id}"
|
||||
class="group text-center"
|
||||
>
|
||||
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
|
||||
<a href="/library/{relatedArtist.id}" class="group text-center">
|
||||
<div
|
||||
class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{#if relatedArtist.imageId}
|
||||
<CachedImage
|
||||
itemId={relatedArtist.id}
|
||||
@@ -242,7 +244,9 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{relatedArtist.name}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
@@ -29,9 +29,7 @@
|
||||
onNavigate,
|
||||
}: Props = $props();
|
||||
|
||||
const linkable = $derived(
|
||||
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
|
||||
);
|
||||
const linkable = $derived((artistItems ?? []).filter((a) => a.id && a.id.trim() !== ""));
|
||||
|
||||
function handleClick(artistId: string, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -95,7 +95,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Name and role -->
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{person.name}
|
||||
</p>
|
||||
{#if person.role}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
!confirm(
|
||||
`Erase watch history for ${subject}?\n\n` +
|
||||
"Every episode is marked unwatched and resume positions are cleared. " +
|
||||
"This cannot be undone."
|
||||
"This cannot be undone.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -55,9 +55,7 @@
|
||||
onCleared?.();
|
||||
} catch (e) {
|
||||
log.error("Failed to clear watch history:", e);
|
||||
alert(
|
||||
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
alert(`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -81,11 +79,7 @@
|
||||
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
||||
></div>
|
||||
{:else}
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg class={size === "lg" ? "w-5 h-5" : "w-4 h-4"} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
|
||||
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
|
||||
|
||||
@@ -5,27 +5,22 @@
|
||||
|
||||
interface Props {
|
||||
people: Person[];
|
||||
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
|
||||
label?: string; // e.g., "Directed by", "Written by"
|
||||
maxShow?: number; // Default: 3
|
||||
roleFilter: string[]; // e.g., ["Director", "Writer", "Producer"]
|
||||
label?: string; // e.g., "Directed by", "Written by"
|
||||
maxShow?: number; // Default: 3
|
||||
}
|
||||
|
||||
let {
|
||||
people,
|
||||
roleFilter,
|
||||
label,
|
||||
maxShow = 3
|
||||
}: Props = $props();
|
||||
let { people, roleFilter, label, maxShow = 3 }: Props = $props();
|
||||
|
||||
// Filter and limit people by role
|
||||
const filteredPeople = $derived(
|
||||
people
|
||||
.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
|
||||
.slice(0, maxShow)
|
||||
.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "")
|
||||
.slice(0, maxShow),
|
||||
);
|
||||
|
||||
const totalMatching = $derived(
|
||||
people.filter(p => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length
|
||||
people.filter((p) => roleFilter.includes(p.type || "") && p.id && p.id.trim() !== "").length,
|
||||
);
|
||||
|
||||
function handlePersonClick(personId: string, e: MouseEvent) {
|
||||
|
||||
@@ -24,13 +24,20 @@
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { itemId, itemName = "", artistName = "", albumName = "", size = "md", className = "" }: Props = $props();
|
||||
let {
|
||||
itemId,
|
||||
itemName = "",
|
||||
artistName = "",
|
||||
albumName = "",
|
||||
size = "md",
|
||||
className = "",
|
||||
}: Props = $props();
|
||||
|
||||
let isProcessing = $state(false);
|
||||
|
||||
// Find download for this item
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
|
||||
);
|
||||
|
||||
const status = $derived(downloadInfo?.status || "not_downloaded");
|
||||
@@ -130,5 +137,12 @@
|
||||
</script>
|
||||
|
||||
<div class="p-2 rounded-full">
|
||||
<DownloadButtonCore {size} state={buttonState} title={getTitle()} onClick={handleClick} {isProcessing} {className} />
|
||||
<DownloadButtonCore
|
||||
{size}
|
||||
state={buttonState}
|
||||
title={getTitle()}
|
||||
onClick={handleClick}
|
||||
{isProcessing}
|
||||
{className}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,14 @@
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { state, size = "md", title, onClick, isProcessing = false, className = "" }: Props = $props();
|
||||
let {
|
||||
state,
|
||||
size = "md",
|
||||
title,
|
||||
onClick,
|
||||
isProcessing = false,
|
||||
className = "",
|
||||
}: Props = $props();
|
||||
|
||||
const sizeMap = {
|
||||
sm: { icon: "w-4 h-4", ring: "w-8 h-8" },
|
||||
@@ -46,13 +53,21 @@
|
||||
onclick={onClick}
|
||||
disabled={isProcessing || state.status === "downloading"}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
{title}
|
||||
class={`relative transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${colorMap[state.status]} ${className}`}
|
||||
>
|
||||
{#if state.status === "downloading"}
|
||||
<!-- Progress Ring -->
|
||||
<svg class="{sizeMap[size].ring} -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="2" class="opacity-20" />
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
@@ -67,7 +82,13 @@
|
||||
style="transition: stroke-dashoffset 0.3s ease;"
|
||||
/>
|
||||
<!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) -->
|
||||
<text x="18" y="20" text-anchor="middle" transform="rotate(90, 18, 18)" class="text-xs font-bold fill-current">
|
||||
<text
|
||||
x="18"
|
||||
y="20"
|
||||
text-anchor="middle"
|
||||
transform="rotate(90, 18, 18)"
|
||||
class="text-xs font-bold fill-current"
|
||||
>
|
||||
{Math.round(state.progress * 100)}%
|
||||
</text>
|
||||
</svg>
|
||||
@@ -79,7 +100,9 @@
|
||||
{:else if state.status === "failed"}
|
||||
<!-- Error Icon -->
|
||||
<svg class={sizeMap[size].icon} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
|
||||
<path
|
||||
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if state.status === "pending"}
|
||||
<!-- Pending Icon (clock) -->
|
||||
@@ -90,7 +113,12 @@
|
||||
{:else}
|
||||
<!-- Download Icon -->
|
||||
<svg class={sizeMap[size].icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -53,13 +53,21 @@
|
||||
// Compute best backdrop source (no fetch, pure derivation)
|
||||
const backdropSource = $derived.by(() => {
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||
return {
|
||||
itemId: episode.id,
|
||||
imageType: "Backdrop" as const,
|
||||
tag: episode.backdropImageTags[0],
|
||||
};
|
||||
}
|
||||
if (episode.imageId) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
||||
}
|
||||
if (series?.backdropImageTags?.[0]) {
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
return {
|
||||
itemId: series.id,
|
||||
imageType: "Backdrop" as const,
|
||||
tag: series.backdropImageTags[0],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -67,8 +75,8 @@
|
||||
// Cast and genres are the episode's own when the server sent them, else the
|
||||
// series' — a list-level episode fetch often carries neither, and an empty
|
||||
// Cast row on an episode of a show with a known cast reads as broken.
|
||||
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
|
||||
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
|
||||
const people = $derived(episode.people?.length ? episode.people : (series?.people ?? []));
|
||||
const genres = $derived(episode.genres?.length ? episode.genres : (series?.genres ?? []));
|
||||
|
||||
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
|
||||
// off the series rather than the episode.
|
||||
@@ -77,7 +85,7 @@
|
||||
const seasonHref = $derived(
|
||||
series && episode.parentIndexNumber != null
|
||||
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
|
||||
: null
|
||||
: null,
|
||||
);
|
||||
|
||||
function formatDuration(ms?: number | null): string {
|
||||
@@ -108,9 +116,7 @@
|
||||
goto(`/library/${series.id}?episode=${ep.id}`);
|
||||
}
|
||||
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const progress = $derived(getProgress(episode));
|
||||
</script>
|
||||
@@ -128,12 +134,16 @@
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"
|
||||
></div>
|
||||
|
||||
<!-- Back button -->
|
||||
{#if onBack}
|
||||
@@ -143,7 +153,12 @@
|
||||
title="Back to series"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -156,7 +171,10 @@
|
||||
{#if seriesName}
|
||||
<p class="text-lg">
|
||||
{#if seriesHref}
|
||||
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
|
||||
<a
|
||||
href={seriesHref}
|
||||
class="text-gray-300 hover:text-white hover:underline transition-colors"
|
||||
>
|
||||
{seriesName}
|
||||
</a>
|
||||
{:else}
|
||||
@@ -192,7 +210,9 @@
|
||||
{#if episode.communityRating}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
{episode.communityRating.toFixed(1)}
|
||||
</span>
|
||||
@@ -200,7 +220,7 @@
|
||||
{#if episode.userData?.isPlayed}
|
||||
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
Watched
|
||||
</span>
|
||||
@@ -218,10 +238,7 @@
|
||||
{#if progress > 0 && progress < 95}
|
||||
<div class="w-64">
|
||||
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{Math.round(progress)}% watched
|
||||
@@ -237,7 +254,7 @@
|
||||
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
||||
</button>
|
||||
@@ -271,89 +288,106 @@
|
||||
strip — continuation content comes before discovery content
|
||||
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
|
||||
{#if hasEpisodeStrip}
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
||||
{#each adjacentEpisodes() as ep (ep.id)}
|
||||
{@const isCurrent = isCurrentEpisode(ep)}
|
||||
{@const epProgress = getProgress(ep)}
|
||||
<button
|
||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||
disabled={isCurrent}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.imageId}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
/>
|
||||
<div
|
||||
class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"
|
||||
>
|
||||
{#each adjacentEpisodes() as ep (ep.id)}
|
||||
{@const isCurrent = isCurrentEpisode(ep)}
|
||||
{@const epProgress = getProgress(ep)}
|
||||
<button
|
||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent
|
||||
? 'ring-2 ring-yellow-400 rounded-lg'
|
||||
: ''}"
|
||||
disabled={isCurrent}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.imageId}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent
|
||||
? ''
|
||||
: 'group-hover/card:scale-105'}"
|
||||
/>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
{#if !isCurrent}
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<!-- Hover overlay -->
|
||||
{#if !isCurrent}
|
||||
<div
|
||||
class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Now Playing indicator -->
|
||||
{#if isCurrent}
|
||||
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
|
||||
Current
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Progress bar -->
|
||||
{#if epProgress > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<!-- Now Playing indicator -->
|
||||
{#if isCurrent}
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {epProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold"
|
||||
>
|
||||
Current
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Played indicator -->
|
||||
{#if ep.userData?.isPlayed}
|
||||
<div class="absolute top-2 right-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
{#if epProgress > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {epProgress}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Episode info -->
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
|
||||
{stripCardLabel(ep, episode)}
|
||||
</span>
|
||||
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
||||
{ep.name}
|
||||
</p>
|
||||
<!-- Played indicator -->
|
||||
{#if ep.userData?.isPlayed}
|
||||
<div class="absolute top-2 right-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ep.overview}
|
||||
<p class="text-gray-400 text-sm line-clamp-2">
|
||||
{ep.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Episode info -->
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
|
||||
{stripCardLabel(ep, episode)}
|
||||
</span>
|
||||
<p
|
||||
class="text-white font-medium truncate {isCurrent
|
||||
? 'text-yellow-400'
|
||||
: 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors"
|
||||
>
|
||||
{ep.name}
|
||||
</p>
|
||||
</div>
|
||||
{#if ep.overview}
|
||||
<p class="text-gray-400 text-sm line-clamp-2">
|
||||
{ep.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
|
||||
|
||||
@@ -45,9 +45,8 @@ vi.mock("$lib/stores/downloads", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/favorites", async () => {
|
||||
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>(
|
||||
"$lib/stores/favorites"
|
||||
);
|
||||
const actual =
|
||||
await vi.importActual<typeof import("$lib/stores/favorites")>("$lib/stores/favorites");
|
||||
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
|
||||
});
|
||||
|
||||
|
||||
@@ -22,13 +22,7 @@
|
||||
onWatchedChanged?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
episode,
|
||||
focused = false,
|
||||
current = false,
|
||||
onclick,
|
||||
onWatchedChanged,
|
||||
}: Props = $props();
|
||||
let { episode, focused = false, current = false, onclick, onWatchedChanged }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
@@ -43,12 +37,12 @@
|
||||
|
||||
// Check if this episode is downloaded
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === episode.id)
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === episode.id),
|
||||
);
|
||||
|
||||
const isDownloaded = $derived(downloadInfo?.status === "completed");
|
||||
const isDownloading = $derived(
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
|
||||
);
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
@@ -74,7 +68,9 @@
|
||||
{onclick}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
<div
|
||||
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
|
||||
>
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
@@ -85,11 +81,15 @@
|
||||
/>
|
||||
|
||||
<!-- Hover overlay with play icon -->
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
|
||||
<div class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,10 +98,7 @@
|
||||
<!-- Progress bar -->
|
||||
{#if progress() > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress()}%"
|
||||
></div>
|
||||
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -110,7 +107,13 @@
|
||||
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
||||
{#if isDownloaded}
|
||||
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<svg
|
||||
class="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -153,7 +156,9 @@
|
||||
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
|
||||
{episodeNumber}.
|
||||
</span>
|
||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||
<h3
|
||||
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{truncateMiddle(episode.name, 56)}
|
||||
</h3>
|
||||
{#if current}
|
||||
@@ -165,8 +170,12 @@
|
||||
{/if}
|
||||
<!-- Played indicator -->
|
||||
{#if episode.userData?.isPlayed}
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -72,9 +72,7 @@
|
||||
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
|
||||
const requestedGenre = $page.url.searchParams.get("genre");
|
||||
if (requestedGenre) {
|
||||
const match = genres.find(
|
||||
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
|
||||
);
|
||||
const match = genres.find((g) => g.name.toLowerCase() === requestedGenre.toLowerCase());
|
||||
if (match) {
|
||||
await loadGenreItems(match);
|
||||
}
|
||||
@@ -157,14 +155,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
const aspectRatioClass = $derived(config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square");
|
||||
const aspectRatioClass = $derived(
|
||||
config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square",
|
||||
);
|
||||
const gridColsClass = $derived(
|
||||
config.itemDisplayMode === "poster"
|
||||
? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
|
||||
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6",
|
||||
);
|
||||
const searchPlaceholder = $derived(
|
||||
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
|
||||
);
|
||||
const noItemsMessage = $derived(
|
||||
config.noItemsMessage ||
|
||||
`No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`,
|
||||
);
|
||||
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
||||
const noItemsMessage = $derived(config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -188,7 +193,7 @@
|
||||
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
||||
|
||||
{#if !loading && filteredGenres.length > 0}
|
||||
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} />
|
||||
<ResultsCounter count={filteredGenres.length} itemType="genre" {searchQuery} />
|
||||
{/if}
|
||||
|
||||
<!-- Genres Grid -->
|
||||
@@ -220,7 +225,9 @@
|
||||
{@html config.genreIcon}
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{genre.name}
|
||||
</p>
|
||||
</button>
|
||||
@@ -244,11 +251,16 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<ResultsCounter count={genreItems.length} itemType={config.itemTypes[0]?.toLowerCase() || "item"} />
|
||||
<ResultsCounter
|
||||
count={genreItems.length}
|
||||
itemType={config.itemTypes[0]?.toLowerCase() || "item"}
|
||||
/>
|
||||
<div class="grid {gridColsClass} gap-4 mt-4">
|
||||
{#each genreItems as item (item.id)}
|
||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||
<div
|
||||
class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2"
|
||||
>
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
@@ -258,7 +270,9 @@
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{truncateMiddle(item.name, 40)}
|
||||
</p>
|
||||
{#if item.productionYear}
|
||||
|
||||
@@ -98,9 +98,7 @@ describe("GenericMediaListPage — two-phase search", () => {
|
||||
await waitFor(() => expect(search).toHaveBeenCalled());
|
||||
await waitFor(() => expect(searchEventHandler).not.toBeNull());
|
||||
// Cache-only phase: nothing to show yet (the results counter reads zero).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy());
|
||||
|
||||
// Phase 2: backend emits the merged cache+server union for this request.
|
||||
expect(capturedRequestId).toBeTypeOf("number");
|
||||
@@ -116,9 +114,7 @@ describe("GenericMediaListPage — two-phase search", () => {
|
||||
|
||||
// The server result must now be reflected in the list. Old code (no
|
||||
// listener) never reached this state — the count stayed at zero.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy());
|
||||
});
|
||||
|
||||
it("ignores a search-event whose requestId is stale", async () => {
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
includeItemTypes: [config.itemType],
|
||||
limit: 10000,
|
||||
},
|
||||
requestId
|
||||
requestId,
|
||||
);
|
||||
// Only apply if this is still the active query.
|
||||
if (requestId === searchRequestId) {
|
||||
@@ -204,7 +204,9 @@
|
||||
navigateUp(config.backPath);
|
||||
}
|
||||
|
||||
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
||||
const searchPlaceholder = $derived(
|
||||
config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`,
|
||||
);
|
||||
|
||||
function handleItemClick(item: MediaItem | Library) {
|
||||
// Navigate to detail page for browseable items
|
||||
@@ -230,10 +232,7 @@
|
||||
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
|
||||
const isAlphaSorted = $derived(sortBy === "SortName");
|
||||
const showAlphaBar = $derived(
|
||||
isAlphaSorted &&
|
||||
!loading &&
|
||||
!debouncedSearchQuery.trim() &&
|
||||
items.length > 30
|
||||
isAlphaSorted && !loading && !debouncedSearchQuery.trim() && items.length > 30,
|
||||
);
|
||||
|
||||
const availableLetters = $derived.by(() => {
|
||||
@@ -264,9 +263,7 @@
|
||||
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
|
||||
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
|
||||
// whichever bars are visible.
|
||||
const bottomGap = $derived(
|
||||
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
|
||||
);
|
||||
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -295,8 +292,8 @@
|
||||
aria-pressed={favoritesOnly}
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
|
||||
{favoritesOnly
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
|
||||
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
|
||||
>
|
||||
<svg
|
||||
@@ -306,7 +303,11 @@
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
Favourites
|
||||
</button>
|
||||
@@ -320,7 +321,7 @@
|
||||
|
||||
<!-- Results Count -->
|
||||
{#if !loading}
|
||||
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
|
||||
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} {searchQuery} />
|
||||
{/if}
|
||||
|
||||
<!-- Items List/Grid -->
|
||||
@@ -349,7 +350,13 @@
|
||||
<div class="flex gap-2">
|
||||
<div bind:this={gridWrapper} class="flex-1 min-w-0">
|
||||
{#if config.displayComponent === "grid"}
|
||||
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
|
||||
<LibraryGrid
|
||||
{items}
|
||||
onItemClick={handleItemClick}
|
||||
musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(
|
||||
config.itemType,
|
||||
)}
|
||||
/>
|
||||
{:else if config.displayComponent === "tracklist"}
|
||||
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
||||
{/if}
|
||||
|
||||
@@ -200,7 +200,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -231,7 +231,7 @@ describe("GenericMediaListPage", () => {
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 10000,
|
||||
}),
|
||||
expect.any(Number)
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -248,7 +248,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -266,11 +266,14 @@ describe("GenericMediaListPage", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
}));
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith(
|
||||
"lib123",
|
||||
expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,7 +296,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -342,7 +345,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -363,10 +366,13 @@ describe("GenericMediaListPage", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
}));
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith(
|
||||
"lib123",
|
||||
expect.objectContaining({
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +388,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -428,7 +434,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -446,9 +452,12 @@ describe("GenericMediaListPage", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
}));
|
||||
expect(mockGetItemsFn).toHaveBeenCalledWith(
|
||||
"lib123",
|
||||
expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,7 +478,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -497,7 +506,7 @@ describe("GenericMediaListPage", () => {
|
||||
expect.objectContaining({
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
}),
|
||||
expect.any(Number)
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -506,10 +515,10 @@ describe("GenericMediaListPage", () => {
|
||||
describe("Loading State", () => {
|
||||
it("should show loading indicator during data fetch", async () => {
|
||||
const mockGetItemsFn = vi.fn(
|
||||
() => new Promise((resolve) => setTimeout(
|
||||
() => resolve({ items: [], totalRecordCount: 0 }),
|
||||
100
|
||||
))
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ items: [], totalRecordCount: 0 }), 100),
|
||||
),
|
||||
);
|
||||
|
||||
const mockRepository = {
|
||||
@@ -518,7 +527,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -554,7 +563,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
const config = {
|
||||
@@ -589,7 +598,7 @@ describe("GenericMediaListPage", () => {
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
mockRepository as any,
|
||||
);
|
||||
|
||||
// Deliver a null current library for this test only.
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
onclick={() => handleToggleGenre(genre.name)}
|
||||
class="px-3 py-1 rounded-full text-sm transition-colors
|
||||
{isSelected
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
{genre.name}
|
||||
</button>
|
||||
|
||||
@@ -6,17 +6,12 @@
|
||||
|
||||
interface Props {
|
||||
genres: string[];
|
||||
maxShow?: number; // Default: unlimited
|
||||
clickable?: boolean; // Default: true
|
||||
itemKind?: MediaKind; // Determines which genre browse page to open
|
||||
maxShow?: number; // Default: unlimited
|
||||
clickable?: boolean; // Default: true
|
||||
itemKind?: MediaKind; // Determines which genre browse page to open
|
||||
}
|
||||
|
||||
let {
|
||||
genres,
|
||||
maxShow,
|
||||
clickable = true,
|
||||
itemKind
|
||||
}: Props = $props();
|
||||
let { genres, maxShow, clickable = true, itemKind }: Props = $props();
|
||||
|
||||
// Map the item kind to its genre-browse surface. Video genres are a tab of
|
||||
// the library page now, not a route of their own (DR-105); linking straight
|
||||
@@ -39,13 +34,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const displayGenres = $derived(
|
||||
maxShow ? genres.slice(0, maxShow) : genres
|
||||
);
|
||||
const displayGenres = $derived(maxShow ? genres.slice(0, maxShow) : genres);
|
||||
|
||||
const hiddenCount = $derived(
|
||||
maxShow && genres.length > maxShow ? genres.length - maxShow : 0
|
||||
);
|
||||
const hiddenCount = $derived(maxShow && genres.length > maxShow ? genres.length - maxShow : 0);
|
||||
|
||||
function handleGenreClick(genre: string) {
|
||||
if (clickable) {
|
||||
@@ -60,7 +51,9 @@
|
||||
<button
|
||||
onclick={() => handleGenreClick(genre)}
|
||||
disabled={!clickable}
|
||||
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable ? 'hover:bg-[var(--color-surface-hover)] cursor-pointer' : 'cursor-default'}"
|
||||
class="px-3 py-1 bg-[var(--color-surface)] rounded-full text-sm transition-colors {clickable
|
||||
? 'hover:bg-[var(--color-surface-hover)] cursor-pointer'
|
||||
: 'cursor-default'}"
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
|
||||
@@ -23,7 +23,17 @@
|
||||
onItemRemove?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
|
||||
let {
|
||||
items,
|
||||
title,
|
||||
loading = false,
|
||||
showViewToggle = true,
|
||||
musicContent = false,
|
||||
onItemClick,
|
||||
sizeLabelFor,
|
||||
downloadedBadgeFor,
|
||||
onItemRemove,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -38,22 +48,26 @@
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onclick={() => library.setViewMode("grid")}
|
||||
class="p-2 rounded transition-colors {$viewMode === 'grid' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
|
||||
class="p-2 rounded transition-colors {$viewMode === 'grid'
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:bg-white/10 hover:text-white'}"
|
||||
aria-label="Grid view"
|
||||
title="Grid view"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/>
|
||||
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => library.setViewMode("list")}
|
||||
class="p-2 rounded transition-colors {$viewMode === 'list' ? 'bg-[var(--color-jellyfin)] text-white' : 'text-gray-400 hover:bg-white/10 hover:text-white'}"
|
||||
class="p-2 rounded transition-colors {$viewMode === 'list'
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:bg-white/10 hover:text-white'}"
|
||||
aria-label="List view"
|
||||
title="List view"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/>
|
||||
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -64,7 +78,11 @@
|
||||
<div class="flex gap-4 overflow-hidden">
|
||||
{#each Array(6) as _}
|
||||
<div class="w-36 flex-shrink-0 animate-pulse">
|
||||
<div class="{musicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div
|
||||
class="{musicContent
|
||||
? 'aspect-square'
|
||||
: 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"
|
||||
></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
<div class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
@@ -75,7 +93,7 @@
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
{:else if $viewMode === "list"}
|
||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||
<LibraryListView {items} showProgress={true} {onItemClick} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each items as item, index (item.id)}
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
}
|
||||
|
||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
||||
return "imageId" in item
|
||||
? (item.imageId ?? undefined)
|
||||
: "imageTag" in item
|
||||
? (item.imageTag ?? undefined)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
@@ -31,7 +35,9 @@
|
||||
case "MusicAlbum":
|
||||
return item.artistItems?.map((a) => a.name).join(", ") || "";
|
||||
case "Episode":
|
||||
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
|
||||
return item.seriesName
|
||||
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
|
||||
: "";
|
||||
case "Movie":
|
||||
case "Series":
|
||||
return item.productionYear?.toString() || "";
|
||||
@@ -40,9 +46,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getProgress(item: MediaItem | Library): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
|
||||
if (
|
||||
!showProgress ||
|
||||
!("userData" in item) ||
|
||||
!item.userData ||
|
||||
!("durationMs" in item) ||
|
||||
!item.durationMs
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
@@ -65,7 +76,8 @@
|
||||
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
|
||||
{@const downloadInfo = getDownloadInfo(item.id)}
|
||||
{@const isDownloaded = downloadInfo?.status === "completed"}
|
||||
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||
{@const isDownloading =
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -79,7 +91,9 @@
|
||||
</span>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
||||
<div
|
||||
class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative"
|
||||
>
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
@@ -90,9 +104,11 @@
|
||||
/>
|
||||
|
||||
<!-- Play overlay on hover -->
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +122,9 @@
|
||||
|
||||
<!-- Title & Subtitle -->
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{truncateMiddle(item.name, 56)}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
@@ -118,19 +136,41 @@
|
||||
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
||||
{#if isDownloaded}
|
||||
<span title="Downloaded">
|
||||
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-label="Downloaded">
|
||||
<svg
|
||||
class="w-4 h-4 text-green-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
aria-label="Downloaded"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
{:else if isDownloading}
|
||||
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
|
||||
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3" class="text-blue-500" />
|
||||
<circle
|
||||
cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.3"
|
||||
class="text-blue-500"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={2 * Math.PI * 10}
|
||||
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))}
|
||||
stroke-linecap="round" class="text-blue-500 transition-all duration-300"
|
||||
stroke-linecap="round"
|
||||
class="text-blue-500 transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -139,8 +179,12 @@
|
||||
|
||||
<!-- Played indicator -->
|
||||
{#if isPlayed}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
aria-current={view === active ? "page" : undefined}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{view === active
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
||||
>
|
||||
{labels[view]}
|
||||
</button>
|
||||
|
||||
@@ -57,7 +57,19 @@
|
||||
aspect?: "square" | "video" | "poster";
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
|
||||
let {
|
||||
item,
|
||||
size = "medium",
|
||||
showProgress = false,
|
||||
showDownloadStatus = true,
|
||||
sizeLabel,
|
||||
downloadedBadge,
|
||||
onRemove,
|
||||
onclick,
|
||||
onLongPress,
|
||||
showFavorite = true,
|
||||
aspect,
|
||||
}: Props = $props();
|
||||
|
||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||
@@ -114,12 +126,12 @@
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id),
|
||||
);
|
||||
|
||||
const isDownloaded = $derived(downloadInfo?.status === "completed");
|
||||
const isDownloading = $derived(
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending",
|
||||
);
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
@@ -135,14 +147,14 @@
|
||||
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
|
||||
// it can show the Queued badge in place of the queue button.
|
||||
const isServerOnly = $derived(
|
||||
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
|
||||
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
|
||||
);
|
||||
|
||||
// The heart is about an item, so libraries never get one, and a greyed
|
||||
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
|
||||
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
|
||||
const isFavorited = $derived(
|
||||
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false
|
||||
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false,
|
||||
);
|
||||
|
||||
let queueError = $state<string | null>(null);
|
||||
@@ -171,7 +183,7 @@
|
||||
undefined,
|
||||
media.name,
|
||||
media.artists?.join(", ") ?? undefined,
|
||||
media.albumName ?? undefined
|
||||
media.albumName ?? undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error("Failed to queue download:", err);
|
||||
@@ -186,7 +198,11 @@
|
||||
};
|
||||
|
||||
const isMusicType = $derived(
|
||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||
"kind" in item &&
|
||||
(item.kind === "track" ||
|
||||
item.kind === "album" ||
|
||||
item.kind === "artist" ||
|
||||
item.kind === "playlist"),
|
||||
);
|
||||
|
||||
const FIXED_ASPECT = {
|
||||
@@ -201,11 +217,13 @@
|
||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||
}
|
||||
// Library
|
||||
return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video";
|
||||
return "collectionType" in item && item.collectionType === "music"
|
||||
? "aspect-square"
|
||||
: "aspect-video";
|
||||
});
|
||||
|
||||
const imageTag = $derived(
|
||||
"imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
|
||||
"imageId" in item ? item.imageId : "imageTag" in item ? item.imageTag : undefined,
|
||||
);
|
||||
|
||||
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||
@@ -226,7 +244,9 @@
|
||||
case "MusicAlbum":
|
||||
return item.artistItems?.map((a) => a.name).join(", ") || "";
|
||||
case "Episode":
|
||||
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
|
||||
return item.seriesName
|
||||
? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}`
|
||||
: "";
|
||||
case "Movie":
|
||||
return item.productionYear?.toString() || "";
|
||||
case "Series":
|
||||
@@ -241,7 +261,9 @@
|
||||
this={isServerOnly ? "div" : "button"}
|
||||
type={isServerOnly ? undefined : "button"}
|
||||
role={isServerOnly ? "group" : undefined}
|
||||
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
|
||||
class="group/card flex flex-col text-left {sizeClasses[
|
||||
size
|
||||
]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
|
||||
style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined}
|
||||
onclick={isServerOnly ? undefined : handleClick}
|
||||
onpointerdown={isServerOnly ? undefined : handlePointerDown}
|
||||
@@ -250,23 +272,35 @@
|
||||
onpointercancel={isServerOnly ? undefined : handlePointerUp}
|
||||
oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined}
|
||||
>
|
||||
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
|
||||
<div
|
||||
class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200"
|
||||
>
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={imageTag}
|
||||
maxWidth={maxWidth}
|
||||
{maxWidth}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly ? 'opacity-40 grayscale' : ''}"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly
|
||||
? 'opacity-40 grayscale'
|
||||
: ''}"
|
||||
/>
|
||||
|
||||
<!-- Hover overlay with smooth gradient (play affordance; hidden for
|
||||
server-only cards, which can't be played offline) -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 {isServerOnly ? '' : 'group-hover/card:opacity-100'} transition-opacity duration-300 flex items-center justify-center">
|
||||
<div class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 {isServerOnly
|
||||
? ''
|
||||
: 'group-hover/card:opacity-100'} transition-opacity duration-300 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl"
|
||||
>
|
||||
<svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,10 +309,7 @@
|
||||
<!-- Progress bar -->
|
||||
{#if progress() > 0}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||
<div
|
||||
class="h-full bg-[var(--color-jellyfin)]"
|
||||
style="width: {progress()}%"
|
||||
></div>
|
||||
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -288,7 +319,7 @@
|
||||
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||
{#if "userData" in item && item.userData?.isPlayed}
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if showHeart}
|
||||
@@ -319,7 +350,13 @@
|
||||
{#if isDownloaded}
|
||||
<!-- Downloaded badge -->
|
||||
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -348,7 +385,13 @@
|
||||
class="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<svg class="absolute inset-0 m-auto w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<svg
|
||||
class="absolute inset-0 m-auto w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -360,13 +403,20 @@
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
}}
|
||||
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
|
||||
title="Remove from device"
|
||||
aria-label="Remove {item.name} from device"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -379,13 +429,28 @@
|
||||
>
|
||||
{#if downloadedBadge === "full"}
|
||||
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg"
|
||||
aria-label="Partially downloaded"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -398,13 +463,30 @@
|
||||
{#if isServerOnly}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
{#if isQueued}
|
||||
<div class="flex flex-col items-center gap-1 text-white" title="Queued — will download on reconnect">
|
||||
<div class="w-11 h-11 rounded-full bg-black/60 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="flex flex-col items-center gap-1 text-white"
|
||||
title="Queued — will download on reconnect"
|
||||
>
|
||||
<div
|
||||
class="w-11 h-11 rounded-full bg-black/60 flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full">Queued</span>
|
||||
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full"
|
||||
>Queued</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
@@ -414,14 +496,26 @@
|
||||
title="Queue download for next connection"
|
||||
aria-label="Queue download for {item.name}"
|
||||
>
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if queueError}
|
||||
<div class="absolute bottom-1 left-1 right-1 text-center text-[10px] text-red-200 bg-black/70 rounded px-1 py-0.5">
|
||||
<div
|
||||
class="absolute bottom-1 left-1 right-1 text-center text-[10px] text-red-200 bg-black/70 rounded px-1 py-0.5"
|
||||
>
|
||||
{queueError}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -429,7 +523,9 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-0.5 {isServerOnly ? 'opacity-60' : ''}">
|
||||
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
<p
|
||||
class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{truncateMiddle(item.name, 40)}
|
||||
</p>
|
||||
{#if subtitle()}
|
||||
|
||||
@@ -16,12 +16,7 @@
|
||||
<script lang="ts" generics="T extends { key: string; ratio: number }">
|
||||
import type { Snippet } from "svelte";
|
||||
import { onDestroy } from "svelte";
|
||||
import {
|
||||
layoutMosaic,
|
||||
layoutMosaicStrip,
|
||||
mosaicTargetHeight,
|
||||
type MosaicTile,
|
||||
} from "./mosaic";
|
||||
import { layoutMosaic, layoutMosaicStrip, mosaicTargetHeight, type MosaicTile } from "./mosaic";
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
@@ -67,7 +62,9 @@
|
||||
});
|
||||
|
||||
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
|
||||
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
|
||||
const sized = $derived(
|
||||
items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })),
|
||||
);
|
||||
const rows = $derived(
|
||||
layout === "strip"
|
||||
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
|
||||
|
||||
@@ -82,8 +82,12 @@
|
||||
|
||||
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
|
||||
artwork. -->
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
|
||||
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5"
|
||||
>
|
||||
<p
|
||||
class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
});
|
||||
|
||||
// Separate movies and series
|
||||
movies = result.items.filter(item => item.kind === "movie");
|
||||
series = result.items.filter(item => item.kind === "series");
|
||||
movies = result.items.filter((item) => item.kind === "movie");
|
||||
series = result.items.filter((item) => item.kind === "series");
|
||||
} catch (e) {
|
||||
log.error("Failed to load filmography:", e);
|
||||
} finally {
|
||||
|
||||
@@ -27,11 +27,9 @@
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
// Extract MediaItem[] from PlaylistEntry[] for TrackList
|
||||
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
|
||||
const tracks = $derived(entries.map((e) => ({ ...e }) as MediaItem));
|
||||
|
||||
const totalDuration = $derived(
|
||||
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
|
||||
);
|
||||
const totalDuration = $derived(entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0));
|
||||
|
||||
onMount(() => {
|
||||
loadPlaylistItems();
|
||||
@@ -53,7 +51,7 @@
|
||||
async function handlePlayAll() {
|
||||
if (entries.length === 0) return;
|
||||
try {
|
||||
const trackIds = entries.map(e => e.id);
|
||||
const trackIds = entries.map((e) => e.id);
|
||||
await playerController.playTracks({
|
||||
trackIds,
|
||||
startIndex: 0,
|
||||
@@ -73,7 +71,7 @@
|
||||
async function handleShufflePlay() {
|
||||
if (entries.length === 0) return;
|
||||
try {
|
||||
const trackIds = entries.map(e => e.id);
|
||||
const trackIds = entries.map((e) => e.id);
|
||||
await playerController.playTracks({
|
||||
trackIds,
|
||||
startIndex: 0,
|
||||
@@ -129,7 +127,7 @@
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
|
||||
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
|
||||
entries = entries.filter((e) => e.playlistItemId !== entry.playlistItemId);
|
||||
toast.success("Track removed");
|
||||
} catch (e) {
|
||||
log.error("Failed to remove track:", e);
|
||||
@@ -161,9 +159,13 @@
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
|
||||
<path
|
||||
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -183,7 +185,10 @@
|
||||
{:else}
|
||||
<button
|
||||
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
|
||||
onclick={() => { editingName = true; editName = playlist.name; }}
|
||||
onclick={() => {
|
||||
editingName = true;
|
||||
editName = playlist.name;
|
||||
}}
|
||||
title="Click to rename"
|
||||
>
|
||||
{playlist.name}
|
||||
@@ -205,7 +210,7 @@
|
||||
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
Play All
|
||||
</button>
|
||||
@@ -215,7 +220,9 @@
|
||||
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||
<path
|
||||
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
|
||||
/>
|
||||
</svg>
|
||||
Shuffle
|
||||
</button>
|
||||
@@ -227,11 +234,13 @@
|
||||
className="self-center"
|
||||
/>
|
||||
<button
|
||||
onclick={() => showDeleteConfirm = true}
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||||
<path
|
||||
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
|
||||
/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
@@ -264,7 +273,7 @@
|
||||
title="Remove from playlist"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 13H5v-2h14v2z"/>
|
||||
<path d="M19 13H5v-2h14v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -278,8 +287,10 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
onclick={() => showDeleteConfirm = false}
|
||||
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }}
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") showDeleteConfirm = false;
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
@@ -296,7 +307,7 @@
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => showDeleteConfirm = false}
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
genres = [],
|
||||
people = [],
|
||||
artistIds = [],
|
||||
limit = 12
|
||||
limit = 12,
|
||||
}: Props = $props();
|
||||
|
||||
let relatedItems = $state<MediaItem[]>([]);
|
||||
@@ -53,7 +53,7 @@
|
||||
if (itemKind === "movie" || itemKind === "series") {
|
||||
try {
|
||||
const result = await repo.getSimilarItems(currentItemId, limit);
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
items = result.items.filter((item) => item.id !== currentItemId);
|
||||
|
||||
if (items.length > 0) {
|
||||
relatedItems = items.slice(0, limit);
|
||||
@@ -72,14 +72,18 @@
|
||||
// maps the neutral kind to the concrete Jellyfin item type it needs.
|
||||
const searchTerm = genres[0];
|
||||
const itemTypeForKind: Record<string, string> = {
|
||||
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
|
||||
movie: "Movie",
|
||||
series: "Series",
|
||||
album: "MusicAlbum",
|
||||
track: "Audio",
|
||||
artist: "MusicArtist",
|
||||
};
|
||||
const result = await repo.search(searchTerm, {
|
||||
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
|
||||
limit: limit * 2
|
||||
limit: limit * 2,
|
||||
});
|
||||
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
items = result.items.filter((item) => item.id !== currentItemId);
|
||||
} catch (e) {
|
||||
log.warn("Failed to load related items by genre:", e);
|
||||
}
|
||||
@@ -91,10 +95,10 @@
|
||||
// Search for other albums by artist name from first artist
|
||||
const result = await repo.search(artistIds[0], {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
limit: limit * 2
|
||||
limit: limit * 2,
|
||||
});
|
||||
|
||||
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
||||
const artistAlbums = result.items.filter((item) => item.id !== currentItemId);
|
||||
items = [...items, ...artistAlbums];
|
||||
} catch (e) {
|
||||
log.warn("Failed to load albums by artist:", e);
|
||||
@@ -102,9 +106,10 @@
|
||||
}
|
||||
|
||||
// Remove duplicates and limit results
|
||||
const uniqueItems = Array.from(
|
||||
new Map(items.map(item => [item.id, item])).values()
|
||||
).slice(0, limit);
|
||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.id, item])).values()).slice(
|
||||
0,
|
||||
limit,
|
||||
);
|
||||
|
||||
relatedItems = uniqueItems;
|
||||
} catch (e) {
|
||||
@@ -144,7 +149,11 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="{isMusicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
||||
<div
|
||||
class="{isMusicContent
|
||||
? 'aspect-square'
|
||||
: 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"
|
||||
></div>
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
|
||||
@@ -17,25 +17,28 @@
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
let { seasonId, seriesName, seasonName, seasonNumber, episodeCount, className = "", size = "md" }: Props = $props();
|
||||
let {
|
||||
seasonId,
|
||||
seriesName,
|
||||
seasonName,
|
||||
seasonNumber,
|
||||
episodeCount,
|
||||
className = "",
|
||||
size = "md",
|
||||
}: Props = $props();
|
||||
|
||||
let isProcessing = $state(false);
|
||||
let showQualityPicker = $state(false);
|
||||
|
||||
// Count downloads for this season
|
||||
const seasonDownloads = $derived(
|
||||
$videoDownloads.filter((d) =>
|
||||
d.seriesName === seriesName &&
|
||||
d.seasonName === seasonName
|
||||
)
|
||||
$videoDownloads.filter((d) => d.seriesName === seriesName && d.seasonName === seasonName),
|
||||
);
|
||||
|
||||
const completedCount = $derived(
|
||||
seasonDownloads.filter((d) => d.status === "completed").length
|
||||
);
|
||||
const completedCount = $derived(seasonDownloads.filter((d) => d.status === "completed").length);
|
||||
|
||||
const inProgressCount = $derived(
|
||||
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
|
||||
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length,
|
||||
);
|
||||
|
||||
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
|
||||
@@ -67,7 +70,7 @@
|
||||
seasonNumber,
|
||||
userId,
|
||||
basePath,
|
||||
quality
|
||||
quality,
|
||||
);
|
||||
|
||||
log.debug(`✅ Queued ${downloadIds.length} episodes for download`);
|
||||
@@ -102,7 +105,9 @@
|
||||
return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`;
|
||||
}
|
||||
if (completedCount > 0) {
|
||||
return size === "sm" ? `${completedCount}/${episodeCount}` : `Download (${completedCount}/${episodeCount})`;
|
||||
return size === "sm"
|
||||
? `${completedCount}/${episodeCount}`
|
||||
: `Download (${completedCount}/${episodeCount})`;
|
||||
}
|
||||
return size === "sm" ? "⬇" : "Download Season";
|
||||
}
|
||||
@@ -118,39 +123,49 @@
|
||||
}
|
||||
|
||||
const sizeClasses = $derived(
|
||||
size === "sm" ? "px-2 py-1 text-xs" :
|
||||
size === "lg" ? "px-6 py-3 text-base" :
|
||||
"px-4 py-2 text-sm"
|
||||
size === "sm"
|
||||
? "px-2 py-1 text-xs"
|
||||
: size === "lg"
|
||||
? "px-6 py-3 text-base"
|
||||
: "px-4 py-2 text-sm",
|
||||
);
|
||||
|
||||
const iconSize = $derived(
|
||||
size === "sm" ? "w-3 h-3" :
|
||||
size === "lg" ? "w-6 h-6" :
|
||||
"w-4 h-4"
|
||||
);
|
||||
const iconSize = $derived(size === "sm" ? "w-3 h-3" : size === "lg" ? "w-6 h-6" : "w-4 h-4");
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing || allDownloaded}
|
||||
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
|
||||
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing ||
|
||||
allDownloaded
|
||||
? 'opacity-70 cursor-not-allowed'
|
||||
: ''}"
|
||||
>
|
||||
{#if inProgressCount > 0}
|
||||
<!-- Spinner -->
|
||||
<svg class="{iconSize} animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else if allDownloaded}
|
||||
<!-- Checkmark -->
|
||||
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Download icon -->
|
||||
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{#if size !== "sm"}
|
||||
@@ -162,7 +177,9 @@
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<div
|
||||
class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-700">
|
||||
<div class="text-sm font-medium text-white">Download Quality</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</div>
|
||||
@@ -174,14 +191,16 @@
|
||||
>
|
||||
<span class="text-sm text-white">{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
onclick={() => (showQualityPicker = false)}
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
@@ -194,7 +213,7 @@
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
onclick={() => (showQualityPicker = false)}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
}: Props = $props();
|
||||
|
||||
const holdsCurrentEpisode = $derived(
|
||||
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
|
||||
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId),
|
||||
);
|
||||
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
|
||||
|
||||
const episodeCount = $derived(episodes.length);
|
||||
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season"),
|
||||
);
|
||||
// Seasons have no page of their own; a season link scrolls to this anchor
|
||||
// inside the series' single continuous episode list.
|
||||
@@ -55,7 +55,9 @@
|
||||
<!-- Season header -->
|
||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||
<!-- Season poster -->
|
||||
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
|
||||
<div
|
||||
class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]"
|
||||
>
|
||||
<CachedImage
|
||||
itemId={season.id}
|
||||
imageType="Primary"
|
||||
@@ -135,7 +137,7 @@
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
seasonName={seasonName}
|
||||
{seasonName}
|
||||
seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
|
||||
@@ -20,16 +20,12 @@
|
||||
let showQualityPicker = $state(false);
|
||||
|
||||
// Count downloads for this series
|
||||
const seriesDownloads = $derived(
|
||||
$videoDownloads.filter((d) => d.seriesName === seriesName)
|
||||
);
|
||||
const seriesDownloads = $derived($videoDownloads.filter((d) => d.seriesName === seriesName));
|
||||
|
||||
const completedCount = $derived(
|
||||
seriesDownloads.filter((d) => d.status === "completed").length
|
||||
);
|
||||
const completedCount = $derived(seriesDownloads.filter((d) => d.status === "completed").length);
|
||||
|
||||
const inProgressCount = $derived(
|
||||
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
|
||||
seriesDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length,
|
||||
);
|
||||
|
||||
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
|
||||
@@ -59,7 +55,7 @@
|
||||
seriesName,
|
||||
userId,
|
||||
basePath,
|
||||
quality
|
||||
quality,
|
||||
);
|
||||
|
||||
log.debug(` Queued ${downloadIds.length} episodes for download`);
|
||||
@@ -113,13 +109,21 @@
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing || allDownloaded}
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-white font-medium transition-colors {getButtonColor()} {isProcessing ||
|
||||
allDownloaded
|
||||
? 'opacity-70 cursor-not-allowed'
|
||||
: ''}"
|
||||
>
|
||||
{#if inProgressCount > 0}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else if allDownloaded}
|
||||
<!-- Checkmark -->
|
||||
@@ -129,7 +133,11 @@
|
||||
{:else}
|
||||
<!-- Download icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{getButtonText()}</span>
|
||||
@@ -137,7 +145,9 @@
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<div
|
||||
class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-700">
|
||||
<div class="text-sm font-medium text-white">Download Quality</div>
|
||||
{#if episodeCount}
|
||||
@@ -151,14 +161,16 @@
|
||||
>
|
||||
<span class="text-sm text-white">{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
onclick={() => (showQualityPicker = false)}
|
||||
class="w-full px-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
@@ -171,7 +183,7 @@
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
onclick={() => (showQualityPicker = false)}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("TrackList Logic Tests", () => {
|
||||
mediaType: "Audio",
|
||||
streamUrl: await repo.getAudioStreamUrl(t.id),
|
||||
jellyfinItemId: t.id,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
expect(queueItems).toHaveLength(2);
|
||||
@@ -110,7 +110,7 @@ describe("TrackList Logic Tests", () => {
|
||||
id: t.id,
|
||||
streamUrl,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockRepository.getAudioStreamUrl).toHaveBeenCalledTimes(2);
|
||||
@@ -279,7 +279,7 @@ describe("TrackList Logic Tests", () => {
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
},
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
showArtist = true,
|
||||
showDownload = false,
|
||||
context,
|
||||
onTrackClick
|
||||
onTrackClick,
|
||||
}: Props = $props();
|
||||
|
||||
let isPlayingTrack = $state<string | null>(null);
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
// Queue will auto-update from Rust backend event
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
||||
const errorMessage = e instanceof Error ? e.message : "Unknown error";
|
||||
log.error("Failed to play track:", errorMessage);
|
||||
toast.error(`Failed to play track: ${errorMessage}`, 5000);
|
||||
} finally {
|
||||
@@ -110,7 +110,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -158,9 +157,7 @@
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(10) as _}
|
||||
<div
|
||||
class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4"
|
||||
>
|
||||
<div class="animate-pulse bg-[var(--color-surface)] rounded-lg p-4 flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-gray-700 rounded"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-700 rounded w-1/3"></div>
|
||||
@@ -199,7 +196,13 @@
|
||||
<!-- Track Rows -->
|
||||
<div class="space-y-1">
|
||||
{#each tracks as track, index (track.id)}
|
||||
<div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
|
||||
<div
|
||||
data-grid-index={index}
|
||||
class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId ===
|
||||
track.id
|
||||
? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Desktop View -->
|
||||
<button
|
||||
onclick={() => handleTrackClick(track, index)}
|
||||
@@ -212,7 +215,9 @@
|
||||
<!-- Index/Play Button -->
|
||||
<div class="w-12 flex items-center justify-center">
|
||||
{#if isPlayingTrack === track.id}
|
||||
<div class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
{:else}
|
||||
<span class="group-hover:hidden text-gray-400">{index + 1}</span>
|
||||
<svg
|
||||
@@ -230,12 +235,21 @@
|
||||
{#if currentlyPlayingId === track.id}
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"></div>
|
||||
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 150ms"></div>
|
||||
<div class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse" style="animation-delay: 300ms"></div>
|
||||
<div
|
||||
class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"
|
||||
style="animation-delay: 150ms"
|
||||
></div>
|
||||
<div
|
||||
class="w-1 h-1 bg-[var(--color-jellyfin)] rounded-full animate-pulse"
|
||||
style="animation-delay: 300ms"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId ===
|
||||
track.id
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: ''}"
|
||||
>
|
||||
{truncateMiddle(track.name, 48)}
|
||||
</span>
|
||||
@@ -250,7 +264,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => handleArtistClick(artist.id, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
|
||||
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
|
||||
>
|
||||
{artist.name}
|
||||
@@ -273,7 +287,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => handleAlbumClick(track.albumId, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
|
||||
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline truncate cursor-pointer"
|
||||
>
|
||||
{track.albumName || "-"}
|
||||
@@ -317,7 +331,9 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
<path
|
||||
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -332,7 +348,9 @@
|
||||
<!-- Track Number -->
|
||||
<div class="w-8 flex-shrink-0 text-center">
|
||||
{#if isPlayingTrack === track.id}
|
||||
<div class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin mx-auto"
|
||||
></div>
|
||||
{:else}
|
||||
<span class="group-hover:hidden text-gray-400 text-sm">{index + 1}</span>
|
||||
<svg
|
||||
@@ -346,7 +364,10 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId ===
|
||||
track.id
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: ''}"
|
||||
>
|
||||
{#if currentlyPlayingId === track.id}
|
||||
<span class="inline-block mr-1">▶</span>
|
||||
@@ -361,7 +382,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => handleArtistClick(artist.id, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
|
||||
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
|
||||
>
|
||||
{artist.name}
|
||||
@@ -379,7 +400,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => handleAlbumClick(track.albumId, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
|
||||
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
|
||||
>
|
||||
{track.albumName || "-"}
|
||||
@@ -394,7 +415,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => handleArtistClick(artist.id, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleArtistClick(artist.id, e)}
|
||||
onkeydown={(e) => e.key === "Enter" && handleArtistClick(artist.id, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
|
||||
>
|
||||
{artist.name}
|
||||
@@ -412,7 +433,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => handleAlbumClick(track.albumId, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick(track.albumId, e)}
|
||||
onkeydown={(e) => e.key === "Enter" && handleAlbumClick(track.albumId, e)}
|
||||
class="text-[var(--color-jellyfin)] hover:underline cursor-pointer"
|
||||
>
|
||||
{track.albumName || "-"}
|
||||
@@ -447,7 +468,9 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
<path
|
||||
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -459,7 +482,7 @@
|
||||
|
||||
<!-- Portal Menu (rendered at document.body to avoid overflow clipping) -->
|
||||
{#if openMenuId && menuPosition}
|
||||
{@const selectedTrack = tracks.find(t => t.id === openMenuId)}
|
||||
{@const selectedTrack = tracks.find((t) => t.id === openMenuId)}
|
||||
{#if selectedTrack}
|
||||
<Portal>
|
||||
<div
|
||||
@@ -472,7 +495,12 @@
|
||||
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Play Next
|
||||
</button>
|
||||
@@ -482,7 +510,12 @@
|
||||
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
Add to Queue
|
||||
</button>
|
||||
@@ -496,7 +529,9 @@
|
||||
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
|
||||
<path
|
||||
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
|
||||
/>
|
||||
</svg>
|
||||
Add to Playlist
|
||||
</button>
|
||||
@@ -508,7 +543,7 @@
|
||||
<!-- Add to Playlist Modal -->
|
||||
<AddToPlaylistModal
|
||||
isOpen={addToPlaylistTrackId !== null}
|
||||
onClose={() => addToPlaylistTrackId = null}
|
||||
onClose={() => (addToPlaylistTrackId = null)}
|
||||
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
|
||||
/>
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ vi.mock("$lib/stores/queue", () => ({
|
||||
setQueue: vi.fn(),
|
||||
addToQueue: vi.fn(),
|
||||
},
|
||||
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
|
||||
currentQueueItem: {
|
||||
subscribe: vi.fn((fn: any) => {
|
||||
fn(null);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./DownloadButton.svelte", () => ({
|
||||
@@ -42,10 +47,30 @@ vi.mock("$lib/stores/library", () => ({
|
||||
loadItem: vi.fn(),
|
||||
setCurrentLibrary: vi.fn(),
|
||||
},
|
||||
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
|
||||
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
|
||||
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
|
||||
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) },
|
||||
libraries: {
|
||||
subscribe: vi.fn((fn: any) => {
|
||||
fn([]);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
libraryItems: {
|
||||
subscribe: vi.fn((fn: any) => {
|
||||
fn([]);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
currentLibrary: {
|
||||
subscribe: vi.fn((fn: any) => {
|
||||
fn(null);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
isLibraryLoading: {
|
||||
subscribe: vi.fn((fn: any) => {
|
||||
fn(false);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Now import the modules after mocks are set up
|
||||
@@ -120,7 +145,9 @@ describe("TrackList", () => {
|
||||
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
|
||||
expect(getAllByText("Song 2").length).toBeGreaterThan(0);
|
||||
// Long names are abbreviated in the middle via truncateMiddle(name, 48).
|
||||
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(0);
|
||||
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows loading skeleton when loading=true", () => {
|
||||
@@ -234,7 +261,7 @@ describe("TrackList", () => {
|
||||
// Find and click the first track button
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
expect(firstTrackButton).toBeTruthy();
|
||||
@@ -250,7 +277,7 @@ describe("TrackList", () => {
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -261,7 +288,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const secondTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 2")
|
||||
btn.textContent?.includes("Song 2"),
|
||||
);
|
||||
|
||||
await fireEvent.click(secondTrackButton!);
|
||||
@@ -278,7 +305,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const thirdTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 3")
|
||||
btn.textContent?.includes("Song 3"),
|
||||
);
|
||||
|
||||
await fireEvent.click(thirdTrackButton!);
|
||||
@@ -297,7 +324,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -305,7 +332,7 @@ describe("TrackList", () => {
|
||||
await waitFor(() => {
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to play track"),
|
||||
expect.anything()
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -322,7 +349,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -330,7 +357,7 @@ describe("TrackList", () => {
|
||||
await waitFor(() => {
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to play track"),
|
||||
expect.anything()
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -339,7 +366,6 @@ describe("TrackList", () => {
|
||||
// Restore mock for other tests
|
||||
(auth.getRepository as any).mockReturnValue(mockRepository as any);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("Custom Callback Tests", () => {
|
||||
@@ -351,7 +377,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -363,7 +389,7 @@ describe("TrackList", () => {
|
||||
|
||||
it("does not call player_play_queue when custom callback provided", async () => {
|
||||
const onTrackClick = vi.fn();
|
||||
const invokeMock = (invoke as any);
|
||||
const invokeMock = invoke as any;
|
||||
|
||||
const { container } = render(TrackList, {
|
||||
props: { tracks: mockTracks, onTrackClick },
|
||||
@@ -371,7 +397,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -391,7 +417,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const secondTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 2")
|
||||
btn.textContent?.includes("Song 2"),
|
||||
);
|
||||
|
||||
await fireEvent.click(secondTrackButton!);
|
||||
@@ -409,7 +435,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -429,7 +455,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -454,9 +480,7 @@ describe("TrackList", () => {
|
||||
const { container } = render(TrackList, { props: { tracks: singleTrack } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const trackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
);
|
||||
const trackButton = Array.from(buttons).find((btn) => btn.textContent?.includes("Song 1"));
|
||||
|
||||
await fireEvent.click(trackButton!);
|
||||
|
||||
@@ -473,7 +497,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
await fireEvent.click(firstTrackButton!);
|
||||
@@ -490,7 +514,7 @@ describe("TrackList", () => {
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const lastTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 3")
|
||||
btn.textContent?.includes("Song 3"),
|
||||
);
|
||||
|
||||
await fireEvent.click(lastTrackButton!);
|
||||
@@ -505,15 +529,13 @@ describe("TrackList", () => {
|
||||
describe("Loading State", () => {
|
||||
it("shows loading spinner when track is clicked", async () => {
|
||||
// Make invoke slow to capture loading state
|
||||
(invoke as any).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
);
|
||||
(invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
fireEvent.click(firstTrackButton!);
|
||||
@@ -526,15 +548,13 @@ describe("TrackList", () => {
|
||||
});
|
||||
|
||||
it("disables track buttons during loading", async () => {
|
||||
(invoke as any).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
);
|
||||
(invoke as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
const { container } = render(TrackList, { props: { tracks: mockTracks } });
|
||||
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const firstTrackButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes("Song 1")
|
||||
btn.textContent?.includes("Song 1"),
|
||||
);
|
||||
|
||||
fireEvent.click(firstTrackButton!);
|
||||
@@ -542,8 +562,8 @@ describe("TrackList", () => {
|
||||
// Track selection buttons should be disabled during loading
|
||||
await waitFor(() => {
|
||||
// Find track buttons (ones containing song names)
|
||||
const trackButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(btn) => btn.textContent?.includes("Song")
|
||||
const trackButtons = Array.from(container.querySelectorAll("button")).filter((btn) =>
|
||||
btn.textContent?.includes("Song"),
|
||||
);
|
||||
expect(trackButtons.length).toBeGreaterThan(0);
|
||||
trackButtons.forEach((btn) => {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
episodeNumber,
|
||||
seasonNumber,
|
||||
size = "md",
|
||||
className = ""
|
||||
className = "",
|
||||
}: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
// Find download for this item
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
|
||||
);
|
||||
|
||||
const status = $derived(downloadInfo?.status || "not_downloaded");
|
||||
@@ -83,7 +83,7 @@
|
||||
filePath = `videos/movies/${safeName}.mp4`;
|
||||
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
|
||||
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_");
|
||||
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}_${safeName}.mp4`;
|
||||
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}_${safeName}.mp4`;
|
||||
} else {
|
||||
filePath = `videos/${safeName}.mp4`;
|
||||
}
|
||||
@@ -96,13 +96,13 @@
|
||||
userId,
|
||||
filePath,
|
||||
"video/mp4",
|
||||
isMovie ? 500 : (1000 - (episodeNumber || 0)), // Movies have medium priority, episodes ordered by number
|
||||
isMovie ? 500 : 1000 - (episodeNumber || 0), // Movies have medium priority, episodes ordered by number
|
||||
itemName || undefined,
|
||||
quality,
|
||||
seriesName,
|
||||
seasonName,
|
||||
episodeNumber,
|
||||
seasonNumber
|
||||
seasonNumber,
|
||||
);
|
||||
log.debug(" Download queued with ID:", downloadId);
|
||||
|
||||
@@ -232,27 +232,59 @@
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
{:else if status === "completed"}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<svg
|
||||
class={sizeClasses[size]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else if status === "pending"}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" />
|
||||
<svg
|
||||
class={sizeClasses[size]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if status === "failed"}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<svg
|
||||
class={sizeClasses[size]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
<svg
|
||||
class={sizeClasses[size]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -263,16 +295,14 @@
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
onclick={() => (showQualityPicker = false)}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
<div
|
||||
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
||||
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
|
||||
>
|
||||
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
||||
Select Quality
|
||||
</div>
|
||||
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">Select Quality</div>
|
||||
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => startDownload(key as QualityPreset)}
|
||||
@@ -280,14 +310,16 @@
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
onclick={() => (showQualityPicker = false)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -32,14 +32,7 @@
|
||||
onChanged?: (watched: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
itemId,
|
||||
watched,
|
||||
scope,
|
||||
size = "lg",
|
||||
showLabel = false,
|
||||
onChanged,
|
||||
}: Props = $props();
|
||||
let { itemId, watched, scope, size = "lg", showLabel = false, onChanged }: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
@@ -60,7 +53,7 @@
|
||||
});
|
||||
|
||||
const subject = $derived(
|
||||
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
|
||||
scope === "series" ? "series" : scope === "season" ? "season" : "episode",
|
||||
);
|
||||
const label = $derived(isWatched ? "Watched" : "Mark watched");
|
||||
const title = $derived(
|
||||
@@ -68,7 +61,7 @@
|
||||
? `Mark this ${subject} unwatched`
|
||||
: scope === "episode"
|
||||
? "Mark this episode watched"
|
||||
: `Mark every episode in this ${subject} watched`
|
||||
: `Mark every episode in this ${subject} watched`,
|
||||
);
|
||||
|
||||
async function handleClick() {
|
||||
@@ -106,7 +99,13 @@
|
||||
{isWatched
|
||||
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
|
||||
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
|
||||
{showLabel
|
||||
? size === 'lg'
|
||||
? 'px-6 py-2'
|
||||
: 'px-3 py-1.5 text-sm'
|
||||
: size === 'lg'
|
||||
? 'p-2'
|
||||
: 'p-1.5'}"
|
||||
>
|
||||
{#if busy}
|
||||
<div
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
|
||||
import {
|
||||
isCurrentEpisode,
|
||||
adjacentEpisodes,
|
||||
compareSeriesOrder,
|
||||
stripCardLabel,
|
||||
} from "./episodeStrip";
|
||||
|
||||
// Minimal episode factory — only the fields the strip logic reads.
|
||||
function ep(
|
||||
id: string,
|
||||
season: number | null,
|
||||
number: number | null,
|
||||
): MediaItem {
|
||||
function ep(id: string, season: number | null, number: number | null): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `S${season}E${number}`,
|
||||
@@ -79,8 +80,15 @@ describe("adjacentEpisodes", () => {
|
||||
const current = eps[4]; // S1E5 — the season finale
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.map((e) => e.id)).toEqual([
|
||||
"s1e2", "s1e3", "s1e4", "s1e5",
|
||||
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
|
||||
"s1e2",
|
||||
"s1e3",
|
||||
"s1e4",
|
||||
"s1e5",
|
||||
"s2e1",
|
||||
"s2e2",
|
||||
"s2e3",
|
||||
"s2e4",
|
||||
"s2e5",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -96,9 +104,7 @@ describe("adjacentEpisodes", () => {
|
||||
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
|
||||
const current = eps[3]; // S1E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.map((e) => e.id)).toEqual([
|
||||
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
|
||||
]);
|
||||
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3"]);
|
||||
});
|
||||
|
||||
it("sorts specials (season 0) after the numbered seasons", () => {
|
||||
|
||||
@@ -26,14 +26,15 @@ const SPECIALS_SEASON = 0;
|
||||
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
|
||||
if (ep.id === current.id) return true;
|
||||
if (
|
||||
ep.indexNumber == null || current.indexNumber == null ||
|
||||
ep.parentIndexNumber == null || current.parentIndexNumber == null
|
||||
ep.indexNumber == null ||
|
||||
current.indexNumber == null ||
|
||||
ep.parentIndexNumber == null ||
|
||||
current.parentIndexNumber == null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ep.parentIndexNumber === current.parentIndexNumber &&
|
||||
ep.indexNumber === current.indexNumber
|
||||
ep.parentIndexNumber === current.parentIndexNumber && ep.indexNumber === current.indexNumber
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,11 @@ describe("buildLibraryMosaic", () => {
|
||||
it("links a category tile to that category's favourites tab", () => {
|
||||
const entries = buildLibraryMosaic([SHOWS]);
|
||||
const tile = entries.find((e) => e.label === "Favourite Shows");
|
||||
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
|
||||
expect(tile).toMatchObject({
|
||||
kind: "favorites",
|
||||
scope: "tv",
|
||||
href: "/library/favorites?scope=tv",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a category's favourites once, however many libraries share it", () => {
|
||||
|
||||
@@ -31,8 +31,7 @@ export type LibraryMosaicEntry = {
|
||||
ratio: number;
|
||||
label: string;
|
||||
} & (
|
||||
| { kind: "library"; library: Library }
|
||||
| { kind: "favorites"; scope: FavoritesScope; href: string }
|
||||
{ kind: "library"; library: Library } | { kind: "favorites"; scope: FavoritesScope; href: string }
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -68,7 +67,13 @@ export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
|
||||
|
||||
for (const lib of libraries) {
|
||||
const ratio = assumedLibraryRatio(lib);
|
||||
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
|
||||
entries.push({
|
||||
key: `library:${lib.id}`,
|
||||
kind: "library",
|
||||
library: lib,
|
||||
ratio,
|
||||
label: lib.name,
|
||||
});
|
||||
|
||||
const scope = asFavoritesScope(lib.favoritesScope);
|
||||
if (!scope || seenScopes.has(scope)) continue;
|
||||
|
||||
@@ -88,9 +88,7 @@ describe("seasonRedirectTarget", () => {
|
||||
});
|
||||
|
||||
it("matches the anchor the season section renders", () => {
|
||||
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
|
||||
`/library/${SERIES}#${seasonAnchorId(2)}`
|
||||
);
|
||||
expect(seasonRedirectTarget(seasonHeader(2))).toBe(`/library/${SERIES}#${seasonAnchorId(2)}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +128,7 @@ describe("groupEpisodesBySeason", () => {
|
||||
it("puts specials after the numbered seasons", () => {
|
||||
const grouped = groupEpisodesBySeason(
|
||||
[seasonHeader(0), seasonHeader(1)],
|
||||
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
|
||||
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)],
|
||||
);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
|
||||
});
|
||||
@@ -156,7 +154,7 @@ describe("groupEpisodesBySeason", () => {
|
||||
it("drops seasons that have no episodes", () => {
|
||||
const grouped = groupEpisodesBySeason(
|
||||
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
||||
[ep("s2e1", 2, 1)]
|
||||
[ep("s2e1", 2, 1)],
|
||||
);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
|
||||
});
|
||||
@@ -171,12 +169,7 @@ describe("groupEpisodesBySeason", () => {
|
||||
describe("initialExpandedSeasons", () => {
|
||||
const seasons = groupEpisodesBySeason(
|
||||
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
||||
[
|
||||
ep("s1e1", 1, 1),
|
||||
ep("s2e1", 2, 1),
|
||||
ep("s2e2", 2, 2),
|
||||
ep("s3e1", 3, 1),
|
||||
]
|
||||
[ep("s1e1", 1, 1), ep("s2e1", 2, 1), ep("s2e2", 2, 2), ep("s3e1", 3, 1)],
|
||||
);
|
||||
|
||||
it("expands only the season holding the current episode", () => {
|
||||
|
||||
@@ -114,10 +114,7 @@ export function seriesPlayLabel(current: MediaItem | null): string {
|
||||
* server did not return one for (a flat series, or a season fetch that failed).
|
||||
* Seasons with no episodes are dropped — an empty accordion row is noise.
|
||||
*/
|
||||
export function groupEpisodesBySeason(
|
||||
seasons: MediaItem[],
|
||||
episodes: MediaItem[]
|
||||
): SeasonData[] {
|
||||
export function groupEpisodesBySeason(seasons: MediaItem[], episodes: MediaItem[]): SeasonData[] {
|
||||
const headerFor = new Map<number, MediaItem>();
|
||||
for (const season of seasons) {
|
||||
const number = season.indexNumber ?? season.parentIndexNumber;
|
||||
@@ -164,7 +161,7 @@ export function groupEpisodesBySeason(
|
||||
export function initialExpandedSeasons(
|
||||
seasons: SeasonData[],
|
||||
currentEpisodeId: string | null | undefined,
|
||||
focusedEpisodeId?: string | null
|
||||
focusedEpisodeId?: string | null,
|
||||
): Set<string> {
|
||||
if (seasons.length === 0) return new Set();
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
|
||||
import {
|
||||
mergedMedia,
|
||||
mergedIsPlaying,
|
||||
mergedPosition,
|
||||
mergedDuration
|
||||
} from "$lib/stores/player";
|
||||
import { mergedMedia, mergedIsPlaying, mergedPosition, mergedDuration } from "$lib/stores/player";
|
||||
import { isRemoteMode } from "$lib/stores/playbackMode";
|
||||
import { selectedSession } from "$lib/stores/sessions";
|
||||
import { formatTime } from "$lib/utils/playbackUnits";
|
||||
@@ -167,167 +162,190 @@
|
||||
style:padding-left="var(--safe-left)"
|
||||
style:padding-right="var(--safe-right)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-sm text-gray-400">Now Playing</p>
|
||||
{#if $isRemoteMode && $selectedSession}
|
||||
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
{$selectedSession.deviceName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Cast Button -->
|
||||
<CastButton size="md" />
|
||||
|
||||
<!-- Queue Button -->
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
onclick={() => (showQueue = !showQueue)}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue ? 'bg-white/10 text-[var(--color-jellyfin)]' : ''}"
|
||||
title="Queue"
|
||||
aria-label="Open queue"
|
||||
onclick={onClose}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => (showSleepTimerModal = true)}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
|
||||
title="Sleep timer"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
{#if $sleepTimerActive}
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-sm text-gray-400">Now Playing</p>
|
||||
{#if $isRemoteMode && $selectedSession}
|
||||
<p class="text-xs text-[var(--color-jellyfin)] flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
{$selectedSession.deviceName}
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume Control (Linux only) -->
|
||||
<VolumeControl size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Cast Button -->
|
||||
<CastButton size="md" />
|
||||
|
||||
<!-- Artwork -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
|
||||
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
|
||||
{#if artworkItemId}
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia?.imageId}
|
||||
maxWidth={500}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
|
||||
<svg class="w-32 h-32 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
<!-- Queue Button -->
|
||||
<button
|
||||
onclick={() => (showQueue = !showQueue)}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors {showQueue
|
||||
? 'bg-white/10 text-[var(--color-jellyfin)]'
|
||||
: ''}"
|
||||
title="Queue"
|
||||
aria-label="Open queue"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Info & Controls -->
|
||||
<div class="p-6 space-y-6 flex-shrink-0">
|
||||
<!-- Title & Artist -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
|
||||
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
{#each displayMedia?.artistItems as artist, i}
|
||||
<button
|
||||
onclick={() => (showSleepTimerModal = true)}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors relative"
|
||||
title="Sleep timer"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
{#if $sleepTimerActive}
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Volume Control (Linux only) -->
|
||||
<VolumeControl size="md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artwork -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
|
||||
<div
|
||||
class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0"
|
||||
>
|
||||
{#if artworkItemId}
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia?.imageId}
|
||||
maxWidth={500}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-[var(--color-surface)] flex items-center justify-center">
|
||||
<svg class="w-32 h-32 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info & Controls -->
|
||||
<div class="p-6 space-y-6 flex-shrink-0">
|
||||
<!-- Title & Artist -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-white truncate">
|
||||
{truncateMiddle(displayMedia?.name, 48)}
|
||||
</h1>
|
||||
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
{#each displayMedia?.artistItems as artist, i}
|
||||
<button
|
||||
onclick={() => navigateToArtist(artist.id)}
|
||||
class="hover:text-white hover:underline transition-colors"
|
||||
>
|
||||
{artist.name}
|
||||
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
|
||||
{/each}
|
||||
{:else if displayMedia?.artists?.length}
|
||||
<span>{displayMedia?.artists.join(", ")}</span>
|
||||
{/if}
|
||||
{#if displayMedia?.albumId && displayMedia?.albumName}
|
||||
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
|
||||
<span class="text-gray-500">•</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => navigateToArtist(artist.id)}
|
||||
onclick={navigateToAlbum}
|
||||
class="hover:text-white hover:underline transition-colors"
|
||||
>
|
||||
{artist.name}
|
||||
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
|
||||
{/each}
|
||||
{:else if displayMedia?.artists?.length}
|
||||
<span>{displayMedia?.artists.join(", ")}</span>
|
||||
{/if}
|
||||
{#if displayMedia?.albumId && displayMedia?.albumName}
|
||||
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
|
||||
<span class="text-gray-500">•</span>
|
||||
{displayMedia?.albumName}
|
||||
</button>
|
||||
{:else if displayMedia?.albumName}
|
||||
<span>{displayMedia?.albumName}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={navigateToAlbum}
|
||||
class="hover:text-white hover:underline transition-colors"
|
||||
>
|
||||
{displayMedia?.albumName}
|
||||
</button>
|
||||
{:else if displayMedia?.albumName}
|
||||
<span>{displayMedia?.albumName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={displayDuration}
|
||||
value={displayPosition}
|
||||
oninput={handleSeekInput}
|
||||
onmousedown={handleSeekStart}
|
||||
ontouchstart={handleSeekStart}
|
||||
onmouseup={handleSeekEnd}
|
||||
ontouchend={handleSeekEnd}
|
||||
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-400">
|
||||
<span>{formatTime(displayPosition)}</span>
|
||||
<span>{formatTime(displayDuration)}</span>
|
||||
<!-- Progress bar -->
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={displayDuration}
|
||||
value={displayPosition}
|
||||
oninput={handleSeekInput}
|
||||
onmousedown={handleSeekStart}
|
||||
ontouchstart={handleSeekStart}
|
||||
onmouseup={handleSeekEnd}
|
||||
ontouchend={handleSeekEnd}
|
||||
class="w-full h-1 accent-[var(--color-jellyfin)] cursor-pointer"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-400">
|
||||
<span>{formatTime(displayPosition)}</span>
|
||||
<span>{formatTime(displayDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-center">
|
||||
<Controls
|
||||
isPlaying={displayIsPlaying}
|
||||
{hasPrevious}
|
||||
{hasNext}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onToggleShuffle={handleToggleShuffle}
|
||||
onCycleRepeat={handleCycleRepeat}
|
||||
onSleepTimerClick={() => (showSleepTimerModal = true)}
|
||||
/>
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-center">
|
||||
<Controls
|
||||
isPlaying={displayIsPlaying}
|
||||
{hasPrevious}
|
||||
{hasNext}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onToggleShuffle={handleToggleShuffle}
|
||||
onCycleRepeat={handleCycleRepeat}
|
||||
onSleepTimerClick={() => (showSleepTimerModal = true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Close content overlay -->
|
||||
<!-- Close content overlay -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SleepTimerModal
|
||||
isOpen={showSleepTimerModal}
|
||||
onClose={() => (showSleepTimerModal = false)}
|
||||
/>
|
||||
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => (showSleepTimerModal = false)} />
|
||||
|
||||
<!-- Queue Panel (slide up from bottom) -->
|
||||
{#if showQueue}
|
||||
|
||||
@@ -90,8 +90,12 @@
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
@@ -113,7 +117,9 @@
|
||||
title="Shuffle"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
|
||||
<path
|
||||
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -192,7 +198,9 @@
|
||||
>
|
||||
{#if repeat === "one"}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z" />
|
||||
<path
|
||||
d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
mergedIsPlaying,
|
||||
mergedPosition,
|
||||
mergedDuration,
|
||||
shouldShowAudioMiniPlayer
|
||||
shouldShowAudioMiniPlayer,
|
||||
} from "$lib/stores/player";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
import { isRemoteMode } from "$lib/stores/playbackMode";
|
||||
@@ -101,9 +101,7 @@
|
||||
// State machine gated visibility - only show when player is playing/paused AND media is audio
|
||||
const shouldShow = $derived($shouldShowAudioMiniPlayer);
|
||||
|
||||
const progress = $derived(
|
||||
calculateProgress(displayPosition, displayDuration)
|
||||
);
|
||||
const progress = $derived(calculateProgress(displayPosition, displayDuration));
|
||||
|
||||
function navigateToArtist(event: MouseEvent, artistId: string) {
|
||||
event.stopPropagation();
|
||||
@@ -284,12 +282,23 @@
|
||||
</script>
|
||||
|
||||
{#if shouldShow && displayMedia}
|
||||
<div class="{className || 'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]">
|
||||
<div
|
||||
class="{className ||
|
||||
'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]"
|
||||
>
|
||||
<!-- Remote Mode Indicator -->
|
||||
{#if $isRemoteMode && $selectedSession}
|
||||
<div class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
<div
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs text-[var(--color-jellyfin)] font-medium">
|
||||
Playing on {$selectedSession.deviceName}
|
||||
@@ -308,7 +317,9 @@
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
<!-- Hover indicator -->
|
||||
<div class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
></div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
@@ -317,14 +328,14 @@
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
|
||||
style="transform: translateX({swipeTransform}px); transition: {isSwiping
|
||||
? 'none'
|
||||
: 'transform 0.3s ease-out'}"
|
||||
>
|
||||
<!-- Row 1: Media info, like, cast, overflow -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Artwork -->
|
||||
<div
|
||||
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
||||
>
|
||||
<div class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
|
||||
{#if displayMedia}
|
||||
<CachedImage
|
||||
itemId={displayMedia.albumId || displayMedia.id}
|
||||
@@ -337,7 +348,9 @@
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
<path
|
||||
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -345,9 +358,7 @@
|
||||
|
||||
<!-- Title & Artist -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||
>
|
||||
<div class="text-sm font-medium text-white truncate block w-full text-left">
|
||||
{truncateMiddle(displayMedia?.name, 40)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||
@@ -381,11 +392,7 @@
|
||||
|
||||
<!-- Like Button -->
|
||||
{#if displayMedia}
|
||||
<FavoriteButton
|
||||
itemId={displayMedia?.id ?? ""}
|
||||
bind:isFavorite
|
||||
size="sm"
|
||||
/>
|
||||
<FavoriteButton itemId={displayMedia?.id ?? ""} bind:isFavorite size="sm" />
|
||||
{/if}
|
||||
|
||||
<!-- Cast Button -->
|
||||
@@ -402,7 +409,9 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
<path
|
||||
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -417,7 +426,12 @@
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
View Queue
|
||||
</button>
|
||||
@@ -428,7 +442,9 @@
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"
|
||||
/>
|
||||
</svg>
|
||||
Go to Album
|
||||
</button>
|
||||
@@ -440,7 +456,9 @@
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
<path
|
||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
|
||||
/>
|
||||
</svg>
|
||||
Go to Artist
|
||||
</button>
|
||||
@@ -451,7 +469,12 @@
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add to Playlist
|
||||
</button>
|
||||
@@ -461,7 +484,12 @@
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
@@ -508,7 +536,7 @@
|
||||
{#if showOverflowMenu}
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => showOverflowMenu = false}
|
||||
onclick={() => (showOverflowMenu = false)}
|
||||
aria-label="Close menu"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
initialCountdownSeconds,
|
||||
isCountdownActive,
|
||||
} from "$lib/stores/nextEpisode";
|
||||
import {
|
||||
cancelAutoPlay,
|
||||
watchNextManually,
|
||||
} from "$lib/services/nextEpisodeService";
|
||||
import { cancelAutoPlay, watchNextManually } from "$lib/services/nextEpisodeService";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
|
||||
// Use series primary image for better visual consistency
|
||||
const imageId = $derived($nextEpisodeItem ? ($nextEpisodeItem.seriesId || $nextEpisodeItem.id) : null);
|
||||
const imageId = $derived(
|
||||
$nextEpisodeItem ? $nextEpisodeItem.seriesId || $nextEpisodeItem.id : null,
|
||||
);
|
||||
|
||||
// Format episode info (S1:E5)
|
||||
const episodeInfo = $derived.by(() => {
|
||||
@@ -75,9 +74,7 @@
|
||||
<!-- Episode Card -->
|
||||
<div class="flex gap-4 p-4">
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
|
||||
>
|
||||
<div class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800">
|
||||
{#if imageId && $nextEpisodeItem.imageId}
|
||||
<CachedImage
|
||||
itemId={imageId}
|
||||
@@ -90,14 +87,8 @@
|
||||
{/if}
|
||||
|
||||
<!-- Play icon overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
currentIndex = null,
|
||||
onItemClick,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
let { items, currentIndex = null, onItemClick, onClose }: Props = $props();
|
||||
|
||||
// Add unique IDs for dnd-zone (required)
|
||||
interface DndItem extends MediaItem {
|
||||
@@ -33,7 +28,7 @@
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
dndId: `${item.id}-${index}`,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
let dragDisabled = $state(true);
|
||||
@@ -47,7 +42,9 @@
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) {
|
||||
function handleConsider(
|
||||
e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>,
|
||||
) {
|
||||
const { items: newItems, info } = e.detail;
|
||||
// Update local state during drag
|
||||
if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) {
|
||||
@@ -59,8 +56,8 @@
|
||||
const { items: newItems, info } = e.detail;
|
||||
|
||||
// Find the moved item by comparing old and new positions
|
||||
const oldIds = dndItems.map(i => i.dndId);
|
||||
const newIds = newItems.map(i => i.dndId);
|
||||
const oldIds = dndItems.map((i) => i.dndId);
|
||||
const newIds = newItems.map((i) => i.dndId);
|
||||
|
||||
// Find indices that changed
|
||||
let fromIndex = -1;
|
||||
@@ -126,7 +123,12 @@
|
||||
aria-label="Close queue"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -151,7 +153,10 @@
|
||||
{#each dndItems as item, index (item.dndId)}
|
||||
<li class="outline-none">
|
||||
<div
|
||||
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex === index ? 'bg-white/10' : ''}"
|
||||
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex ===
|
||||
index
|
||||
? 'bg-white/10'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<button
|
||||
@@ -163,7 +168,9 @@
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"/>
|
||||
<path
|
||||
d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -177,7 +184,11 @@
|
||||
<!-- Index or playing indicator -->
|
||||
<div class="w-6 text-center flex-shrink-0">
|
||||
{#if currentIndex === index}
|
||||
<svg class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{:else}
|
||||
@@ -201,7 +212,11 @@
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
||||
<p
|
||||
class="text-sm font-medium truncate {currentIndex === index
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-white'}"
|
||||
>
|
||||
{truncateMiddle(item.name, 48)}
|
||||
</p>
|
||||
{#if item.artists?.length}
|
||||
@@ -225,7 +240,12 @@
|
||||
aria-label="Remove from queue"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<!-- TRACES: UR-026 | DR-029, DR-050 -->
|
||||
<script lang="ts">
|
||||
import {
|
||||
sleepTimer,
|
||||
sleepTimerMode,
|
||||
sleepTimerActive,
|
||||
} from "$lib/stores/sleepTimer";
|
||||
import { sleepTimer, sleepTimerMode, sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
import ScrollPicker from "$lib/components/common/ScrollPicker.svelte";
|
||||
|
||||
@@ -96,7 +92,9 @@
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") onClose?.();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sleep-timer-title"
|
||||
@@ -108,23 +106,14 @@
|
||||
role="none"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="px-6 py-4 border-b border-gray-800 flex items-center justify-between"
|
||||
>
|
||||
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">
|
||||
Sleep Timer
|
||||
</h2>
|
||||
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
|
||||
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">Sleep Timer</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -176,7 +165,9 @@
|
||||
selectedValue={selectedMinutes}
|
||||
visibleCount={3}
|
||||
itemHeight={56}
|
||||
onSelect={(val) => { selectedMinutes = val as number; }}
|
||||
onSelect={(val) => {
|
||||
selectedMinutes = val as number;
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onclick={handleSetTimer}
|
||||
@@ -194,11 +185,7 @@
|
||||
onclick={handleEndOfTrack}
|
||||
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
<span class="text-white">{getEndOfTrackLabel()}</span>
|
||||
@@ -208,27 +195,19 @@
|
||||
<!-- Episode countdown (only for TV episodes) -->
|
||||
{#if isEpisode}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">
|
||||
Stop after episodes
|
||||
</h3>
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after episodes</h3>
|
||||
<div class="space-y-2">
|
||||
{#each episodePresets as count}
|
||||
<button
|
||||
onclick={() => handleEpisodePreset(count)}
|
||||
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-white"
|
||||
>{count} more episode{count !== 1 ? "s" : ""}</span
|
||||
>
|
||||
<span class="text-white">{count} more episode{count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock video element for testing seek behavior
|
||||
function createMockVideoElement(options: {
|
||||
paused?: boolean;
|
||||
autoplay?: boolean;
|
||||
currentTime?: number;
|
||||
} = {}) {
|
||||
function createMockVideoElement(
|
||||
options: {
|
||||
paused?: boolean;
|
||||
autoplay?: boolean;
|
||||
currentTime?: number;
|
||||
} = {},
|
||||
) {
|
||||
const listeners: Record<string, (() => void)[]> = {};
|
||||
|
||||
return {
|
||||
@@ -13,11 +15,11 @@ function createMockVideoElement(options: {
|
||||
autoplay: options.autoplay ?? true,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
|
||||
pause: vi.fn(function(this: any) {
|
||||
pause: vi.fn(function (this: any) {
|
||||
this.paused = true;
|
||||
}),
|
||||
|
||||
play: vi.fn(function(this: any) {
|
||||
play: vi.fn(function (this: any) {
|
||||
this.paused = false;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
@@ -29,13 +31,13 @@ function createMockVideoElement(options: {
|
||||
|
||||
removeEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (listeners[event]) {
|
||||
listeners[event] = listeners[event].filter(h => h !== handler);
|
||||
listeners[event] = listeners[event].filter((h) => h !== handler);
|
||||
}
|
||||
}),
|
||||
|
||||
// Helper to trigger events in tests
|
||||
_triggerEvent: (event: string) => {
|
||||
listeners[event]?.forEach(h => h());
|
||||
listeners[event]?.forEach((h) => h());
|
||||
},
|
||||
|
||||
_getListeners: () => listeners,
|
||||
@@ -283,7 +285,13 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
// Simulate new position
|
||||
const newPosition = 120;
|
||||
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
if (
|
||||
newPosition &&
|
||||
newPosition > 0 &&
|
||||
isMediaReady &&
|
||||
videoElement &&
|
||||
hasPerformedInitialSeek
|
||||
) {
|
||||
hasPerformedInitialSeek = false;
|
||||
videoElement.currentTime = newPosition;
|
||||
currentTime = newPosition;
|
||||
@@ -301,7 +309,13 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
const newPosition = 120;
|
||||
|
||||
let seekTriggered = false;
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
if (
|
||||
newPosition &&
|
||||
newPosition > 0 &&
|
||||
isMediaReady &&
|
||||
videoElement &&
|
||||
hasPerformedInitialSeek
|
||||
) {
|
||||
seekTriggered = true;
|
||||
}
|
||||
|
||||
@@ -315,7 +329,13 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
const newPosition = 120;
|
||||
|
||||
let seekTriggered = false;
|
||||
if (newPosition && newPosition > 0 && isMediaReady && videoElement && hasPerformedInitialSeek) {
|
||||
if (
|
||||
newPosition &&
|
||||
newPosition > 0 &&
|
||||
isMediaReady &&
|
||||
videoElement &&
|
||||
hasPerformedInitialSeek
|
||||
) {
|
||||
seekTriggered = true;
|
||||
}
|
||||
|
||||
@@ -355,8 +375,10 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
let errorCaught = false;
|
||||
|
||||
// Simulate a video element that throws on currentTime set
|
||||
Object.defineProperty(videoElement, 'currentTime', {
|
||||
set: () => { throw new Error('Seek not allowed'); },
|
||||
Object.defineProperty(videoElement, "currentTime", {
|
||||
set: () => {
|
||||
throw new Error("Seek not allowed");
|
||||
},
|
||||
get: () => 0,
|
||||
});
|
||||
|
||||
@@ -371,7 +393,7 @@ describe("VideoPlayer Resume Logic", () => {
|
||||
|
||||
it("should handle play() rejection gracefully", async () => {
|
||||
const videoElement = createMockVideoElement();
|
||||
videoElement.play = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
|
||||
videoElement.play = vi.fn().mockRejectedValue(new Error("Autoplay blocked"));
|
||||
|
||||
let errorCaught = false;
|
||||
try {
|
||||
|
||||
@@ -146,9 +146,7 @@ async function mountNativePlayer() {
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
// The native path must NOT be overridden to HTML5 and must NOT be stopped —
|
||||
// if it were, these tests would be guarding the HTML5 path by accident.
|
||||
await waitFor(() =>
|
||||
expect(utils.container.querySelector("video")).toBeNull()
|
||||
);
|
||||
await waitFor(() => expect(utils.container.querySelector("video")).toBeNull());
|
||||
expect(playerStop).not.toHaveBeenCalled();
|
||||
return utils;
|
||||
}
|
||||
@@ -168,11 +166,7 @@ function poster(container: HTMLElement): HTMLElement | null {
|
||||
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
|
||||
* the store is what the component must read.
|
||||
*/
|
||||
async function backendReports(
|
||||
kind: "playing" | "paused" | "error",
|
||||
position = 0,
|
||||
duration = 0
|
||||
) {
|
||||
async function backendReports(kind: "playing" | "paused" | "error", position = 0, duration = 0) {
|
||||
const media = makeEpisode();
|
||||
if (kind === "playing") player.setPlaying(media, position, duration);
|
||||
else if (kind === "paused") player.setPaused(media, position, duration);
|
||||
@@ -219,7 +213,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
|
||||
await backendReports("paused", 5, 1440);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull(),
|
||||
);
|
||||
|
||||
await backendReports("playing", 6, 1440);
|
||||
@@ -228,25 +222,21 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
// both dims and covers the ExoPlayer surface while it plays. Before the
|
||||
// mirror, nothing after init could take it down, because the only other
|
||||
// writer was the never-emitted `player://state-changed` channel.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
|
||||
});
|
||||
|
||||
it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
|
||||
const { container } = await mountNativePlayer();
|
||||
|
||||
await backendReports("playing", 5, 1440);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull());
|
||||
|
||||
await backendReports("paused", 6, 1440);
|
||||
|
||||
// The mirror has to work in both directions, or pausing leaves no affordance
|
||||
// to resume.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -284,14 +274,18 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||
// defeated the first attempt: a one-shot timer armed on entry fired here,
|
||||
// declined, and was never re-armed.
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain("opacity-0");
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain(
|
||||
"opacity-0",
|
||||
);
|
||||
|
||||
// Playback starts late; the countdown has to restart on its own.
|
||||
player.setPlaying(makeEpisode(), 5, 1440);
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain("opacity-0")
|
||||
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain(
|
||||
"opacity-0",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -150,9 +150,7 @@ async function mountAndroidPlayer() {
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
expect(video).not.toBeNull();
|
||||
@@ -160,11 +158,7 @@ async function mountAndroidPlayer() {
|
||||
}
|
||||
|
||||
/** Scrub the seek bar to `target` seconds like a user drag. */
|
||||
async function scrubTo(
|
||||
slider: HTMLInputElement,
|
||||
video: HTMLVideoElement,
|
||||
target: number
|
||||
) {
|
||||
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
||||
await fireEvent.mouseDown(slider);
|
||||
slider.value = String(target);
|
||||
await fireEvent.input(slider);
|
||||
@@ -201,8 +195,8 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
||||
600,
|
||||
"src-1",
|
||||
null,
|
||||
true // HTML5 path: the webview owns playback after the override
|
||||
)
|
||||
true, // HTML5 path: the webview owns playback after the override
|
||||
),
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
@@ -100,7 +100,22 @@
|
||||
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
||||
}
|
||||
|
||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
|
||||
let {
|
||||
media,
|
||||
streamUrl,
|
||||
mediaSourceId,
|
||||
initialPosition,
|
||||
needsTranscoding = false,
|
||||
onClose,
|
||||
onSeek,
|
||||
onReportProgress,
|
||||
onReportStart,
|
||||
onReportStop,
|
||||
onEnded,
|
||||
onNext,
|
||||
hasNext = false,
|
||||
isLive = false,
|
||||
}: Props = $props();
|
||||
|
||||
// The id this player instance reports progress against. Snapshotted from the
|
||||
// media prop so a late reportStop (e.g. from onDestroy during autoplay
|
||||
@@ -140,12 +155,7 @@
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
return;
|
||||
}
|
||||
setHtml5VideoState(
|
||||
true,
|
||||
videoElement.videoWidth,
|
||||
videoElement.videoHeight,
|
||||
isPlaying
|
||||
);
|
||||
setHtml5VideoState(true, videoElement.videoWidth, videoElement.videoHeight, isPlaying);
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
@@ -242,8 +252,12 @@
|
||||
const adapterBridge: Html5ElementBridge = {
|
||||
getElement: () => videoElement,
|
||||
getSeekOffset: () => seekOffset,
|
||||
setSeekOffset: (o) => { seekOffset = o; },
|
||||
setStreamUrl: (u) => { currentStreamUrl = u; },
|
||||
setSeekOffset: (o) => {
|
||||
seekOffset = o;
|
||||
},
|
||||
setStreamUrl: (u) => {
|
||||
currentStreamUrl = u;
|
||||
},
|
||||
destroyHls: tearDownHls,
|
||||
getMediaSourceId: () => mediaSourceId ?? null,
|
||||
};
|
||||
@@ -278,7 +292,6 @@
|
||||
return videoDuration;
|
||||
});
|
||||
|
||||
|
||||
// The audio tracks available for this item, as the server described them.
|
||||
//
|
||||
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
|
||||
@@ -293,19 +306,22 @@
|
||||
log.debug("No media or mediaStreams available");
|
||||
return [];
|
||||
}
|
||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
||||
const tracks = media.mediaStreams.filter((stream) => stream.kind === "audio");
|
||||
log.debug("Found audio tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
|
||||
// Function to find best matching audio track based on preference
|
||||
function findBestAudioTrack(preference: { audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null }) {
|
||||
function findBestAudioTrack(preference: {
|
||||
audioTrackDisplayTitle?: string | null;
|
||||
audioTrackLanguage?: string | null;
|
||||
}) {
|
||||
const tracks = audioTracks();
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
// Try to match by display title first
|
||||
if (preference.audioTrackDisplayTitle) {
|
||||
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
||||
const match = tracks.find((t) => t.displayTitle === preference.audioTrackDisplayTitle);
|
||||
if (match) {
|
||||
log.debug("Matched audio track by display title:", match.displayTitle);
|
||||
return match.index;
|
||||
@@ -314,7 +330,7 @@
|
||||
|
||||
// Try to match by language
|
||||
if (preference.audioTrackLanguage) {
|
||||
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
||||
const match = tracks.find((t) => t.language === preference.audioTrackLanguage);
|
||||
if (match) {
|
||||
log.debug("Matched audio track by language:", match.language);
|
||||
return match.index;
|
||||
@@ -322,8 +338,11 @@
|
||||
}
|
||||
|
||||
// Fall back to default track
|
||||
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
||||
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
||||
const defaultTrack = tracks.find((t) => t.isDefault) || tracks[0];
|
||||
log.debug(
|
||||
"Using default/first audio track:",
|
||||
defaultTrack.displayTitle || defaultTrack.language,
|
||||
);
|
||||
return defaultTrack.index;
|
||||
}
|
||||
|
||||
@@ -388,7 +407,7 @@
|
||||
// Cross-origin <track> fetches use the media element's CORS setting; see
|
||||
// videoCrossOriginMode for why this is opt-in and same-origin-only.
|
||||
const videoCrossOrigin = $derived(
|
||||
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
|
||||
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -408,7 +427,10 @@
|
||||
renderedSubtitleTracks = tracks;
|
||||
// Keep the menu's checkmark and the element's text tracks in agreement:
|
||||
// a selection that no longer resolves collapses to "Off".
|
||||
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
|
||||
const selected = reconcileSelectedSubtitle(
|
||||
tracks,
|
||||
untrack(() => selectedSubtitleIndex),
|
||||
);
|
||||
selectedSubtitleIndex = selected;
|
||||
// The <track> children were just (re)created, so re-apply the selection to
|
||||
// the new TextTrack objects — otherwise a surviving selection shows nothing.
|
||||
@@ -439,7 +461,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Sleep-timer expiry pause is now driven by the backend through the player
|
||||
// adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
|
||||
// pause() (see handleControlCommand / the sleep_timer_expired case). This
|
||||
@@ -545,12 +566,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const isHlsStream = currentStreamUrl.includes('.m3u8');
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
|
||||
if (isHlsStream && Hls.isSupported()) {
|
||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||
if (hls) {
|
||||
log.debug('Cleaning up existing HLS instance');
|
||||
log.debug("Cleaning up existing HLS instance");
|
||||
// Detach from media element first to stop all audio/video
|
||||
hls.detachMedia();
|
||||
// Stop loading and flush buffers
|
||||
@@ -564,7 +585,7 @@
|
||||
// This is critical to prevent dual audio streams
|
||||
if (videoElement.src) {
|
||||
videoElement.pause(); // Ensure playback is stopped
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.removeAttribute("src");
|
||||
videoElement.load(); // Reset the media element and clear all buffers
|
||||
videoElement.currentTime = 0;
|
||||
}
|
||||
@@ -574,7 +595,7 @@
|
||||
setTimeout(() => {
|
||||
if (!videoElement) return;
|
||||
|
||||
log.debug('Creating new HLS instance for:', currentStreamUrl);
|
||||
log.debug("Creating new HLS instance for:", currentStreamUrl);
|
||||
|
||||
// Create new HLS instance
|
||||
hls = new Hls({
|
||||
@@ -602,14 +623,14 @@
|
||||
|
||||
// Listen for media attached event
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
log.debug('HLS.js attached to video element');
|
||||
log.debug("HLS.js attached to video element");
|
||||
// Load the HLS stream
|
||||
hls!.loadSource(currentStreamUrl);
|
||||
});
|
||||
|
||||
// Listen for manifest parsed event
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
log.debug('HLS manifest parsed, ready to play');
|
||||
log.debug("HLS manifest parsed, ready to play");
|
||||
});
|
||||
|
||||
// On the Android WebView the element's own `canplay` may not fire for
|
||||
@@ -626,7 +647,11 @@
|
||||
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
||||
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
||||
log.warn(
|
||||
"HLS canplay fallback - revealing video (readyState:",
|
||||
videoElement.readyState,
|
||||
")",
|
||||
);
|
||||
markMediaReady();
|
||||
}
|
||||
}, 5000);
|
||||
@@ -636,7 +661,7 @@
|
||||
|
||||
// Handle errors
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
log.error('HLS error:', data);
|
||||
log.error("HLS error:", data);
|
||||
if (data.fatal) {
|
||||
// Is this the stream ending or the stream breaking? Jellyfin's
|
||||
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
||||
@@ -647,31 +672,37 @@
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hlsFatalRecoveryAttempts++;
|
||||
switch (fatalNetworkErrorAction({
|
||||
positionSeconds: currentTime,
|
||||
knownDurationSeconds: knownDuration,
|
||||
attempts: hlsFatalRecoveryAttempts,
|
||||
})) {
|
||||
case 'ended':
|
||||
log.debug('Fatal network error near end of stream - treating as ended');
|
||||
switch (
|
||||
fatalNetworkErrorAction({
|
||||
positionSeconds: currentTime,
|
||||
knownDurationSeconds: knownDuration,
|
||||
attempts: hlsFatalRecoveryAttempts,
|
||||
})
|
||||
) {
|
||||
case "ended":
|
||||
log.debug("Fatal network error near end of stream - treating as ended");
|
||||
notifyEnded();
|
||||
break;
|
||||
case 'retry':
|
||||
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
case "retry":
|
||||
log.error(
|
||||
"Fatal network error, trying to recover (attempt",
|
||||
hlsFatalRecoveryAttempts,
|
||||
")",
|
||||
);
|
||||
hls!.startLoad();
|
||||
break;
|
||||
case 'giveUp':
|
||||
log.error('Fatal network error, max recovery attempts reached');
|
||||
case "giveUp":
|
||||
log.error("Fatal network error, max recovery attempts reached");
|
||||
hls!.destroy();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
log.error('Fatal media error, trying to recover');
|
||||
log.error("Fatal media error, trying to recover");
|
||||
hls!.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
log.error('Unrecoverable HLS error');
|
||||
log.error("Unrecoverable HLS error");
|
||||
hls!.destroy();
|
||||
break;
|
||||
}
|
||||
@@ -681,7 +712,7 @@
|
||||
|
||||
// Cleanup on effect re-run
|
||||
return () => {
|
||||
log.debug('Effect cleanup: destroying HLS instance');
|
||||
log.debug("Effect cleanup: destroying HLS instance");
|
||||
if (hls) {
|
||||
hls.detachMedia();
|
||||
hls.stopLoad();
|
||||
@@ -692,13 +723,13 @@
|
||||
videoElement.pause();
|
||||
}
|
||||
};
|
||||
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS support (Safari)
|
||||
log.debug('Using native HLS support');
|
||||
log.debug("Using native HLS support");
|
||||
videoElement.src = currentStreamUrl;
|
||||
} else {
|
||||
// Not an HLS stream, use regular video element
|
||||
log.debug('Using regular video element for non-HLS stream');
|
||||
log.debug("Using regular video element for non-HLS stream");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -707,7 +738,12 @@
|
||||
if (videoElement) {
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = 1.0;
|
||||
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
||||
log.debug(
|
||||
"Video element configured: muted=",
|
||||
videoElement.muted,
|
||||
"volume=",
|
||||
videoElement.volume,
|
||||
);
|
||||
|
||||
// DIAGNOSTIC: Check if video has audio tracks
|
||||
if ((videoElement as any).audioTracks) {
|
||||
@@ -715,7 +751,7 @@
|
||||
|
||||
// Set initial audio track (prefer default track)
|
||||
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
||||
const defaultTrack = audioTracks().find(t => t.isDefault);
|
||||
const defaultTrack = audioTracks().find((t) => t.isDefault);
|
||||
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
||||
log.debug("Selected default audio track:", selectedAudioTrackIndex);
|
||||
}
|
||||
@@ -724,7 +760,10 @@
|
||||
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
|
||||
}
|
||||
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
||||
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
||||
log.debug(
|
||||
"webkitAudioDecodedByteCount:",
|
||||
(videoElement as any).webkitAudioDecodedByteCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -768,10 +807,7 @@
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
onMount(() => {
|
||||
Promise.all([
|
||||
commands.playerGetStreamingQualities(),
|
||||
commands.playerGetVideoSettings(),
|
||||
])
|
||||
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()])
|
||||
.then(([qualities, settings]) => {
|
||||
streamingQualities = qualities;
|
||||
// Optional on the wire (serde default) — absent means uncapped.
|
||||
@@ -883,14 +919,18 @@
|
||||
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
||||
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
||||
try {
|
||||
log.debug("Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
||||
log.debug(
|
||||
"Using HTML5 for direct stream - stopping backend player to prevent dual audio",
|
||||
);
|
||||
await commands.playerStop();
|
||||
didStopBackendEarly = true; // Track that we stopped the backend
|
||||
} catch (err) {
|
||||
log.warn("Failed to stop backend player:", err);
|
||||
}
|
||||
} else if (useHtml5Element && needsTranscoding) {
|
||||
log.debug("Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
||||
log.debug(
|
||||
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
|
||||
);
|
||||
// Backend is kept running but should not play audio since HTML5 element handles playback
|
||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||
}
|
||||
@@ -902,7 +942,9 @@
|
||||
{
|
||||
const host = createRustReportHost(media.id, {
|
||||
onEnded: () => notifyEnded(),
|
||||
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
||||
onStreamUrlChanged: (u) => {
|
||||
currentStreamUrl = u;
|
||||
},
|
||||
});
|
||||
playerAdapter = createAdapter({
|
||||
backendKind: useHtml5Element ? "html5" : "native",
|
||||
@@ -966,12 +1008,12 @@
|
||||
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
|
||||
currentTime = event.payload.position;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
nativeUnlisteners.push(
|
||||
await listen("player://state-changed", (event: any) => {
|
||||
isPlaying = event.payload.state === "playing";
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1053,7 +1095,7 @@
|
||||
` paused=${videoElement.paused}` +
|
||||
` seeking=${videoElement.seeking}` +
|
||||
` rate=${videoElement.playbackRate}` +
|
||||
` buffered=${bufferedRanges.join(", ")}`
|
||||
` buffered=${bufferedRanges.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
@@ -1124,7 +1166,7 @@
|
||||
// Stop video element playback
|
||||
if (videoElement) {
|
||||
videoElement.pause();
|
||||
videoElement.src = '';
|
||||
videoElement.src = "";
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
@@ -1199,7 +1241,12 @@
|
||||
log.debug("Needs transcoding:", needsTranscoding);
|
||||
|
||||
// For direct streams without runTimeTicks, use video element's duration
|
||||
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
||||
if (
|
||||
videoElement &&
|
||||
videoElement.duration &&
|
||||
!isNaN(videoElement.duration) &&
|
||||
videoElement.duration !== Infinity
|
||||
) {
|
||||
const newDuration = videoElement.duration;
|
||||
log.debug("Setting videoDuration to:", newDuration);
|
||||
videoDuration = newDuration;
|
||||
@@ -1262,8 +1309,17 @@
|
||||
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
||||
await doSeek();
|
||||
} else {
|
||||
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
||||
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
||||
log.debug(
|
||||
"Deferring foreground seek until loadedmetadata:",
|
||||
(seekOffset + seekTo).toFixed(1),
|
||||
);
|
||||
el.addEventListener(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
void doSeek();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1368,7 +1424,13 @@
|
||||
log.error("Network state:", networkStates[video.networkState] || video.networkState);
|
||||
|
||||
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
||||
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
||||
const readyStates = [
|
||||
"HAVE_NOTHING",
|
||||
"HAVE_METADATA",
|
||||
"HAVE_CURRENT_DATA",
|
||||
"HAVE_FUTURE_DATA",
|
||||
"HAVE_ENOUGH_DATA",
|
||||
];
|
||||
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
|
||||
}
|
||||
|
||||
@@ -1399,10 +1461,16 @@
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement) {
|
||||
log.warn("canplay event did not fire within 5 seconds");
|
||||
log.debug("Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
||||
log.debug(
|
||||
"Fallback check - readyState:",
|
||||
videoElement.readyState,
|
||||
"networkState:",
|
||||
videoElement.networkState,
|
||||
);
|
||||
|
||||
// Check if video is actually ready despite event not firing
|
||||
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||
if (videoElement.readyState >= 3) {
|
||||
// HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
|
||||
markMediaReady();
|
||||
}
|
||||
@@ -1519,7 +1587,7 @@
|
||||
` ended=${el?.ended}` +
|
||||
` isSeeking=${isSeeking}` +
|
||||
` isBuffering=${isBuffering}` +
|
||||
` handoff=${handoffState.active}`
|
||||
` handoff=${handoffState.active}`,
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
@@ -1612,7 +1680,7 @@
|
||||
await playerController.seekVideo(
|
||||
targetTime,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex ?? null
|
||||
selectedAudioTrackIndex ?? null,
|
||||
);
|
||||
|
||||
// Resume smooth updates if still playing after the seek settled.
|
||||
@@ -1684,12 +1752,14 @@
|
||||
if (!media) return;
|
||||
// Ask the server for an audio-only stream of this video item (no video
|
||||
// decode), carrying the selected audio track and resume position.
|
||||
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
|
||||
media.id,
|
||||
mediaSourceId ?? undefined,
|
||||
pos,
|
||||
selectedAudioTrackIndex ?? undefined,
|
||||
);
|
||||
const audioUrl = await auth
|
||||
.getRepository()
|
||||
.getAudioOnlyStreamUrlForVideo(
|
||||
media.id,
|
||||
mediaSourceId ?? undefined,
|
||||
pos,
|
||||
selectedAudioTrackIndex ?? undefined,
|
||||
);
|
||||
await commands.playerEnterBackgroundAudio(
|
||||
{
|
||||
id: media.id,
|
||||
@@ -2110,7 +2180,7 @@
|
||||
streamIndex,
|
||||
arrayIndex,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null
|
||||
mediaSourceId ?? null,
|
||||
);
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
@@ -2125,7 +2195,7 @@
|
||||
if (!userId) return;
|
||||
|
||||
// Find the selected track info
|
||||
const selectedTrack = audioTracks().find(t => t.index === streamIndex);
|
||||
const selectedTrack = audioTracks().find((t) => t.index === streamIndex);
|
||||
if (selectedTrack) {
|
||||
await commands.storageSaveSeriesAudioPreference(
|
||||
userId,
|
||||
@@ -2133,9 +2203,12 @@
|
||||
media.serverId ?? "",
|
||||
selectedTrack.displayTitle || null,
|
||||
selectedTrack.language || null,
|
||||
streamIndex
|
||||
streamIndex,
|
||||
);
|
||||
log.debug(
|
||||
"Saved series audio preference:",
|
||||
selectedTrack.displayTitle || selectedTrack.language,
|
||||
);
|
||||
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to save series audio preference:", err);
|
||||
@@ -2175,7 +2248,7 @@
|
||||
quality,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex
|
||||
selectedAudioTrackIndex,
|
||||
);
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
@@ -2245,7 +2318,12 @@
|
||||
try {
|
||||
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||
await commands.playerSetSubtitleTrack(indexToUse);
|
||||
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||
log.debug(
|
||||
"Native backend subtitle track changed - streamIndex:",
|
||||
streamIndex,
|
||||
"position:",
|
||||
indexToUse,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to set subtitle track:", error);
|
||||
}
|
||||
@@ -2269,7 +2347,7 @@
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col z-50"
|
||||
class:bg-black={useHtml5Element}
|
||||
style:background-color={!useHtml5Element ? 'transparent' : ''}
|
||||
style:background-color={!useHtml5Element ? "transparent" : ""}
|
||||
onmousemove={handleMouseMove}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
@@ -2282,44 +2360,44 @@
|
||||
{#if !!useHtml5Element}
|
||||
<!-- HTML5 video for desktop/non-Android platforms -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
style="filter: brightness({brightness})"
|
||||
playsinline
|
||||
autoplay
|
||||
muted={false}
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onloadedmetadata={handleLoadedMetadata}
|
||||
oncanplay={handleCanPlay}
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
onended={handleEnded}
|
||||
onerror={handleError}
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!--
|
||||
bind:this={videoElement}
|
||||
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl}
|
||||
crossorigin={videoCrossOrigin}
|
||||
class={videoFitClass()}
|
||||
class:invisible={!isMediaReady}
|
||||
style="filter: brightness({brightness})"
|
||||
playsinline
|
||||
autoplay
|
||||
muted={false}
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onloadedmetadata={handleLoadedMetadata}
|
||||
oncanplay={handleCanPlay}
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
onended={handleEnded}
|
||||
onerror={handleError}
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!--
|
||||
Subtitles for the HTML5 path. `src` is a resolved string (see
|
||||
renderedSubtitleTracks); `data-stream-index` is what
|
||||
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
|
||||
attribute: a default track auto-shows, which would contradict the
|
||||
menu opening on "Off".
|
||||
-->
|
||||
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={track.url}
|
||||
srclang={track.srclang}
|
||||
label={track.label}
|
||||
data-stream-index={track.streamIndex}
|
||||
/>
|
||||
{/each}
|
||||
</video>
|
||||
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={track.url}
|
||||
srclang={track.srclang}
|
||||
label={track.label}
|
||||
data-stream-index={track.streamIndex}
|
||||
/>
|
||||
{/each}
|
||||
</video>
|
||||
{:else}
|
||||
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
||||
<!-- Leave this area transparent so video shows through -->
|
||||
@@ -2350,7 +2428,9 @@
|
||||
|
||||
<!-- Loading spinner overlay -->
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2360,8 +2440,12 @@
|
||||
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||
<path
|
||||
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
|
||||
/>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
|
||||
>{SEEK_BACKWARD_SECONDS}</text
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2371,8 +2455,12 @@
|
||||
<div class="absolute right-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||
<path
|
||||
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
|
||||
/>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
|
||||
>+{SEEK_FORWARD_SECONDS}</text
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2383,12 +2471,17 @@
|
||||
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div class="bg-black/60 rounded-lg px-4 py-3 backdrop-blur-sm flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" />
|
||||
<path
|
||||
d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-white text-xs font-medium">Brightness</span>
|
||||
<div class="w-24 h-1 bg-white/30 rounded-full mt-1">
|
||||
<div class="h-full bg-white rounded-full" style="width: {((brightness - 0.3) / 1.4) * 100}%"></div>
|
||||
<div
|
||||
class="h-full bg-white rounded-full"
|
||||
style="width: {((brightness - 0.3) / 1.4) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2398,7 +2491,9 @@
|
||||
<!-- Loading overlay for seeking -->
|
||||
{#if isSeeking}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if !isPlaying}
|
||||
<!-- Play overlay. Visually this IS the video surface, so it is marked
|
||||
@@ -2454,7 +2549,9 @@
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 text-white/80 text-sm">
|
||||
<!-- No resolved Jellyfin id → no headshot available. -->
|
||||
<span class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400">
|
||||
<span
|
||||
class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400"
|
||||
>
|
||||
{actor.name.slice(0, 1)}
|
||||
</span>
|
||||
<span>{actor.name}</span>
|
||||
@@ -2506,9 +2603,9 @@
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarRelease}
|
||||
onmousedown={() => isDraggingSeekBar = true}
|
||||
onmousedown={() => (isDraggingSeekBar = true)}
|
||||
onmouseup={handleSeekBarRelease}
|
||||
ontouchstart={() => isDraggingSeekBar = true}
|
||||
ontouchstart={() => (isDraggingSeekBar = true)}
|
||||
ontouchend={handleSeekBarRelease}
|
||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
@@ -2522,7 +2619,11 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Play/Pause -->
|
||||
<button onclick={togglePlayPause} class="text-white hover:text-gray-300" aria-label={isPlaying ? "Pause" : "Play"}>
|
||||
<button
|
||||
onclick={togglePlayPause}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
@@ -2554,13 +2655,17 @@
|
||||
aria-label="Select audio track"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Audio Track Menu -->
|
||||
{#if showAudioTrackMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Audio Track
|
||||
@@ -2568,7 +2673,10 @@
|
||||
{#each audioTracks() as track, i}
|
||||
<button
|
||||
onclick={() => selectAudioTrack(track.index, i)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex === track.index ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${i + 1}`}
|
||||
@@ -2577,8 +2685,12 @@
|
||||
{/if}
|
||||
</span>
|
||||
{#if selectedAudioTrackIndex === track.index}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2600,12 +2712,16 @@
|
||||
>
|
||||
<!-- Speedometer: bitrate ceiling -->
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"/>
|
||||
<path
|
||||
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showQualityMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Quality
|
||||
@@ -2613,15 +2729,22 @@
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => selectQuality(quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2641,13 +2764,17 @@
|
||||
aria-label="Select subtitles"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"/>
|
||||
<path
|
||||
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Subtitle Menu -->
|
||||
{#if showSubtitleMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Subtitles
|
||||
@@ -2655,12 +2782,19 @@
|
||||
<!-- Off option -->
|
||||
<button
|
||||
onclick={() => selectSubtitle(null)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === null ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
null
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">Off</span>
|
||||
{#if selectedSubtitleIndex === null}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2668,7 +2802,10 @@
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">
|
||||
@@ -2685,8 +2822,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedSubtitleIndex === track.index}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -2699,15 +2840,23 @@
|
||||
|
||||
<!-- Sleep Timer -->
|
||||
{#if $sleepTimerActive}
|
||||
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
|
||||
<SleepTimerIndicator
|
||||
onClick={() => {
|
||||
showSleepTimerModal = true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { showSleepTimerModal = true; }}
|
||||
onclick={() => {
|
||||
showSleepTimerModal = true;
|
||||
}}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
|
||||
<path
|
||||
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -2723,7 +2872,9 @@
|
||||
aria-label="Picture in picture"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
|
||||
<path
|
||||
d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -2733,23 +2884,35 @@
|
||||
{#if backgroundAudioSupported}
|
||||
<button
|
||||
onclick={toggleBackgroundAudio}
|
||||
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
|
||||
class={backgroundAudioOn
|
||||
? "text-blue-400 hover:text-blue-300"
|
||||
: "text-white hover:text-gray-300"}
|
||||
aria-label="Background audio"
|
||||
aria-pressed={backgroundAudioOn}
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
|
||||
<path
|
||||
d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Fullscreen -->
|
||||
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Toggle fullscreen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
{#if isFullscreen}
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
<path
|
||||
d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
|
||||
/>
|
||||
{:else}
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
<path
|
||||
d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
@@ -2757,7 +2920,12 @@
|
||||
<!-- Close -->
|
||||
<button onclick={onClose} class="text-white hover:text-gray-300" aria-label="Close">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2765,7 +2933,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
|
||||
<SleepTimerModal
|
||||
isOpen={showSleepTimerModal}
|
||||
onClose={() => {
|
||||
showSleepTimerModal = false;
|
||||
}}
|
||||
mediaType={media?.type}
|
||||
/>
|
||||
|
||||
<style>
|
||||
@keyframes fade-out {
|
||||
|
||||
@@ -116,7 +116,7 @@ function touchAt(el: Element, x: number) {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [touch] as unknown as Touch[],
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,9 +139,7 @@ async function mountAndroidPlayer() {
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
return { ...utils, slider, video };
|
||||
@@ -157,11 +155,7 @@ function touch(x: number, y: number) {
|
||||
* A real drag along the bar moves the finger far enough that the container's
|
||||
* swipe detector (50px) would trigger if it were still listening.
|
||||
*/
|
||||
async function touchScrubTo(
|
||||
slider: HTMLInputElement,
|
||||
video: HTMLVideoElement,
|
||||
target: number
|
||||
) {
|
||||
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
||||
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
||||
// drag; the horizontal travel is what matters.
|
||||
@@ -187,7 +181,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
@@ -216,7 +210,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
await tick();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
{#if showSlider}
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => showSlider = false}
|
||||
onclick={() => (showSlider = false)}
|
||||
aria-label="Close volume"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const initialHandoffState: BackgroundAudioState = {
|
||||
*/
|
||||
export function shouldEnterBackgroundAudio(
|
||||
toggleOn: boolean,
|
||||
state: BackgroundAudioState
|
||||
state: BackgroundAudioState,
|
||||
): boolean {
|
||||
return toggleOn && !state.active;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean
|
||||
*/
|
||||
export function shouldResumeOnForeground(
|
||||
wasPlaying: boolean,
|
||||
nativeStateKind: string | undefined
|
||||
nativeStateKind: string | undefined,
|
||||
): boolean {
|
||||
return wasPlaying && nativeStateKind !== "paused";
|
||||
}
|
||||
|
||||
@@ -37,10 +37,7 @@ export interface FatalNetworkErrorInput {
|
||||
}
|
||||
|
||||
/** Whether a failure at this position should be read as the stream ending. */
|
||||
export function isNearEndOfStream(
|
||||
positionSeconds: number,
|
||||
knownDurationSeconds: number
|
||||
): boolean {
|
||||
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
|
||||
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
|
||||
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
|
||||
}
|
||||
|
||||
@@ -16,34 +16,26 @@ describe("nativeSignalRevealsVideo", () => {
|
||||
"leaves the poster up on state %s",
|
||||
(state) => {
|
||||
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reveals on a position tick that carries a duration", () => {
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
|
||||
).toBe(true);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals on a position tick that has advanced, even with no duration", () => {
|
||||
// Live streams report no duration; an advancing position is still proof
|
||||
// that the surface has content.
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
|
||||
).toBe(true);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the poster up on an empty position tick", () => {
|
||||
// A tick before anything is loaded proves nothing, and revealing here would
|
||||
// show a transparent hole through the app.
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
|
||||
).toBe(false);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a negative position as progress", () => {
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })
|
||||
).toBe(false);
|
||||
expect(nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
|
||||
/** A player event that might mean "the surface has a picture on it". */
|
||||
export type NativeRevealSignal =
|
||||
| { kind: "state"; state: string }
|
||||
| { kind: "position"; position: number; duration: number };
|
||||
{ kind: "state"; state: string } | { kind: "position"; position: number; duration: number };
|
||||
|
||||
/**
|
||||
* Whether `signal` proves the native backend is rendering, and the poster card
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "episode-1",
|
||||
isVideo: true,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: null,
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
isVideo: false,
|
||||
startPosition: 42,
|
||||
forceRestart: false,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
activeMediaId: "episode-2",
|
||||
isVideo: true,
|
||||
forceRestart: true,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe("shouldReuseActivePlayback", () => {
|
||||
describe("resolvePlayerSurface", () => {
|
||||
it("renders the video surface for video with a stream URL", () => {
|
||||
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe(
|
||||
"video"
|
||||
"video",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,8 +68,18 @@ describe("subtitleStreamsOf", () => {
|
||||
*/
|
||||
it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
|
||||
const streams: SubtitleStreamLike[] = [
|
||||
{ index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false },
|
||||
{ index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true },
|
||||
{
|
||||
index: 2,
|
||||
kind: "subtitle",
|
||||
displayTitle: "English PGS SDH",
|
||||
supportsExternalDelivery: false,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
kind: "subtitle",
|
||||
displayTitle: "English Text SDH",
|
||||
supportsExternalDelivery: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
|
||||
@@ -119,7 +129,9 @@ describe("subtitleStreamsOf", () => {
|
||||
|
||||
describe("subtitleTrackLabel", () => {
|
||||
it("prefers the display title, then language, then the index", () => {
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe(
|
||||
"English (SRT)",
|
||||
);
|
||||
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
|
||||
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
|
||||
});
|
||||
@@ -309,10 +321,7 @@ describe("nativeSubtitleArrayIndex", () => {
|
||||
});
|
||||
|
||||
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, "VideoPlayer.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
||||
|
||||
it("renders <track> elements instead of leaving them commented out", () => {
|
||||
expect(source).not.toContain("Temporarily disabled to debug playback issues");
|
||||
|
||||
@@ -209,9 +209,7 @@ export function videoCrossOriginMode(
|
||||
*
|
||||
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||
*/
|
||||
export function nativeSubtitleTracks(
|
||||
tracks: readonly RenderableSubtitleTrack[],
|
||||
): SubtitleTrack[] {
|
||||
export function nativeSubtitleTracks(tracks: readonly RenderableSubtitleTrack[]): SubtitleTrack[] {
|
||||
return tracks.map((track) => ({
|
||||
index: track.streamIndex,
|
||||
url: track.url,
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("seek target resolution", () => {
|
||||
// that starts at/after the media end, which the server never produces —
|
||||
// the fetch times out and the gap-controller stalls in a pause loop.
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
|
||||
DURATION - END_SEEK_MARGIN_SECONDS
|
||||
DURATION - END_SEEK_MARGIN_SECONDS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -214,9 +214,9 @@ describe("control-surface touches are not gestures", () => {
|
||||
});
|
||||
|
||||
it("treats anything inside the controls bar as a control", () => {
|
||||
expect(
|
||||
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
|
||||
).toBe(true);
|
||||
expect(isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a tap on the bare video surface through as a gesture", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const TOUCH_CLICK_SUPPRESS_MS = 700;
|
||||
* testable without a DOM.
|
||||
*/
|
||||
export function isControlSurfaceTouch(
|
||||
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
|
||||
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>,
|
||||
): boolean {
|
||||
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
|
||||
for (const node of ancestors) {
|
||||
@@ -75,7 +75,7 @@ export function isControlSurfaceTouch(
|
||||
export function isSynthesizedTouchClick(
|
||||
detail: number,
|
||||
now: number,
|
||||
lastTouchTapAt: number
|
||||
lastTouchTapAt: number,
|
||||
): boolean {
|
||||
if (detail === 0) return true;
|
||||
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
|
||||
@@ -219,7 +219,9 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||
|
||||
const base =
|
||||
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||
pendingTarget != null &&
|
||||
Math.abs(pendingTarget - reportedPosition) > 0.5 &&
|
||||
pendingTarget > reportedPosition
|
||||
? pendingTarget
|
||||
: reportedPosition;
|
||||
|
||||
|
||||
@@ -37,10 +37,7 @@ export function fittedVideoSize(
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
containerWidth / intrinsicWidth,
|
||||
containerHeight / intrinsicHeight,
|
||||
);
|
||||
const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
|
||||
|
||||
return {
|
||||
width: intrinsicWidth * scale,
|
||||
|
||||
@@ -32,9 +32,12 @@
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
// Find music library for playlist browsing
|
||||
const musicLib = $libraries.find(lib => lib.collectionType === "music");
|
||||
const musicLib = $libraries.find((lib) => lib.collectionType === "music");
|
||||
if (musicLib) {
|
||||
const result = await repo.getItems(musicLib.id, { includeItemTypes: ["Playlist"], limit: 100 });
|
||||
const result = await repo.getItems(musicLib.id, {
|
||||
includeItemTypes: ["Playlist"],
|
||||
limit: 100,
|
||||
});
|
||||
playlists = result.items;
|
||||
} else {
|
||||
// Try searching for playlists without a parent
|
||||
@@ -79,7 +82,9 @@
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") onClose?.();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
@@ -97,9 +102,11 @@
|
||||
onclick={handleNewPlaylist}
|
||||
class="w-full flex items-center gap-3 p-3 bg-[var(--color-background)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-[var(--color-jellyfin)] rounded flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
class="w-10 h-10 bg-[var(--color-jellyfin)] rounded flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-white font-medium">New Playlist</span>
|
||||
@@ -136,7 +143,9 @@
|
||||
{:else}
|
||||
<div class="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
|
||||
<path
|
||||
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
creating = true;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.createPlaylist(trimmed, initialItemIds.length > 0 ? initialItemIds : undefined);
|
||||
const result = await repo.createPlaylist(
|
||||
trimmed,
|
||||
initialItemIds.length > 0 ? initialItemIds : undefined,
|
||||
);
|
||||
toast.success(`Playlist "${trimmed}" created`);
|
||||
name = "";
|
||||
onClose?.();
|
||||
@@ -48,7 +51,9 @@
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") onClose?.();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
let scope = $state<SearchScope>(
|
||||
isSearchRoute($page.url.pathname)
|
||||
? parseSearchScope($page.url.searchParams.get("scope"))
|
||||
: resolveSearchScope($page.url.pathname)
|
||||
: resolveSearchScope($page.url.pathname),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -80,9 +80,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Search
|
||||
bind:value
|
||||
bind:inputEl
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Search bind:value bind:inputEl placeholder="Search your library..." onSearch={handleSearch} />
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("on /search", () => {
|
||||
|
||||
expect(goto).toHaveBeenCalledWith(
|
||||
"/search?q=jazzy&scope=tv",
|
||||
expect.objectContaining({ replaceState: true })
|
||||
expect.objectContaining({ replaceState: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
{:else if groups.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
<path
|
||||
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user