diff --git a/package.json b/package.json index ec223a59..d06117bb 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/src/core/services/stats-window-layer.ts b/src/core/services/stats-window-layer.ts new file mode 100644 index 00000000..5fb9ca57 --- /dev/null +++ b/src/core/services/stats-window-layer.ts @@ -0,0 +1,29 @@ +export type StatsWindowLayerSuspensionState = { + count: number; +}; + +export function createStatsWindowLayerSuspensionState(): StatsWindowLayerSuspensionState { + return { count: 0 }; +} + +export function isStatsWindowLayerSuspended(state: StatsWindowLayerSuspensionState): boolean { + return state.count > 0; +} + +export function suspendStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean { + state.count += 1; + return state.count === 1; +} + +export function restoreStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean { + if (state.count <= 0) { + return false; + } + + state.count -= 1; + return state.count === 0; +} + +export function resetStatsWindowLayerSuspension(state: StatsWindowLayerSuspensionState): void { + state.count = 0; +} diff --git a/src/core/services/stats-window-lifecycle.test.ts b/src/core/services/stats-window-lifecycle.test.ts new file mode 100644 index 00000000..78dee9bc --- /dev/null +++ b/src/core/services/stats-window-lifecycle.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createStatsWindowLayerSuspensionState, + isStatsWindowLayerSuspended, + resetStatsWindowLayerSuspension, + restoreStatsWindowLayer, + suspendStatsWindowLayer, +} from './stats-window-layer'; + +test('stats window layer suspension reset clears missed native dialog closes', () => { + const state = createStatsWindowLayerSuspensionState(); + + assert.equal(suspendStatsWindowLayer(state), true); + assert.equal(suspendStatsWindowLayer(state), false); + assert.equal(isStatsWindowLayerSuspended(state), true); + + resetStatsWindowLayerSuspension(state); + + assert.equal(isStatsWindowLayerSuspended(state), false); + assert.equal(restoreStatsWindowLayer(state), false); + assert.equal(suspendStatsWindowLayer(state), true); +}); diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index ddb8b2d4..e99f20d3 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -15,11 +15,18 @@ import { STATS_WINDOW_TITLE, } from './stats-window-runtime.js'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; +import { + createStatsWindowLayerSuspensionState, + isStatsWindowLayerSuspended, + resetStatsWindowLayerSuspension, + restoreStatsWindowLayer, + suspendStatsWindowLayer, +} from './stats-window-layer.js'; let statsWindow: BrowserWindow | null = null; let toggleRegistered = false; let nativeDialogLayerRegistered = false; -let nativeDialogLayerSuspensionCount = 0; +const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState(); export interface StatsWindowOptions { /** Absolute path to stats/dist/ directory */ @@ -67,7 +74,7 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo } export function promoteStatsOverlayAbovePlayback(): boolean { - if (nativeDialogLayerSuspensionCount > 0) { + if (isStatsWindowLayerSuspended(nativeDialogLayerSuspension)) { return false; } @@ -91,8 +98,7 @@ export function demoteStatsOverlayBelowDialogs(): boolean { } export function suspendStatsWindowLayerForNativeDialog(): void { - nativeDialogLayerSuspensionCount += 1; - if (nativeDialogLayerSuspensionCount !== 1) { + if (!suspendStatsWindowLayer(nativeDialogLayerSuspension)) { return; } @@ -100,16 +106,15 @@ export function suspendStatsWindowLayerForNativeDialog(): void { } export function restoreStatsWindowLayerAfterNativeDialog(): void { - if (nativeDialogLayerSuspensionCount <= 0) { - return; - } - - nativeDialogLayerSuspensionCount -= 1; - if (nativeDialogLayerSuspensionCount === 0) { + if (restoreStatsWindowLayer(nativeDialogLayerSuspension)) { promoteStatsOverlayAbovePlayback(); } } +function resetStatsWindowLayerAfterLifecycleEnd(): void { + resetStatsWindowLayerSuspension(nativeDialogLayerSuspension); +} + export async function withStatsWindowLayerSuspendedForNativeDialog( showDialog: () => Promise, ): Promise { @@ -172,6 +177,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { statsWindow.on('closed', () => { options.onVisibilityChanged?.(false); statsWindow = null; + resetStatsWindowLayerAfterLifecycleEnd(); }); statsWindow.webContents.on('before-input-event', (event, input) => { @@ -222,4 +228,5 @@ export function destroyStatsWindow(): void { statsWindow.destroy(); statsWindow = null; } + resetStatsWindowLayerAfterLifecycleEnd(); } diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 0aa22286..760de82f 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -118,6 +118,16 @@ test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecr assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null); }); +test('resolveLinuxPasswordStoreValue keeps scanning after a bare password-store flag', () => { + assert.equal( + resolveLinuxPasswordStoreValue( + ['SubMiner.AppImage', '--password-store', '--start', '--password-store=kwallet6'], + 'linux', + ), + 'kwallet6', + ); +}); + test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => { const switches: Array<[string, string | undefined]> = []; applyEarlyLinuxCommandLineSwitches( diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 744d1fb6..de4e2447 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -79,6 +79,7 @@ function removePassiveStartupArgs(argv: string[]): string[] { } function getPasswordStoreArg(argv: string[]): string | null { + let resolved: string | null = null; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith(PASSWORD_STORE_ARG)) { @@ -88,17 +89,18 @@ function getPasswordStoreArg(argv: string[]): string | null { if (arg === PASSWORD_STORE_ARG) { const value = argv[i + 1]; if (value && !value.startsWith('--')) { - return value; + resolved = value.trim(); + i += 1; } - return null; + continue; } const [prefix, value] = arg.split('=', 2); if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) { - return value.trim(); + resolved = value.trim(); } } - return null; + return resolved; } function normalizePasswordStoreArg(value: string): string { diff --git a/src/main.ts b/src/main.ts index 778968f7..2263744b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,7 @@ import { applyControllerConfigUpdate } from './main/controller-config-update.js' import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { startAppControlServer } from './main/runtime/app-control-server'; +import { markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState } from './main/runtime/jellyfin-remote-playback'; import { getAppControlSocketPath } from './shared/app-control'; import { type CancelLinuxMpvFullscreenOverlayRefreshBurst, @@ -44,6 +45,7 @@ import { import { mergeAiConfig } from './ai/config'; function getPasswordStoreArg(argv: string[]): string | null { + let resolved: string | null = null; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith('--password-store')) { @@ -53,17 +55,18 @@ function getPasswordStoreArg(argv: string[]): string | null { if (arg === '--password-store') { const value = argv[i + 1]; if (value && !value.startsWith('--')) { - return value; + resolved = value.trim(); + i += 1; } - return null; + continue; } const [prefix, value] = arg.split('=', 2); if (prefix === '--password-store' && value && value.trim().length > 0) { - return value.trim(); + resolved = value.trim(); } } - return null; + return resolved; } function normalizePasswordStoreArg(value: string): string { @@ -4465,9 +4468,7 @@ const { }, signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), markJellyfinRemotePlaybackLoaded: (path) => { - if (activeJellyfinRemotePlayback) { - activeJellyfinRemotePlayback.loadedMediaPath = path; - } + markJellyfinRemotePlaybackLoadedState(activeJellyfinRemotePlayback, path); }, scheduleCharacterDictionarySync: () => { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index c78e8d57..d327a8bc 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -82,8 +82,9 @@ test('playback handler drives mpv commands and playback state', async () => { setLastProgressAtMs: (value) => calls.push(`progress:${value}`), reportPlaying: (payload) => reportPayloads.push(payload as Record), showMpvOsd: (text) => calls.push(`osd:${text}`), - recordJellyfinPlaybackMetadata: (metadata) => - statsMetadata.push(metadata as Record), + recordJellyfinPlaybackMetadata: (metadata) => { + statsMetadata.push(metadata as Record); + }, }); await handler({ @@ -164,7 +165,9 @@ test('playback handler publishes Jellyfin title before loading tokenized stream setLastProgressAtMs: () => {}, reportPlaying: () => {}, showMpvOsd: () => {}, - updateCurrentMediaTitle: (title) => timeline.push(`title:${title}`), + updateCurrentMediaTitle: (title) => { + timeline.push(`title:${title}`); + }, }); await handler({ @@ -353,3 +356,51 @@ test('playback handler does not let media title failures block playback startup' assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); }); + +test('playback handler handles rejected best-effort hook promises', async () => { + const commands: Array> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 5', + itemTitle: 'Episode 5', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + updateCurrentMediaTitle: async () => { + throw new Error('title async unavailable'); + }, + recordJellyfinPlaybackMetadata: async () => { + throw new Error('stats async unavailable'); + }, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-5', + }); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']); +}); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index bc68471b..5783cca9 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -28,6 +28,14 @@ export type JellyfinPlaybackStatsMetadata = { itemId: string; }; +function runBestEffortPlaybackHook(callback: () => void | Promise): void { + try { + void Promise.resolve(callback()).catch(() => {}); + } catch { + // Best-effort metadata/title hooks must not block playback startup. + } +} + function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string { if (typeof startTimeTicksOverride !== 'number') return url; try { @@ -78,8 +86,10 @@ export function createPlayJellyfinItemInMpvHandler(deps: { eventName: 'start'; }) => void; showMpvOsd: (text: string) => void; - recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void; - updateCurrentMediaTitle?: (title: string) => void; + recordJellyfinPlaybackMetadata?: ( + metadata: JellyfinPlaybackStatsMetadata, + ) => void | Promise; + updateCurrentMediaTitle?: (title: string) => void | Promise; }) { return async (params: { session: JellyfinAuthSession; @@ -112,8 +122,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode'; - try { - deps.updateCurrentMediaTitle?.(plan.title); + runBestEffortPlaybackHook(() => deps.updateCurrentMediaTitle?.(plan.title)); + runBestEffortPlaybackHook(() => deps.recordJellyfinPlaybackMetadata?.({ mediaPath: playbackUrl, displayTitle: plan.title, @@ -122,10 +132,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: { seasonNumber: plan.seasonNumber, episodeNumber: plan.episodeNumber, itemId: params.itemId, - }); - } catch { - // Best-effort metadata/title hooks must not block playback startup. - } + }), + ); deps.setActivePlayback({ itemId: params.itemId, mediaSourceId: undefined, diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index 0edb9780..338b9e1f 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + markJellyfinRemotePlaybackLoaded, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, secondsToJellyfinTicks, @@ -335,6 +336,21 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback' assert.equal(cleared, false); }); +test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => { + const playback = { + itemId: 'item-2', + playMethod: 'Transcode' as const, + loadedMediaPath: 'https://stream.example/video.m3u8', + }; + + markJellyfinRemotePlaybackLoaded(playback, ''); + markJellyfinRemotePlaybackLoaded(playback, ' '); + assert.equal(playback.loadedMediaPath, 'https://stream.example/video.m3u8'); + + markJellyfinRemotePlaybackLoaded(playback, ' https://stream.example/next.m3u8 '); + assert.equal(playback.loadedMediaPath, 'https://stream.example/next.m3u8'); +}); + test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => { let cleared = false; let stopped = false; diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 305bdfaf..0b68f579 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -34,6 +34,16 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): return Math.max(0, Math.floor(seconds * ticksPerSecond)); } +export function markJellyfinRemotePlaybackLoaded( + playback: ActiveJellyfinRemotePlaybackState | null, + path: string, +): void { + const normalizedPath = path.trim(); + if (playback && normalizedPath) { + playback.loadedMediaPath = normalizedPath; + } +} + function isMpvPauseEnabled(value: unknown): boolean { if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 2b4b6d6a..cbd57454 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -219,7 +219,7 @@ test('confirmDayGroupDelete uses singular for one session', async () => { try { assert.equal(await confirmDayGroupDelete('Yesterday', 1), true); - assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']); + assert.deepEqual(calls, ['Delete this session from Yesterday and all associated data?']); } finally { globalThis.confirm = originalConfirm; } diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index 41cbd664..5480437a 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -44,8 +44,13 @@ export function confirmSessionDelete(): Promise { } export function confirmDayGroupDelete(dayLabel: string, count: number): Promise { + if (count === 1) { + return confirmWithStatsNativeDialogLayer( + `Delete this session from ${dayLabel} and all associated data?`, + ); + } return confirmWithStatsNativeDialogLayer( - `Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`, + `Delete all ${count} sessions from ${dayLabel} and all associated data?`, ); }