diff --git a/docker-compose.yml b/docker-compose.yml index 191e0647..1c6030bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/scripts/extract-traces.test.ts b/scripts/extract-traces.test.ts index d88fcced..caef48f7 100644 --- a/scripts/extract-traces.test.ts +++ b/scripts/extract-traces.test.ts @@ -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/", () => { @@ -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 diff --git a/scripts/extract-traces.ts b/scripts/extract-traces.ts index 612bebc1..69cf4249 100644 --- a/scripts/extract-traces.ts +++ b/scripts/extract-traces.ts @@ -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`); } diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 5bb8d58e..79988167 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -55,9 +55,7 @@ function loadRequirementDescriptions(): Map { } 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)); diff --git a/scripts/set-version.test.ts b/scripts/set-version.test.ts index 22ce6d7b..1c6f5b29 100644 --- a/scripts/set-version.test.ts +++ b/scripts/set-version.test.ts @@ -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; } diff --git a/scripts/tauri-security-config.test.ts b/scripts/tauri-security-config.test.ts index 5f92c785..13d90e45 100644 --- a/scripts/tauri-security-config.test.ts +++ b/scripts/tauri-security-config.test.ts @@ -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; diff --git a/src/app.css b/src/app.css index 824a1ae8..49094cd1 100644 --- a/src/app.css +++ b/src/app.css @@ -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; } diff --git a/src/app.html b/src/app.html index 4fc18c67..af62232e 100644 --- a/src/app.html +++ b/src/app.html @@ -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). --> - + JellyTau %sveltekit.head% diff --git a/src/lib/api/autoplay.test.ts b/src/lib/api/autoplay.test.ts index 3956b0ef..ef32db1d 100644 --- a/src/lib/api/autoplay.test.ts +++ b/src/lib/api/autoplay.test.ts @@ -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 }); }); diff --git a/src/lib/api/autoplay.ts b/src/lib/api/autoplay.ts index f84ee43d..0aa8f255 100644 --- a/src/lib/api/autoplay.ts +++ b/src/lib/api/autoplay.ts @@ -14,9 +14,7 @@ export async function getAutoplaySettings(): Promise { return commands.playerGetAutoplaySettings(); } -export async function setAutoplaySettings( - settings: AutoplaySettings -): Promise { +export async function setAutoplaySettings(settings: AutoplaySettings): Promise { return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings); } diff --git a/src/lib/api/backend-integration.test.ts b/src/lib/api/backend-integration.test.ts index 830430e9..368644a7 100644 --- a/src/lib/api/backend-integration.test.ts +++ b/src/lib/api/backend-integration.test.ts @@ -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 () => { diff --git a/src/lib/api/repository-client.test.ts b/src/lib/api/repository-client.test.ts index 0cb6826d..3ef2b6ee 100644 --- a/src/lib/api/repository-client.test.ts +++ b/src/lib/api/repository-client.test.ts @@ -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, - }) + }), ); }); }); diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 71d153a8..b762c595 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -40,7 +40,7 @@ export class RepositoryClient { serverUrl: string, userId: string, accessToken: string, - serverId: string + serverId: string, ): Promise { 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 { - 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 { - return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null); + return commands.repositoryGetRediscoverAlbums( + this.ensureHandle(), + parentId ?? null, + limit ?? null, + ); } async getGenres(parentId?: string): Promise { @@ -229,13 +237,13 @@ export class RepositoryClient { async getVideoStreamUrl( itemId: string, mediaSourceId?: string, - audioStreamIndex?: number + audioStreamIndex?: number, ): Promise { 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 { 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 { + async getImageUrl( + itemId: string, + imageType: ImageType = "Primary", + options?: ImageOptions, + ): Promise { 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 { - 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 { - return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null); + return commands.repositoryGetVideoDownloadUrl( + this.ensureHandle(), + itemId, + quality, + mediaSourceId ?? null, + ); } // ===== Favorite Methods (via Rust) ===== diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 98ad9789..d4405a48 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -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"; diff --git a/src/lib/components/AppHeader.svelte b/src/lib/components/AppHeader.svelte index ad6fa1da..96f837e5 100644 --- a/src/lib/components/AppHeader.svelte +++ b/src/lib/components/AppHeader.svelte @@ -19,36 +19,49 @@ const withSearch = $derived(showHeaderSearch({ pathname })); -
+
- - JellyTau - + JellyTau
diff --git a/src/lib/components/account/AccountMenu.svelte b/src/lib/components/account/AccountMenu.svelte index c1fa616f..8311c1d8 100644 --- a/src/lib/components/account/AccountMenu.svelte +++ b/src/lib/components/account/AccountMenu.svelte @@ -82,7 +82,9 @@
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)} > - + Downloads @@ -121,8 +128,18 @@ onclick={() => close(false)} > - - + + Settings @@ -133,7 +150,12 @@ onclick={() => close(false)} > - + Display @@ -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" > - + Sign out diff --git a/src/lib/components/auth/ReauthModal.svelte b/src/lib/components/auth/ReauthModal.svelte index 46313918..4c2ca36f 100644 --- a/src/lib/components/auth/ReauthModal.svelte +++ b/src/lib/components/auth/ReauthModal.svelte @@ -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 @@
{ 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 @@
-
- +
+
-

- Session Expired -

+

Session Expired

- Your session on {serverName} has expired. - Please enter your password to continue. + Your session on {serverName} has expired. Please + enter your password to continue.

@@ -102,7 +99,10 @@
Username
-
+
{username}
@@ -140,8 +140,19 @@ > {#if $isAuthLoading} - - + + Authenticating... {:else} diff --git a/src/lib/components/common/BackButton.svelte b/src/lib/components/common/BackButton.svelte index c95b219c..b606b8d4 100644 --- a/src/lib/components/common/BackButton.svelte +++ b/src/lib/components/common/BackButton.svelte @@ -27,7 +27,11 @@ aria-label={label} class={`text-gray-400 hover:text-white transition-colors ${className}`} > - + diff --git a/src/lib/components/common/CachedImage.svelte b/src/lib/components/common/CachedImage.svelte index 93832afc..c8ee8c86 100644 --- a/src/lib/components/common/CachedImage.svelte +++ b/src/lib/components/common/CachedImage.svelte @@ -86,11 +86,17 @@ {#if loading} -
+
{:else if error || !imageUrl}
- +
{:else} diff --git a/src/lib/components/common/ResultsCounter.svelte b/src/lib/components/common/ResultsCounter.svelte index 93bf1c1e..3aec3955 100644 --- a/src/lib/components/common/ResultsCounter.svelte +++ b/src/lib/components/common/ResultsCounter.svelte @@ -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); diff --git a/src/lib/components/common/ScrollPicker.svelte b/src/lib/components/common/ScrollPicker.svelte index 68e4e403..a8699231 100644 --- a/src/lib/components/common/ScrollPicker.svelte +++ b/src/lib/components/common/ScrollPicker.svelte @@ -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; } }); -
+
-
-
+
+
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} diff --git a/src/lib/components/common/SearchBar.test.ts b/src/lib/components/common/SearchBar.test.ts index 68fded9f..4bffcbd0 100644 --- a/src/lib/components/common/SearchBar.test.ts +++ b/src/lib/components/common/SearchBar.test.ts @@ -146,7 +146,7 @@ describe("SearchBar", () => { it("should handle special characters in value", () => { render(SearchBar, { props: { - value: '@$%^&*()', + value: "@$%^&*()", placeholder: "Search...", onInput: vi.fn(), }, diff --git a/src/lib/components/downloads/DownloadItem.svelte b/src/lib/components/downloads/DownloadItem.svelte index 06ec20eb..a6f99257 100644 --- a/src/lib/components/downloads/DownloadItem.svelte +++ b/src/lib/components/downloads/DownloadItem.svelte @@ -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 @@ } -
+
@@ -115,12 +117,32 @@
{#if download.mediaType === "video"} - - + + {:else} - - + + {/if}
@@ -135,10 +157,16 @@

{download.seriesName} {#if download.seasonNumber !== undefined && download.episodeNumber !== undefined} - • S{String(download.seasonNumber).padStart(2, '0')}E{String(download.episodeNumber).padStart(2, '0')} + + • S{String(download.seasonNumber).padStart(2, "0")}E{String( + download.episodeNumber, + ).padStart(2, "0")} {/if} {#if download.qualityPreset && download.qualityPreset !== "original"} - {download.qualityPreset} + {download.qualityPreset} {/if}

{:else if download.mediaType === "video"} @@ -146,20 +174,27 @@

Movie {#if download.qualityPreset && download.qualityPreset !== "original"} - {download.qualityPreset} + {download.qualityPreset} {/if}

{:else if download.artistName || download.albumName}

- {download.artistName}{download.artistName && download.albumName ? ' • ' : ''}{download.albumName} + {download.artistName}{download.artistName && download.albumName + ? " • " + : ""}{download.albumName}

{/if}
{getStatusText()} - {#if download.downloadSource === 'auto'} - Auto + {#if download.downloadSource === "auto"} + Auto {/if}
@@ -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" > - + @@ -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" > - + @@ -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" > - + @@ -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" > - + - + - +
- - + +

{formatBytes($downloadedDeviceTotal)} @@ -131,10 +141,7 @@ {#if currentLibrary}

- / @@ -163,8 +170,18 @@ {:else if $downloadedLibraries.length === 0}
- - + +

Nothing downloaded yet

@@ -185,12 +202,26 @@ onclick={() => openLibrary(lib)} class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105" > -

- - +
+ +
-

+

{lib.name}

diff --git a/src/lib/components/home/Carousel.svelte b/src/lib/components/home/Carousel.svelte index f1e4710c..2cb41972 100644 --- a/src/lib/components/home/Carousel.svelte +++ b/src/lib/components/home/Carousel.svelte @@ -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 @@

{title}

{#if showAll} - {/if} @@ -74,7 +70,7 @@ aria-label="Scroll left" > - + {/if} @@ -86,7 +82,7 @@ aria-label="Scroll right" > - + {/if} diff --git a/src/lib/components/home/HeroBanner.svelte b/src/lib/components/home/HeroBanner.svelte index aced0a72..828f5af5 100644 --- a/src/lib/components/home/HeroBanner.svelte +++ b/src/lib/components/home/HeroBanner.svelte @@ -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} -
+
{/if} @@ -175,7 +185,9 @@ {#if currentItem.communityRating} - + {currentItem.communityRating.toFixed(1)} @@ -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" > - + Play @@ -225,12 +237,17 @@ {#if items.length > 1} -
+
{#each items as _, idx} {/each}
@@ -242,7 +259,12 @@ aria-label="Previous item" > - + @@ -252,7 +274,7 @@ aria-label="Next item" > - + {/if} diff --git a/src/lib/components/library/AlbumDownloadButton.svelte b/src/lib/components/library/AlbumDownloadButton.svelte index 32fe5666..a176b7e6 100644 --- a/src/lib/components/library/AlbumDownloadButton.svelte +++ b/src/lib/components/library/AlbumDownloadButton.svelte @@ -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 @@ {:else if isFullyDownloaded} - + {:else if failedCount > 0} @@ -210,13 +188,7 @@ {:else} - + 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}`} > diff --git a/src/lib/components/library/ArtistDetailView.svelte b/src/lib/components/library/ArtistDetailView.svelte index 2af02d53..0b4d1106 100644 --- a/src/lib/components/library/ArtistDetailView.svelte +++ b/src/lib/components/library/ArtistDetailView.svelte @@ -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" /> -
+
{/if} @@ -168,11 +170,10 @@ {:else}
{#each albums as album (album.id)} - -
+ +
{#if album.imageId} {/if}
-

+

{truncateMiddle(album.name, 40)}

{#if album.productionYear} @@ -226,11 +229,10 @@ {:else}
{#each relatedArtists as relatedArtist (relatedArtist.id)} - -
+ +
{#if relatedArtist.imageId} {/if}
-

+

{relatedArtist.name}

diff --git a/src/lib/components/library/ArtistLinks.svelte b/src/lib/components/library/ArtistLinks.svelte index e739a100..85cac7ca 100644 --- a/src/lib/components/library/ArtistLinks.svelte +++ b/src/lib/components/library/ArtistLinks.svelte @@ -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(); diff --git a/src/lib/components/library/CastSection.svelte b/src/lib/components/library/CastSection.svelte index 2948b437..f322a133 100644 --- a/src/lib/components/library/CastSection.svelte +++ b/src/lib/components/library/CastSection.svelte @@ -95,7 +95,9 @@
-

+

{person.name}

{#if person.role} diff --git a/src/lib/components/library/ClearHistoryButton.svelte b/src/lib/components/library/ClearHistoryButton.svelte index 90f2092b..91bca942 100644 --- a/src/lib/components/library/ClearHistoryButton.svelte +++ b/src/lib/components/library/ClearHistoryButton.svelte @@ -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'}" >
{:else} - + 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) { diff --git a/src/lib/components/library/DownloadButton.svelte b/src/lib/components/library/DownloadButton.svelte index b5857d4e..53e052d6 100644 --- a/src/lib/components/library/DownloadButton.svelte +++ b/src/lib/components/library/DownloadButton.svelte @@ -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 @@
- +
diff --git a/src/lib/components/library/DownloadButtonCore.svelte b/src/lib/components/library/DownloadButtonCore.svelte index b88ff5ed..1dc11ce8 100644 --- a/src/lib/components/library/DownloadButtonCore.svelte +++ b/src/lib/components/library/DownloadButtonCore.svelte @@ -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"} - + - + {Math.round(state.progress * 100)}% @@ -79,7 +100,9 @@ {:else if state.status === "failed"} - + {:else if state.status === "pending"} @@ -90,7 +113,12 @@ {:else} - + {/if} diff --git a/src/lib/components/library/EpisodeFocusView.svelte b/src/lib/components/library/EpisodeFocusView.svelte index 73896e62..509f97b7 100644 --- a/src/lib/components/library/EpisodeFocusView.svelte +++ b/src/lib/components/library/EpisodeFocusView.svelte @@ -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)); @@ -128,12 +134,16 @@ class="absolute inset-0 w-full h-full object-cover" /> {:else} -
+
{/if}
-
+
{#if onBack} @@ -143,7 +153,12 @@ title="Back to series" > - + {/if} @@ -156,7 +171,10 @@ {#if seriesName}

{#if seriesHref} - + {seriesName} {:else} @@ -192,7 +210,9 @@ {#if episode.communityRating} - + {episode.communityRating.toFixed(1)} @@ -200,7 +220,7 @@ {#if episode.userData?.isPlayed} - + Watched @@ -218,10 +238,7 @@ {#if progress > 0 && progress < 95}

-
+

{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" > - + {progress > 0 && progress < 95 ? "Resume" : "Play"} @@ -271,89 +288,106 @@ strip — continuation content comes before discovery content (ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 --> {#if hasEpisodeStrip} -

-

More Episodes

+
+

More Episodes

-
- {#each adjacentEpisodes() as ep (ep.id)} - {@const isCurrent = isCurrentEpisode(ep)} - {@const epProgress = getProgress(ep)} - - {/each} + + +
+
+ + {stripCardLabel(ep, episode)} + +

+ {ep.name} +

+
+ {#if ep.overview} +

+ {ep.overview} +

+ {/if} +
+ + {/each} +
-
{/if} -
+
-
+
-
+
- +
@@ -98,10 +98,7 @@ {#if progress() > 0}
-
+
{/if} @@ -110,7 +107,13 @@
{#if isDownloaded}
- +
@@ -153,7 +156,9 @@ {episodeNumber}. -

+

{truncateMiddle(episode.name, 56)}

{#if current} @@ -165,8 +170,12 @@ {/if} {#if episode.userData?.isPlayed} - - + + {/if}
diff --git a/src/lib/components/library/GenericGenreBrowser.svelte b/src/lib/components/library/GenericGenreBrowser.svelte index 5992b058..1f27b2d1 100644 --- a/src/lib/components/library/GenericGenreBrowser.svelte +++ b/src/lib/components/library/GenericGenreBrowser.svelte @@ -72,9 +72,7 @@ // Auto-select a genre when linked with ?genre= (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`);
@@ -188,7 +193,7 @@ {#if !loading && filteredGenres.length > 0} - + {/if} @@ -220,7 +225,9 @@ {@html config.genreIcon}
-

+

{genre.name}

@@ -244,11 +251,16 @@
{:else}
- +
{#each genreItems as item (item.id)} @@ -320,7 +321,7 @@ {#if !loading} - + {/if} @@ -349,7 +350,13 @@
{#if config.displayComponent === "grid"} - + {:else if config.displayComponent === "tracklist"} {/if} diff --git a/src/lib/components/library/GenericMediaListPage.test.ts b/src/lib/components/library/GenericMediaListPage.test.ts index 9bd55aca..a705c3af 100644 --- a/src/lib/components/library/GenericMediaListPage.test.ts +++ b/src/lib/components/library/GenericMediaListPage.test.ts @@ -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. diff --git a/src/lib/components/library/GenreFilter.svelte b/src/lib/components/library/GenreFilter.svelte index a682cabd..532c8d25 100644 --- a/src/lib/components/library/GenreFilter.svelte +++ b/src/lib/components/library/GenreFilter.svelte @@ -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} diff --git a/src/lib/components/library/GenreTags.svelte b/src/lib/components/library/GenreTags.svelte index 85a8f58f..36293884 100644 --- a/src/lib/components/library/GenreTags.svelte +++ b/src/lib/components/library/GenreTags.svelte @@ -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 @@ diff --git a/src/lib/components/library/LibraryGrid.svelte b/src/lib/components/library/LibraryGrid.svelte index ef5f24a1..c9bdadb2 100644 --- a/src/lib/components/library/LibraryGrid.svelte +++ b/src/lib/components/library/LibraryGrid.svelte @@ -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();
@@ -38,22 +48,26 @@
@@ -64,7 +78,11 @@
{#each Array(6) as _}
-
+
@@ -75,7 +93,7 @@

No items found

{:else if $viewMode === "list"} - + {:else}
{#each items as item, index (item.id)} diff --git a/src/lib/components/library/LibraryListView.svelte b/src/lib/components/library/LibraryListView.svelte index 75ec6a54..b587eb95 100644 --- a/src/lib/components/library/LibraryListView.svelte +++ b/src/lib/components/library/LibraryListView.svelte @@ -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"} diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 43724af5..c0f2eaaf 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -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(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} > -
+
-
-
-
+
+
+
- +
@@ -275,10 +309,7 @@ {#if progress() > 0}
-
+
{/if} @@ -288,7 +319,7 @@
{#if "userData" in item && item.userData?.isPlayed} - + {/if} {#if showHeart} @@ -319,7 +350,13 @@ {#if isDownloaded}
- +
@@ -348,7 +385,13 @@ class="transition-all duration-300" /> - +
@@ -360,13 +403,20 @@ {#if onRemove} {/if} @@ -379,13 +429,28 @@ > {#if downloadedBadge === "full"}
- +
{:else} -
- +
+
@@ -398,13 +463,30 @@ {#if isServerOnly}
{#if isQueued} -
-
- - +
+
+ +
- Queued + Queued
{:else} {/if}
{#if queueError} -
+
{queueError}
{/if} @@ -429,7 +523,9 @@
-

+

{truncateMiddle(item.name, 40)}

{#if subtitle()} diff --git a/src/lib/components/library/MosaicGrid.svelte b/src/lib/components/library/MosaicGrid.svelte index 16ebc52c..3eaabccc 100644 --- a/src/lib/components/library/MosaicGrid.svelte +++ b/src/lib/components/library/MosaicGrid.svelte @@ -16,12 +16,7 @@
{/each} {/if} diff --git a/src/lib/components/library/SeasonSection.svelte b/src/lib/components/library/SeasonSection.svelte index 5d9cfda6..b7a4bb25 100644 --- a/src/lib/components/library/SeasonSection.svelte +++ b/src/lib/components/library/SeasonSection.svelte @@ -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 @@
-
+
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 @@ {/each} {/if} diff --git a/src/lib/components/library/TrackList.logic.test.ts b/src/lib/components/library/TrackList.logic.test.ts index 5bc4b51d..37e46356 100644 --- a/src/lib/components/library/TrackList.logic.test.ts +++ b/src/lib/components/library/TrackList.logic.test.ts @@ -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"); }); }); diff --git a/src/lib/components/library/TrackList.svelte b/src/lib/components/library/TrackList.svelte index f90f3dfc..f1441ee3 100644 --- a/src/lib/components/library/TrackList.svelte +++ b/src/lib/components/library/TrackList.svelte @@ -40,7 +40,7 @@ showArtist = true, showDownload = false, context, - onTrackClick + onTrackClick, }: Props = $props(); let isPlayingTrack = $state(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}
{#each Array(10) as _} -
+
@@ -199,7 +196,13 @@
{#each tracks as track, index (track.id)} -
+
@@ -332,7 +348,9 @@
{#if isPlayingTrack === track.id} -
+
{:else} {index + 1}

{#if currentlyPlayingId === track.id} @@ -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" > - +

@@ -459,7 +482,7 @@ {#if openMenuId && menuPosition} - {@const selectedTrack = tracks.find(t => t.id === openMenuId)} + {@const selectedTrack = tracks.find((t) => t.id === openMenuId)} {#if selectedTrack}
- + Play Next @@ -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" > - + Add to Queue @@ -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" > - + Add to Playlist @@ -508,7 +543,7 @@ addToPlaylistTrackId = null} + onClose={() => (addToPlaylistTrackId = null)} trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []} /> diff --git a/src/lib/components/library/TrackList.test.ts b/src/lib/components/library/TrackList.test.ts index b8b8bb0f..e9364c0f 100644 --- a/src/lib/components/library/TrackList.test.ts +++ b/src/lib/components/library/TrackList.test.ts @@ -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) => { diff --git a/src/lib/components/library/VideoDownloadButton.svelte b/src/lib/components/library/VideoDownloadButton.svelte index 3d9677db..c8ad8623 100644 --- a/src/lib/components/library/VideoDownloadButton.svelte +++ b/src/lib/components/library/VideoDownloadButton.svelte @@ -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" > - + {:else if status === "completed"} - + {:else if status === "pending"} - - + + {:else if status === "failed"} - - + + {:else} - - + + {/if}
@@ -263,16 +295,14 @@ {#if showQualityPicker}
-
- Select Quality -
+
Select Quality
{#each Object.entries(QUALITY_PRESETS) as [key, preset]} {/each} - -
-

Now Playing

- {#if $isRemoteMode && $selectedSession} -

- - - - {$selectedSession.deviceName} -

- {/if} -
- -
- - - - + +
- +
- - -
-
+
+ + - -
-
- {#if artworkItemId} - - {:else} -
- - + +
- {/if} -
-
+ - -
- -
-

{truncateMiddle(displayMedia?.name, 48)}

-
- {#if displayMedia?.artistItems?.length} - {#each displayMedia?.artistItems as artist, i} + + + + +
+
+ + +
+
+ {#if artworkItemId} + + {:else} +
+ + + +
+ {/if} +
+
+ + +
+ +
+

+ {truncateMiddle(displayMedia?.name, 48)} +

+
+ {#if displayMedia?.artistItems?.length} + {#each displayMedia?.artistItems as artist, i} + {#if i < (displayMedia?.artistItems?.length ?? 0) - 1},{/if} + {/each} + {:else if displayMedia?.artists?.length} + {displayMedia?.artists.join(", ")} + {/if} + {#if displayMedia?.albumId && displayMedia?.albumName} + {#if displayMedia?.artistItems?.length || displayMedia?.artists?.length} + + {/if} {#if i < (displayMedia?.artistItems?.length ?? 0) - 1},{/if} - {/each} - {:else if displayMedia?.artists?.length} - {displayMedia?.artists.join(", ")} - {/if} - {#if displayMedia?.albumId && displayMedia?.albumName} - {#if displayMedia?.artistItems?.length || displayMedia?.artists?.length} - + {displayMedia?.albumName} + + {:else if displayMedia?.albumName} + {displayMedia?.albumName} {/if} - - {:else if displayMedia?.albumName} - {displayMedia?.albumName} - {/if} +
-
- -
- -
- {formatTime(displayPosition)} - {formatTime(displayDuration)} + +
+ +
+ {formatTime(displayPosition)} + {formatTime(displayDuration)} +
-
- -
- (showSleepTimerModal = true)} - /> + +
+ (showSleepTimerModal = true)} + /> +
-
+
{/if} - (showSleepTimerModal = false)} -/> + (showSleepTimerModal = false)} /> {#if showQueue} diff --git a/src/lib/components/player/Controls.svelte b/src/lib/components/player/Controls.svelte index 84857c33..ef155635 100644 --- a/src/lib/components/player/Controls.svelte +++ b/src/lib/components/player/Controls.svelte @@ -90,8 +90,12 @@ aria-label="Sleep timer" > - + {:else} @@ -113,7 +117,9 @@ title="Shuffle" > - + @@ -192,7 +198,9 @@ > {#if repeat === "one"} - + {:else} diff --git a/src/lib/components/player/MiniPlayer.svelte b/src/lib/components/player/MiniPlayer.svelte index 2853cd9b..aeff4ca2 100644 --- a/src/lib/components/player/MiniPlayer.svelte +++ b/src/lib/components/player/MiniPlayer.svelte @@ -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 @@ {#if shouldShow && displayMedia} -
+
{#if $isRemoteMode && $selectedSession} -
- - +
+ + Playing on {$selectedSession.deviceName} @@ -308,7 +317,9 @@ style="width: {progress}%" >
-
+
-
+
{#if displayMedia} - +
{/if} @@ -345,9 +358,7 @@
-
+
{truncateMiddle(displayMedia?.name, 40)}
@@ -381,11 +392,7 @@ {#if displayMedia} - + {/if} @@ -402,7 +409,9 @@ aria-label="More options" > - + @@ -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" > - + View Queue @@ -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" > - + Go to Album @@ -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" > - + Go to Artist @@ -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" > - + Add to Playlist @@ -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" > - + Share @@ -508,7 +536,7 @@ {#if showOverflowMenu} {/if} diff --git a/src/lib/components/player/NextEpisodePopup.svelte b/src/lib/components/player/NextEpisodePopup.svelte index 95158702..8b63d13c 100644 --- a/src/lib/components/player/NextEpisodePopup.svelte +++ b/src/lib/components/player/NextEpisodePopup.svelte @@ -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 @@
-
+
{#if imageId && $nextEpisodeItem.imageId} -
- +
+
diff --git a/src/lib/components/player/Queue.svelte b/src/lib/components/player/Queue.svelte index 9074202e..f7a44fb5 100644 --- a/src/lib/components/player/Queue.svelte +++ b/src/lib/components/player/Queue.svelte @@ -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" > - +
@@ -151,7 +153,10 @@ {#each dndItems as item, index (item.dndId)}
  • @@ -177,7 +184,11 @@
    {#if currentIndex === index} - + {:else} @@ -201,7 +212,11 @@
    -

    +

    {truncateMiddle(item.name, 48)}

    {#if item.artists?.length} @@ -225,7 +240,12 @@ aria-label="Remove from queue" > - +
    diff --git a/src/lib/components/player/SleepTimerModal.svelte b/src/lib/components/player/SleepTimerModal.svelte index 54ff0aff..5dba317c 100644 --- a/src/lib/components/player/SleepTimerModal.svelte +++ b/src/lib/components/player/SleepTimerModal.svelte @@ -1,10 +1,6 @@ - + diff --git a/src/lib/components/search/HeaderSearch.test.ts b/src/lib/components/search/HeaderSearch.test.ts index 1508ff97..ed8d82e8 100644 --- a/src/lib/components/search/HeaderSearch.test.ts +++ b/src/lib/components/search/HeaderSearch.test.ts @@ -104,7 +104,7 @@ describe("on /search", () => { expect(goto).toHaveBeenCalledWith( "/search?q=jazzy&scope=tv", - expect.objectContaining({ replaceState: true }) + expect.objectContaining({ replaceState: true }), ); }); }); diff --git a/src/lib/components/search/SearchResults.svelte b/src/lib/components/search/SearchResults.svelte index ec023688..33708fc3 100644 --- a/src/lib/components/search/SearchResults.svelte +++ b/src/lib/components/search/SearchResults.svelte @@ -34,7 +34,9 @@ {:else if groups.length === 0}
    - +

    No results found

    diff --git a/src/lib/components/search/SearchScopeChips.svelte b/src/lib/components/search/SearchScopeChips.svelte index 077345f3..fd33ba1a 100644 --- a/src/lib/components/search/SearchScopeChips.svelte +++ b/src/lib/components/search/SearchScopeChips.svelte @@ -31,11 +31,7 @@ } -
    +
    {#each SEARCH_SCOPES as s, i (s)} diff --git a/src/lib/components/sessions/RemoteControls.svelte b/src/lib/components/sessions/RemoteControls.svelte index f2cd6d53..637f0b00 100644 --- a/src/lib/components/sessions/RemoteControls.svelte +++ b/src/lib/components/sessions/RemoteControls.svelte @@ -16,7 +16,7 @@ const supportsSeek = $derived(playState?.canSeek ?? false); const supportsNextPrevious = $derived( (session.supportedCommands ?? []).includes("NextTrack") && - (session.supportedCommands ?? []).includes("PreviousTrack") + (session.supportedCommands ?? []).includes("PreviousTrack"), ); async function handlePlayPause() { @@ -81,9 +81,9 @@ const hours = Math.floor(minutes / 60); if (hours > 0) { - return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; + return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } - return `${minutes}:${String(seconds % 60).padStart(2, '0')}`; + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; } const positionPercent = $derived(() => { @@ -197,11 +197,15 @@
    {#if playState.isMuted || (playState.volumeLevel ?? 0) === 0} - + {:else if (playState.volumeLevel ?? 0) < 50} {:else} - + {/if} @@ -224,7 +228,12 @@ {:else}
    - + 0) { - return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; + return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } - return `${minutes}:${String(seconds % 60).padStart(2, '0')}`; + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; } const playState = $derived(session.playState); @@ -28,11 +28,11 @@
    @@ -76,7 +76,11 @@ Paused {:else} - + Playing diff --git a/src/lib/components/sessions/SessionPickerModal.svelte b/src/lib/components/sessions/SessionPickerModal.svelte index 24c64b7a..af3a4cf6 100644 --- a/src/lib/components/sessions/SessionPickerModal.svelte +++ b/src/lib/components/sessions/SessionPickerModal.svelte @@ -19,13 +19,15 @@ let { isOpen = false, onClose, onSelectSession }: Props = $props(); // MACs currently part of any sync group, for rendering toggle state. - const groupedMacs = $derived(new Set(($lmsSync.groups ?? []).flatMap( - (g) => [g.masterMac, ...(g.slaveMacs ?? [])] - ))); + const groupedMacs = $derived( + new Set(($lmsSync.groups ?? []).flatMap((g) => [g.masterMac, ...(g.slaveMacs ?? [])])), + ); // The sync master is the LMS zone we're currently controlling. Zones can only // be fused once we have a master to fuse them into. - const masterMac = $derived(isLmsSession($selectedSession) ? macForSession($selectedSession) : null); + const masterMac = $derived( + isLmsSession($selectedSession) ? macForSession($selectedSession) : null, + ); function isZoneFused(session: Session): boolean { const mac = macForSession(session); @@ -111,11 +113,23 @@ function getSessionIcon(client: string | null | undefined): string { const clientLower = (client ?? "").toLowerCase(); - if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) { + if ( + clientLower.includes("tv") || + clientLower.includes("roku") || + clientLower.includes("android tv") + ) { return "tv"; - } else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) { + } else if ( + clientLower.includes("web") || + clientLower.includes("chrome") || + clientLower.includes("firefox") + ) { return "web"; - } else if (clientLower.includes("mobile") || clientLower.includes("ios") || clientLower.includes("android")) { + } else if ( + clientLower.includes("mobile") || + clientLower.includes("ios") || + clientLower.includes("android") + ) { return "phone"; } return "device"; @@ -134,7 +148,9 @@
    { if (e.key === 'Escape' && onClose) onClose(); }} + onkeydown={(e) => { + if (e.key === "Escape" && onClose) onClose(); + }} role="dialog" aria-modal="true" aria-labelledby="session-picker-title" @@ -148,16 +164,19 @@ >
    -

    - Cast to Device -

    +

    Cast to Device

    @@ -168,7 +187,9 @@
    -
    +

    Searching for devices...

    @@ -177,9 +198,13 @@
    {#if $selectedSession} -
    +
    - Connected + Connected
    -
    - +
    + {#if getSessionIcon($selectedSession.client) === "tv"} - + {:else if getSessionIcon($selectedSession.client) === "web"} - + {:else if getSessionIcon($selectedSession.client) === "phone"} - + {:else} - + {/if}
    @@ -215,28 +254,41 @@ {@const lmsZone = isLmsSession(session)} {@const fused = lmsZone && isZoneFused(session)} @@ -354,7 +438,9 @@ {#if $isTransferring}
    -
    +

    Transferring playback...

    @@ -392,10 +487,14 @@ {#if $transferError} -
    +
    - + {$transferError}
    @@ -405,7 +504,12 @@ aria-label="Dismiss error" > - +
    diff --git a/src/lib/components/sessions/SessionsList.svelte b/src/lib/components/sessions/SessionsList.svelte index fe168fb4..2e08466d 100644 --- a/src/lib/components/sessions/SessionsList.svelte +++ b/src/lib/components/sessions/SessionsList.svelte @@ -48,7 +48,9 @@ {#if $sessions.isLoading && $sessions.sessions.length === 0}
    -
    +

    Loading sessions...

    @@ -75,7 +77,12 @@ {:else if !$sessions.isLoading}
    - +

    No Active Sessions

    - No controllable Jellyfin sessions found. Start playing media on another device to control it from here. + No controllable Jellyfin sessions found. Start playing media on another device to control it + from here.

    {/if} diff --git a/src/lib/components/settings/SearchGroupOrderList.svelte b/src/lib/components/settings/SearchGroupOrderList.svelte index 0002bfdf..24b93613 100644 --- a/src/lib/components/settings/SearchGroupOrderList.svelte +++ b/src/lib/components/settings/SearchGroupOrderList.svelte @@ -64,7 +64,7 @@ ondrop={(e) => onDrop(e, i)} ondragend={onDragEnd} class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border transition-colors {overIndex === - i && dragIndex !== i + i && dragIndex !== i ? 'border-[var(--color-jellyfin)]' : 'border-gray-700'} {dragIndex === i ? 'opacity-50' : ''}" > @@ -76,7 +76,9 @@ viewBox="0 0 24 24" aria-hidden="true" > - + {i + 1} @@ -89,7 +91,13 @@ aria-label="Move {GROUP_LABELS[id]} up" class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent" > -
  • diff --git a/src/lib/components/sync/PendingSyncList.svelte b/src/lib/components/sync/PendingSyncList.svelte index bfc42642..8bef7ea2 100644 --- a/src/lib/components/sync/PendingSyncList.svelte +++ b/src/lib/components/sync/PendingSyncList.svelte @@ -74,9 +74,9 @@

    - Changes made while the server was unreachable — watch positions and watched - flags — waiting to reach Jellyfin. They send themselves when the server comes - back. This is not the download queue; downloaded media lives under Downloads. + Changes made while the server was unreachable — watch positions and watched flags — waiting to + reach Jellyfin. They send themselves when the server comes back. This is not the download queue; + downloaded media lives under Downloads.

    {#if loading} diff --git a/src/lib/components/sync/PendingSyncModal.svelte b/src/lib/components/sync/PendingSyncModal.svelte index 8ac9cdbc..d04e41fc 100644 --- a/src/lib/components/sync/PendingSyncModal.svelte +++ b/src/lib/components/sync/PendingSyncModal.svelte @@ -23,7 +23,9 @@
    { if (e.key === "Escape") onClose(); }} + onkeydown={(e) => { + if (e.key === "Escape") onClose(); + }} role="dialog" aria-modal="true" aria-labelledby="pending-sync-title" @@ -35,16 +37,19 @@ role="none" >
    -

    - Waiting to sync -

    +

    Waiting to sync

    diff --git a/src/lib/player/adapters/adapterSelection.test.ts b/src/lib/player/adapters/adapterSelection.test.ts index 011f8def..9ba18e1e 100644 --- a/src/lib/player/adapters/adapterSelection.test.ts +++ b/src/lib/player/adapters/adapterSelection.test.ts @@ -82,14 +82,14 @@ describe("createAdapter", () => { it("requires a bridge for the HTML5 adapter", () => { expect(() => - createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }) + createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }), ).toThrow(/bridge/i); }); // The native adapter owns no DOM element, so it must not demand a bridge. it("does not require a bridge for the native adapter", () => { expect(() => - createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }) + createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }), ).not.toThrow(); }); }); diff --git a/src/lib/player/adapters/html5Adapter.test.ts b/src/lib/player/adapters/html5Adapter.test.ts index c44c3279..23d918bb 100644 --- a/src/lib/player/adapters/html5Adapter.test.ts +++ b/src/lib/player/adapters/html5Adapter.test.ts @@ -51,7 +51,9 @@ function makeBridge(overrides: Partial = {}): Html5ElementBr return { getElement: () => null, getSeekOffset: () => offset, - setSeekOffset: vi.fn((o: number) => { offset = o; }), + setSeekOffset: vi.fn((o: number) => { + offset = o; + }), setStreamUrl: vi.fn(), destroyHls: vi.fn(), getMediaSourceId: () => "msid-1", @@ -102,7 +104,7 @@ describe("Html5PlayerAdapter", () => { it("play() does not report an interrupted-by-pause AbortError as an error", async () => { const abort = new DOMException( "The play() request was interrupted by a call to pause().", - "AbortError" + "AbortError", ); video.play = vi.fn(async () => { throw abort; @@ -135,7 +137,7 @@ describe("Html5PlayerAdapter", () => { video.paused = false; r(); }; - }) + }), ); const first = adapter.play(); diff --git a/src/lib/player/adapters/html5Adapter.ts b/src/lib/player/adapters/html5Adapter.ts index bb11a8b0..d06bb0b4 100644 --- a/src/lib/player/adapters/html5Adapter.ts +++ b/src/lib/player/adapters/html5Adapter.ts @@ -257,11 +257,7 @@ export class Html5PlayerAdapter implements PlayerAdapter { * distinction is the caller's to act on: a missing `seeked` is cosmetic, a * missing `canplay` means the reload failed. */ - private waitForEvent( - el: HTMLVideoElement, - event: string, - timeoutMs: number - ): Promise { + private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise { return new Promise((resolve) => { const done = (fired: boolean) => { el.removeEventListener(event, listener); diff --git a/src/lib/player/adapters/nativeAdapter.test.ts b/src/lib/player/adapters/nativeAdapter.test.ts index e8b76585..3b93db0e 100644 --- a/src/lib/player/adapters/nativeAdapter.test.ts +++ b/src/lib/player/adapters/nativeAdapter.test.ts @@ -30,8 +30,14 @@ import type { AdapterHost } from "./types"; function makeHost(): AdapterHost { return { - onState: vi.fn(), onPosition: vi.fn(), onMediaLoaded: vi.fn(), onEnded: vi.fn(), - onError: vi.fn(), onStreamUrlChanged: vi.fn(), onBuffering: vi.fn(), onReady: vi.fn(), + onState: vi.fn(), + onPosition: vi.fn(), + onMediaLoaded: vi.fn(), + onEnded: vi.fn(), + onError: vi.fn(), + onStreamUrlChanged: vi.fn(), + onBuffering: vi.fn(), + onReady: vi.fn(), }; } @@ -67,9 +73,14 @@ describe("NativePlayerAdapter", () => { it("load() seeds a resume position", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 90, isLive: false, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 90, + isLive: false, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(adapter.getPosition()).toBe(90); }); @@ -80,18 +91,28 @@ describe("NativePlayerAdapter", () => { // adapter must actually *issue* the seek. it("load() issues the resume seek to the backend, not just records it", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 90, isLive: false, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 90, + isLive: false, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(playerSeek).toHaveBeenCalledWith(90); }); it("load() does not seek when starting from the beginning", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 0, isLive: false, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 0, + isLive: false, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(playerSeek).not.toHaveBeenCalled(); }); @@ -100,9 +121,14 @@ describe("NativePlayerAdapter", () => { // no-op and at worst knocks the HLS window off its live edge. it("load() never seeks a live stream", async () => { await adapter.load("url", { - mediaId: "m", mediaSourceId: null, needsTranscoding: false, - initialPosition: 90, isLive: true, audioTrackIndex: null, - knownDuration: 0, subtitleTracks: [], + mediaId: "m", + mediaSourceId: null, + needsTranscoding: false, + initialPosition: 90, + isLive: true, + audioTrackIndex: null, + knownDuration: 0, + subtitleTracks: [], }); expect(playerSeek).not.toHaveBeenCalled(); }); diff --git a/src/lib/player/adapters/nativeAdapter.ts b/src/lib/player/adapters/nativeAdapter.ts index ba0d0719..248f9cbb 100644 --- a/src/lib/player/adapters/nativeAdapter.ts +++ b/src/lib/player/adapters/nativeAdapter.ts @@ -101,7 +101,7 @@ export class NativePlayerAdapter implements PlayerAdapter { } async selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise { - const indexToUse = streamIndex === null ? null : arrayIndex ?? streamIndex; + const indexToUse = streamIndex === null ? null : (arrayIndex ?? streamIndex); await commands.playerSetSubtitleTrack(indexToUse); } diff --git a/src/lib/player/adapters/rustReportHost.ts b/src/lib/player/adapters/rustReportHost.ts index d0971054..84504e78 100644 --- a/src/lib/player/adapters/rustReportHost.ts +++ b/src/lib/player/adapters/rustReportHost.ts @@ -35,7 +35,7 @@ let lastPositionReport = 0; export async function reportState( state: "playing" | "paused" | "loading" | "stopped" | "idle", - mediaId: string | null + mediaId: string | null, ): Promise { try { await commands.playerReportState(state, mediaId); @@ -47,7 +47,7 @@ export async function reportState( export async function reportPosition( position: number, duration: number, - { force = false }: ReportPositionOptions = {} + { force = false }: ReportPositionOptions = {}, ): Promise { const now = Date.now(); if (!force && now - lastPositionReport < POSITION_REPORT_INTERVAL_MS) { @@ -81,7 +81,9 @@ export function resetReporting(): void { */ export function createRustReportHost( mediaId: string, - view: Partial> = {} + view: Partial< + Pick + > = {}, ): AdapterHost { return { onState: (state) => void reportState(state, mediaId), diff --git a/src/lib/player/index.ts b/src/lib/player/index.ts index 7581bb7f..03824bcf 100644 --- a/src/lib/player/index.ts +++ b/src/lib/player/index.ts @@ -136,7 +136,7 @@ async function seek(positionSeconds: number) { async function seekVideo( positionSeconds: number, mediaSourceId: string | null, - audioTrackIndex: number | null + audioTrackIndex: number | null, ): Promise { const adapter = activeAdapter; if (!adapter) { @@ -148,7 +148,7 @@ async function seekVideo( positionSeconds, mediaSourceId, audioTrackIndex, - adapter.kind === "html5" + adapter.kind === "html5", )) as any; // Serde keeps these snake_case (only the "strategy" tag is camelCase). if (response.strategy === "reloadStream") { @@ -169,7 +169,7 @@ async function switchAudioTrack( streamIndex: number, arrayIndex: number, currentPosition: number | null, - mediaSourceId: string | null + mediaSourceId: string | null, ): Promise { const adapter = activeAdapter; if (!adapter) return; @@ -179,7 +179,7 @@ async function switchAudioTrack( arrayIndex, adapter.kind === "html5", currentPosition, - mediaSourceId + mediaSourceId, )) as any; if (response.strategy === "reloadStream") { await adapter.reloadSource(response.new_url!, response.position!); @@ -198,7 +198,7 @@ async function setStreamQuality( quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, - audioTrackIndex: number | null + audioTrackIndex: number | null, ): Promise { const adapter = activeAdapter; if (!adapter) return; @@ -208,7 +208,7 @@ async function setStreamQuality( adapter.kind === "html5", currentPosition, mediaSourceId, - audioTrackIndex + audioTrackIndex, )) as any; // Serde keeps these snake_case (only the "strategy" tag is camelCase). if (response.strategy === "reloadStream") { @@ -305,10 +305,7 @@ async function addTrackById(trackId: string, position: "next" | "end" = "end") { } /** Add multiple tracks to the queue by ID. */ -async function addTracksByIds( - trackIds: string[], - position: "next" | "end" = "end" -) { +async function addTracksByIds(trackIds: string[], position: "next" | "end" = "end") { await commands.playerAddTracksByIds(requireHandle(), { trackIds, position }); } diff --git a/src/lib/player/localSource.test.ts b/src/lib/player/localSource.test.ts index ea75aa3d..dd542722 100644 --- a/src/lib/player/localSource.test.ts +++ b/src/lib/player/localSource.test.ts @@ -19,7 +19,7 @@ describe("downloadedFilePath", () => { // Rows only hold a relative path before the worker completes them, but a // half-migrated database can still carry one. expect(downloadedFilePath("/var/data/jellytau", "videos/film.mp4")).toBe( - "/var/data/jellytau/videos/film.mp4" + "/var/data/jellytau/videos/film.mp4", ); }); diff --git a/src/lib/services/favorites.test.ts b/src/lib/services/favorites.test.ts index e94480cb..f6bc2d09 100644 --- a/src/lib/services/favorites.test.ts +++ b/src/lib/services/favorites.test.ts @@ -51,9 +51,7 @@ describe("favorites service", () => { await toggleFavorite("item-123", false); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_toggle_favorite" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_toggle_favorite"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("itemId", "item-123"); expect(call![1]).toHaveProperty("isFavorite", true); @@ -65,9 +63,7 @@ describe("favorites service", () => { await toggleFavorite("item-123", false); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_toggle_favorite" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_toggle_favorite"); expect(call![1]).toHaveProperty("userId", "user-123"); }); @@ -105,9 +101,7 @@ describe("favorites service", () => { await toggleFavorite("item-123", false); - const markSyncedCall = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_mark_synced" - ); + const markSyncedCall = invokeSpy.mock.calls.find((c) => c[0] === "storage_mark_synced"); expect(markSyncedCall).toBeDefined(); expect(markSyncedCall![1]).toHaveProperty("itemId", "item-123"); }); @@ -117,9 +111,7 @@ describe("favorites service", () => { const authModule = vi.mocked(auth); authModule.getUserId = vi.fn(() => null); - await expect(toggleFavorite("item-123", false)).rejects.toThrow( - "Not authenticated" - ); + await expect(toggleFavorite("item-123", false)).rejects.toThrow("Not authenticated"); }); it("should handle server sync failure gracefully", async () => { diff --git a/src/lib/services/favorites.ts b/src/lib/services/favorites.ts index 5d7fe11a..dbbd4eeb 100644 --- a/src/lib/services/favorites.ts +++ b/src/lib/services/favorites.ts @@ -23,10 +23,7 @@ const log = createLogger("Favorites"); * @returns The new favorite status * @throws Error if not authenticated or database update fails */ -export async function toggleFavorite( - itemId: string, - currentIsFavorite: boolean -): Promise { +export async function toggleFavorite(itemId: string, currentIsFavorite: boolean): Promise { const userId = auth.getUserId(); if (!userId) { throw new Error("Not authenticated"); diff --git a/src/lib/services/imageCache.test.ts b/src/lib/services/imageCache.test.ts index 3e7ab4c8..b30c6210 100644 --- a/src/lib/services/imageCache.test.ts +++ b/src/lib/services/imageCache.test.ts @@ -52,34 +52,22 @@ describe("image cache service", () => { describe("getCachedImageUrl", () => { it("should build server URL with default image type", async () => { - const url = await getCachedImageUrl( - "http://server.local:8096", - "item-123" - ); + const url = await getCachedImageUrl("http://server.local:8096", "item-123"); expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary"); }); it("should build server URL with custom image type", async () => { - const url = await getCachedImageUrl( - "http://server.local:8096", - "item-123", - "Backdrop" - ); + const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop"); expect(url).toContain("Backdrop"); }); it("should include image options in URL", async () => { - const url = await getCachedImageUrl( - "http://server.local:8096", - "item-123", - "Primary", - { - maxWidth: 300, - maxHeight: 400, - quality: 90, - tag: "abc123", - } - ); + const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", { + maxWidth: 300, + maxHeight: 400, + quality: 90, + tag: "abc123", + }); expect(url).toContain("maxWidth=300"); expect(url).toContain("maxHeight=400"); expect(url).toContain("quality=90"); @@ -92,9 +80,7 @@ describe("image cache service", () => { await getCachedImageUrl("http://server.local:8096", "item-123"); - const saveCall = invokeSpy.mock.calls.find( - (call) => call[0] === "thumbnail_save" - ); + const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save"); expect(saveCall).toBeDefined(); expect(saveCall![1]).toHaveProperty("itemId", "item-123"); expect(saveCall![1]).toHaveProperty("imageType", "Primary"); @@ -117,9 +103,7 @@ describe("image cache service", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const setLimitCall = invokeSpy.mock.calls.find( - (call) => call[0] === "thumbnail_set_limit" - ); + const setLimitCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_set_limit"); expect(setLimitCall).toBeDefined(); expect(setLimitCall![1]).toHaveProperty("limitBytes", limit); }); @@ -139,9 +123,7 @@ describe("image cache service", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const deleteCall = invokeSpy.mock.calls.find( - (call) => call[0] === "thumbnail_delete_item" - ); + const deleteCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_delete_item"); expect(deleteCall).toBeDefined(); expect(deleteCall![1]).toHaveProperty("itemId", "item-456"); }); diff --git a/src/lib/services/imageCache.ts b/src/lib/services/imageCache.ts index efb60063..381f888a 100644 --- a/src/lib/services/imageCache.ts +++ b/src/lib/services/imageCache.ts @@ -35,7 +35,7 @@ export async function getCachedImageUrl( maxHeight?: number; quality?: number; tag?: string; - } = {} + } = {}, ): Promise { const tag = options.tag || "default"; @@ -114,8 +114,7 @@ export async function deleteItemCache(itemId: string): Promise { export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } diff --git a/src/lib/services/networkType.test.ts b/src/lib/services/networkType.test.ts index 157ef4a2..17e984a0 100644 --- a/src/lib/services/networkType.test.ts +++ b/src/lib/services/networkType.test.ts @@ -4,153 +4,153 @@ * TRACES: UR-053 | DR-074 | UT-066 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const setNetworkState = vi.fn(); const getDownloadsAllowed = vi.fn(); -vi.mock('$lib/api/bindings', () => ({ - commands: { - setNetworkState: (...args: unknown[]) => setNetworkState(...args), - getDownloadsAllowed: () => getDownloadsAllowed() - } +vi.mock("$lib/api/bindings", () => ({ + commands: { + setNetworkState: (...args: unknown[]) => setNetworkState(...args), + getDownloadsAllowed: () => getDownloadsAllowed(), + }, })); import { - isNetworkDetectionSupported, - reportNetworkState, - startNetworkReporting, - areDownloadsAllowed -} from './networkType'; + isNetworkDetectionSupported, + reportNetworkState, + startNetworkReporting, + areDownloadsAllowed, +} from "./networkType"; /** Install a fake Android bridge on window. */ function installBridge(overrides: Partial> = {}) { - const bridge = { - currentType: vi.fn(() => 'wifi'), - isUnmetered: vi.fn(() => true), - isAcceptable: vi.fn(() => true), - isSupported: vi.fn(() => true), - ...overrides - }; - (window as unknown as Record).AndroidNetworkType = bridge; - return bridge; + const bridge = { + currentType: vi.fn(() => "wifi"), + isUnmetered: vi.fn(() => true), + isAcceptable: vi.fn(() => true), + isSupported: vi.fn(() => true), + ...overrides, + }; + (window as unknown as Record).AndroidNetworkType = bridge; + return bridge; } function removeBridge() { - delete (window as unknown as Record).AndroidNetworkType; + delete (window as unknown as Record).AndroidNetworkType; } -describe('networkType service', () => { - beforeEach(() => { - vi.clearAllMocks(); - setNetworkState.mockResolvedValue(null); - getDownloadsAllowed.mockResolvedValue(true); - removeBridge(); - }); +describe("networkType service", () => { + beforeEach(() => { + vi.clearAllMocks(); + setNetworkState.mockResolvedValue(null); + getDownloadsAllowed.mockResolvedValue(true); + removeBridge(); + }); - afterEach(() => { - removeBridge(); - }); + afterEach(() => { + removeBridge(); + }); - describe('isNetworkDetectionSupported', () => { - it('is false with no Android bridge (desktop)', () => { - expect(isNetworkDetectionSupported()).toBe(false); - }); + describe("isNetworkDetectionSupported", () => { + it("is false with no Android bridge (desktop)", () => { + expect(isNetworkDetectionSupported()).toBe(false); + }); - it('is true when the Android bridge is present', () => { - installBridge(); - expect(isNetworkDetectionSupported()).toBe(true); - }); + it("is true when the Android bridge is present", () => { + installBridge(); + expect(isNetworkDetectionSupported()).toBe(true); + }); - it('is false when the bridge throws', () => { - installBridge({ - isSupported: vi.fn(() => { - throw new Error('bridge exploded'); - }) - }); - expect(isNetworkDetectionSupported()).toBe(false); - }); - }); + it("is false when the bridge throws", () => { + installBridge({ + isSupported: vi.fn(() => { + throw new Error("bridge exploded"); + }), + }); + expect(isNetworkDetectionSupported()).toBe(false); + }); + }); - describe('reportNetworkState', () => { - it('does not call the backend on desktop', async () => { - await reportNetworkState(); - expect(setNetworkState).not.toHaveBeenCalled(); - }); + describe("reportNetworkState", () => { + it("does not call the backend on desktop", async () => { + await reportNetworkState(); + expect(setNetworkState).not.toHaveBeenCalled(); + }); - it('reports transport and metered-ness from the bridge', async () => { - installBridge({ - currentType: vi.fn(() => 'cellular'), - isUnmetered: vi.fn(() => false) - }); + it("reports transport and metered-ness from the bridge", async () => { + installBridge({ + currentType: vi.fn(() => "cellular"), + isUnmetered: vi.fn(() => false), + }); - await reportNetworkState(); + await reportNetworkState(); - expect(setNetworkState).toHaveBeenCalledWith({ - networkType: 'cellular', - unmetered: false - }); - }); + expect(setNetworkState).toHaveBeenCalledWith({ + networkType: "cellular", + unmetered: false, + }); + }); - it('reports metered WiFi as WiFi-but-metered, not as unmetered', async () => { - // A phone hotspot: WiFi transport, metered connection. - installBridge({ - currentType: vi.fn(() => 'wifi'), - isUnmetered: vi.fn(() => false) - }); + it("reports metered WiFi as WiFi-but-metered, not as unmetered", async () => { + // A phone hotspot: WiFi transport, metered connection. + installBridge({ + currentType: vi.fn(() => "wifi"), + isUnmetered: vi.fn(() => false), + }); - await reportNetworkState(); + await reportNetworkState(); - expect(setNetworkState).toHaveBeenCalledWith({ - networkType: 'wifi', - unmetered: false - }); - }); + expect(setNetworkState).toHaveBeenCalledWith({ + networkType: "wifi", + unmetered: false, + }); + }); - it('swallows backend errors so the UI never breaks', async () => { - installBridge(); - setNetworkState.mockRejectedValue(new Error('ipc down')); + it("swallows backend errors so the UI never breaks", async () => { + installBridge(); + setNetworkState.mockRejectedValue(new Error("ipc down")); - await expect(reportNetworkState()).resolves.toBeUndefined(); - }); - }); + await expect(reportNetworkState()).resolves.toBeUndefined(); + }); + }); - describe('startNetworkReporting', () => { - it('reports once immediately and again on network change', async () => { - installBridge(); + describe("startNetworkReporting", () => { + it("reports once immediately and again on network change", async () => { + installBridge(); - const stop = startNetworkReporting(); - await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); + const stop = startNetworkReporting(); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); - window.dispatchEvent(new CustomEvent('jellytau-network-changed')); - await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2)); + window.dispatchEvent(new CustomEvent("jellytau-network-changed")); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2)); - stop(); - }); + stop(); + }); - it('stops reporting after teardown', async () => { - installBridge(); + it("stops reporting after teardown", async () => { + installBridge(); - const stop = startNetworkReporting(); - await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); - stop(); + const stop = startNetworkReporting(); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); + stop(); - window.dispatchEvent(new CustomEvent('jellytau-network-changed')); - // Give any stray listener a chance to fire before asserting. - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(setNetworkState).toHaveBeenCalledTimes(1); - }); - }); + window.dispatchEvent(new CustomEvent("jellytau-network-changed")); + // Give any stray listener a chance to fire before asserting. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(setNetworkState).toHaveBeenCalledTimes(1); + }); + }); - describe('areDownloadsAllowed', () => { - it('returns the backend verdict', async () => { - getDownloadsAllowed.mockResolvedValue(false); - expect(await areDownloadsAllowed()).toBe(false); - }); + describe("areDownloadsAllowed", () => { + it("returns the backend verdict", async () => { + getDownloadsAllowed.mockResolvedValue(false); + expect(await areDownloadsAllowed()).toBe(false); + }); - it('fails open if the query errors, so the UI never falsely blames WiFi', async () => { - getDownloadsAllowed.mockRejectedValue(new Error('ipc down')); - expect(await areDownloadsAllowed()).toBe(true); - }); - }); + it("fails open if the query errors, so the UI never falsely blames WiFi", async () => { + getDownloadsAllowed.mockRejectedValue(new Error("ipc down")); + expect(await areDownloadsAllowed()).toBe(true); + }); + }); }); diff --git a/src/lib/services/networkType.ts b/src/lib/services/networkType.ts index e44d9bb8..ec1d0eb6 100644 --- a/src/lib/services/networkType.ts +++ b/src/lib/services/networkType.ts @@ -10,41 +10,41 @@ * TRACES: UR-053 | DR-074 */ -import { commands } from '$lib/api/bindings'; -import type { NetworkType } from '$lib/api/bindings'; -import { createLogger } from '$lib/utils/logger'; +import { commands } from "$lib/api/bindings"; +import type { NetworkType } from "$lib/api/bindings"; +import { createLogger } from "$lib/utils/logger"; -const log = createLogger('NetworkType'); +const log = createLogger("NetworkType"); /** The Android bridge, present only in the Android WebView. */ interface AndroidNetworkTypeBridge { - currentType(): NetworkType; - isUnmetered(): boolean; - isAcceptable(wifiOnly: boolean): boolean; - isSupported(): boolean; + currentType(): NetworkType; + isUnmetered(): boolean; + isAcceptable(wifiOnly: boolean): boolean; + isSupported(): boolean; } declare global { - interface Window { - AndroidNetworkType?: AndroidNetworkTypeBridge; - } + interface Window { + AndroidNetworkType?: AndroidNetworkTypeBridge; + } } /** Event dispatched into the WebView by MainActivity on any network change. */ -const NETWORK_CHANGED_EVENT = 'jellytau-network-changed'; +const NETWORK_CHANGED_EVENT = "jellytau-network-changed"; function bridge(): AndroidNetworkTypeBridge | undefined { - if (typeof window === 'undefined') return undefined; - return window.AndroidNetworkType; + if (typeof window === "undefined") return undefined; + return window.AndroidNetworkType; } /** Whether native network detection is available (Android only). */ export function isNetworkDetectionSupported(): boolean { - try { - return bridge()?.isSupported() ?? false; - } catch { - return false; - } + try { + return bridge()?.isSupported() ?? false; + } catch { + return false; + } } /** @@ -54,19 +54,19 @@ export function isNetworkDetectionSupported(): boolean { * means downloads run unconditionally. */ export async function reportNetworkState(): Promise { - const android = bridge(); - if (!android) return; + const android = bridge(); + if (!android) return; - try { - const networkType = android.currentType(); - const unmetered = android.isUnmetered(); + try { + const networkType = android.currentType(); + const unmetered = android.isUnmetered(); - await commands.setNetworkState({ networkType, unmetered }); - } catch (error) { - // Never let network reporting break the UI — the gate fails closed on - // the Rust side, so a missed report at worst delays a queued download. - log.warn('Failed to report network state:', error); - } + await commands.setNetworkState({ networkType, unmetered }); + } catch (error) { + // Never let network reporting break the UI — the gate fails closed on + // the Rust side, so a missed report at worst delays a queued download. + log.warn("Failed to report network state:", error); + } } /** @@ -77,25 +77,25 @@ export async function reportNetworkState(): Promise { * Returns a teardown function. */ export function startNetworkReporting(): () => void { - if (typeof window === 'undefined') return () => {}; + if (typeof window === "undefined") return () => {}; - void reportNetworkState(); + void reportNetworkState(); - const onChange = () => { - void reportNetworkState(); - }; + const onChange = () => { + void reportNetworkState(); + }; - window.addEventListener(NETWORK_CHANGED_EVENT, onChange); - // The browser's own online/offline events are a useful extra nudge on - // desktop-style webviews where the native callback may not fire. - window.addEventListener('online', onChange); - window.addEventListener('offline', onChange); + window.addEventListener(NETWORK_CHANGED_EVENT, onChange); + // The browser's own online/offline events are a useful extra nudge on + // desktop-style webviews where the native callback may not fire. + window.addEventListener("online", onChange); + window.addEventListener("offline", onChange); - return () => { - window.removeEventListener(NETWORK_CHANGED_EVENT, onChange); - window.removeEventListener('online', onChange); - window.removeEventListener('offline', onChange); - }; + return () => { + window.removeEventListener(NETWORK_CHANGED_EVENT, onChange); + window.removeEventListener("online", onChange); + window.removeEventListener("offline", onChange); + }; } /** @@ -103,10 +103,10 @@ export function startNetworkReporting(): () => void { * downloads UI to show "Waiting for WiFi" instead of a stuck-looking queue. */ export async function areDownloadsAllowed(): Promise { - try { - return await commands.getDownloadsAllowed(); - } catch (error) { - log.warn('Failed to query download gate:', error); - return true; - } + try { + return await commands.getDownloadsAllowed(); + } catch (error) { + log.warn("Failed to query download gate:", error); + return true; + } } diff --git a/src/lib/services/offlineCatalog.reload.test.ts b/src/lib/services/offlineCatalog.reload.test.ts index 45398ac1..65f1f448 100644 --- a/src/lib/services/offlineCatalog.reload.test.ts +++ b/src/lib/services/offlineCatalog.reload.test.ts @@ -44,7 +44,7 @@ const h = vi.hoisted(() => { // issued" and "command accepted". pending: [] as Array<() => void>, setShowServerCatalog: vi.fn( - () => new Promise((resolve) => h.pending.push(() => resolve())) + () => new Promise((resolve) => h.pending.push(() => resolve())), ), }; }); @@ -77,7 +77,7 @@ describe("offline filter refetch signal (DR-143)", () => { beforeEach(() => { h.setShowServerCatalog.mockReset(); h.setShowServerCatalog.mockImplementation( - () => new Promise((resolve) => h.pending.push(() => resolve())) + () => new Promise((resolve) => h.pending.push(() => resolve())), ); h.pending.length = 0; h.isConnectedStore.reset(true); diff --git a/src/lib/services/offlineCatalog.ts b/src/lib/services/offlineCatalog.ts index a8917b99..69ffaa92 100644 --- a/src/lib/services/offlineCatalog.ts +++ b/src/lib/services/offlineCatalog.ts @@ -118,9 +118,7 @@ export async function syncCatalog(): Promise { syncInProgress = true; try { const result = await commands.syncFullCatalog(handle); - log.info( - `Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)` - ); + log.info(`Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)`); await refreshSyncStatus(); } catch (err) { log.warn("Full catalog sync failed:", err); @@ -139,9 +137,7 @@ export async function resumeQueued(): Promise { try { const result = await commands.resumeQueuedDownloads(handle); if (result.resolved > 0 || result.failed > 0) { - log.info( - `Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed` - ); + log.info(`Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed`); } } catch (err) { log.warn("Failed to resume queued downloads:", err); diff --git a/src/lib/services/pendingSync.logic.test.ts b/src/lib/services/pendingSync.logic.test.ts index 1061cdd9..a591ac43 100644 --- a/src/lib/services/pendingSync.logic.test.ts +++ b/src/lib/services/pendingSync.logic.test.ts @@ -39,9 +39,7 @@ describe("pending sync row description", () => { }); it("names the item when the catalog knows it, and falls back to the id", () => { - expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe( - "The Expanse S01E01", - ); + expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe("The Expanse S01E01"); expect(describeSubject(row({ itemName: null, itemId: "abc123" }))).toBe("abc123"); expect(describeSubject(row({ itemName: null, itemId: null }))).toBe("Unknown item"); }); diff --git a/src/lib/services/playbackReporting.test.ts b/src/lib/services/playbackReporting.test.ts index bfe5b420..b875e745 100644 --- a/src/lib/services/playbackReporting.test.ts +++ b/src/lib/services/playbackReporting.test.ts @@ -47,7 +47,7 @@ describe("playback reporting service", () => { it("should accept optional contextType and contextId", async () => { await expect( - reportPlaybackStart("item-123", 0, "container", "container-456") + reportPlaybackStart("item-123", 0, "container", "container-456"), ).resolves.toBeUndefined(); }); @@ -57,9 +57,7 @@ describe("playback reporting service", () => { await reportPlaybackStart("item-123", 60); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_context" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("positionMs", 60000); // 60 seconds }); @@ -70,9 +68,7 @@ describe("playback reporting service", () => { await reportPlaybackStart("item-123", 30); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_context" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context"); expect(call![1]).toHaveProperty("contextType", "single"); expect(call![1]).toHaveProperty("contextId", null); }); @@ -83,9 +79,7 @@ describe("playback reporting service", () => { await reportPlaybackStart("item-123", 0); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_context" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_context"); expect(call![1]).toHaveProperty("userId", "user-123"); }); }); @@ -105,9 +99,7 @@ describe("playback reporting service", () => { await reportPlaybackProgress("item-123", 30); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_progress" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("itemId", "item-123"); }); @@ -118,9 +110,7 @@ describe("playback reporting service", () => { await reportPlaybackProgress("item-123", 45); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_progress" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress"); expect(call![1]).toHaveProperty("positionMs", 45000); // 45 seconds }); }); @@ -136,9 +126,7 @@ describe("playback reporting service", () => { await reportPlaybackStopped("item-123", 120); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_update_playback_progress" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_update_playback_progress"); expect(call).toBeDefined(); }); @@ -167,7 +155,7 @@ describe("playback reporting service", () => { expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( "item-123", - 90000 // 90 seconds in ms + 90000, // 90 seconds in ms ); }); @@ -192,9 +180,7 @@ describe("playback reporting service", () => { await markAsPlayed("item-123"); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "storage_mark_played" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "storage_mark_played"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("itemId", "item-123"); }); @@ -215,10 +201,7 @@ describe("playback reporting service", () => { await markAsPlayed("item-123"); expect(mockRepo.getItem).toHaveBeenCalledWith("item-123"); - expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( - "item-123", - 10000 - ); + expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith("item-123", 10000); }); it("should handle items without durationMs", async () => { diff --git a/src/lib/services/playbackReporting.ts b/src/lib/services/playbackReporting.ts index d59fd834..2260fad1 100644 --- a/src/lib/services/playbackReporting.ts +++ b/src/lib/services/playbackReporting.ts @@ -30,7 +30,7 @@ export async function reportPlaybackStart( itemId: string, positionSeconds: number, contextType: "container" | "single" = "single", - contextId: string | null = null + contextId: string | null = null, ): Promise { const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); @@ -42,13 +42,19 @@ export async function reportPlaybackStart( positionSeconds, "context:", contextType, - contextId + contextId, ); // Update local DB with context (always works, even offline) if (userId) { try { - await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId); + await commands.storageUpdatePlaybackContext( + userId, + itemId, + positionMs, + contextType, + contextId, + ); } catch (e) { log.error("Failed to update playback context:", e); } @@ -72,7 +78,7 @@ export async function reportPlaybackStart( export async function reportPlaybackProgress( itemId: string, positionSeconds: number, - _isPaused = false + _isPaused = false, ): Promise { const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); @@ -100,7 +106,10 @@ export async function reportPlaybackProgress( * * TRACES: UR-005, UR-025 | DR-028 */ -export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise { +export async function reportPlaybackStopped( + itemId: string, + positionSeconds: number, +): Promise { const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); diff --git a/src/lib/services/playerEvents.regression.test.ts b/src/lib/services/playerEvents.regression.test.ts index 9cf17c5c..f900051b 100644 --- a/src/lib/services/playerEvents.regression.test.ts +++ b/src/lib/services/playerEvents.regression.test.ts @@ -168,7 +168,7 @@ describe("Player Events — recoverable errors get one chance before stopping", it("does not stop the player when Rust re-opened the stream", async () => { const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockImplementation(async (cmd: string) => - cmd === "player_recover_stream" ? true : null + cmd === "player_recover_stream" ? true : null, ); const { initPlayerEvents } = await import("./playerEvents"); @@ -187,7 +187,7 @@ describe("Player Events — recoverable errors get one chance before stopping", it("stops the player when recovery declines", async () => { const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockImplementation(async (cmd: string) => - cmd === "player_recover_stream" ? false : null + cmd === "player_recover_stream" ? false : null, ); const { initPlayerEvents } = await import("./playerEvents"); diff --git a/src/lib/services/playerEvents.test.ts b/src/lib/services/playerEvents.test.ts index 4ac16132..6664f64f 100644 --- a/src/lib/services/playerEvents.test.ts +++ b/src/lib/services/playerEvents.test.ts @@ -102,7 +102,7 @@ describe("Player Events Service", () => { // console.error is called with: ("Failed to initialize player events:", Error) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to initialize player events"), - expect.any(Error) + expect.any(Error), ); }); }); diff --git a/src/lib/services/playerEvents.ts b/src/lib/services/playerEvents.ts index 662c39a7..b1458960 100644 --- a/src/lib/services/playerEvents.ts +++ b/src/lib/services/playerEvents.ts @@ -139,7 +139,7 @@ function handlePlayerEvent(event: PlayerStatusEvent): void { event.current_episode, event.next_episode, event.countdown_seconds, - event.auto_advance + event.auto_advance, ); break; @@ -377,7 +377,7 @@ function handleShowNextEpisodePopup( currentEpisodeItem: MediaItem, nextEpisodeItem: MediaItem, countdownSeconds: number, - autoAdvance: boolean + autoAdvance: boolean, ): void { // Update next episode store to show popup nextEpisode.showPopup(currentEpisodeItem, nextEpisodeItem, countdownSeconds, autoAdvance); diff --git a/src/lib/services/preload.test.ts b/src/lib/services/preload.test.ts index e26c1b1f..de66bffb 100644 --- a/src/lib/services/preload.test.ts +++ b/src/lib/services/preload.test.ts @@ -70,9 +70,7 @@ describe("preload service", () => { await preloadUpcomingTracks(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call).toBeDefined(); }); @@ -82,9 +80,7 @@ describe("preload service", () => { await preloadUpcomingTracks(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call![1]).toHaveProperty("userId", "user-123"); }); @@ -94,9 +90,7 @@ describe("preload service", () => { await preloadUpcomingTracks({ userId: "user-456" }); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call![1]).toHaveProperty("userId", "user-456"); }); @@ -110,9 +104,7 @@ describe("preload service", () => { await preloadUpcomingTracks(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call).toBeUndefined(); }); @@ -141,9 +133,7 @@ describe("preload service", () => { await preloadUpcomingTracks({ debug: true, userId: "user-789" }); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_preload_upcoming" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_preload_upcoming"); expect(call![1]).toHaveProperty("userId", "user-789"); }); }); @@ -165,9 +155,7 @@ describe("preload service", () => { const config = makeConfig({ queuePrecacheEnabled: true }); await updateCacheConfig(config); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_set_cache_config" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_set_cache_config"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("config", config); }); @@ -203,9 +191,7 @@ describe("preload service", () => { await getCacheConfig(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "player_get_cache_config" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "player_get_cache_config"); expect(call).toBeDefined(); }); diff --git a/src/lib/services/preload.ts b/src/lib/services/preload.ts index 745c8b02..9e7df9bc 100644 --- a/src/lib/services/preload.ts +++ b/src/lib/services/preload.ts @@ -5,18 +5,18 @@ * TRACES: UR-004, UR-011 | DR-006, DR-015 */ -import { commands } from '$lib/api/bindings'; -import type { CacheConfig } from '$lib/api/bindings'; -import { auth } from '$lib/stores/auth'; -import { createLogger } from '$lib/utils/logger'; +import { commands } from "$lib/api/bindings"; +import type { CacheConfig } from "$lib/api/bindings"; +import { auth } from "$lib/stores/auth"; +import { createLogger } from "$lib/utils/logger"; -const log = createLogger('Preload'); +const log = createLogger("Preload"); interface PreloadOptions { - /** Enable debug logging */ - debug?: boolean; - /** Override user ID (defaults to current session user) */ - userId?: string; + /** Enable debug logging */ + debug?: boolean; + /** Override user ID (defaults to current session user) */ + userId?: string; } /** @@ -24,51 +24,51 @@ interface PreloadOptions { * This should be called after playback starts or advances to the next track */ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promise { - const { debug = false, userId: overrideUserId } = options; + const { debug = false, userId: overrideUserId } = options; - try { - // Get current user ID - const userId = overrideUserId || auth.getUserId(); + try { + // Get current user ID + const userId = overrideUserId || auth.getUserId(); - if (!userId) { - if (debug) log.debug('No active user session, skipping preload'); - return; - } + if (!userId) { + if (debug) log.debug("No active user session, skipping preload"); + return; + } - if (debug) log.debug('Triggering preload for user:', userId); + if (debug) log.debug("Triggering preload for user:", userId); - // downloadBasePath is currently unused in the backend - const result = await commands.playerPreloadUpcoming(userId, '/downloads'); + // downloadBasePath is currently unused in the backend + const result = await commands.playerPreloadUpcoming(userId, "/downloads"); - if (debug) { - log.debug('Result:', { - queued: result.queuedCount, - alreadyDownloaded: result.alreadyDownloaded, - skipped: result.skipped - }); - } + if (debug) { + log.debug("Result:", { + queued: result.queuedCount, + alreadyDownloaded: result.alreadyDownloaded, + skipped: result.skipped, + }); + } - // Log meaningful results - if (result.queuedCount > 0) { - log.debug(`Queued ${result.queuedCount} track(s) for background download`); - } - } catch (error) { - // Fail silently - preloading is a background optimization - // Don't interrupt the user's playback experience - log.warn('Failed to preload upcoming tracks:', error); - } + // Log meaningful results + if (result.queuedCount > 0) { + log.debug(`Queued ${result.queuedCount} track(s) for background download`); + } + } catch (error) { + // Fail silently - preloading is a background optimization + // Don't interrupt the user's playback experience + log.warn("Failed to preload upcoming tracks:", error); + } } /** * Update smart cache configuration */ export async function updateCacheConfig(config: CacheConfig): Promise { - await commands.playerSetCacheConfig(config); + await commands.playerSetCacheConfig(config); } /** * Get current cache configuration */ export async function getCacheConfig(): Promise { - return await commands.playerGetCacheConfig(); + return await commands.playerGetCacheConfig(); } diff --git a/src/lib/services/skipReporting.test.ts b/src/lib/services/skipReporting.test.ts index d757f25f..69bee9ca 100644 --- a/src/lib/services/skipReporting.test.ts +++ b/src/lib/services/skipReporting.test.ts @@ -12,9 +12,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; const markAsPlayed = vi.fn(async (_itemId: string) => undefined); -const reportPlaybackStopped = vi.fn( - async (_itemId: string, _positionSeconds: number) => undefined -); +const reportPlaybackStopped = vi.fn(async (_itemId: string, _positionSeconds: number) => undefined); vi.mock("./playbackReporting", () => ({ markAsPlayed: (itemId: string) => markAsPlayed(itemId), diff --git a/src/lib/services/syncService.ts b/src/lib/services/syncService.ts index 9663f850..a5520a02 100644 --- a/src/lib/services/syncService.ts +++ b/src/lib/services/syncService.ts @@ -63,7 +63,7 @@ class SyncService { async queueMutation( operation: SyncOperation, itemId: string, - payload?: Record + payload?: Record, ): Promise { const userId = auth.getUserId(); if (!userId) { @@ -74,7 +74,7 @@ class SyncService { userId, operation, itemId, - payload ? JSON.stringify(payload) : null + payload ? JSON.stringify(payload) : null, ); log.debug(`Queued ${operation} for item ${itemId}, id: ${id}`); @@ -90,10 +90,7 @@ class SyncService { * Queue playback progress update * Also updates local state immediately */ - async queuePlaybackProgress( - itemId: string, - positionMs: number - ): Promise { + async queuePlaybackProgress(itemId: string, positionMs: number): Promise { // Update local state first await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs); @@ -184,7 +181,11 @@ class SyncService { return this.queueMutation("playlist_remove_items", playlistId, { entryIds }); } - async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise { + async queuePlaylistReorderItem( + playlistId: string, + itemId: string, + newIndex: number, + ): Promise { return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex }); } diff --git a/src/lib/services/webviewAudio.ts b/src/lib/services/webviewAudio.ts index da61de62..a7fca42b 100644 --- a/src/lib/services/webviewAudio.ts +++ b/src/lib/services/webviewAudio.ts @@ -60,7 +60,7 @@ async function handleLoad( url: string, mediaId: string | null, position: number, - autoplay: boolean + autoplay: boolean, ): Promise { if (!audioEl) return; diff --git a/src/lib/stores/__mocks__/tauri.ts b/src/lib/stores/__mocks__/tauri.ts index e2bd816d..97c7adfa 100644 --- a/src/lib/stores/__mocks__/tauri.ts +++ b/src/lib/stores/__mocks__/tauri.ts @@ -13,10 +13,7 @@ const invokeResponses: Map = new Map(); /** * Mock invoke function that captures calls */ -export const mockInvoke = async ( - command: string, - args?: Record -): Promise => { +export const mockInvoke = async (command: string, args?: Record): Promise => { const callArgs = args || {}; invokeHistory.push({ command, args: callArgs }); @@ -72,10 +69,7 @@ export const clearInvokeHistory = (): void => { /** * Verify a command was called with expected parameters */ -export const expectInvokeCall = ( - command: string, - expectedArgs: Record -): void => { +export const expectInvokeCall = (command: string, expectedArgs: Record): void => { const calls = getInvokeCalls_ForCommand(command); if (calls.length === 0) { @@ -92,7 +86,7 @@ export const expectInvokeCall = ( throw new Error( `Parameter "${key}" mismatch:\n` + ` Expected: ${JSON.stringify(expectedValue)}\n` + - ` Actual: ${JSON.stringify(actualValue)}` + ` Actual: ${JSON.stringify(actualValue)}`, ); } } @@ -104,7 +98,7 @@ export const expectInvokeCall = ( export const getInvokeParameter = ( command: string, paramName: string, - callIndex = -1 // -1 = last call + callIndex = -1, // -1 = last call ): any => { const calls = getInvokeCalls_ForCommand(command); diff --git a/src/lib/stores/appState.ts b/src/lib/stores/appState.ts index 368c42a0..94b08342 100644 --- a/src/lib/stores/appState.ts +++ b/src/lib/stores/appState.ts @@ -1,6 +1,6 @@ // Application-wide UI state store // TRACES: UR-005 | DR-005, DR-009 -import { writable } from 'svelte/store'; +import { writable } from "svelte/store"; // App-wide state (root layout) export const isInitialized = writable(false); diff --git a/src/lib/stores/auth.test.ts b/src/lib/stores/auth.test.ts index ef35aafb..551ef462 100644 --- a/src/lib/stores/auth.test.ts +++ b/src/lib/stores/auth.test.ts @@ -237,9 +237,7 @@ describe("auth store", () => { // Expected - might fail due to mocking } - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "auth_connect_to_server" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "auth_connect_to_server"); expect(call).toBeDefined(); expect(call![1]).toHaveProperty("serverUrl"); }); @@ -265,9 +263,7 @@ describe("auth store", () => { const { invoke } = await import("@tauri-apps/api/core"); const invokeSpy = vi.mocked(invoke); - const loginCall = invokeSpy.mock.calls.find( - (c) => c[0] === "auth_login" - ); + const loginCall = invokeSpy.mock.calls.find((c) => c[0] === "auth_login"); expect(loginCall).toBeDefined(); expect(loginCall![1]).toHaveProperty("username", "testuser"); expect(loginCall![1]).toHaveProperty("password", "password123"); @@ -288,7 +284,7 @@ describe("auth store", () => { expect.objectContaining({ username: "user", password: "pass", - }) + }), ); }); }); @@ -323,8 +319,10 @@ describe("auth store", () => { await auth.logout(); // Either auth_get_session or auth_logout should be called - const callNames = invokeSpy.mock.calls.map(c => c[0]); - expect(callNames.some(name => ["auth_get_session", "player_disable_jellyfin"].includes(name))).toBe(true); + const callNames = invokeSpy.mock.calls.map((c) => c[0]); + expect( + callNames.some((name) => ["auth_get_session", "player_disable_jellyfin"].includes(name)), + ).toBe(true); }); }); @@ -340,9 +338,7 @@ describe("auth store", () => { await auth.getCurrentSession(); - const call = invokeSpy.mock.calls.find( - (c) => c[0] === "auth_get_session" - ); + const call = invokeSpy.mock.calls.find((c) => c[0] === "auth_get_session"); expect(call).toBeDefined(); }); }); diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 246e228a..add0bcbe 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -172,7 +172,12 @@ function createAuthStore() { // we mark authenticated — the first screen (library overview) reads // through it — so keep it awaited. repository = new RepositoryClient(); - await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId); + await repository.create( + session.serverUrl, + session.userId, + session.accessToken, + session.serverId, + ); // Configure the Rust player for playback reporting. This is NOT needed to // render the first screen (it only matters once playback starts), so run @@ -185,7 +190,7 @@ function createAuthStore() { session.serverUrl, session.accessToken, session.userId, - deviceId + deviceId, ); log.debug("Rust player configured for automatic playback reporting"); } catch (error) { @@ -209,19 +214,21 @@ function createAuthStore() { // Start connectivity monitoring early to avoid appearing offline on startup log.debug("Starting early connectivity monitoring..."); - connectivity.startMonitoring(session.serverUrl, { - onServerReconnected: () => { - // Retry session verification when server becomes reachable - retryVerification(); - // Resume downloads queued while offline, then refresh the catalog. - // Lazy import to avoid an auth <-> offlineCatalog import cycle. - import("$lib/services/offlineCatalog") - .then((m) => m.onReconnected()) - .catch((err) => log.warn("Catalog reconnect failed:", err)); - }, - }).catch((error) => { - log.error("Failed to start connectivity monitoring:", error); - }); + connectivity + .startMonitoring(session.serverUrl, { + onServerReconnected: () => { + // Retry session verification when server becomes reachable + retryVerification(); + // Resume downloads queued while offline, then refresh the catalog. + // Lazy import to avoid an auth <-> offlineCatalog import cycle. + import("$lib/services/offlineCatalog") + .then((m) => m.onReconnected()) + .catch((err) => log.warn("Catalog reconnect failed:", err)); + }, + }) + .catch((error) => { + log.error("Failed to start connectivity monitoring:", error); + }); // Start background session verification — fire-and-forget. This is // already asynchronous work (results arrive via the auth:* events wired @@ -313,7 +320,7 @@ function createAuthStore() { authResult.user.id, authResult.serverId, authResult.user.name, - authResult.accessToken + authResult.accessToken, ); await commands.storageSetActiveUser(authResult.user.id, authResult.serverId); @@ -332,7 +339,12 @@ function createAuthStore() { // Create RepositoryClient repository = new RepositoryClient(); - await repository.create(serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); + await repository.create( + serverUrl, + authResult.user.id, + authResult.accessToken, + authResult.serverId, + ); // Configure Rust player try { @@ -341,7 +353,7 @@ function createAuthStore() { serverUrl, authResult.accessToken, authResult.user.id, - playerDeviceId + playerDeviceId, ); log.debug("Rust player configured for playback reporting"); } catch (error) { @@ -398,7 +410,7 @@ function createAuthStore() { authResult.user.id, authResult.serverId, authResult.user.name, - authResult.accessToken + authResult.accessToken, ); // Recreate repository with new credentials @@ -406,7 +418,12 @@ function createAuthStore() { await repository.destroy(); const session = await commands.authGetSession(); if (session) { - await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); + await repository.create( + session.serverUrl, + authResult.user.id, + authResult.accessToken, + authResult.serverId, + ); } } @@ -417,7 +434,7 @@ function createAuthStore() { repository ? await getCurrentSessionServerUrl() : "", authResult.accessToken, authResult.user.id, - playerDeviceId + playerDeviceId, ); } catch (error) { log.error("Failed to reconfigure player:", error); diff --git a/src/lib/stores/connectivity.ts b/src/lib/stores/connectivity.ts index 8bde0b58..d7c2e390 100644 --- a/src/lib/stores/connectivity.ts +++ b/src/lib/stores/connectivity.ts @@ -166,8 +166,10 @@ function createConnectivityStore() { isChecking: status.isChecking, })); - log.debug("Started monitoring. Initial status:", - status.isServerReachable ? "ONLINE" : "OFFLINE"); + log.debug( + "Started monitoring. Initial status:", + status.isServerReachable ? "ONLINE" : "OFFLINE", + ); } catch (error) { log.error("Failed to start monitoring:", error); update((s) => ({ @@ -246,8 +248,5 @@ export const isServerReachable = derived(connectivity, ($c) => $c.isServerReacha // a brief full-catalog flash before the first probe beats flipping the app to // "offline" on launch. See docs/architecture/07-connectivity.md. // TRACES: UR-052 | DR-079 -export const isConnected = derived( - connectivity, - ($c) => $c.isServerReachable -); +export const isConnected = derived(connectivity, ($c) => $c.isServerReachable); export const connectionError = derived(connectivity, ($c) => $c.connectionError); diff --git a/src/lib/stores/continueWatchingFilter.test.ts b/src/lib/stores/continueWatchingFilter.test.ts index 951427c1..7e137609 100644 --- a/src/lib/stores/continueWatchingFilter.test.ts +++ b/src/lib/stores/continueWatchingFilter.test.ts @@ -14,16 +14,13 @@ import { describe, it, expect } from "vitest"; import type { MediaItem } from "$lib/api/types"; -import { - filterSupersededResumeItems, - filterInProgressNextUpItems, -} from "./continueWatchingFilter"; +import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter"; function episode( id: string, seriesId: string, season: number | undefined, - index: number | undefined + index: number | undefined, ): MediaItem { return { id, @@ -115,15 +112,12 @@ describe("filterSupersededResumeItems", () => { const result = filterSupersededResumeItems(resume, nextUp); - expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]); + expect(result.map((i) => i.id)).toEqual(["a-s1e2", "c-s1e3"]); }); it("uses the furthest-ahead next-up entry for a series", () => { const resume = [episode("s1e2", "series-a", 1, 2)]; - const nextUp = [ - episode("s1e1", "series-a", 1, 1), - episode("s1e8", "series-a", 1, 8), - ]; + const nextUp = [episode("s1e1", "series-a", 1, 1), episode("s1e8", "series-a", 1, 8)]; expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]); }); @@ -143,7 +137,7 @@ describe("filterInProgressNextUpItems", () => { const resume = [episode("s1e4", "series-a", 1, 4)]; const nextUp = [episode("s1e5", "series-a", 1, 5)]; - expect(filterInProgressNextUpItems(nextUp, resume).map(i => i.id)).toEqual(["s1e5"]); + expect(filterInProgressNextUpItems(nextUp, resume).map((i) => i.id)).toEqual(["s1e5"]); }); it("only suppresses the started episode, not the rest of the row", () => { @@ -156,7 +150,7 @@ describe("filterInProgressNextUpItems", () => { const result = filterInProgressNextUpItems(nextUp, resume); - expect(result.map(i => i.id)).toEqual(["b-s1e1", "c-s2e3"]); + expect(result.map((i) => i.id)).toEqual(["b-s1e1", "c-s2e3"]); }); it("is a no-op when nothing is in progress", () => { diff --git a/src/lib/stores/continueWatchingFilter.ts b/src/lib/stores/continueWatchingFilter.ts index 303dbf14..33f935e7 100644 --- a/src/lib/stores/continueWatchingFilter.ts +++ b/src/lib/stores/continueWatchingFilter.ts @@ -54,7 +54,7 @@ function isAheadOf(a: MediaItem, b: MediaItem): boolean { */ export function filterSupersededResumeItems( resumeItems: MediaItem[], - nextUpItems: MediaItem[] + nextUpItems: MediaItem[], ): MediaItem[] { if (nextUpItems.length === 0) return resumeItems; @@ -69,7 +69,7 @@ export function filterSupersededResumeItems( } } - return resumeItems.filter(item => { + return resumeItems.filter((item) => { if (item.kind !== "episode" || !item.seriesId) return true; const ahead = frontier.get(item.seriesId); if (!ahead) return true; @@ -92,10 +92,10 @@ export function filterSupersededResumeItems( */ export function filterInProgressNextUpItems( nextUpItems: MediaItem[], - resumeItems: MediaItem[] + resumeItems: MediaItem[], ): MediaItem[] { if (resumeItems.length === 0) return nextUpItems; - const inProgress = new Set(resumeItems.map(item => item.id)); - return nextUpItems.filter(item => !inProgress.has(item.id)); + const inProgress = new Set(resumeItems.map((item) => item.id)); + return nextUpItems.filter((item) => !inProgress.has(item.id)); } diff --git a/src/lib/stores/downloads.test.ts b/src/lib/stores/downloads.test.ts index 5a845cfb..68d2e840 100644 --- a/src/lib/stores/downloads.test.ts +++ b/src/lib/stores/downloads.test.ts @@ -24,10 +24,12 @@ describe("downloads store", () => { mockInvoke.mockReset(); // Capture the event handler when listen is called - mockListen.mockImplementation((_event: string, handler: (event: { payload: unknown }) => void) => { - eventHandler = handler; - return Promise.resolve(() => {}); - }); + mockListen.mockImplementation( + (_event: string, handler: (event: { payload: unknown }) => void) => { + eventHandler = handler; + return Promise.resolve(() => {}); + }, + ); }); afterEach(async () => { @@ -76,7 +78,7 @@ describe("downloads store", () => { "user-1", "/path/to/file.mp3", "audio/mpeg", - 10 + 10, ); expect(mockInvoke).toHaveBeenCalledWith("download_item", { @@ -98,34 +100,32 @@ describe("downloads store", () => { it("should refresh downloads after queuing", async () => { const { downloads } = await import("./downloads"); - mockInvoke - .mockResolvedValueOnce(123) - .mockResolvedValueOnce({ - downloads: [ - { - id: 123, - itemId: "item-1", - userId: "user-1", - filePath: "/path/to/file.mp3", - status: "pending", - progress: 0, - bytesDownloaded: 0, - queuedAt: "2024-01-01T00:00:00Z", - retryCount: 0, - priority: 10, - mediaType: "audio", - downloadSource: "user", - }, - ], - stats: { - total: 1, - activeCount: 0, - queuedCount: 1, - completedCount: 0, - failedCount: 0, - pausedCount: 0, + mockInvoke.mockResolvedValueOnce(123).mockResolvedValueOnce({ + downloads: [ + { + id: 123, + itemId: "item-1", + userId: "user-1", + filePath: "/path/to/file.mp3", + status: "pending", + progress: 0, + bytesDownloaded: 0, + queuedAt: "2024-01-01T00:00:00Z", + retryCount: 0, + priority: 10, + mediaType: "audio", + downloadSource: "user", }, - }); + ], + stats: { + total: 1, + activeCount: 0, + queuedCount: 1, + completedCount: 0, + failedCount: 0, + pausedCount: 0, + }, + }); await downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3"); @@ -315,18 +315,76 @@ describe("downloads store", () => { // `transfers` derivation: active + pending + failed. // TRACES: UR-055 | DR-084 | UT-052 it("transfers set excludes completed downloads", async () => { - const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import( - "./downloads" - ); + const { downloads, activeDownloads, pendingDownloads, failedDownloads } = + await import("./downloads"); mockInvoke.mockResolvedValueOnce({ downloads: [ - { id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, - { id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, - { id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, - { id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user" }, + { + id: 1, + itemId: "a", + userId: "u", + filePath: "/a", + status: "downloading", + progress: 0.5, + bytesDownloaded: 5, + queuedAt: "t", + retryCount: 0, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, + { + id: 2, + itemId: "b", + userId: "u", + filePath: "/b", + status: "pending", + progress: 0, + bytesDownloaded: 0, + queuedAt: "t", + retryCount: 0, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, + { + id: 3, + itemId: "c", + userId: "u", + filePath: "/c", + status: "completed", + progress: 1, + bytesDownloaded: 9, + queuedAt: "t", + retryCount: 0, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, + { + id: 4, + itemId: "d", + userId: "u", + filePath: "/d", + status: "failed", + progress: 0, + bytesDownloaded: 0, + queuedAt: "t", + retryCount: 1, + priority: 0, + mediaType: "audio", + downloadSource: "user", + }, ], - stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0 }, + stats: { + total: 4, + activeCount: 1, + queuedCount: 1, + completedCount: 1, + failedCount: 1, + pausedCount: 0, + }, }); await downloads.refresh("u"); @@ -920,9 +978,9 @@ describe("downloads store", () => { return Promise.reject(new Error(`Unexpected command: ${command}`)); }); - await expect( - downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3") - ).rejects.toThrow("Backend error: failed to queue download"); + await expect(downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3")).rejects.toThrow( + "Backend error: failed to queue download", + ); }); it("should throw error when refresh fails", async () => { @@ -935,9 +993,9 @@ describe("downloads store", () => { return Promise.reject(new Error(`Unexpected command: ${command}`)); }); - await expect( - downloads.refresh("user-1") - ).rejects.toThrow("Backend error: failed to fetch downloads"); + await expect(downloads.refresh("user-1")).rejects.toThrow( + "Backend error: failed to fetch downloads", + ); }); }); }); diff --git a/src/lib/stores/downloads.ts b/src/lib/stores/downloads.ts index 0593cae2..f717ec01 100644 --- a/src/lib/stores/downloads.ts +++ b/src/lib/stores/downloads.ts @@ -1,79 +1,79 @@ // Download manager state store // TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 -import { writable, derived, get } from 'svelte/store'; -import { commands } from '$lib/api/bindings'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { createLogger } from '$lib/utils/logger'; +import { writable, derived, get } from "svelte/store"; +import { commands } from "$lib/api/bindings"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { createLogger } from "$lib/utils/logger"; -const log = createLogger('Downloads'); +const log = createLogger("Downloads"); // Event listener state let unlistenFn: UnlistenFn | null = null; let isEventsInitialized = false; export interface DownloadInfo { - id: number; - itemId: string; - userId: string; - filePath: string; - fileSize?: number; - mimeType?: string; - status: 'pending' | 'downloading' | 'completed' | 'failed' | 'paused'; - progress: number; - bytesDownloaded: number; - queuedAt: string; - startedAt?: string; - completedAt?: string; - errorMessage?: string; - retryCount: number; - priority: number; - // Item metadata for display (audio) - itemName?: string; - artistName?: string; - albumName?: string; - // Video-specific metadata - seriesName?: string; - seasonName?: string; - episodeNumber?: number; - seasonNumber?: number; - qualityPreset?: string; - mediaType: 'audio' | 'video'; - // Download source tracking - downloadSource: 'user' | 'auto'; + id: number; + itemId: string; + userId: string; + filePath: string; + fileSize?: number; + mimeType?: string; + status: "pending" | "downloading" | "completed" | "failed" | "paused"; + progress: number; + bytesDownloaded: number; + queuedAt: string; + startedAt?: string; + completedAt?: string; + errorMessage?: string; + retryCount: number; + priority: number; + // Item metadata for display (audio) + itemName?: string; + artistName?: string; + albumName?: string; + // Video-specific metadata + seriesName?: string; + seasonName?: string; + episodeNumber?: number; + seasonNumber?: number; + qualityPreset?: string; + mediaType: "audio" | "video"; + // Download source tracking + downloadSource: "user" | "auto"; } export interface DownloadEvent { - type: - | 'queued' - | 'started' - | 'progress' - | 'completed' - | 'failed' - | 'paused' - | 'cancelled' - | 'waitingForNetwork'; - /** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */ - downloadId: number; - itemId: string; - bytesDownloaded?: number; - totalBytes?: number; - progress?: number; - filePath?: string; - error?: string; + type: + | "queued" + | "started" + | "progress" + | "completed" + | "failed" + | "paused" + | "cancelled" + | "waitingForNetwork"; + /** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */ + downloadId: number; + itemId: string; + bytesDownloaded?: number; + totalBytes?: number; + progress?: number; + filePath?: string; + error?: string; } export interface DownloadStats { - total: number; - activeCount: number; - queuedCount: number; - completedCount: number; - failedCount: number; - pausedCount: number; + total: number; + activeCount: number; + queuedCount: number; + completedCount: number; + failedCount: number; + pausedCount: number; } interface DownloadsState { - downloads: Record; - stats: DownloadStats; + downloads: Record; + stats: DownloadStats; } /** @@ -86,430 +86,437 @@ interface DownloadsState { export const waitingForNetwork = writable(false); function createDownloadsStore() { - const { subscribe, update, set } = writable({ - downloads: {}, - stats: { - total: 0, - activeCount: 0, - queuedCount: 0, - completedCount: 0, - failedCount: 0, - pausedCount: 0 - } - }); + const { subscribe, update, set } = writable({ + downloads: {}, + stats: { + total: 0, + activeCount: 0, + queuedCount: 0, + completedCount: 0, + failedCount: 0, + pausedCount: 0, + }, + }); - // Prevent concurrent refresh calls (race condition protection) - let refreshInProgress = false; - let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null; + // Prevent concurrent refresh calls (race condition protection) + let refreshInProgress = false; + let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null; - // Helper function to refresh downloads (avoids `this` binding issues) - async function refreshDownloads(userId: string, statusFilter?: string[]): Promise { - // If a refresh is already in progress, queue this request instead - if (refreshInProgress) { - log.debug('🔄 Refresh already in progress, queuing request for user:', userId); - pendingRefreshRequest = { userId, statusFilter }; - return; - } + // Helper function to refresh downloads (avoids `this` binding issues) + async function refreshDownloads(userId: string, statusFilter?: string[]): Promise { + // If a refresh is already in progress, queue this request instead + if (refreshInProgress) { + log.debug("🔄 Refresh already in progress, queuing request for user:", userId); + pendingRefreshRequest = { userId, statusFilter }; + return; + } - refreshInProgress = true; + refreshInProgress = true; - try { - log.debug('🔄 Refreshing downloads for user:', userId); - const response = (await commands.getDownloads( - userId, - statusFilter ?? null - )) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats }; - log.debug(' Got', response.downloads.length, 'downloads from backend'); - log.debug(' Stats:', response.stats); + try { + log.debug("🔄 Refreshing downloads for user:", userId); + const response = (await commands.getDownloads(userId, statusFilter ?? null)) as unknown as { + downloads: DownloadInfo[]; + stats: DownloadStats; + }; + log.debug(" Got", response.downloads.length, "downloads from backend"); + log.debug(" Stats:", response.stats); - update((state) => { - const downloadsMap: Record = {}; + update((state) => { + const downloadsMap: Record = {}; - for (const download of response.downloads) { - downloadsMap[download.id] = download; - } + for (const download of response.downloads) { + downloadsMap[download.id] = download; + } - // No count calculation - use pre-computed stats from Rust! - return { - downloads: downloadsMap, - stats: response.stats - }; - }); - } catch (error) { - log.error('Failed to refresh downloads:', error); - throw error; - } finally { - refreshInProgress = false; + // No count calculation - use pre-computed stats from Rust! + return { + downloads: downloadsMap, + stats: response.stats, + }; + }); + } catch (error) { + log.error("Failed to refresh downloads:", error); + throw error; + } finally { + refreshInProgress = false; - // Process queued request if any - if (pendingRefreshRequest) { - const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest; - pendingRefreshRequest = null; - await refreshDownloads(queuedUserId, queuedFilter); - } - } - } + // Process queued request if any + if (pendingRefreshRequest) { + const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest; + pendingRefreshRequest = null; + await refreshDownloads(queuedUserId, queuedFilter); + } + } + } - return { - subscribe, + return { + subscribe, - /** - * Queue a single item for download - */ - async downloadItem( - itemId: string, - userId: string, - filePath: string, - mimeType?: string, - priority?: number, - itemName?: string, - artistName?: string, - albumName?: string - ): Promise { - try { - log.debug('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName }); - const downloadId = await commands.downloadItem({ - itemId, - userId, - filePath, - mimeType: mimeType ?? null, - priority: priority ?? null, - itemName: itemName ?? null, - artistName: artistName ?? null, - albumName: albumName ?? null, - expectedSize: null - }); - log.debug(' Got download ID from backend:', downloadId); + /** + * Queue a single item for download + */ + async downloadItem( + itemId: string, + userId: string, + filePath: string, + mimeType?: string, + priority?: number, + itemName?: string, + artistName?: string, + albumName?: string, + ): Promise { + try { + log.debug("📥 downloadItem called:", { + itemId, + userId, + filePath, + itemName, + artistName, + albumName, + }); + const downloadId = await commands.downloadItem({ + itemId, + userId, + filePath, + mimeType: mimeType ?? null, + priority: priority ?? null, + itemName: itemName ?? null, + artistName: artistName ?? null, + albumName: albumName ?? null, + expectedSize: null, + }); + log.debug(" Got download ID from backend:", downloadId); - // Fetch download info and add to store - log.debug(' Refreshing downloads...'); - await refreshDownloads(userId); - log.debug(' Refresh complete. Store state:', get({ subscribe })); + // Fetch download info and add to store + log.debug(" Refreshing downloads..."); + await refreshDownloads(userId); + log.debug(" Refresh complete. Store state:", get({ subscribe })); - return downloadId; - } catch (error) { - log.error('Failed to queue download:', error); - throw error; - } - }, + return downloadId; + } catch (error) { + log.error("Failed to queue download:", error); + throw error; + } + }, - /** - * Queue an entire album for download. - * - * The backend does all of it — listing the album's tracks, queueing them, - * resolving each stream URL and starting the queue. It returns the queued - * row ids for reporting only; nothing here pairs them back to tracks. - * - * TRACES: UR-018, UR-055 | DR-173 - */ - async downloadAlbum( - handle: string, - albumId: string, - userId: string, - basePath: string - ): Promise { - try { - log.debug('📥 downloadAlbum called:', { albumId, userId, basePath }); - const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath); - log.debug(' Got download IDs from backend:', downloadIds); + /** + * Queue an entire album for download. + * + * The backend does all of it — listing the album's tracks, queueing them, + * resolving each stream URL and starting the queue. It returns the queued + * row ids for reporting only; nothing here pairs them back to tracks. + * + * TRACES: UR-018, UR-055 | DR-173 + */ + async downloadAlbum( + handle: string, + albumId: string, + userId: string, + basePath: string, + ): Promise { + try { + log.debug("📥 downloadAlbum called:", { albumId, userId, basePath }); + const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath); + log.debug(" Got download IDs from backend:", downloadIds); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadIds; - } catch (error) { - log.error('Failed to queue album download:', error); - throw error; - } - }, + return downloadIds; + } catch (error) { + log.error("Failed to queue album download:", error); + throw error; + } + }, - /** - * Queue a video item (movie/episode) for download with quality preset - */ - async downloadVideo( - itemId: string, - userId: string, - filePath: string, - mimeType?: string, - priority?: number, - itemName?: string, - qualityPreset?: string, - seriesName?: string, - seasonName?: string, - episodeNumber?: number, - seasonNumber?: number - ): Promise { - try { - log.debug('🎬 downloadVideo called:', { - itemId, - userId, - filePath, - itemName, - qualityPreset, - seriesName - }); - const downloadId = await commands.downloadVideo({ - itemId, - userId, - filePath, - mimeType: mimeType ?? null, - priority: priority ?? null, - itemName: itemName ?? null, - qualityPreset: qualityPreset ?? null, - seriesName: seriesName ?? null, - seasonName: seasonName ?? null, - episodeNumber: episodeNumber ?? null, - seasonNumber: seasonNumber ?? null - }); - log.debug(' Got download ID from backend:', downloadId); + /** + * Queue a video item (movie/episode) for download with quality preset + */ + async downloadVideo( + itemId: string, + userId: string, + filePath: string, + mimeType?: string, + priority?: number, + itemName?: string, + qualityPreset?: string, + seriesName?: string, + seasonName?: string, + episodeNumber?: number, + seasonNumber?: number, + ): Promise { + try { + log.debug("🎬 downloadVideo called:", { + itemId, + userId, + filePath, + itemName, + qualityPreset, + seriesName, + }); + const downloadId = await commands.downloadVideo({ + itemId, + userId, + filePath, + mimeType: mimeType ?? null, + priority: priority ?? null, + itemName: itemName ?? null, + qualityPreset: qualityPreset ?? null, + seriesName: seriesName ?? null, + seasonName: seasonName ?? null, + episodeNumber: episodeNumber ?? null, + seasonNumber: seasonNumber ?? null, + }); + log.debug(" Got download ID from backend:", downloadId); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadId; - } catch (error) { - log.error('Failed to queue video download:', error); - throw error; - } - }, + return downloadId; + } catch (error) { + log.error("Failed to queue video download:", error); + throw error; + } + }, - /** - * Queue all episodes of a series for download - */ - async downloadSeries( - seriesId: string, - seriesName: string, - userId: string, - basePath: string, - qualityPreset?: string - ): Promise { - try { - log.debug('📺 downloadSeries called:', { - seriesId, - seriesName, - userId, - basePath, - qualityPreset - }); - const downloadIds = await commands.downloadSeries( - seriesId, - seriesName, - userId, - basePath, - qualityPreset ?? null - ); - log.debug(' Queued', downloadIds.length, 'episodes for download'); + /** + * Queue all episodes of a series for download + */ + async downloadSeries( + seriesId: string, + seriesName: string, + userId: string, + basePath: string, + qualityPreset?: string, + ): Promise { + try { + log.debug("📺 downloadSeries called:", { + seriesId, + seriesName, + userId, + basePath, + qualityPreset, + }); + const downloadIds = await commands.downloadSeries( + seriesId, + seriesName, + userId, + basePath, + qualityPreset ?? null, + ); + log.debug(" Queued", downloadIds.length, "episodes for download"); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadIds; - } catch (error) { - log.error('Failed to queue series download:', error); - throw error; - } - }, + return downloadIds; + } catch (error) { + log.error("Failed to queue series download:", error); + throw error; + } + }, - /** - * Queue all episodes of a season for download - */ - async downloadSeason( - seasonId: string, - seriesName: string, - seasonName: string, - seasonNumber: number, - userId: string, - basePath: string, - qualityPreset?: string - ): Promise { - try { - log.debug('📺 downloadSeason called:', { - seasonId, - seriesName, - seasonName, - seasonNumber, - qualityPreset - }); - const downloadIds = await commands.downloadSeason( - seasonId, - seriesName, - seasonName, - seasonNumber, - userId, - basePath, - qualityPreset ?? null - ); - log.debug(' Queued', downloadIds.length, 'episodes for download'); + /** + * Queue all episodes of a season for download + */ + async downloadSeason( + seasonId: string, + seriesName: string, + seasonName: string, + seasonNumber: number, + userId: string, + basePath: string, + qualityPreset?: string, + ): Promise { + try { + log.debug("📺 downloadSeason called:", { + seasonId, + seriesName, + seasonName, + seasonNumber, + qualityPreset, + }); + const downloadIds = await commands.downloadSeason( + seasonId, + seriesName, + seasonName, + seasonNumber, + userId, + basePath, + qualityPreset ?? null, + ); + log.debug(" Queued", downloadIds.length, "episodes for download"); - // Refresh downloads - await refreshDownloads(userId); + // Refresh downloads + await refreshDownloads(userId); - return downloadIds; - } catch (error) { - log.error('Failed to queue season download:', error); - throw error; - } - }, + return downloadIds; + } catch (error) { + log.error("Failed to queue season download:", error); + throw error; + } + }, - /** - * Pin an item's metadata (protects from cache clear) - */ - async pinItem(itemId: string): Promise { - try { - await commands.pinItem(itemId); - } catch (error) { - log.error('Failed to pin item:', error); - throw error; - } - }, + /** + * Pin an item's metadata (protects from cache clear) + */ + async pinItem(itemId: string): Promise { + try { + await commands.pinItem(itemId); + } catch (error) { + log.error("Failed to pin item:", error); + throw error; + } + }, - /** - * Unpin an item's metadata - */ - async unpinItem(itemId: string): Promise { - try { - await commands.unpinItem(itemId); - } catch (error) { - log.error('Failed to unpin item:', error); - throw error; - } - }, + /** + * Unpin an item's metadata + */ + async unpinItem(itemId: string): Promise { + try { + await commands.unpinItem(itemId); + } catch (error) { + log.error("Failed to unpin item:", error); + throw error; + } + }, - /** - * Check if an item is pinned - */ - async isItemPinned(itemId: string): Promise { - try { - return await commands.isItemPinned(itemId); - } catch (error) { - log.error('Failed to check pin status:', error); - return false; - } - }, + /** + * Check if an item is pinned + */ + async isItemPinned(itemId: string): Promise { + try { + return await commands.isItemPinned(itemId); + } catch (error) { + log.error("Failed to check pin status:", error); + return false; + } + }, - /** - * Pause a download - */ - async pause(downloadId: number): Promise { - try { - await commands.pauseDownload(downloadId); - } catch (error) { - log.error('Failed to pause download:', error); - throw error; - } - }, + /** + * Pause a download + */ + async pause(downloadId: number): Promise { + try { + await commands.pauseDownload(downloadId); + } catch (error) { + log.error("Failed to pause download:", error); + throw error; + } + }, - /** - * Resume a paused download - */ - async resume(downloadId: number): Promise { - try { - await commands.resumeDownload(downloadId); - } catch (error) { - log.error('Failed to resume download:', error); - throw error; - } - }, + /** + * Resume a paused download + */ + async resume(downloadId: number): Promise { + try { + await commands.resumeDownload(downloadId); + } catch (error) { + log.error("Failed to resume download:", error); + throw error; + } + }, - /** - * Cancel a download - */ - async cancel(downloadId: number): Promise { - try { - await commands.cancelDownload(downloadId); - } catch (error) { - log.error('Failed to cancel download:', error); - throw error; - } - }, + /** + * Cancel a download + */ + async cancel(downloadId: number): Promise { + try { + await commands.cancelDownload(downloadId); + } catch (error) { + log.error("Failed to cancel download:", error); + throw error; + } + }, - /** - * Delete a completed download - */ - async delete(downloadId: number): Promise { - try { - await commands.deleteDownload(downloadId); - update((state) => { - const { [downloadId]: removed, ...remaining } = state.downloads; - return { ...state, downloads: remaining }; - }); - } catch (error) { - log.error('Failed to delete download:', error); - throw error; - } - }, + /** + * Delete a completed download + */ + async delete(downloadId: number): Promise { + try { + await commands.deleteDownload(downloadId); + update((state) => { + const { [downloadId]: removed, ...remaining } = state.downloads; + return { ...state, downloads: remaining }; + }); + } catch (error) { + log.error("Failed to delete download:", error); + throw error; + } + }, - /** - * Refresh downloads list from backend - */ - refresh: refreshDownloads, + /** + * Refresh downloads list from backend + */ + refresh: refreshDownloads, - /** - * Update a specific download in the store (for event handling) - */ - updateDownload(downloadId: number, updates: Partial): void { - update((state) => { - const download = state.downloads[downloadId]; - if (!download) { - log.debug(' Download not in store:', downloadId); - return state; - } + /** + * Update a specific download in the store (for event handling) + */ + updateDownload(downloadId: number, updates: Partial): void { + update((state) => { + const download = state.downloads[downloadId]; + if (!download) { + log.debug(" Download not in store:", downloadId); + return state; + } - const updatedDownload = { ...download, ...updates }; - const newDownloads = { ...state.downloads, [downloadId]: updatedDownload }; + const updatedDownload = { ...download, ...updates }; + const newDownloads = { ...state.downloads, [downloadId]: updatedDownload }; - log.debug(' Store updated for download', downloadId, ':', updates); - // No count calculation - stats remain as-is until next refresh - return { - downloads: newDownloads, - stats: state.stats - }; - }); - }, + log.debug(" Store updated for download", downloadId, ":", updates); + // No count calculation - stats remain as-is until next refresh + return { + downloads: newDownloads, + stats: state.stats, + }; + }); + }, - /** - * Remove a download from the store - */ - removeDownload(downloadId: number): void { - update((state) => { - const { [downloadId]: removed, ...remaining } = state.downloads; - if (!removed) return state; + /** + * Remove a download from the store + */ + removeDownload(downloadId: number): void { + update((state) => { + const { [downloadId]: removed, ...remaining } = state.downloads; + if (!removed) return state; - // No count calculation - stats remain as-is until next refresh - return { - downloads: remaining, - stats: state.stats - }; - }); - } - }; + // No count calculation - stats remain as-is until next refresh + return { + downloads: remaining, + stats: state.stats, + }; + }); + }, + }; } export const downloads = createDownloadsStore(); // Derived stores export const activeDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'downloading') + Object.values($d.downloads).filter((d) => d.status === "downloading"), ); export const completedDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'completed') + Object.values($d.downloads).filter((d) => d.status === "completed"), ); export const pendingDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'pending') + Object.values($d.downloads).filter((d) => d.status === "pending"), ); export const failedDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.status === 'failed') + Object.values($d.downloads).filter((d) => d.status === "failed"), ); export const videoDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.mediaType === 'video') + Object.values($d.downloads).filter((d) => d.mediaType === "video"), ); export const audioDownloads = derived(downloads, ($d) => - Object.values($d.downloads).filter((d) => d.mediaType === 'audio' || !d.mediaType) + Object.values($d.downloads).filter((d) => d.mediaType === "audio" || !d.mediaType), ); /** @@ -517,32 +524,32 @@ export const audioDownloads = derived(downloads, ($d) => * Should be called once when the app starts (e.g., in +layout.svelte). */ export async function initDownloadEvents(): Promise { - if (isEventsInitialized) { - log.warn('Download events already initialized'); - return; - } + if (isEventsInitialized) { + log.warn("Download events already initialized"); + return; + } - try { - log.debug('🎧 Setting up download event listener...'); - unlistenFn = await listen('download-event', (event) => { - const payload = event.payload; - log.debug('📬 Received download event:', payload.type, 'for download:', payload.downloadId); - log.debug(' Full event payload:', JSON.stringify(payload)); + try { + log.debug("🎧 Setting up download event listener..."); + unlistenFn = await listen("download-event", (event) => { + const payload = event.payload; + log.debug("📬 Received download event:", payload.type, "for download:", payload.downloadId); + log.debug(" Full event payload:", JSON.stringify(payload)); - // Update the store based on event type - downloads.subscribe((state) => { - const download = state.downloads[payload.downloadId]; - log.debug(' Current download state:', download ? download.status : 'NOT IN STORE'); - })(); // Immediately unsubscribe after reading + // Update the store based on event type + downloads.subscribe((state) => { + const download = state.downloads[payload.downloadId]; + log.debug(" Current download state:", download ? download.status : "NOT IN STORE"); + })(); // Immediately unsubscribe after reading - handleDownloadEvent(payload); - }); + handleDownloadEvent(payload); + }); - isEventsInitialized = true; - log.debug('✅ Download event listener registered successfully'); - } catch (err) { - log.error('❌ Failed to register download event listener:', err); - } + isEventsInitialized = true; + log.debug("✅ Download event listener registered successfully"); + } catch (err) { + log.error("❌ Failed to register download event listener:", err); + } } /** @@ -550,121 +557,122 @@ export async function initDownloadEvents(): Promise { * Should be called when the app is destroyed. */ export function cleanupDownloadEvents(): void { - if (unlistenFn) { - unlistenFn(); - unlistenFn = null; - } - isEventsInitialized = false; + if (unlistenFn) { + unlistenFn(); + unlistenFn = null; + } + isEventsInitialized = false; } /** * Check if the download event listener is initialized. */ export function isDownloadEventsInitialized(): boolean { - return isEventsInitialized; + return isEventsInitialized; } /** * Handle a download event and update the store. */ function handleDownloadEvent(payload: DownloadEvent): void { - const currentState = get(downloads); - const download = currentState.downloads[payload.downloadId]; + const currentState = get(downloads); + const download = currentState.downloads[payload.downloadId]; - switch (payload.type) { - case 'queued': - // Just increment queue count - the download will be fetched on refresh - break; + switch (payload.type) { + case "queued": + // Just increment queue count - the download will be fetched on refresh + break; - case 'started': - if (download) { - updateDownloadInStore(payload.downloadId, { - status: 'downloading', - startedAt: new Date().toISOString() - }); - } - break; + case "started": + if (download) { + updateDownloadInStore(payload.downloadId, { + status: "downloading", + startedAt: new Date().toISOString(), + }); + } + break; - case 'progress': - if (download && payload.progress !== undefined) { - updateDownloadInStore(payload.downloadId, { - progress: payload.progress, - bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded, - fileSize: payload.totalBytes || download.fileSize - }); - } - break; + case "progress": + if (download && payload.progress !== undefined) { + updateDownloadInStore(payload.downloadId, { + progress: payload.progress, + bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded, + fileSize: payload.totalBytes || download.fileSize, + }); + } + break; - case 'completed': - if (download) { - // Persist to database - commands.markDownloadCompleted( - payload.downloadId, - payload.totalBytes || download.fileSize || download.bytesDownloaded, - payload.filePath || download.filePath - ).catch((err) => log.error('Failed to persist download completion:', err)); + case "completed": + if (download) { + // Persist to database + commands + .markDownloadCompleted( + payload.downloadId, + payload.totalBytes || download.fileSize || download.bytesDownloaded, + payload.filePath || download.filePath, + ) + .catch((err) => log.error("Failed to persist download completion:", err)); - updateDownloadInStore(payload.downloadId, { - status: 'completed', - progress: 1.0, - completedAt: new Date().toISOString(), - filePath: payload.filePath || download.filePath - }); - } - break; + updateDownloadInStore(payload.downloadId, { + status: "completed", + progress: 1.0, + completedAt: new Date().toISOString(), + filePath: payload.filePath || download.filePath, + }); + } + break; - case 'failed': - if (download) { - // Persist to database - commands.markDownloadFailed( - payload.downloadId, - payload.error || 'Unknown error' - ).catch((err) => log.error('Failed to persist download failure:', err)); + case "failed": + if (download) { + // Persist to database + commands + .markDownloadFailed(payload.downloadId, payload.error || "Unknown error") + .catch((err) => log.error("Failed to persist download failure:", err)); - updateDownloadInStore(payload.downloadId, { - status: 'failed', - errorMessage: payload.error - }); - } - break; + updateDownloadInStore(payload.downloadId, { + status: "failed", + errorMessage: payload.error, + }); + } + break; - case 'paused': - if (download) { - updateDownloadInStore(payload.downloadId, { - status: 'paused' - }); - } - break; + case "paused": + if (download) { + updateDownloadInStore(payload.downloadId, { + status: "paused", + }); + } + break; - case 'cancelled': - removeDownloadFromStore(payload.downloadId); - break; + case "cancelled": + removeDownloadFromStore(payload.downloadId); + break; - case 'waitingForNetwork': - // Queue-wide, not tied to one download: the pump refused to start - // anything because WiFi-only is on and we're on a metered network. - waitingForNetwork.set(true); - break; - } + case "waitingForNetwork": + // Queue-wide, not tied to one download: the pump refused to start + // anything because WiFi-only is on and we're on a metered network. + waitingForNetwork.set(true); + break; + } - // Any per-download progress proves the gate isn't holding us any more. - if (payload.type === 'started' || payload.type === 'progress') { - waitingForNetwork.set(false); - } + // Any per-download progress proves the gate isn't holding us any more. + if (payload.type === "started" || payload.type === "progress") { + waitingForNetwork.set(false); + } } /** * Helper to update a download in the store. */ function updateDownloadInStore(downloadId: number, updates: Partial): void { - log.debug(' updateDownloadInStore:', downloadId, updates); - downloads.updateDownload(downloadId, updates); + log.debug(" updateDownloadInStore:", downloadId, updates); + downloads.updateDownload(downloadId, updates); } /** * Helper to remove a download from the store. */ function removeDownloadFromStore(downloadId: number): void { - log.debug(' removeDownloadFromStore:', downloadId); - downloads.removeDownload(downloadId); + log.debug(" removeDownloadFromStore:", downloadId); + downloads.removeDownload(downloadId); } diff --git a/src/lib/stores/favorites.ts b/src/lib/stores/favorites.ts index e32507eb..acf83c67 100644 --- a/src/lib/stores/favorites.ts +++ b/src/lib/stores/favorites.ts @@ -56,7 +56,7 @@ export function clearAllFavorites(): void { */ export function resolveIsFavorite( item: Pick | null | undefined, - overrideMap: Map + overrideMap: Map, ): boolean { if (!item) return false; const override = overrideMap.get(item.id); @@ -80,7 +80,7 @@ export function isFavoriteNow(item: Pick): boolean */ export function retainFavorites>( items: T[], - overrideMap: Map + overrideMap: Map, ): T[] { return items.filter((item) => resolveIsFavorite(item, overrideMap)); } diff --git a/src/lib/stores/home.ts b/src/lib/stores/home.ts index 95ebf294..ff5dd016 100644 --- a/src/lib/stores/home.ts +++ b/src/lib/stores/home.ts @@ -3,10 +3,7 @@ import { writable, derived } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import { auth } from "./auth"; -import { - filterSupersededResumeItems, - filterInProgressNextUpItems, -} from "./continueWatchingFilter"; +import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("HomeStore"); @@ -45,7 +42,11 @@ function createHomeStore() { async function loadHomeSections() { // Only show loading spinner when no data is available yet - update(s => ({ ...s, isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, error: null })); + update((s) => ({ + ...s, + isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, + error: null, + })); try { const repo = auth.getRepository(); @@ -67,7 +68,9 @@ function createHomeStore() { ]); const valueOr = (i: number, fallback: T): T => - settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult).value : fallback; + settled[i].status === "fulfilled" + ? (settled[i] as PromiseFulfilledResult).value + : fallback; const rawResume = valueOr(0, [] as typeof initialState.resumeItems); const rawNextUp = valueOr(1, [] as typeof initialState.nextUpItems); @@ -90,7 +93,7 @@ function createHomeStore() { // Use resume items or latest as hero items const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5); - update(s => ({ + update((s) => ({ ...s, heroItems: hero, resumeItems: resume, @@ -105,7 +108,7 @@ function createHomeStore() { })); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load home sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load home sections:", error); } } @@ -124,13 +127,13 @@ function createHomeStore() { export const home = createHomeStore(); // Derived stores for convenience -export const heroItems = derived(home, $home => $home.heroItems); -export const resumeItems = derived(home, $home => $home.resumeItems); -export const nextUpItems = derived(home, $home => $home.nextUpItems); -export const latestItems = derived(home, $home => $home.latestItems); -export const recentlyPlayedAudio = derived(home, $home => $home.recentlyPlayedAudio); -export const resumeMovies = derived(home, $home => $home.resumeMovies); -export const favoriteMovies = derived(home, $home => $home.favoriteMovies); -export const favoriteShows = derived(home, $home => $home.favoriteShows); -export const favoriteMusic = derived(home, $home => $home.favoriteMusic); -export const isHomeLoading = derived(home, $home => $home.isLoading); +export const heroItems = derived(home, ($home) => $home.heroItems); +export const resumeItems = derived(home, ($home) => $home.resumeItems); +export const nextUpItems = derived(home, ($home) => $home.nextUpItems); +export const latestItems = derived(home, ($home) => $home.latestItems); +export const recentlyPlayedAudio = derived(home, ($home) => $home.recentlyPlayedAudio); +export const resumeMovies = derived(home, ($home) => $home.resumeMovies); +export const favoriteMovies = derived(home, ($home) => $home.favoriteMovies); +export const favoriteShows = derived(home, ($home) => $home.favoriteShows); +export const favoriteMusic = derived(home, ($home) => $home.favoriteMusic); +export const isHomeLoading = derived(home, ($home) => $home.isLoading); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 43cd08eb..b80401d9 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -50,12 +50,7 @@ export { } from "./queue"; // Sessions store -export { - sessions, - activeSessions, - selectedSession, - controllableSessions, -} from "./sessions"; +export { sessions, activeSessions, selectedSession, controllableSessions } from "./sessions"; // Sleep timer store export { diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts index 4420c2e8..9fadc012 100644 --- a/src/lib/stores/library.ts +++ b/src/lib/stores/library.ts @@ -115,7 +115,7 @@ function createLibraryStore() { async function loadItems( parentId: string, - options: { startIndex?: number; limit?: number; genres?: string[] } = {} + options: { startIndex?: number; limit?: number; genres?: string[] } = {}, ) { update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); @@ -206,7 +206,7 @@ function createLibraryStore() { const item = await repo.getItem(itemId); log.debug(`loadItem(${itemId}): ${item.name} (${item.kind})`); - log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`); + log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : "NO"}`); if (item.people && item.people.length > 0) { item.people.forEach((p, i) => { log.debug(` [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`); @@ -257,7 +257,7 @@ function createLibraryStore() { // Add 10-second timeout to prevent indefinite hanging const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("Search timeout - please try again")), 10000) + setTimeout(() => reject(new Error("Search timeout - please try again")), 10000), ); // Phase 1: the command resolves with instant local-cache results. The @@ -267,10 +267,7 @@ function createLibraryStore() { // never names a Jellyfin item type in connection with search. const options: SearchOptions = { limit: 10000, scope }; - const result = await Promise.race([ - repo.search(query, options, requestId), - timeoutPromise - ]); + const result = await Promise.race([repo.search(query, options, requestId), timeoutPromise]); // Only apply if this is still the active query (a newer search may have // started while we awaited). diff --git a/src/lib/stores/librarySearchScope.test.ts b/src/lib/stores/librarySearchScope.test.ts index 871ca1f6..487ed83c 100644 --- a/src/lib/stores/librarySearchScope.test.ts +++ b/src/lib/stores/librarySearchScope.test.ts @@ -98,9 +98,7 @@ describe("library.search scoping", () => { // First search resolves *after* a newer one has already started; its // results must not clobber the fresher ones. let resolveFirst: (value: unknown) => void = () => {}; - searchMock.mockImplementationOnce( - () => new Promise((resolve) => (resolveFirst = resolve)) - ); + searchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))); searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }])); const first = library.search("old", "all"); diff --git a/src/lib/stores/lmsSync.test.ts b/src/lib/stores/lmsSync.test.ts index 508a5ea7..bc2d917c 100644 --- a/src/lib/stores/lmsSync.test.ts +++ b/src/lib/stores/lmsSync.test.ts @@ -43,12 +43,16 @@ describe("lmsSync store", () => { it("fusing preserves existing slaves and adds the new zone", async () => { // Initial group: master M with slave A. - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }, + ]); await lmsSync.refresh(); // create returns void; refresh after returns the updated group. mockInvoke.mockResolvedValueOnce(undefined); - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }, + ]); await lmsSync.fuseZone("M", "B"); @@ -60,11 +64,15 @@ describe("lmsSync store", () => { }); it("decoupling a slave unsyncs just that player", async () => { - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }, + ]); await lmsSync.refresh(); mockInvoke.mockResolvedValueOnce(undefined); // unsync - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }, + ]); await lmsSync.decoupleZone("A"); @@ -72,7 +80,9 @@ describe("lmsSync store", () => { }); it("decoupling the master dissolves the whole group", async () => { - mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + mockInvoke.mockResolvedValueOnce([ + { masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }, + ]); await lmsSync.refresh(); mockInvoke.mockResolvedValueOnce(undefined); // dissolve diff --git a/src/lib/stores/movies.ts b/src/lib/stores/movies.ts index 457657b4..1e7425a9 100644 --- a/src/lib/stores/movies.ts +++ b/src/lib/stores/movies.ts @@ -51,7 +51,7 @@ function createMoviesStore() { !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.imageId; async function loadSections(libraryId: string) { - update(s => ({ + update((s) => ({ ...s, isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0, error: null, @@ -72,7 +72,7 @@ function createMoviesStore() { recursive: true, limit: SECTION_LIMIT, }) - .then(r => r.items) + .then((r) => r.items) .catch(() => [] as MediaItem[]), ]); @@ -80,7 +80,7 @@ function createMoviesStore() { // additions, then random picks from across the library. const heroItems = buildHeroMix([resume, latest, surprise], hasArt); - update(s => ({ + update((s) => ({ ...s, continueWatching: resume, recentlyAdded: latest, @@ -93,7 +93,7 @@ function createMoviesStore() { loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load movie sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load movie sections:", error); } } @@ -126,15 +126,15 @@ function createMoviesStore() { log.warn(`Failed to load genre row "${genre.name}":`, e); return { id: genre.id, name: genre.name, items: [] }; } - }) + }), ); const genreRows = rows - .filter(row => row.items.length > 0) + .filter((row) => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length) .slice(0, MAX_GENRE_ROWS); - update(s => ({ ...s, genreRows })); + update((s) => ({ ...s, genreRows })); } catch (e) { log.warn("Failed to load movie genre rows:", e); } @@ -153,5 +153,5 @@ function createMoviesStore() { export const movies = createMoviesStore(); -export const moviesHeroItems = derived(movies, $m => $m.heroItems); -export const isMoviesLoading = derived(movies, $m => $m.isLoading); +export const moviesHeroItems = derived(movies, ($m) => $m.heroItems); +export const isMoviesLoading = derived(movies, ($m) => $m.isLoading); diff --git a/src/lib/stores/music.ts b/src/lib/stores/music.ts index d077e817..8ba8c7bf 100644 --- a/src/lib/stores/music.ts +++ b/src/lib/stores/music.ts @@ -60,7 +60,7 @@ function createMusicStore() { !!i.imageId || !!(i.backdropImageTags && i.backdropImageTags.length > 0); async function loadSections(libraryId: string) { - update(s => ({ + update((s) => ({ ...s, isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0, error: null, @@ -69,35 +69,37 @@ function createMusicStore() { try { const repo = auth.getRepository(); - const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all([ - repo.getRecentlyPlayedAudio(SECTION_LIMIT), - repo.getItems(libraryId, { - includeItemTypes: ["MusicAlbum"], - sortBy: "DateCreated", - sortOrder: "Descending", - recursive: true, - limit: SECTION_LIMIT, - }), - repo.getItems(libraryId, { - includeItemTypes: ["Playlist"], - sortBy: "SortName", - sortOrder: "Ascending", - recursive: true, - limit: SECTION_LIMIT, - }), - repo.getRediscoverAlbums(libraryId, SECTION_LIMIT), - // Random pool so the hero rotation changes between visits (SortBy=Random - // shuffles server-side online, and via SQLite RANDOM() offline). - repo - .getItems(libraryId, { + const [recentlyPlayed, newlyAdded, playlistsResult, rediscover, surprise] = await Promise.all( + [ + repo.getRecentlyPlayedAudio(SECTION_LIMIT), + repo.getItems(libraryId, { includeItemTypes: ["MusicAlbum"], - sortBy: "Random", + sortBy: "DateCreated", + sortOrder: "Descending", recursive: true, limit: SECTION_LIMIT, - }) - .then(r => r.items) - .catch(() => [] as MediaItem[]), - ]); + }), + repo.getItems(libraryId, { + includeItemTypes: ["Playlist"], + sortBy: "SortName", + sortOrder: "Ascending", + recursive: true, + limit: SECTION_LIMIT, + }), + repo.getRediscoverAlbums(libraryId, SECTION_LIMIT), + // Random pool so the hero rotation changes between visits (SortBy=Random + // shuffles server-side online, and via SQLite RANDOM() offline). + repo + .getItems(libraryId, { + includeItemTypes: ["MusicAlbum"], + sortBy: "Random", + recursive: true, + limit: SECTION_LIMIT, + }) + .then((r) => r.items) + .catch(() => [] as MediaItem[]), + ], + ); // Nothing is filtered here: folders the user chose to hide are already // gone, dropped by the repository layer that answered these queries. @@ -107,7 +109,7 @@ function createMusicStore() { // random albums from across the library. const heroItems = buildHeroMix([recentlyPlayed, rediscover, surprise], hasArt); - update(s => ({ + update((s) => ({ ...s, recentlyPlayed, newlyAdded: newlyAdded.items, @@ -122,7 +124,7 @@ function createMusicStore() { loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load music sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load music sections:", error); } } @@ -172,19 +174,17 @@ function createMusicStore() { // Treat that as "no usable counts" and fall through to the probe path, // which ranks by genres' real album counts instead. const positiveCounts = genres - .map(g => g.albumCount) + .map((g) => g.albumCount) .filter((c): c is number => c != null && c > 0); const hasUsefulCounts = new Set(positiveCounts).size > 1; let genreRows: GenreRow[]; if (hasUsefulCounts) { // Rank by reported count, pick a diverse subset, then fetch only those. - const ranked = [...genres].sort( - (a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0) - ); + const ranked = [...genres].sort((a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)); const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS); - genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter( - row => row.items.length > 0 + genreRows = (await Promise.all(chosen.map((g) => loadGenreRow(libraryId, g)))).filter( + (row) => row.items.length > 0, ); } else { // No usable counts (offline, a server that ignores Fields=ItemCounts, @@ -194,14 +194,14 @@ function createMusicStore() { // list instead, so the probe pool spans A→Z; then drop empties, rank // by what came back, and pick a diverse subset. const probed = sampleAcross(genres, MAX_GENRES_PROBED); - const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g))); + const rows = await Promise.all(probed.map((g) => loadGenreRow(libraryId, g))); const populated = rows - .filter(row => row.items.length > 0) + .filter((row) => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length); genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS); } - update(s => ({ ...s, genreRows })); + update((s) => ({ ...s, genreRows })); } catch (e) { log.warn("Failed to load music genre rows:", e); } @@ -220,5 +220,5 @@ function createMusicStore() { export const music = createMusicStore(); -export const musicHeroItems = derived(music, $m => $m.heroItems); -export const isMusicLoading = derived(music, $m => $m.isLoading); +export const musicHeroItems = derived(music, ($m) => $m.heroItems); +export const isMusicLoading = derived(music, ($m) => $m.isLoading); diff --git a/src/lib/stores/nextEpisode.ts b/src/lib/stores/nextEpisode.ts index 6f492623..f2e0e6ba 100644 --- a/src/lib/stores/nextEpisode.ts +++ b/src/lib/stores/nextEpisode.ts @@ -47,7 +47,7 @@ function createNextEpisodeStore() { currentEpisode: MediaItem, nextEpisode: MediaItem, countdownSeconds: number, - autoPlayEnabled: boolean + autoPlayEnabled: boolean, ): void { update((s) => ({ ...s, @@ -105,4 +105,7 @@ export const currentEpisodeItem = derived(nextEpisode, ($ne) => $ne.currentEpiso export const countdownSeconds = derived(nextEpisode, ($ne) => $ne.countdownSeconds); export const initialCountdownSeconds = derived(nextEpisode, ($ne) => $ne.initialCountdownSeconds); export const isAutoPlayEnabled = derived(nextEpisode, ($ne) => $ne.autoPlayEnabled); -export const isCountdownActive = derived(nextEpisode, ($ne) => $ne.isVisible && $ne.countdownSeconds > 0); +export const isCountdownActive = derived( + nextEpisode, + ($ne) => $ne.isVisible && $ne.countdownSeconds > 0, +); diff --git a/src/lib/stores/playbackMode.test.ts b/src/lib/stores/playbackMode.test.ts index f86510e5..10193e62 100644 --- a/src/lib/stores/playbackMode.test.ts +++ b/src/lib/stores/playbackMode.test.ts @@ -387,7 +387,6 @@ describe("playbackMode store", () => { expect(state.remoteSessionId).toBeNull(); expect(mockSelectSession).toHaveBeenCalledWith(null); }); - }); describe("transfer reconciles to Rust on completion", () => { diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts index e3ff04d8..89204594 100644 --- a/src/lib/stores/playbackMode.ts +++ b/src/lib/stores/playbackMode.ts @@ -107,7 +107,12 @@ function createPlaybackModeStore() { // Rust handles everything - just wait for it to complete // It includes its own 5-second timeout for track loading - log.debug("About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride); + log.debug( + "About to invoke playback_mode_transfer_to_remote with sessionId:", + sessionId, + "position:", + positionOverride, + ); await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride); log.debug("Invoke completed successfully"); @@ -298,9 +303,7 @@ function createPlaybackModeStore() { const currentState = get({ subscribe }); if (currentState.mode === "remote") { log.debug("Lockscreen requested disconnect; transferring to local"); - transferToLocal().catch((e) => - log.error("Lockscreen-triggered transfer failed:", e), - ); + transferToLocal().catch((e) => log.error("Lockscreen-triggered transfer failed:", e)); } return; } @@ -317,8 +320,7 @@ function createPlaybackModeStore() { return; } const mode = event.payload.mode as PlaybackMode; - const remoteSessionId = - mode === "remote" ? event.payload.session_id ?? null : null; + const remoteSessionId = mode === "remote" ? (event.payload.session_id ?? null) : null; // Ignore no-op re-broadcasts. The backend re-emits on every set_mode, and // local playback drives set_mode("local") from BOTH the frontend @@ -327,10 +329,7 @@ function createPlaybackModeStore() { // one, deselecting the remote session mid-cast and tripping the // disconnect-to-idle watchdog (breaking the lockscreen card, remote // volume, and — via the resulting mode flap — local audio). - if ( - currentState.mode === mode && - currentState.remoteSessionId === remoteSessionId - ) { + if (currentState.mode === mode && currentState.remoteSessionId === remoteSessionId) { return; } @@ -352,8 +351,16 @@ function createPlaybackModeStore() { // If we're in remote mode but session is gone or lost control capability // Don't interfere during an active transfer (we intentionally clear the session) - if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) { - if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) { + if ( + currentState.mode === "remote" && + currentState.remoteSessionId && + !currentState.isTransferring + ) { + if ( + !session || + session.id !== currentState.remoteSessionId || + !session.supportsMediaControl + ) { consecutiveMisses++; log.warn(`Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`); diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index 756e7947..dda1725c 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -93,7 +93,7 @@ function createPlayerStore() { ...s.state, position, // Update duration if provided and valid - duration: duration !== undefined && duration > 0 ? duration : s.state.duration + duration: duration !== undefined && duration > 0 ? duration : s.state.duration, }, }; } @@ -174,10 +174,13 @@ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { // (Type, runTimeTicks, primaryImageTag). Map it onto the neutral MediaItem the // UI consumes. Only the coarse audio/video split matters here for display. const kind: MediaKind = - npi.Type === "Movie" ? "movie" : - npi.Type === "Episode" ? "episode" : - npi.Type === "MusicAlbum" ? "album" : - "track"; + npi.Type === "Movie" + ? "movie" + : npi.Type === "Episode" + ? "episode" + : npi.Type === "MusicAlbum" + ? "album" + : "track"; return { id: npi.id ?? "", name: npi.name ?? "", @@ -194,15 +197,12 @@ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { export const mergedMedia = derived< [typeof isRemoteMode, typeof selectedSession, typeof currentMedia], MediaItem | null ->( - [isRemoteMode, selectedSession, currentMedia], - ([$isRemote, $session, $local]) => { - if ($isRemote && $session?.nowPlayingItem) { - return nowPlayingToMediaItem($session.nowPlayingItem); - } - return $local ?? null; +>([isRemoteMode, selectedSession, currentMedia], ([$isRemote, $session, $local]) => { + if ($isRemote && $session?.nowPlayingItem) { + return nowPlayingToMediaItem($session.nowPlayingItem); } -); + return $local ?? null; +}); /** * Merged isPlaying state - prefers remote session when in remote mode @@ -214,7 +214,7 @@ export const mergedIsPlaying = derived( return !$session.playState.isPaused; } return $localIsPlaying; - } + }, ); /** @@ -227,7 +227,7 @@ export const mergedPosition = derived( return ticksToSeconds($session.playState.positionTicks ?? 0); } return $localPosition; - } + }, ); /** @@ -240,7 +240,7 @@ export const mergedDuration = derived( return ticksToSeconds($session.nowPlayingItem.runTimeTicks); } return $localDuration; - } + }, ); /** @@ -255,7 +255,7 @@ export const mergedVolume = derived( return ($session.playState.volumeLevel ?? 100) / 100; } return $localVolume; - } + }, ); /** @@ -317,5 +317,5 @@ export const shouldShowAudioMiniPlayer = derived( // playing / paused / loading / seeking — audio is active, show the bar. return true; - } + }, ); diff --git a/src/lib/stores/queue.ts b/src/lib/stores/queue.ts index e3894018..19f62eb5 100644 --- a/src/lib/stores/queue.ts +++ b/src/lib/stores/queue.ts @@ -192,7 +192,7 @@ export const queue = createQueueStore(); export const queueItems = derived(queue, ($q) => $q.items); export const currentQueueIndex = derived(queue, ($q) => $q.currentIndex); export const currentQueueItem = derived(queue, ($q) => - $q.currentIndex !== null ? $q.items[$q.currentIndex] : null + $q.currentIndex !== null ? $q.items[$q.currentIndex] : null, ); export const isShuffle = derived(queue, ($q) => $q.shuffle); export const repeatMode = derived(queue, ($q) => $q.repeat); diff --git a/src/lib/stores/searchGroupOrder.test.ts b/src/lib/stores/searchGroupOrder.test.ts index 9ad86dfe..520db85e 100644 --- a/src/lib/stores/searchGroupOrder.test.ts +++ b/src/lib/stores/searchGroupOrder.test.ts @@ -42,7 +42,7 @@ describe("searchGroupOrder", () => { it("loads a stored order", async () => { localStorage.setItem( STORAGE_KEY, - JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"]) + JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"]), ); const { searchGroupOrder } = await import("./searchGroupOrder"); expect(get(searchGroupOrder)).toEqual([ @@ -97,15 +97,7 @@ describe("searchGroupOrder", () => { // Default is shows, episodes, movies, songs, … — move movies up one. searchGroupOrder.move("movies", -1); - const expected = [ - "shows", - "movies", - "episodes", - "songs", - "albums", - "artists", - "people", - ]; + const expected = ["shows", "movies", "episodes", "songs", "albums", "artists", "people"]; expect(get(searchGroupOrder)).toEqual(expected); expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected); diff --git a/src/lib/stores/sessions.test.ts b/src/lib/stores/sessions.test.ts index b7432dbc..08aedeab 100644 --- a/src/lib/stores/sessions.test.ts +++ b/src/lib/stores/sessions.test.ts @@ -76,7 +76,7 @@ describe("sessions store", () => { vi.useFakeTimers(); // Ensure window.setInterval and window.clearInterval are available - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { global.window = window as any; } }); diff --git a/src/lib/stores/sessions.ts b/src/lib/stores/sessions.ts index 2edb7f4e..e05afb4c 100644 --- a/src/lib/stores/sessions.ts +++ b/src/lib/stores/sessions.ts @@ -33,7 +33,9 @@ function createSessionsStore() { const sessions = event.payload.sessions as unknown as Session[]; log.debug(`Received ${sessions.length} sessions from backend`); sessions.forEach((s, i) => { - log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`); + log.debug( + `Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`, + ); }); update((s) => ({ ...s, @@ -55,7 +57,9 @@ function createSessionsStore() { log.debug(`Manual refresh returned ${sessions.length} sessions`); sessions.forEach((s, i) => { - log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`); + log.debug( + `Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`, + ); }); update((s) => ({ @@ -76,7 +80,6 @@ function createSessionsStore() { } } - /** * Select a session for control */ @@ -140,7 +143,10 @@ function createSessionsStore() { /** * Seek to position (in ticks) */ - async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise { + async function sendSeek( + sessionId: string | null | undefined, + positionTicks: number, + ): Promise { try { await commands.remoteSessionSeek(sessionId ?? "", positionTicks); // Don't refresh immediately for seek to avoid UI lag @@ -182,7 +188,7 @@ function createSessionsStore() { async function playOnSession( sessionId: string | null | undefined, itemIds: string[], - startIndex = 0 + startIndex = 0, ): Promise { log.debug("========== playOnSession called =========="); log.debug("sessionId:", sessionId); @@ -224,9 +230,8 @@ export const sessions = createSessionsStore(); /** * Sessions that are currently playing media */ -export const activeSessions = derived( - sessions, - ($sessions) => $sessions.sessions.filter((s) => s.nowPlayingItem !== null) +export const activeSessions = derived(sessions, ($sessions) => + $sessions.sessions.filter((s) => s.nowPlayingItem !== null), ); /** @@ -234,22 +239,22 @@ export const activeSessions = derived( */ export const selectedSession = derived( sessions, - ($sessions) => - $sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null + ($sessions) => $sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null, ); /** * Controllable sessions (support remote control) */ -export const controllableSessions = derived( - sessions, - ($sessions) => { - const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl); - log.debug(`Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`); - $sessions.sessions.forEach((s, i) => { - const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE"; - log.debug(` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`); - }); - return controllable; - } -); +export const controllableSessions = derived(sessions, ($sessions) => { + const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl); + log.debug( + `Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`, + ); + $sessions.sessions.forEach((s, i) => { + const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE"; + log.debug( + ` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`, + ); + }); + return controllable; +}); diff --git a/src/lib/stores/sleepTimer.ts b/src/lib/stores/sleepTimer.ts index 7e4e3178..9f060dc8 100644 --- a/src/lib/stores/sleepTimer.ts +++ b/src/lib/stores/sleepTimer.ts @@ -69,16 +69,10 @@ export const sleepTimerExpiredSignal = writable(0); // Derived stores for convenient access export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode); -export const sleepTimerActive = derived( - sleepTimer, - ($s) => $s.mode.kind !== "off" -); +export const sleepTimerActive = derived(sleepTimer, ($s) => $s.mode.kind !== "off"); -export const sleepTimerRemainingSeconds = derived( - sleepTimer, - ($s) => $s.remainingSeconds -); +export const sleepTimerRemainingSeconds = derived(sleepTimer, ($s) => $s.remainingSeconds); export const sleepTimerRemainingEpisodes = derived(sleepTimer, ($s) => - $s.mode.kind === "episodes" ? $s.mode.remaining : 0 + $s.mode.kind === "episodes" ? $s.mode.remaining : 0, ); diff --git a/src/lib/stores/tv.ts b/src/lib/stores/tv.ts index 8b07242c..1ff59829 100644 --- a/src/lib/stores/tv.ts +++ b/src/lib/stores/tv.ts @@ -5,10 +5,7 @@ import { writable, derived } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import { auth } from "./auth"; import { buildHeroMix } from "$lib/utils/heroMix"; -import { - filterSupersededResumeItems, - filterInProgressNextUpItems, -} from "./continueWatchingFilter"; +import { filterSupersededResumeItems, filterInProgressNextUpItems } from "./continueWatchingFilter"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("TvStore"); @@ -60,7 +57,7 @@ function createTvStore() { !!i.imageId; async function loadSections(libraryId: string) { - update(s => ({ + update((s) => ({ ...s, isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0, error: null, @@ -82,7 +79,7 @@ function createTvStore() { recursive: true, limit: SECTION_LIMIT, }) - .then(r => r.items) + .then((r) => r.items) .catch(() => [] as MediaItem[]), ]); @@ -91,8 +88,8 @@ function createTvStore() { // Then drop episodes the user has moved past — a stale partial position // behind the series' Next Up entry isn't something to continue. const continueWatching = filterSupersededResumeItems( - resume.filter(i => i.kind === "episode" || i.kind === "movie"), - rawNextUp + resume.filter((i) => i.kind === "episode" || i.kind === "movie"), + rawNextUp, ); // And drop from Next Up the episodes that are already under way — those // are Continue Watching's, or the two rows show the same cards. @@ -102,7 +99,7 @@ function createTvStore() { // recent additions, and random series from across the library. const heroItems = buildHeroMix([continueWatching, nextUp, latest, surprise], hasArt); - update(s => ({ + update((s) => ({ ...s, continueWatching, nextUp, @@ -116,7 +113,7 @@ function createTvStore() { loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load TV sections"; - update(s => ({ ...s, isLoading: false, error: message })); + update((s) => ({ ...s, isLoading: false, error: message })); log.error("Failed to load TV sections:", error); } } @@ -149,15 +146,15 @@ function createTvStore() { log.warn(`Failed to load genre row "${genre.name}":`, e); return { id: genre.id, name: genre.name, items: [] }; } - }) + }), ); const genreRows = rows - .filter(row => row.items.length > 0) + .filter((row) => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length) .slice(0, MAX_GENRE_ROWS); - update(s => ({ ...s, genreRows })); + update((s) => ({ ...s, genreRows })); } catch (e) { log.warn("Failed to load TV genre rows:", e); } @@ -176,5 +173,5 @@ function createTvStore() { export const tv = createTvStore(); -export const tvHeroItems = derived(tv, $t => $t.heroItems); -export const isTvLoading = derived(tv, $t => $t.isLoading); +export const tvHeroItems = derived(tv, ($t) => $t.heroItems); +export const isTvLoading = derived(tv, ($t) => $t.isLoading); diff --git a/src/lib/utils/debounce.test.ts b/src/lib/utils/debounce.test.ts index d1c21fbc..4f07e887 100644 --- a/src/lib/utils/debounce.test.ts +++ b/src/lib/utils/debounce.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; */ export function createDebouncedFunction any>( fn: T, - delayMs: number = 300 + delayMs: number = 300, ) { let timeout: ReturnType | null = null; diff --git a/src/lib/utils/duration.ts b/src/lib/utils/duration.ts index ca728f6d..46eea385 100644 --- a/src/lib/utils/duration.ts +++ b/src/lib/utils/duration.ts @@ -37,7 +37,10 @@ export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" * @param format Format type: "mm:ss" (default) or "hh:mm:ss" * @returns Formatted duration string */ -export function formatSecondsDuration(seconds: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string { +export function formatSecondsDuration( + seconds: number, + format: "mm:ss" | "hh:mm:ss" = "mm:ss", +): string { if (format === "hh:mm:ss") { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); diff --git a/src/lib/utils/favoritesView.ts b/src/lib/utils/favoritesView.ts index c1135ca3..34c0a206 100644 --- a/src/lib/utils/favoritesView.ts +++ b/src/lib/utils/favoritesView.ts @@ -43,13 +43,9 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites * * TRACES: UR-075 | DR-175 */ -export function asFavoritesScope( - scope: SearchScope | null | undefined, -): FavoritesScope | null { +export function asFavoritesScope(scope: SearchScope | null | undefined): FavoritesScope | null { if (!scope) return null; - return (FAVORITE_SCOPES as readonly string[]).includes(scope) - ? (scope as FavoritesScope) - : null; + return (FAVORITE_SCOPES as readonly string[]).includes(scope) ? (scope as FavoritesScope) : null; } /** URL for a tab. The default scope is omitted, keeping the base URL clean. */ diff --git a/src/lib/utils/genreDiversity.test.ts b/src/lib/utils/genreDiversity.test.ts index 5d984d37..34335914 100644 --- a/src/lib/utils/genreDiversity.test.ts +++ b/src/lib/utils/genreDiversity.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { selectDiverseGenres, sampleAcross } from "./genreDiversity"; -const g = (...names: string[]) => names.map(name => ({ name })); +const g = (...names: string[]) => names.map((name) => ({ name })); describe("sampleAcross", () => { it("returns input unchanged when at or under the count", () => { @@ -32,7 +32,7 @@ describe("selectDiverseGenres", () => { it("seeds with the most populous genre (first in input)", () => { const out = selectDiverseGenres(g("Rock", "Jazz", "Hip Hop"), 1); - expect(out.map(x => x.name)).toEqual(["Rock"]); + expect(out.map((x) => x.name)).toEqual(["Rock"]); }); it("spreads the family instead of stacking near-synonyms", () => { @@ -45,35 +45,28 @@ describe("selectDiverseGenres", () => { "Pop Rock", "Jazz", "Hip Hop", - "Classical" + "Classical", ); - const names = selectDiverseGenres(input, 4).map(x => x.name); + const names = selectDiverseGenres(input, 4).map((x) => x.name); expect(names[0]).toBe("Rock"); // seeded by count expect(names).toContain("Jazz"); expect(names).toContain("Hip Hop"); expect(names).toContain("Classical"); // Only the seed represents the Rock cluster. - expect(names.filter(n => n.includes("Rock"))).toEqual(["Rock"]); + expect(names.filter((n) => n.includes("Rock"))).toEqual(["Rock"]); }); it("preserves count order as the tie-breaker among equally-distinct genres", () => { // All four are mutually distinct (no shared tokens), so every pick after // the seed is a distance tie and should follow input (count) order. const input = g("Rock", "Jazz", "Blues", "Folk"); - expect(selectDiverseGenres(input, 3).map(x => x.name)).toEqual([ - "Rock", - "Jazz", - "Blues", - ]); + expect(selectDiverseGenres(input, 3).map((x) => x.name)).toEqual(["Rock", "Jazz", "Blues"]); }); it("is case- and separator-insensitive when comparing", () => { const input = g("Hip Hop", "hip-hop", "Reggae"); // "Hip Hop" and "hip-hop" share all tokens → the second is redundant. - expect(selectDiverseGenres(input, 2).map(x => x.name)).toEqual([ - "Hip Hop", - "Reggae", - ]); + expect(selectDiverseGenres(input, 2).map((x) => x.name)).toEqual(["Hip Hop", "Reggae"]); }); }); diff --git a/src/lib/utils/genreDiversity.ts b/src/lib/utils/genreDiversity.ts index d9c2c9a2..c6336c28 100644 --- a/src/lib/utils/genreDiversity.ts +++ b/src/lib/utils/genreDiversity.ts @@ -34,7 +34,7 @@ function tokenize(name: string): Set { name .toLowerCase() .split(/[^a-z0-9]+/) - .filter(Boolean) + .filter(Boolean), ); } @@ -54,11 +54,11 @@ function jaccard(a: Set, b: Set): number { */ export function selectDiverseGenres( candidates: T[], - limit: number + limit: number, ): T[] { if (candidates.length <= limit) return candidates.slice(); - const tokens = candidates.map(c => tokenize(c.name)); + const tokens = candidates.map((c) => tokenize(c.name)); const chosen: number[] = []; const remaining = new Set(candidates.map((_, i) => i)); @@ -91,5 +91,5 @@ export function selectDiverseGenres( remaining.delete(best); } - return chosen.map(i => candidates[i]); + return chosen.map((i) => candidates[i]); } diff --git a/src/lib/utils/heroMix.test.ts b/src/lib/utils/heroMix.test.ts index a3532665..e6785ffe 100644 --- a/src/lib/utils/heroMix.test.ts +++ b/src/lib/utils/heroMix.test.ts @@ -36,13 +36,13 @@ describe("buildHeroMix", () => { it("filters items without artwork", () => { const result = buildHeroMix([[item("a", false), item("b")]], hasArt); - expect(result.map(i => i.id)).toEqual(["b"]); + expect(result.map((i) => i.id)).toEqual(["b"]); }); it("de-duplicates across pools", () => { const a = item("a"); const result = buildHeroMix([[a], [a, item("b")]], hasArt); - const ids = result.map(i => i.id); + const ids = result.map((i) => i.id); expect(ids).toHaveLength(new Set(ids).size); expect(ids).toContain("a"); expect(ids).toContain("b"); @@ -63,8 +63,8 @@ describe("buildHeroMix", () => { // count 4 with perPool 2: exactly 2 from each pool, no backfill needed. for (let run = 0; run < 20; run++) { const result = buildHeroMix([a, b], hasArt, 4, 2); - const fromA = result.filter(i => i.id.startsWith("a")).length; - const fromB = result.filter(i => i.id.startsWith("b")).length; + const fromA = result.filter((i) => i.id.startsWith("a")).length; + const fromB = result.filter((i) => i.id.startsWith("b")).length; expect(fromA).toBe(2); expect(fromB).toBe(2); } diff --git a/src/lib/utils/heroMix.ts b/src/lib/utils/heroMix.ts index 4a499108..9cb5f11f 100644 --- a/src/lib/utils/heroMix.ts +++ b/src/lib/utils/heroMix.ts @@ -31,15 +31,15 @@ export function buildHeroMix( pools: MediaItem[][], hasArt: (item: MediaItem) => boolean, count = 6, - perPool = 2 + perPool = 2, ): MediaItem[] { const seen = new Set(); - const usable = pools.map(pool => - pool.filter(item => { + const usable = pools.map((pool) => + pool.filter((item) => { if (!hasArt(item) || seen.has(item.id)) return false; seen.add(item.id); return true; - }) + }), ); const picked = new Set(); @@ -54,6 +54,6 @@ export function buildHeroMix( if (picks.length === 0) return []; const [leader, ...rest] = picks; - const leftovers = shuffle(usable.flat().filter(item => !picked.has(item.id))); + const leftovers = shuffle(usable.flat().filter((item) => !picked.has(item.id))); return [leader, ...shuffle(rest), ...leftovers].slice(0, count); } diff --git a/src/lib/utils/layoutShell.ts b/src/lib/utils/layoutShell.ts index 2d2418ce..05b95752 100644 --- a/src/lib/utils/layoutShell.ts +++ b/src/lib/utils/layoutShell.ts @@ -30,15 +30,8 @@ export interface BottomUiVisibilityInput { * The bottom nav is shown on every authenticated route except the full-screen * player and the login route. */ -export function showBottomNav({ - pathname, - isAuthenticated, -}: BottomUiVisibilityInput): boolean { - return ( - isAuthenticated && - !pathname.startsWith("/player/") && - !pathname.startsWith("/login") - ); +export function showBottomNav({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean { + return isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login"); } /** @@ -81,15 +74,8 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean { * * TRACES: UR-054 | DR-076 */ -export function showGlobalHeader({ - pathname, - isAuthenticated, -}: BottomUiVisibilityInput): boolean { - return ( - isAuthenticated && - !routeOwnsLayout({ pathname }) && - !pathname.startsWith("/settings") - ); +export function showGlobalHeader({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean { + return isAuthenticated && !routeOwnsLayout({ pathname }) && !pathname.startsWith("/settings"); } /** diff --git a/src/lib/utils/menuPosition.ts b/src/lib/utils/menuPosition.ts index e061c329..891e38b5 100644 --- a/src/lib/utils/menuPosition.ts +++ b/src/lib/utils/menuPosition.ts @@ -6,7 +6,7 @@ export interface MenuPosition { x: number; y: number; - placement: 'bottom' | 'top'; + placement: "bottom" | "top"; } /** @@ -19,7 +19,7 @@ export interface MenuPosition { export function calculateMenuPosition( triggerElement: HTMLElement, menuWidth: number = 160, - menuHeight: number = 120 + menuHeight: number = 120, ): MenuPosition { const rect = triggerElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; @@ -32,20 +32,20 @@ export function calculateMenuPosition( const fitsAbove = spaceAbove >= menuHeight + 8; let y: number; - let placement: 'bottom' | 'top'; + let placement: "bottom" | "top"; if (fitsBelow) { // Prefer below if there's space y = rect.bottom + 4; // 4px gap - placement = 'bottom'; + placement = "bottom"; } else if (fitsAbove) { // Show above if no space below y = rect.top - menuHeight - 4; // 4px gap - placement = 'top'; + placement = "top"; } else { // Not enough space either way - prefer below and let it extend y = rect.bottom + 4; - placement = 'bottom'; + placement = "bottom"; } // Horizontal positioning - align right edge of menu with right edge of button diff --git a/src/lib/utils/nativeVideoLayers.test.ts b/src/lib/utils/nativeVideoLayers.test.ts index a280dc62..c8b49832 100644 --- a/src/lib/utils/nativeVideoLayers.test.ts +++ b/src/lib/utils/nativeVideoLayers.test.ts @@ -79,8 +79,7 @@ describe("native-video compositing layers (DR-185)", () => { .filter((attr) => attr !== "data-native-video"); const unset = [...new Set(attributes)].filter((attr) => !markup.includes(attr)); - expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`) - .toEqual([]); + expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`).toEqual([]); }); it("still sets data-native-video on from the store", () => { diff --git a/src/lib/utils/navigation.test.ts b/src/lib/utils/navigation.test.ts index 800423db..3111c6b8 100644 --- a/src/lib/utils/navigation.test.ts +++ b/src/lib/utils/navigation.test.ts @@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const goto = vi.fn(); // Capture the afterNavigate callback so tests can simulate navigations and thus // drive the in-app depth counter that canGoBack/navigateBack rely on. -let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null = - null; +let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null = null; vi.mock("$app/navigation", () => ({ goto: (...args: unknown[]) => goto(...args), afterNavigate: (cb: (nav: any) => void) => { diff --git a/src/lib/utils/pictureInPicture.ts b/src/lib/utils/pictureInPicture.ts index 0682b0b3..575d79a5 100644 --- a/src/lib/utils/pictureInPicture.ts +++ b/src/lib/utils/pictureInPicture.ts @@ -114,7 +114,7 @@ export function setHtml5VideoState( active: boolean, width: number, height: number, - playing: boolean + playing: boolean, ): void { try { bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing); diff --git a/src/lib/utils/safeArea.test.ts b/src/lib/utils/safeArea.test.ts index 1bf5f6fd..84aae266 100644 --- a/src/lib/utils/safeArea.test.ts +++ b/src/lib/utils/safeArea.test.ts @@ -212,8 +212,8 @@ describe("safe-area wiring in source", () => { for (const edge of ["top", "right", "bottom", "left"]) { expect(css).toMatch( new RegExp( - `--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)` - ) + `--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`, + ), ); } }); diff --git a/src/lib/utils/scrollContainer.ts b/src/lib/utils/scrollContainer.ts index e6d72b51..2a6eae80 100644 --- a/src/lib/utils/scrollContainer.ts +++ b/src/lib/utils/scrollContainer.ts @@ -46,7 +46,7 @@ export function clearScrollMemories(): void { export function useScrollRestore( getElement: () => HTMLElement | null | undefined, - containerId: string + containerId: string, ): void { const memory = memoryFor(containerId); diff --git a/src/lib/utils/scrollRestore.ts b/src/lib/utils/scrollRestore.ts index b620b21c..354e51c3 100644 --- a/src/lib/utils/scrollRestore.ts +++ b/src/lib/utils/scrollRestore.ts @@ -30,10 +30,7 @@ export type NavKind = "enter" | "popstate" | "forward"; /** What to do with the container once the new route has rendered. */ -export type ScrollAction = - | { kind: "reset" } - | { kind: "restore"; top: number } - | { kind: "none" }; +export type ScrollAction = { kind: "reset" } | { kind: "restore"; top: number } | { kind: "none" }; /** * Collapse SvelteKit's navigation types into the three cases that matter. diff --git a/src/lib/utils/searchScope.test.ts b/src/lib/utils/searchScope.test.ts index 62a5d0b1..09f16b32 100644 --- a/src/lib/utils/searchScope.test.ts +++ b/src/lib/utils/searchScope.test.ts @@ -155,7 +155,7 @@ describe("groupsForScope", () => { "albums", "artists", "people", - ]) + ]), ).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]); }); @@ -291,7 +291,7 @@ describe("composeSearchGroups", () => { const groups = composeSearchGroups( [{ id: "x", type: null }, { id: "y" }] as { id: string; type?: string | null }[], "all", - DEFAULT_GROUP_ORDER + DEFAULT_GROUP_ORDER, ); expect(groups).toEqual([]); }); @@ -311,13 +311,7 @@ describe("moveGroup", () => { }); it("moves a group down", () => { - expect(moveGroup(order, "songs", 1)).toEqual([ - "albums", - "songs", - "artists", - "movies", - "shows", - ]); + expect(moveGroup(order, "songs", 1)).toEqual(["albums", "songs", "artists", "movies", "shows"]); }); it("is a no-op at the boundaries", () => { @@ -340,20 +334,8 @@ describe("reorderGroups", () => { const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"]; it("moves an item from one index to another", () => { - expect(reorderGroups(order, 0, 4)).toEqual([ - "albums", - "artists", - "movies", - "shows", - "songs", - ]); - expect(reorderGroups(order, 4, 0)).toEqual([ - "shows", - "songs", - "albums", - "artists", - "movies", - ]); + expect(reorderGroups(order, 0, 4)).toEqual(["albums", "artists", "movies", "shows", "songs"]); + expect(reorderGroups(order, 4, 0)).toEqual(["shows", "songs", "albums", "artists", "movies"]); }); it("is a no-op for equal or out-of-range indices", () => { diff --git a/src/lib/utils/searchScope.ts b/src/lib/utils/searchScope.ts index 73de0fec..fa74f99f 100644 --- a/src/lib/utils/searchScope.ts +++ b/src/lib/utils/searchScope.ts @@ -98,7 +98,7 @@ export function shouldNavigateToSearch(pathname: string, query: string): boolean /** Read a `?scope=` value, falling back when it is absent or unrecognised. */ export function parseSearchScope( raw: string | null | undefined, - fallback: SearchScope = "all" + fallback: SearchScope = "all", ): SearchScope { return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback; } @@ -126,7 +126,7 @@ export interface SearchSeed { */ export function seedFromSearchUrl( params: URLSearchParams, - applied: SearchSeed | null + applied: SearchSeed | null, ): SearchSeed | null { const seed: SearchSeed = { query: params.get("q") ?? "", @@ -141,13 +141,7 @@ export function seedFromSearchUrl( // --------------------------------------------------------------------------- export type SearchGroupId = - | "shows" - | "episodes" - | "movies" - | "songs" - | "albums" - | "artists" - | "people"; + "shows" | "episodes" | "movies" | "songs" | "albums" | "artists" | "people"; /** * Shipped default order. @@ -268,12 +262,12 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] { /** Groups visible under a scope, in the user's configured order. */ export function groupsForScope( scope: SearchScope, - order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER + order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER, ): SearchGroupId[] { // A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives // only under `all` — the `=== scope` test already excludes it elsewhere. return normalizeGroupOrder(order as SearchGroupId[]).filter( - (id) => scope === "all" || GROUP_SCOPE[id] === scope + (id) => scope === "all" || GROUP_SCOPE[id] === scope, ); } @@ -292,7 +286,7 @@ export interface SearchGroup { export function composeSearchGroups( results: readonly T[], scope: SearchScope, - order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER + order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER, ): SearchGroup[] { return groupsForScope(scope, order) .map((id) => { @@ -310,7 +304,7 @@ export function composeSearchGroups( export function moveGroup( order: readonly SearchGroupId[], id: SearchGroupId, - delta: number + delta: number, ): SearchGroupId[] { const next = [...order]; const from = next.indexOf(id); @@ -325,7 +319,7 @@ export function moveGroup( export function reorderGroups( order: readonly SearchGroupId[], from: number, - to: number + to: number, ): SearchGroupId[] { const next = [...order]; if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next; diff --git a/src/lib/utils/validation.ts b/src/lib/utils/validation.ts index 8e8820a1..1a23cec3 100644 --- a/src/lib/utils/validation.ts +++ b/src/lib/utils/validation.ts @@ -87,7 +87,12 @@ export function validateUrlPathSegment(segment: string): void { /** * Validate numeric parameter (width, height, quality, etc.) */ -export function validateNumericParam(value: unknown, min = 0, max = 10000, name = "parameter"): number { +export function validateNumericParam( + value: unknown, + min = 0, + max = 10000, + name = "parameter", +): number { // Must be an actual number, not a string that looks like a number if (typeof value !== "number") { throw new Error(`Invalid ${name}: must be an integer`); diff --git a/src/lib/utils/videoSurface.ts b/src/lib/utils/videoSurface.ts index 41eeb281..5409ae10 100644 --- a/src/lib/utils/videoSurface.ts +++ b/src/lib/utils/videoSurface.ts @@ -78,7 +78,7 @@ export function enableNativeVideoCompositing(): void { // console bridge forwards this to logcat under the JellyTauWeb tag. log.error( "AndroidVideoSurface bridge is MISSING - the webview will " + - "stay opaque and native video will play as audio with no picture" + "stay opaque and native video will play as audio with no picture", ); return; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5cb6a5ea..901253ee 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -13,7 +13,12 @@ import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads"; import { syncService } from "$lib/services/syncService"; import { clearFavorite } from "$lib/stores/favorites"; - import { onReconnected as onCatalogReconnected, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog"; + import { + onReconnected as onCatalogReconnected, + refreshSyncStatus, + showServerCatalog, + lastCatalogSync, + } from "$lib/services/offlineCatalog"; import { playbackMode } from "$lib/stores/playbackMode"; import { sessions } from "$lib/stores/sessions"; import ReauthModal from "$lib/components/auth/ReauthModal.svelte"; @@ -22,7 +27,12 @@ import BottomUi from "$lib/components/BottomUi.svelte"; import AppHeader from "$lib/components/AppHeader.svelte"; import PendingSyncModal from "$lib/components/sync/PendingSyncModal.svelte"; - import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState"; + import { + isInitialized, + pendingSyncCount, + isAndroid, + showSleepTimerModal, + } from "$lib/stores/appState"; import { showBottomNav as computeShowBottomNav, showGlobalMiniPlayer as computeShowGlobalMiniPlayer, @@ -73,7 +83,7 @@ // gone. See BottomUi.svelte. const pathname = $derived($page.url.pathname); const showBottomNav = $derived( - computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }) + computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }), ); const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname })); @@ -81,7 +91,7 @@ // authenticated non-immersive route that doesn't own its own layout. Library // renders its own AppHeader; settings/player/login get none. (UR-054) const showGlobalHeader = $derived( - computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated }) + computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated }), ); // Library/settings/player/login own their own full-height flex column @@ -95,7 +105,7 @@ // bottom UI at all (login, the full-screen player). Exactly one owner, or the // bar is either ignored or double-padded. (UR-066) const shellPadsBottom = $derived( - shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }) + shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }), ); onMount(async () => { @@ -138,12 +148,9 @@ // the session overrides for those ids so the next render reads the freshly // cached server value rather than a stale local guess. // TRACES: UR-069 | DR-120 - stopFavoritesListener = await listen<{ itemIds: string[] }>( - "favorites-changed", - (event) => { - for (const id of event.payload?.itemIds ?? []) clearFavorite(id); - } - ); + stopFavoritesListener = await listen<{ itemIds: string[] }>("favorites-changed", (event) => { + for (const id of event.payload?.itemIds ?? []) clearFavorite(id); + }); // Report the network transport to the backend and keep it current, so the // WiFi-only download gate has real data to act on (UR-053). No-op on @@ -156,9 +163,7 @@ // not-downloaded until the user opens the Downloads page. const userId = get(auth).user?.id; if (userId) { - downloads.refresh(userId).catch((err) => - log.error("Initial downloads refresh failed:", err) - ); + downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err)); } // Start sync service for offline mutation queue @@ -170,9 +175,7 @@ // whole time. Safe when it isn't: an unreachable server leaves rows queued // without spending their retry budget (DR-131). if (get(auth).user?.id) { - commands.syncProcessPending().catch((err) => - log.debug("Startup sync drain skipped:", err) - ); + commands.syncProcessPending().catch((err) => log.debug("Startup sync drain skipped:", err)); } // Load the last-sync hint for the offline banner. The catalog *index* is no @@ -210,16 +213,18 @@ connectivity.forceCheck().catch((error) => { // If check fails, monitoring might not be started yet, so start it log.debug("Queue status check failed, starting monitoring:", error); - connectivity.startMonitoring(session.serverUrl, { - onServerReconnected: () => { - // Retry session verification when server becomes reachable - auth.retryVerification(); - // Resume offline-queued downloads and refresh the catalog. - void onCatalogReconnected(); - }, - }).catch((monitorError) => { - log.error("Failed to start connectivity monitoring:", monitorError); - }); + connectivity + .startMonitoring(session.serverUrl, { + onServerReconnected: () => { + // Retry session verification when server becomes reachable + auth.retryVerification(); + // Resume offline-queued downloads and refresh the catalog. + void onCatalogReconnected(); + }, + }) + .catch((monitorError) => { + log.error("Failed to start connectivity monitoring:", monitorError); + }); }); } }); @@ -280,9 +285,16 @@ {#if isInitialized} {#if $isAuthenticated && !$isConnected} -
    +
    - + You're offline. Some features may be limited. - showSleepTimerModal.set(false)} - /> + showSleepTimerModal.set(false)} /> - (showPendingSync = false)} - /> + (showPendingSync = false)} /> {:else}
    -
    +
    {/if}
    diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cd7a3ffc..c58e25da 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -115,7 +115,7 @@ // Playlist libraries live inside the Music landing page, not as top-level shortcuts. const shortcutLibraries = $derived( - $libraries.filter((lib) => lib.collectionType !== "playlists") + $libraries.filter((lib) => lib.collectionType !== "playlists"), ); // The shortcut strip is a mosaic row: one height, each tile as wide as its own @@ -124,7 +124,11 @@ // TRACES: UR-075 | DR-174 const LIBRARY_STRIP_HEIGHT = 132; const libraryTiles = $derived( - shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib })) + shortcutLibraries.map((lib) => ({ + key: lib.id, + ratio: assumedLibraryRatio(lib), + library: lib, + })), ); function handleLibraryClick(lib: Library) { @@ -148,9 +152,9 @@ } const heroItems = $derived($home.heroItems); - const resumeItems = $derived($home.resumeItems.filter( - i => i.kind === "movie" || i.kind === "episode" - )); + const resumeItems = $derived( + $home.resumeItems.filter((i) => i.kind === "movie" || i.kind === "episode"), + ); const nextUpItems = $derived($home.nextUpItems); const latestItems = $derived($home.latestItems); const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio); @@ -165,136 +169,145 @@ {#if isLoading}
    -
    +
    {:else} -
    +
    + + {#if heroItems.length > 0} + + {/if} - - {#if heroItems.length > 0} - - {/if} - - - {#if shortcutLibraries.length > 0} -
    -

    Your Libraries

    -
    - - {#snippet tile(entry)} - handleLibraryClick(entry.library)} - /> - {/snippet} - + + {#if shortcutLibraries.length > 0} +
    +

    Your Libraries

    +
    + + {#snippet tile(entry)} + handleLibraryClick(entry.library)} + /> + {/snippet} + +
    -
    - {/if} + {/if} - - {#if resumeMovies.length > 0} - - {/if} + + {#if resumeMovies.length > 0} + + {/if} - - {#if nextUpItems.length > 0} - - {/if} + + {#if nextUpItems.length > 0} + + {/if} - - {#if recentlyPlayedAudio.length > 0} - - {/if} + + {#if recentlyPlayedAudio.length > 0} + + {/if} - - {#if resumeItems.length > 0} - - {/if} + + {#if resumeItems.length > 0} + + {/if} - - {#if latestItems.length > 0} - - {/if} + + {#if latestItems.length > 0} + + {/if} - - {#if favoriteMovies.length > 0} - goto("/library/favorites?scope=movies")} - /> - {/if} + {#if favoriteMovies.length > 0} + goto("/library/favorites?scope=movies")} + /> + {/if} - {#if favoriteShows.length > 0} - goto("/library/favorites?scope=tv")} - /> - {/if} + {#if favoriteShows.length > 0} + goto("/library/favorites?scope=tv")} + /> + {/if} - {#if favoriteMusic.length > 0} - goto("/library/favorites?scope=music")} - /> - {/if} + {#if favoriteMusic.length > 0} + goto("/library/favorites?scope=music")} + /> + {/if} - -
    - -
    + +
    + +
    {/if} diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte index f01f7d5a..521c9207 100644 --- a/src/routes/downloads/+page.svelte +++ b/src/routes/downloads/+page.svelte @@ -16,7 +16,13 @@ @@ -106,7 +106,10 @@

    {$currentLibrary?.name ?? "Movies"}

    {:else if isLoading}
    -
    +
    {:else}
    @@ -168,7 +173,9 @@ {/each} {#if !hasContent} -

    Nothing here yet. Add some movies to your library to fill this page.

    +

    + Nothing here yet. Add some movies to your library to fill this page. +

    {/if}
    {/if} diff --git a/src/routes/library/music/+page.svelte b/src/routes/library/music/+page.svelte index ebecc46c..e5859d68 100644 --- a/src/routes/library/music/+page.svelte +++ b/src/routes/library/music/+page.svelte @@ -97,13 +97,15 @@ recentlyPlayed.length > 0 || newlyAdded.length > 0 || playlists.length > 0 || - rediscover.length > 0 + rediscover.length > 0, ); {#if isLoading}
    -
    +
    {:else}
    @@ -111,13 +113,21 @@

    Music

    @@ -129,11 +139,7 @@ {#if recentlyPlayed.length > 0} - + {/if} @@ -176,7 +182,9 @@ {/each} {#if !hasContent} -

    Nothing here yet. Start playing some music to fill this page.

    +

    + Nothing here yet. Start playing some music to fill this page. +

    {/if} @@ -188,8 +196,14 @@ onclick={() => goto(category.route)} class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors" > -
    - +
    +
    diff --git a/src/routes/library/music/playlists/+page.svelte b/src/routes/library/music/playlists/+page.svelte index 2b5ba45e..1a223896 100644 --- a/src/routes/library/music/playlists/+page.svelte +++ b/src/routes/library/music/playlists/+page.svelte @@ -27,19 +27,16 @@
    - showCreateModal = false} -/> + (showCreateModal = false)} /> diff --git a/src/routes/library/tv/+page.svelte b/src/routes/library/tv/+page.svelte index 2256d80f..67a1dc40 100644 --- a/src/routes/library/tv/+page.svelte +++ b/src/routes/library/tv/+page.svelte @@ -114,7 +114,7 @@ heroItems.length > 0 || continueWatching.length > 0 || nextUp.length > 0 || - recentlyAdded.length > 0 + recentlyAdded.length > 0, ); @@ -123,7 +123,10 @@

    {$currentLibrary?.name ?? "TV Shows"}

    {:else if isLoading}
    -
    +
    {:else}
    @@ -190,7 +195,9 @@ {/each} {#if !hasContent} -

    Nothing here yet. Start watching something to fill this page.

    +

    + Nothing here yet. Start watching something to fill this page. +

    {/if}
    {/if} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 00e87d01..b559e1ab 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -28,7 +28,8 @@ // Reject plain HTTP — all connections must use HTTPS if (serverUrl.trim().toLowerCase().startsWith("http://")) { - localError = "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com)."; + localError = + "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com)."; connecting = false; return; } @@ -80,7 +81,9 @@ {#if $isLoading}
    -
    +
    {:else if step === "server"} @@ -111,7 +114,9 @@ class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2" > {#if connecting} -
    +
    Connecting... {:else} Connect @@ -126,7 +131,12 @@ class="text-gray-400 hover:text-white text-sm flex items-center gap-1" > - + Back @@ -186,13 +196,28 @@ {#if showPassword} - + {:else} - - + + {/if} @@ -211,7 +236,9 @@ class="w-full py-3 px-4 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center gap-2" > {#if loggingIn} -
    +
    Signing in... {:else} Sign In diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index d258c9b8..33472657 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -8,13 +8,27 @@ import type { MediaItem, MediaKind } from "$lib/api/types"; import { auth } from "$lib/stores/auth"; import { library } from "$lib/stores/library"; - import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue"; + import { + queue, + currentQueueItem, + isShuffle, + repeatMode, + hasNext as hasNextStore, + hasPrevious as hasPreviousStore, + } from "$lib/stores/queue"; import { downloads, type DownloadInfo } from "$lib/stores/downloads"; - import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player"; + import { + playbackPosition, + playbackDuration, + currentMedia as storeCurrentMedia, + } from "$lib/stores/player"; import { get } from "svelte/store"; import AudioPlayer from "$lib/components/player/AudioPlayer.svelte"; import VideoPlayer from "$lib/components/player/VideoPlayer.svelte"; - import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface"; + import { + shouldReuseActivePlayback, + resolvePlayerSurface, + } from "$lib/components/player/playerSurface"; import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte"; import { reportPlaybackStart, @@ -100,7 +114,14 @@ const id = itemId; const restart = restartParam; if (id && id !== loadedItemId) { - autoPlayLog.debug("$effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart); + autoPlayLog.debug( + "$effect triggered: loading new item", + id, + "(was:", + loadedItemId, + ") restart:", + restart, + ); // restart=true (advancing to next episode) forces start-from-beginning, // bypassing the resume-progress check. loadAndPlay(id, restart ? 0 : undefined, restart); @@ -121,8 +142,7 @@ // treat it as video when it carries a video media stream. function isVideoChannelItem(item: MediaItem): boolean { return ( - item.kind === "channelItem" && - (item.mediaStreams?.some((s) => s.kind === "video") ?? false) + item.kind === "channelItem" && (item.mediaStreams?.some((s) => s.kind === "video") ?? false) ); } @@ -140,7 +160,15 @@ currentMedia = item; // Check if this is a non-playable collection type that should be viewed in library instead - const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"]; + const collectionKinds: MediaKind[] = [ + "album", + "artist", + "series", + "season", + "folder", + "playlist", + "channel", + ]; if (item.kind && collectionKinds.includes(item.kind)) { log.debug("loadAndPlay: Redirecting collection type to library:", item.kind); goto(`/library/${id}`); @@ -150,7 +178,8 @@ // Determine if this is video content (Movie, Episode, live TV channels, and // channel leaf items that carry a video stream). isLive = item.kind === "liveChannel"; - isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item); + isVideo = + item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item); // If this track is already playing in the backend, just show the UI // without restarting playback (e.g., when expanding from MiniPlayer). @@ -190,7 +219,16 @@ // When forceRestart is set (advancing to a next episode) we always start // from the beginning, skipping the resume check and resume dialog. const userId = auth.getUserId(); - log.debug("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart); + log.debug( + "Resume check - userId:", + userId, + "itemId:", + id, + "startPosition:", + startPosition, + "forceRestart:", + forceRestart, + ); // Live streams have no fixed position - never resume. if (!startPosition && !forceRestart && userId && !isLive) { @@ -203,7 +241,14 @@ const totalSeconds = item.durationMs / 1000; const progressPercent = (positionSeconds / totalSeconds) * 100; - log.debug("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent); + log.debug( + "Resume check - positionSeconds:", + positionSeconds, + "totalSeconds:", + totalSeconds, + "progressPercent:", + progressPercent, + ); // Store for later use regardless of whether dialog is shown retrievedProgressSeconds = positionSeconds; @@ -216,10 +261,22 @@ loading = false; return; // Wait for user decision } else { - log.debug("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90); + log.debug( + "Resume check - NOT showing dialog. Position > 30?", + positionSeconds > 30, + "Progress < 90?", + progressPercent < 90, + ); } } else { - log.debug("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs); + log.debug( + "Resume check - No valid progress found. Has progress?", + !!progress, + "Has position?", + progress?.positionMs, + "Has runtime?", + !!item.durationMs, + ); } } catch (e) { log.error("Failed to check saved progress:", e); @@ -232,12 +289,15 @@ // Check if this item is downloaded locally const downloadsState = get(downloads); const localDownload = Object.values(downloadsState.downloads).find( - (d: DownloadInfo) => d.itemId === id && d.status === "completed" + (d: DownloadInfo) => d.itemId === id && d.status === "completed", ); if (localDownload) { // Use local file for playback - log.debug("loadAndPlay: Found local download, using offline playback:", localDownload.filePath); + log.debug( + "loadAndPlay: Found local download, using offline playback:", + localDownload.filePath, + ); isOfflinePlayback = true; // Get the storage path and resolve the file's location. A completed @@ -309,7 +369,12 @@ if (isVideo) { // Playback API now detects HEVC/10-bit and returns transcoded URL when needed - log.debug("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding); + log.debug( + "loadAndPlay: Using video stream, directPlay:", + playbackInfo.directPlay, + "needsTranscoding:", + playbackInfo.needsTranscoding, + ); mediaSourceId = playbackInfo.mediaSourceId; // Prefer a completed download over streaming. Audio has done this @@ -336,7 +401,7 @@ log.debug( source.isLocal ? "loadAndPlay: Playing downloaded file from disk" - : `loadAndPlay: Using stream URL: ${streamUrl}` + : `loadAndPlay: Using stream URL: ${streamUrl}`, ); // Set initial position for the video player to seek to after load. @@ -358,68 +423,105 @@ // For audio, use MPV backend log.debug("loadAndPlay: Using MPV backend for audio"); - // Check if we have a queue parameter (e.g., queue=parent:albumId) - const queueParamValue = queueParam; - if (queueParamValue?.startsWith("parent:")) { - const parentId = queueParamValue.substring(7); // Remove "parent:" prefix - log.debug("loadAndPlay: Loading queue from parent:", parentId); + // Check if we have a queue parameter (e.g., queue=parent:albumId) + const queueParamValue = queueParam; + if (queueParamValue?.startsWith("parent:")) { + const parentId = queueParamValue.substring(7); // Remove "parent:" prefix + log.debug("loadAndPlay: Loading queue from parent:", parentId); - // Fetch all tracks from the parent (album/playlist) - const result = await repo.getItems(parentId, { - sortBy: "SortName", - sortOrder: "Ascending", - limit: 500, - }); - const audioTracks = result.items.filter(t => t.kind === "track"); + // Fetch all tracks from the parent (album/playlist) + const result = await repo.getItems(parentId, { + sortBy: "SortName", + sortOrder: "Ascending", + limit: 500, + }); + const audioTracks = result.items.filter((t) => t.kind === "track"); - if (audioTracks.length > 0) { - // Find the index of the current item in the tracks - const startIndex = audioTracks.findIndex(t => t.id === id); - const actualStartIndex = startIndex >= 0 ? startIndex : 0; + if (audioTracks.length > 0) { + // Find the index of the current item in the tracks + const startIndex = audioTracks.findIndex((t) => t.id === id); + const actualStartIndex = startIndex >= 0 ? startIndex : 0; - log.debug("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex); + log.debug( + "loadAndPlay: Building queue with", + audioTracks.length, + "tracks, startIndex:", + actualStartIndex, + ); - // Build queue items with stream URLs - // Add error handling and logging for each track - const queueItems = await Promise.all(audioTracks.map(async (t, idx) => { - try { - log.debug(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`); - const trackStreamUrl = await repo.getAudioStreamUrl(t.id); - if (!trackStreamUrl) { - log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`); - throw new Error(`Failed to get stream URL for ${t.name}`); - } - return { - id: t.id, - title: t.name, - artist: t.artists?.join(", ") || null, - album: t.albumName || null, - duration: t.durationMs ? t.durationMs / 1000 : null, - artworkUrl: t.imageId - ? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId }) - : null, - mediaType: "audio", - streamUrl: trackStreamUrl, - jellyfinItemId: t.id, - }; - } catch (e) { - log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e); - throw e; // Re-throw to fail fast and show error to user - } - })); + // Build queue items with stream URLs + // Add error handling and logging for each track + const queueItems = await Promise.all( + audioTracks.map(async (t, idx) => { + try { + log.debug( + `loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`, + ); + const trackStreamUrl = await repo.getAudioStreamUrl(t.id); + if (!trackStreamUrl) { + log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`); + throw new Error(`Failed to get stream URL for ${t.name}`); + } + return { + id: t.id, + title: t.name, + artist: t.artists?.join(", ") || null, + album: t.albumName || null, + duration: t.durationMs ? t.durationMs / 1000 : null, + artworkUrl: t.imageId + ? repo.getImageUrl(t.albumId || t.id, "Primary", { + maxWidth: 300, + tag: t.imageId, + }) + : null, + mediaType: "audio", + streamUrl: trackStreamUrl, + jellyfinItemId: t.id, + }; + } catch (e) { + log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e); + throw e; // Re-throw to fail fast and show error to user + } + }), + ); - // Use player_play_queue to set up the backend queue - await commands.playerPlayQueue({ - items: queueItems, - startIndex: actualStartIndex, - shuffle: shuffleParam, - } as unknown as PlayQueueRequest); + // Use player_play_queue to set up the backend queue + await commands.playerPlayQueue({ + items: queueItems, + startIndex: actualStartIndex, + shuffle: shuffleParam, + } as unknown as PlayQueueRequest); - // Queue will auto-update from Rust backend event - log.debug("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks"); + // Queue will auto-update from Rust backend event + log.debug( + "loadAndPlay: Successfully set up queue with", + audioTracks.length, + "tracks", + ); + } else { + // Fallback to single item playback + log.debug( + "loadAndPlay: No audio tracks found in parent, falling back to single item", + ); + // Use player_play_tracks - backend fetches all metadata from single ID + const repo = auth.getRepository(); + const repositoryHandle = repo.getHandle(); + + await commands.playerPlayTracks(repositoryHandle, { + trackIds: [item.id], + startIndex: 0, + shuffle: false, + context: { + type: "search", + searchQuery: "", + }, + }); + + // Queue will auto-update from Rust backend event + log.debug("loadAndPlay: Set queue with single item:", item.name); + } } else { - // Fallback to single item playback - log.debug("loadAndPlay: No audio tracks found in parent, falling back to single item"); + // No queue parameter - single item playback // Use player_play_tracks - backend fetches all metadata from single ID const repo = auth.getRepository(); const repositoryHandle = repo.getHandle(); @@ -437,25 +539,6 @@ // Queue will auto-update from Rust backend event log.debug("loadAndPlay: Set queue with single item:", item.name); } - } else { - // No queue parameter - single item playback - // Use player_play_tracks - backend fetches all metadata from single ID - const repo = auth.getRepository(); - const repositoryHandle = repo.getHandle(); - - await commands.playerPlayTracks(repositoryHandle, { - trackIds: [item.id], - startIndex: 0, - shuffle: false, - context: { - type: "search", - searchQuery: "", - }, - }); - - // Queue will auto-update from Rust backend event - log.debug("loadAndPlay: Set queue with single item:", item.name); - } // Seek to start position if provided if (startPosition) { @@ -468,11 +551,22 @@ loading = false; // Fetch next episode for video episodes (for skip button) - nextEpisodeLog.debug("Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name); + nextEpisodeLog.debug( + "Post-load check: isVideo=", + isVideo, + "currentMedia=", + currentMedia?.kind, + currentMedia?.name, + ); if (isVideo && currentMedia) { fetchNextEpisode(currentMedia); } else { - nextEpisodeLog.debug("Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia); + nextEpisodeLog.debug( + "Skipped fetchNextEpisode - isVideo:", + isVideo, + "currentMedia:", + !!currentMedia, + ); } } catch (e) { log.error("loadAndPlay error:", e); @@ -545,7 +639,10 @@ * * TRACES: UR-004, UR-005, UR-021 | DR-181 */ - async function handleVideoSeek(_positionSeconds: number, audioStreamIndex?: number): Promise { + async function handleVideoSeek( + _positionSeconds: number, + audioStreamIndex?: number, + ): Promise { const repo = auth.getRepository(); const id = itemId; if (!id) throw new Error("No item ID"); @@ -604,7 +701,13 @@ // and check for next episodes. HTML5 video plays independently of the Rust // backend queue, so the backend needs these to know what just finished. const mediaId = currentMedia?.id ?? null; - autoPlayLog.debug("Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId); + autoPlayLog.debug( + "Video ended. currentMedia:", + mediaId, + currentMedia?.name, + "itemId (URL):", + itemId, + ); try { const repo = auth.getRepository(); const repoHandle = repo.getHandle(); @@ -616,7 +719,14 @@ async function fetchNextEpisode(media: MediaItem) { nextEpisode = null; - nextEpisodeLog.debug("fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name }); + nextEpisodeLog.debug("fetchNextEpisode called:", { + kind: media.kind, + seriesId: media.seriesId, + seasonId: media.seasonId, + indexNumber: media.indexNumber, + id: media.id, + name: media.name, + }); if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) { nextEpisodeLog.debug("Skipping - not an episode or missing seasonId/indexNumber"); return; @@ -624,17 +734,37 @@ try { const repo = auth.getRepository(); // Fetch all episodes in the season sorted by episode number - const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 }); - const episodes = result.items.filter(e => e.kind === "episode"); - nextEpisodeLog.debug("Season has", episodes.length, "episodes, current index:", media.indexNumber); + const result = await repo.getItems(media.seasonId, { + sortBy: "IndexNumber", + sortOrder: "Ascending", + limit: 500, + }); + const episodes = result.items.filter((e) => e.kind === "episode"); + nextEpisodeLog.debug( + "Season has", + episodes.length, + "episodes, current index:", + media.indexNumber, + ); // Find the episode after the current one by index number - const currentIdx = episodes.findIndex(e => e.id === media.id); + const currentIdx = episodes.findIndex((e) => e.id === media.id); if (currentIdx >= 0 && currentIdx < episodes.length - 1) { nextEpisode = episodes[currentIdx + 1]; - nextEpisodeLog.debug("Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber); + nextEpisodeLog.debug( + "Set nextEpisode:", + nextEpisode.name, + "index:", + nextEpisode.indexNumber, + ); } else { - nextEpisodeLog.debug("No next episode in season (current position:", currentIdx, "of", episodes.length, ")"); + nextEpisodeLog.debug( + "No next episode in season (current position:", + currentIdx, + "of", + episodes.length, + ")", + ); } } catch (e) { nextEpisodeLog.error("Failed to fetch next episode:", e); @@ -664,9 +794,9 @@ const secs = Math.floor(seconds % 60); if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; } - return `${minutes}:${secs.toString().padStart(2, '0')}`; + return `${minutes}:${secs.toString().padStart(2, "0")}`; } @@ -675,7 +805,9 @@

    Resume Playback?

    - You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo ? 'video' : 'audio'}. + You've watched {savedProgress.progressPercent.toFixed(0)}% of this {isVideo + ? "video" + : "audio"}.

    Resume from {formatTime(savedProgress.positionSeconds)} or start from the beginning? @@ -700,11 +832,9 @@

    Playback Error

    -
    {error}
    -
    @@ -713,7 +843,9 @@
    -
    +
    {:else if surface === "video" && streamUrl} - +

    Search your entire library

    Find music, movies, shows, and more

    diff --git a/src/routes/sessions/+page.svelte b/src/routes/sessions/+page.svelte index 4102fb0d..e7343831 100644 --- a/src/routes/sessions/+page.svelte +++ b/src/routes/sessions/+page.svelte @@ -18,9 +18,7 @@

    Remote Sessions

    -

    - Control playback on other Jellyfin clients -

    +

    Control playback on other Jellyfin clients

    @@ -38,8 +36,15 @@
    {:else} -
    - +
    + -
    +

    How to use Remote Sessions

    • Start playing media on another Jellyfin client (TV, web browser, mobile app)
    • diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index d43d5d1d..ce2fe824 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -26,10 +26,7 @@ import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte"; import { library, viewMode } from "$lib/stores/library"; import { auth } from "$lib/stores/auth"; - import { - isNetworkDetectionSupported, - reportNetworkState, - } from "$lib/services/networkType"; + import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType"; import { experimentalNativeVideo } from "$lib/stores/nativeVideo"; import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities"; import { createLogger } from "$lib/utils/logger"; @@ -442,9 +439,7 @@

      Display

      -

      - How your library and collections are laid out -

      +

      How your library and collections are laid out

      Layout

      @@ -486,10 +481,9 @@

      Hidden Folders

      - Folders to leave out of music browsing and search. Useful when a - music library also holds podcasts or audiobooks. Hidden folders can - still be opened from a direct link, and anything already playing or - downloaded is unaffected. + Folders to leave out of music browsing and search. Useful when a music library also + holds podcasts or audiobooks. Hidden folders can still be opened from a direct link, and + anything already playing or downloaded is unaffected.

      @@ -497,8 +491,7 @@

      Loading folders...

      {:else if exclusionCandidates.length === 0}

      - No music folders to choose from. Connect to your server to pick - folders to hide. + No music folders to choose from. Connect to your server to pick folders to hide.

      {:else}
      @@ -506,7 +499,7 @@

      - Changes apply to listings loaded from now on; reopen a page to see - them take effect. + Changes apply to listings loaded from now on; reopen a page to see them take effect.

      {/if}
      @@ -538,9 +528,7 @@

      Crossfade

      -

      - Fade between tracks for seamless transitions -

      +

      Fade between tracks for seamless transitions

      @@ -569,9 +557,7 @@

      Gapless Playback

      -

      - Eliminate silence between tracks in albums -

      +

      Eliminate silence between tracks in albums

      @@ -820,10 +802,10 @@

      Streaming Quality

      - Limit how much bandwidth video streams may use. Lower settings ask the - server to transcode before sending, which saves data on metered or slow - connections at the cost of picture quality. You can also change this for - a single video from the player's quality menu. + Limit how much bandwidth video streams may use. Lower settings ask the server to + transcode before sending, which saves data on metered or slow connections at the cost of + picture quality. You can also change this for a single video from the player's quality + menu.

      {#each streamingQualities as [quality, label, detail]} @@ -831,8 +813,8 @@ onclick={() => handleStreamingQualityChange(quality)} class="py-3 px-3 rounded-lg transition-all text-left {videoSettings.streamingQuality === quality - ? 'bg-[var(--color-jellyfin)] text-white' - : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" + ? 'bg-[var(--color-jellyfin)] text-white' + : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" aria-pressed={videoSettings.streamingQuality === quality} >
      {label}
      @@ -841,8 +823,8 @@ {/each}

      - Applies to videos started from now on; a video already playing keeps the - quality it started at. + Applies to videos started from now on; a video already playing keeps the quality it + started at.

      @@ -861,11 +843,10 @@

      - Decode video with the device's hardware decoder instead of the - built-in web player, for better performance and battery life, - and so picture-in-picture shows the video rather than the app. - On by default. Turn it off to fall back to the built-in web - player if a video misbehaves. + Decode video with the device's hardware decoder instead of the built-in web + player, for better performance and battery life, and so picture-in-picture shows + the video rather than the app. On by default. Turn it off to fall back to the + built-in web player if a video misbehaves.

      -

      - Takes effect the next time you start a video. -

      +

      Takes effect the next time you start a video.

      {/if}
      @@ -896,8 +875,8 @@

      Result Group Order

      - Drag or use the arrows to choose the order search result groups appear in. - Empty groups are hidden automatically. + Drag or use the arrows to choose the order search result groups appear in. Empty groups + are hidden automatically.

      @@ -928,7 +907,9 @@ Loading... {:else if cacheStats} - {formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 ? "Unlimited" : formatBytes(cacheStats.limitBytes)} + {formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 + ? "Unlimited" + : formatBytes(cacheStats.limitBytes)} {/if}
    @@ -937,7 +918,11 @@
    @@ -999,9 +984,7 @@

    Storage Limit

    -

    - Maximum storage for offline downloads -

    +

    Maximum storage for offline downloads

    @@ -1100,9 +1081,7 @@ onclick={handleWifiOnlyToggle} class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly ? 'bg-[var(--color-jellyfin)]' - : 'bg-gray-600'} {networkDetectionSupported - ? '' - : 'opacity-50 cursor-not-allowed'}" + : 'bg-gray-600'} {networkDetectionSupported ? '' : 'opacity-50 cursor-not-allowed'}" aria-label="Toggle WiFi only downloads" aria-pressed={cacheConfig.wifiOnly} disabled={!networkDetectionSupported} @@ -1135,16 +1114,15 @@

    About these settings:

    • - Crossfade smoothly blends the end of one track with the - beginning of the next + Crossfade smoothly blends the end of one track with the beginning of + the next
    • - Gapless removes silence between tracks for continuous - album playback + Gapless removes silence between tracks for continuous album playback
    • - Normalization evens out loudness between tracks - in real time, toward your selected level + Normalization evens out loudness between tracks in real time, toward + your selected level
    diff --git a/vitest.config.ts b/vitest.config.ts index 0c55890d..70244235 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,13 +14,7 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - exclude: [ - "node_modules/", - "src/test/", - "**/*.test.ts", - "**/*.spec.ts", - "src-tauri/", - ], + exclude: ["node_modules/", "src/test/", "**/*.test.ts", "**/*.spec.ts", "src-tauri/"], }, }, resolve: {