chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
This commit is contained in:
+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