From d51e7fe401fd9c3e498cc38427f404253c8c9cab Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 31 Mar 2026 12:28:29 -0700 Subject: [PATCH] Add playlist browser overlay modal (#37) --- CHANGELOG.md | 5 + README.md | 6 + ...l-for-sibling-video-files-and-mpv-queue.md | 66 +- ...ithout-requiring-a-subtitle-hover-cycle.md | 67 ++ ...ion-tracker-SQLite-timestamp-truncation.md | 29 + ...post-watch-updates-for-watched-episodes.md | 50 ++ changes/260-playlist-browser.md | 5 + changes/261-macos-overlay-passthrough.md | 5 + changes/262-anilist-post-watch-dedupe.md | 5 + docs-site/configuration.md | 3 +- docs-site/shortcuts.md | 3 +- docs-site/usage.md | 2 + plugin/subminer/process.lua | 23 +- scripts/test-plugin-start-gate.lua | 25 + .../definitions/domain-registry.test.ts | 1 + src/config/definitions/shared.ts | 2 + .../immersion-tracker-service.test.ts | 181 +++-- .../services/immersion-tracker-service.ts | 54 +- .../__tests__/query-split-modules.test.ts | 5 +- .../immersion-tracker/__tests__/query.test.ts | 273 +++++--- .../services/immersion-tracker/lifetime.ts | 144 ++-- .../immersion-tracker/maintenance.test.ts | 27 +- .../services/immersion-tracker/maintenance.ts | 49 +- .../immersion-tracker/query-lexical.ts | 15 +- .../immersion-tracker/query-library.ts | 94 ++- .../immersion-tracker/query-maintenance.ts | 7 +- .../immersion-tracker/query-sessions.ts | 108 ++- .../immersion-tracker/query-shared.ts | 211 ++++++ .../immersion-tracker/query-trends.ts | 148 ++-- .../services/immersion-tracker/session.ts | 14 +- .../immersion-tracker/storage-session.test.ts | 8 +- .../services/immersion-tracker/storage.ts | 136 ++-- .../services/immersion-tracker/time.test.ts | 22 + src/core/services/immersion-tracker/time.ts | 22 + src/core/services/ipc-command.test.ts | 26 + src/core/services/ipc-command.ts | 12 + src/core/services/ipc.test.ts | 153 +++- src/core/services/ipc.ts | 61 ++ src/core/services/overlay-visibility.test.ts | 4 +- src/core/services/overlay-visibility.ts | 2 +- src/main.ts | 23 + src/main/dependencies.ts | 12 + src/main/ipc-mpv-command.ts | 2 + src/main/runtime/anilist-post-watch.test.ts | 51 ++ src/main/runtime/anilist-post-watch.ts | 3 + .../composers/ipc-runtime-composer.test.ts | 15 + .../ipc-bridge-actions-main-deps.test.ts | 1 + src/main/runtime/ipc-bridge-actions.test.ts | 1 + .../runtime/ipc-mpv-command-main-deps.test.ts | 5 + src/main/runtime/ipc-mpv-command-main-deps.ts | 1 + src/main/runtime/playlist-browser-ipc.ts | 46 ++ .../runtime/playlist-browser-open.test.ts | 28 + src/main/runtime/playlist-browser-open.ts | 23 + .../runtime/playlist-browser-runtime.test.ts | 487 +++++++++++++ src/main/runtime/playlist-browser-runtime.ts | 361 ++++++++++ .../runtime/playlist-browser-sort.test.ts | 50 ++ src/main/runtime/playlist-browser-sort.ts | 129 ++++ src/preload.ts | 17 + src/renderer/handlers/keyboard.test.ts | 49 ++ src/renderer/handlers/keyboard.ts | 7 + src/renderer/index.html | 29 + .../modals/playlist-browser-renderer.ts | 144 ++++ src/renderer/modals/playlist-browser.test.ts | 659 ++++++++++++++++++ src/renderer/modals/playlist-browser.ts | 407 +++++++++++ src/renderer/modals/session-help.ts | 2 + src/renderer/renderer.ts | 25 +- src/renderer/state.ts | 13 + src/renderer/style.css | 500 +++++++++---- src/renderer/utils/dom.ts | 14 + src/shared/ipc/contracts.ts | 7 + src/types/runtime.ts | 45 ++ 71 files changed, 4586 insertions(+), 643 deletions(-) create mode 100644 backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md create mode 100644 backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md create mode 100644 backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md create mode 100644 changes/260-playlist-browser.md create mode 100644 changes/261-macos-overlay-passthrough.md create mode 100644 changes/262-anilist-post-watch-dedupe.md create mode 100644 src/main/runtime/playlist-browser-ipc.ts create mode 100644 src/main/runtime/playlist-browser-open.test.ts create mode 100644 src/main/runtime/playlist-browser-open.ts create mode 100644 src/main/runtime/playlist-browser-runtime.test.ts create mode 100644 src/main/runtime/playlist-browser-runtime.ts create mode 100644 src/main/runtime/playlist-browser-sort.test.ts create mode 100644 src/main/runtime/playlist-browser-sort.ts create mode 100644 src/renderer/modals/playlist-browser-renderer.ts create mode 100644 src/renderer/modals/playlist-browser.test.ts create mode 100644 src/renderer/modals/playlist-browser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 873a18af..214a470f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Fixed +- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass. + ## v0.10.0 (2026-03-29) ### Changed diff --git a/README.md b/README.md index 1a4c2e3a..70e53035 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
+### Playlist Browser + +Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback. + +
+ ### Integrations diff --git a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md index a8d09979..a80abeab 100644 --- a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md +++ b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md @@ -1,9 +1,11 @@ --- id: TASK-255 title: Add overlay playlist browser modal for sibling video files and mpv queue -status: To Do -assignee: [] +status: In Progress +assignee: + - '@codex' created_date: '2026-03-30 05:46' +updated_date: '2026-03-31 05:59' labels: - feature - overlay @@ -24,6 +26,62 @@ Add an in-session overlay modal that opens from a keybinding during active playb - [ ] #2 The modal shows video files from the current media file's parent directory in best-effort episode order and highlights the current file when present. - [ ] #3 The modal shows the active mpv playlist/queue with enough metadata to identify the current item and queued order. - [ ] #4 The user can add a directory file to the mpv playlist, remove playlist items, and reorder playlist items from the modal using both mouse and keyboard interactions. -- [ ] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order. -- [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal. +- [x] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order. +- [x] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal. + +## Implementation Plan + + +1. Add playlist-browser domain types, IPC channels, overlay modal registration, special command, and default keybinding for Ctrl+Alt+P. +2. Write failing tests for best-effort episode sorting and main playlist-browser runtime snapshot/mutation behavior. +3. Implement playlist-browser main/runtime helpers for local sibling video discovery, mpv playlist normalization, and append/play/remove/move operations with refreshed snapshots. +4. Wire preload and main-process IPC handlers that expose snapshot and mutation methods to the renderer. +5. Write failing renderer and keyboard tests for modal open/close, split-pane interaction, keyboard controls, and degraded states. +6. Implement playlist-browser modal markup, DOM/state, renderer composition, keyboard routing, and session-help labeling. +7. Run targeted test lanes first, then the maintained verification gate relevant to the touched surfaces; update task notes/criteria as checks pass. + +2026-03-30 CodeRabbit follow-up: 1) add failing runtime coverage for unreadable playlist-browser file stat failures, 2) add failing renderer coverage for stale snapshot UI reset on refresh failure/close, 3) add failing renderer coverage to block playlist-browser open when another modal already owns the overlay, 4) implement minimal fixes, 5) rerun targeted tests plus typecheck for touched surfaces. + +2026-03-30 current CodeRabbit round: verify 4 unresolved threads, ignore already-fixed outdated dblclick thread if current code matches, add failing-first coverage for selection preservation / timestamp fixture consistency / string test-clock alignment, implement minimal fixes, rerun targeted tests plus typecheck. + +2026-03-30 latest CodeRabbit round on PR #37: 1) add failing coverage for negative fractional numeric __subminerTestNowMs input so nowMs() matches the string-backed path, 2) add failing coverage that playlist-browser modal tests restore absent window/document globals without leaving undefined-valued properties behind, 3) refactor repeated playlist-browser modal test harness into a shared setup/teardown fixture while preserving assertions, 4) implement minimal fixes, 5) rerun touched tests plus typecheck. + +2026-03-30 latest CodeRabbit follow-up after ff760ea: tighten the new cleanup regression so env.restore() always runs under assertion failure, and make the keydown test's append mock return a post-append mutated snapshot before exercising Ctrl+ArrowDown. Re-run targeted playlist-browser tests plus typecheck. + + +## Implementation Notes + + +Implemented overlay playlist browser modal with split directory/playlist panes, Ctrl+Alt+P keybinding, main/preload IPC, mpv queue mutations, and best-effort sibling episode sorting. + +Added tests for sort/runtime logic, IPC wiring, keyboard routing, and playlist-browser modal behavior. + +Verification: `bun run typecheck` passed; targeted playlist-browser and IPC tests passed; `bun run build` passed; `bun run test:smoke:dist` passed. + +Repo gate blockers outside this feature: `bun run test:fast` hits existing Bun `node:test` NotImplementedError cases plus unrelated immersion-tracker failures; `bun run test:env` fails in existing immersion-tracker sqlite tests. + +2026-03-30: Fixed playlist-browser local playback regression where subtitle track IDs leaked across episode jumps. `playPlaylistBrowserIndexRuntime` now reapplies local subtitle auto-selection defaults (`sub-auto=fuzzy`, `sid=auto`, `secondary-sid=auto`) before `playlist-play-index` for local filesystem targets only; remote playlist entries remain untouched. Added runtime regression tests for both paths. + +2026-03-30: Follow-up subtitle regression fix. Pre-jump `sid=auto` was ineffective because mpv resolved it against the current episode before `playlist-play-index`. Local playlist jumps now set `sub-auto=fuzzy`, switch episodes, then schedule a delayed rearm of `sid=auto` and `secondary-sid=auto` so selection happens against the new file's tracks. Added failing-first runtime coverage for delayed local rearm and remote no-op behavior. + +2026-03-30: Cleaned up playlist-browser runtime local-play subtitle-rearm flow by extracting focused helpers without changing behavior. Added public docs/readme coverage for the default `Ctrl+Alt+P` playlist browser keybinding and modal, plus changelog fragment `changes/260-playlist-browser.md`. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts`, `bun run typecheck`, `bun run docs:test`, `bun run docs:build`, `bun run changelog:lint`, `bun run build`. + +2026-03-30: Pulled unresolved CodeRabbit review threads for PR #37. Actionable set is three items: unreadable-file stat error handling in playlist-browser runtime, stale playlist-browser DOM after failed refresh/close, and missing modal-ownership guard before opening the playlist-browser overlay. Proceeding test-first for each. + +2026-03-30: Addressed current CodeRabbit follow-up findings for PR #37. Fixed playlist-browser unreadable-file stat handling, stale playlist-browser DOM reset on refresh failure/close, modal-ownership guard before opening the playlist-browser overlay, async rejection surfacing for PLAYLIST_BROWSER_OPEN IPC commands, overlay bootstrap before playlist-browser open dispatch, texthooker option normalization in the mpv plugin, and superseded local subtitle-rearm suppression. Added targeted regressions plus new playlist-browser-open helper coverage. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts src/main/runtime/playlist-browser-open.test.ts src/core/services/ipc-command.test.ts src/renderer/modals/playlist-browser.test.ts`, `lua scripts/test-plugin-start-gate.lua`, `bun run typecheck`, `bun run build`. + +Addressed CodeRabbit follow-ups on the playlist browser PR: clamped stale playingIndex values, failed mutation paths when MPV rejects send(), added temp-dir cleanup in runtime tests, and blocked action-button dblclick bubbling in the renderer. Verification: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts`. + +Additional follow-up: moved playlist-browser keydown handling ahead of keyboard-driven lookup controls so KeyH/ArrowLeft/ArrowRight and related chords are routed to the modal first. Verification refreshed with `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`, `bun run typecheck`, and `bun run build`. + +Split playlist-browser UI row rendering into `src/renderer/modals/playlist-browser-renderer.ts` and left `src/renderer/modals/playlist-browser.ts` as the controller/wiring layer. Moved playlist-browser IPC/runtime wiring into `src/main/runtime/playlist-browser-ipc.ts` and collapsed the `src/main.ts` registration block to use that helper. Verification after refactor: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`. + +2026-03-30 PR #37 unresolved CodeRabbit threads currently reduce to three likely-actionable items plus one outdated renderer dblclick thread to verify against HEAD before touching code. + +2026-03-30 Addressed latest unresolved CodeRabbit items on PR #37: preserved playlist-browser selection across mutation snapshots, taught nowMs() to honor string-backed test clocks so it stays aligned with currentDbTimestamp(), and normalized maintenance test timestamp fixtures to toDbTimestamp(). The older playlist-browser dblclick thread remains unresolved in GitHub state but current HEAD already contains that fix in playlist-browser-renderer.ts. + +2026-03-30 latest CodeRabbit remediation on PR #37: switched nowMs() numeric test-clock branch from Math.floor() to Math.trunc() so numeric and string-backed mock clocks agree for negative fractional values. Refactored playlist-browser modal tests onto a shared setup/teardown fixture that restores global window/document descriptors correctly, and added regression coverage that injected globals are deleted when originally absent. Verification: `bun test src/core/services/immersion-tracker/time.test.ts src/renderer/modals/playlist-browser.test.ts`, `bun run typecheck`. + +2026-03-30 CodeRabbit follow-up: wrapped the injected-globals cleanup regression in try/finally so restore always runs, and changed the keydown test append mock to return createMutationSnapshot() before exercising Ctrl+ArrowDown. Verified with `bun test src/renderer/modals/playlist-browser.test.ts` and `bun run typecheck`. + diff --git a/backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md b/backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md new file mode 100644 index 00000000..86312619 --- /dev/null +++ b/backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md @@ -0,0 +1,67 @@ +--- +id: TASK-260 +title: >- + Fix macOS overlay subtitle sidebar passthrough without requiring a subtitle + hover cycle +status: Done +assignee: + - '@codex' +created_date: '2026-03-31 00:58' +updated_date: '2026-03-31 01:01' +labels: + - bug + - macos + - overlay + - subtitle-sidebar + - passthrough +dependencies: [] +references: + - >- + /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts + - /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/mouse.ts + - /Users/sudacode/projects/japanese/SubMiner/src/main/overlay-runtime.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts +documentation: + - docs/workflow/verification.md +priority: high +--- + +## Description + + +On macOS, opening the overlay-layout subtitle sidebar should allow click-through outside the sidebar immediately. Users should not need to first hover subtitle content before passthrough/click-through starts working, including when no subtitle line is currently visible. + + +## Acceptance Criteria + +- [x] #1 With the overlay-layout subtitle sidebar open on macOS, areas outside the sidebar pass clicks through immediately after open without requiring a prior subtitle hover. +- [x] #2 When no subtitle line is currently visible, opening the subtitle sidebar still leaves non-sidebar overlay regions click-through on macOS. +- [x] #3 Regression coverage exercises the first-open/idle passthrough path so overlay interactivity does not depend on a later hover cycle. + + +## Implementation Plan + + +1. Add/adjust focused overlay visibility regressions for the tracked macOS visible overlay so the default idle state stays click-through instead of forcing mouse interaction. +2. Update main-process visible overlay visibility sync to keep the tracked macOS overlay passive by default and let renderer hover/sidebar state opt into interaction. +3. Run focused verification for overlay visibility and any dependent runtime tests, then update task notes/criteria/final summary with the confirmed outcome. + + +## Implementation Notes + + +Investigation points to a main-process override on macOS: renderer sidebar open path already requests mouse passthrough outside the panel, but visible-overlay visibility sync still hard-sets the tracked overlay window interactive on macOS (`mouse-ignore:false`). Window-tracker focus/visibility resync can therefore undo renderer passthrough until a later hover cycle re-applies it. + +Added a failing regression in `src/core/services/overlay-visibility.test.ts` showing the tracked macOS visible overlay was still forced interactive by main-process visibility sync (`mouse-ignore:false`) instead of staying forwarded click-through. + +Updated `src/core/services/overlay-visibility.ts` so tracked macOS visible overlays now default to `setIgnoreMouseEvents(true, { forward: true })`, matching the renderer-side passthrough model and preventing window-tracker/focus resync from undoing idle sidebar clickthrough. + + +## Final Summary + + +Fixed the macOS subtitle-sidebar passthrough regression by changing tracked visible-overlay startup/visibility sync to stay click-through by default in the main process. Previously `updateVisibleOverlayVisibility` forced the macOS overlay window interactive, which could override renderer sidebar passthrough until a later hover cycle repaired it. Added a regression in `src/core/services/overlay-visibility.test.ts` and verified with `bun test src/core/services/overlay-visibility.test.ts`, `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/handlers/mouse.test.ts`, and `bun run typecheck`. + diff --git a/backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md b/backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md new file mode 100644 index 00000000..f900d685 --- /dev/null +++ b/backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md @@ -0,0 +1,29 @@ +--- +id: TASK-261 +title: Fix immersion tracker SQLite timestamp truncation +status: In Progress +assignee: [] +created_date: '2026-03-31 01:45' +labels: + - immersion-tracker + - sqlite + - bug +dependencies: [] +references: + - src/core/services/immersion-tracker +priority: medium +ordinal: 1200 +--- + +## Description + + +Current-epoch millisecond values are being truncated by the libsql driver when bound as numeric parameters, which corrupts session, telemetry, lifetime, and rollup timestamps. + + +## Acceptance Criteria + +- [ ] #1 Current-epoch millisecond timestamps persist correctly in session, telemetry, lifetime, and rollup tables +- [ ] #2 Startup backfill and destroy/finalize flows keep retained sessions and lifetime summaries consistent +- [ ] #3 Regression tests cover the destroyed-session, startup backfill, and distinct-day/distinct-video lifetime semantics + diff --git a/backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md b/backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md new file mode 100644 index 00000000..0fce26fe --- /dev/null +++ b/backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md @@ -0,0 +1,50 @@ +--- +id: TASK-262 +title: Fix duplicate AniList post-watch updates for watched episodes +status: Done +assignee: + - codex +created_date: '2026-03-31 19:03' +updated_date: '2026-03-31 19:05' +labels: + - bug + - anilist +dependencies: [] +--- + +## Description + + +Watching an episode can currently produce two AniList activity updates for the same episode. The duplicate happens when the post-watch flow drains a queued retry for the current episode and then proceeds to run the live post-watch update for that same media/episode in the same pass. User report says this reproduces both when crossing the watched threshold naturally and when using the mark-watched keybinding. Fix the duplicate so one successful watch produces at most one AniList progress update for a given mediaKey/episode pair. + + +## Acceptance Criteria + +- [x] #1 A watched episode triggers at most one AniList post-watch progress update for a given media key and episode during a single post-watch pass, even if that episode already exists in the retry queue. +- [x] #2 Both watched-threshold and manual mark-watched flows are protected by regression coverage for the duplicate-update case. +- [x] #3 Relevant user-visible change note is added if required by repo policy. + + +## Implementation Plan + + +1. Reproduce the duplicate in a unit test around `createMaybeRunAnilistPostWatchUpdateHandler` by simulating a ready retry for the same `mediaKey::episode` the live path would also submit. +2. Fix the handler so that after processing a queued retry, it does not perform a second live update when the retry already satisfied the current attempt key. +3. Run focused AniList runtime tests and adjacent immersion tests to confirm both threshold-driven and manual mark-watched entry points stay covered through the shared post-watch path. + + +## Implementation Notes + + +Added a regression in `src/main/runtime/anilist-post-watch.test.ts` for the case where `processNextAnilistRetryUpdate()` already satisfies the current `mediaKey::episode` before the live path runs. + +Updated `createMaybeRunAnilistPostWatchUpdateHandler` to re-check `hasAttemptedUpdateKey(attemptKey)` immediately after draining the retry queue and short-circuit before a second live AniList submission. + +Verification: `bun test src/main/runtime/anilist-post-watch.test.ts src/main/runtime/anilist-post-watch-main-deps.test.ts`; `bun test src/core/services/immersion-tracker-service.test.ts --test-name-pattern 'recordPlaybackPosition marks watched at 85% completion|markActiveVideoWatched'`; `bun run typecheck`; `bun run changelog:lint`. + + +## Final Summary + + +Fixed duplicate AniList post-watch submissions by short-circuiting the live update path when a ready retry item already handled the current `mediaKey::episode` in the same pass. Added a focused regression test for the retry-plus-live duplicate scenario and a changelog fragment documenting the fix. + diff --git a/changes/260-playlist-browser.md b/changes/260-playlist-browser.md new file mode 100644 index 00000000..1c443a29 --- /dev/null +++ b/changes/260-playlist-browser.md @@ -0,0 +1,5 @@ +type: added +area: overlay + +- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback. +- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback. diff --git a/changes/261-macos-overlay-passthrough.md b/changes/261-macos-overlay-passthrough.md new file mode 100644 index 00000000..0ecdcfe9 --- /dev/null +++ b/changes/261-macos-overlay-passthrough.md @@ -0,0 +1,5 @@ +type: fixed +area: overlay + +- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first. +- Add regression coverage for the macOS visible-overlay passthrough default. diff --git a/changes/262-anilist-post-watch-dedupe.md b/changes/262-anilist-post-watch-dedupe.md new file mode 100644 index 00000000..5dc2477f --- /dev/null +++ b/changes/262-anilist-post-watch-dedupe.md @@ -0,0 +1,5 @@ +type: fixed +area: anilist + +- Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass. +- Add regression coverage for the retry-queue plus live-update duplicate path. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 0b6e2b69..b26b9e2c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. | `Space` | `["cycle", "pause"]` | Toggle pause | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | +| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | @@ -507,7 +508,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. { "key": "Space", "command": null } ``` -**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. +**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 73521e2c..2310f549 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus. | `Space` | Toggle mpv pause | | `J` | Cycle primary subtitle track | | `Shift+J` | Cycle secondary subtitle track | +| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | | `ArrowRight` | Seek forward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds | | `ArrowUp` | Seek forward 60 seconds | @@ -56,7 +57,7 @@ These control playback and subtitle display. They require overlay window focus. | `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | -These keybindings can be overridden or disabled via the `keybindings` config array. +These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave). diff --git a/docs-site/usage.md b/docs-site/usage.md index b5287dbc..bbc67773 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh `Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config. ::: +Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback. + Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`. ### Drag-and-Drop diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 50f72cf3..d4f9a723 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -34,6 +34,17 @@ function M.create(ctx) return options_helper.coerce_bool(raw_pause_until_ready, false) end + local function resolve_texthooker_enabled(override_value) + if override_value ~= nil then + return options_helper.coerce_bool(override_value, false) + end + local raw_texthooker_enabled = opts.texthooker_enabled + if raw_texthooker_enabled == nil then + raw_texthooker_enabled = opts["texthooker-enabled"] + end + return options_helper.coerce_bool(raw_texthooker_enabled, false) + end + local function resolve_pause_until_ready_timeout_seconds() local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds if raw_timeout_seconds == nil then @@ -192,10 +203,7 @@ function M.create(ctx) table.insert(args, "--hide-visible-overlay") end - local texthooker_enabled = overrides.texthooker_enabled - if texthooker_enabled == nil then - texthooker_enabled = opts.texthooker_enabled - end + local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled) if texthooker_enabled then table.insert(args, "--texthooker") end @@ -296,10 +304,7 @@ function M.create(ctx) return end - local texthooker_enabled = overrides.texthooker_enabled - if texthooker_enabled == nil then - texthooker_enabled = opts.texthooker_enabled - end + local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled) local socket_path = overrides.socket_path or opts.socket_path local should_pause_until_ready = ( overrides.auto_start_trigger == true @@ -498,7 +503,7 @@ function M.create(ctx) end end) - if opts.texthooker_enabled then + if resolve_texthooker_enabled(nil) then ensure_texthooker_running(function() end) end end) diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 7b20ec28..dc4489fb 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -531,6 +531,31 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "no", + socket_path = "/tmp/subminer-socket", + texthooker_enabled = "no", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for disabled texthooker auto-start scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + local start_call = find_start_call(recorded.async_calls) + assert_true(start_call ~= nil, "disabled texthooker auto-start should still issue --start command") + assert_true(not call_has_arg(start_call, "--texthooker"), "disabled texthooker should not include --texthooker on --start") + assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "disabled texthooker should not issue a helper texthooker command") +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 4bc079fc..17051fe5 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -80,6 +80,7 @@ test('default keybindings include primary and secondary subtitle track cycling o assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']); assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']); assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']); + assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']); }); test('default keybindings include fullscreen on F', () => { diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 26ac978f..3cf34241 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = { SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', YOUTUBE_PICKER_OPEN: '__youtube-picker-open', + PLAYLIST_BROWSER_OPEN: '__playlist-browser-open', } as const; export const DEFAULT_KEYBINDINGS: NonNullable = [ @@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable = [ command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], }, { key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, + { key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'KeyQ', command: ['quit'] }, diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index e286f126..3134f129 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { toMonthKey } from './immersion-tracker/maintenance'; import { enqueueWrite } from './immersion-tracker/queue'; +import { toDbTimestamp } from './immersion-tracker/query-shared'; import { Database, type DatabaseSync } from './immersion-tracker/sqlite'; import { nowMs as trackerNowMs } from './immersion-tracker/time'; import { @@ -185,7 +186,7 @@ test('destroy finalizes active session and persists final telemetry', async () = const db = new Database(dbPath); const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as { - ended_at_ms: number | null; + ended_at_ms: string | number | null; } | null; const telemetryCountRow = db .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry') @@ -193,7 +194,7 @@ test('destroy finalizes active session and persists final telemetry', async () = db.close(); assert.ok(sessionRow); - assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0); + assert.notEqual(sessionRow?.ended_at_ms, null); assert.ok(Number(telemetryCountRow.total) >= 2); } finally { tracker?.destroy(); @@ -504,7 +505,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal episodes_started: number; episodes_completed: number; anime_completed: number; - last_rebuilt_ms: number | null; + last_rebuilt_ms: string | number | null; } | null; const appliedSessions = rebuildApi.db .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions') @@ -518,7 +519,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal assert.equal(globalRow?.episodes_started, 2); assert.equal(globalRow?.episodes_completed, 2); assert.equal(globalRow?.anime_completed, 1); - assert.equal(globalRow?.last_rebuilt_ms, rebuild.rebuiltAtMs); + assert.equal(globalRow?.last_rebuilt_ms, toDbTimestamp(rebuild.rebuiltAtMs)); assert.equal(appliedSessions?.total, 2); } finally { tracker?.destroy(); @@ -629,97 +630,89 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a const startedAtMs = trackerNowMs() - 10_000; const sampleMs = startedAtMs + 5_000; - db.exec(` - INSERT INTO imm_anime ( - anime_id, - canonical_title, - normalized_title_key, - episodes_total, - CREATED_DATE, - LAST_UPDATE_DATE - ) VALUES ( - 1, - 'KonoSuba', - 'konosuba', - 10, - ${startedAtMs}, - ${startedAtMs} - ); + db.prepare( + ` + INSERT INTO imm_anime ( + anime_id, + canonical_title, + normalized_title_key, + episodes_total, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?) + `, + ).run(1, 'KonoSuba', 'konosuba', 10, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs)); - INSERT INTO imm_videos ( - video_id, - video_key, - canonical_title, - anime_id, - watched, - source_type, - duration_ms, - CREATED_DATE, - LAST_UPDATE_DATE - ) VALUES ( - 1, - 'local:/tmp/konosuba-s02e05.mkv', - 'KonoSuba S02E05', - 1, - 1, - 1, - 0, - ${startedAtMs}, - ${startedAtMs} - ); + db.prepare( + ` + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + anime_id, + watched, + source_type, + duration_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + 1, + 'local:/tmp/konosuba-s02e05.mkv', + 'KonoSuba S02E05', + 1, + 1, + 1, + 0, + toDbTimestamp(startedAtMs), + toDbTimestamp(startedAtMs), + ); - INSERT INTO imm_sessions ( - session_id, - session_uuid, - video_id, - started_at_ms, - status, - ended_media_ms, - CREATED_DATE, - LAST_UPDATE_DATE - ) VALUES ( - 1, - '11111111-1111-1111-1111-111111111111', - 1, - ${startedAtMs}, - 1, - 321000, - ${startedAtMs}, - ${sampleMs} - ); + db.prepare( + ` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + ended_media_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + 1, + '11111111-1111-1111-1111-111111111111', + 1, + toDbTimestamp(startedAtMs), + 1, + 321000, + toDbTimestamp(startedAtMs), + toDbTimestamp(sampleMs), + ); - INSERT INTO imm_session_telemetry ( - session_id, - sample_ms, - total_watched_ms, - active_watched_ms, - lines_seen, - tokens_seen, - cards_mined, - lookup_count, - lookup_hits, - pause_count, - pause_ms, - seek_forward_count, - seek_backward_count, - media_buffer_events - ) VALUES ( - 1, - ${sampleMs}, - 5000, - 4000, - 12, - 120, - 2, - 5, - 3, - 1, - 250, - 1, - 0, - 0 - ); - `); + db.prepare( + ` + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(1, toDbTimestamp(sampleMs), 5000, 4000, 12, 120, 2, 5, 3, 1, 250, 1, 0, 0); tracker.destroy(); tracker = new Ctor({ dbPath }); @@ -734,7 +727,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a `, ) .get() as { - ended_at_ms: number | null; + ended_at_ms: string | number | null; status: number; ended_media_ms: number | null; active_watched_ms: number; @@ -769,7 +762,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a .get() as { total: number } | null; assert.ok(sessionRow); - assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs); + assert.equal(sessionRow?.ended_at_ms, toDbTimestamp(sampleMs)); assert.equal(sessionRow?.status, 2); assert.equal(sessionRow?.ended_media_ms, 321_000); assert.equal(sessionRow?.active_watched_ms, 4000); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 159f1b6c..276cd3d6 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -309,6 +309,9 @@ export class ImmersionTrackerService { private readonly eventsRetentionMs: number; private readonly telemetryRetentionMs: number; private readonly sessionsRetentionMs: number; + private readonly eventsRetentionDays: number | null; + private readonly telemetryRetentionDays: number | null; + private readonly sessionsRetentionDays: number | null; private readonly dailyRollupRetentionMs: number; private readonly monthlyRollupRetentionMs: number; private readonly vacuumIntervalMs: number; @@ -365,46 +368,54 @@ export class ImmersionTrackerService { ); const retention = policy.retention ?? {}; - const daysToRetentionMs = ( + const daysToRetentionWindow = ( value: number | undefined, - fallbackMs: number, + fallbackDays: number, maxDays: number, - ): number => { - const fallbackDays = Math.floor(fallbackMs / 86_400_000); + ): { ms: number; days: number | null } => { const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays); - return resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000; + return { + ms: resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000, + days: resolvedDays === 0 ? null : resolvedDays, + }; }; - this.eventsRetentionMs = daysToRetentionMs( + const eventsRetention = daysToRetentionWindow( retention.eventsDays, - DEFAULT_EVENTS_RETENTION_MS, + 7, 3650, ); - this.telemetryRetentionMs = daysToRetentionMs( + const telemetryRetention = daysToRetentionWindow( retention.telemetryDays, - DEFAULT_TELEMETRY_RETENTION_MS, + 30, 3650, ); - this.sessionsRetentionMs = daysToRetentionMs( + const sessionsRetention = daysToRetentionWindow( retention.sessionsDays, - DEFAULT_SESSIONS_RETENTION_MS, + 30, 3650, ); - this.dailyRollupRetentionMs = daysToRetentionMs( + this.eventsRetentionMs = eventsRetention.ms; + this.eventsRetentionDays = eventsRetention.days; + this.telemetryRetentionMs = telemetryRetention.ms; + this.telemetryRetentionDays = telemetryRetention.days; + this.sessionsRetentionMs = sessionsRetention.ms; + this.sessionsRetentionDays = sessionsRetention.days; + this.dailyRollupRetentionMs = daysToRetentionWindow( retention.dailyRollupsDays, - DEFAULT_DAILY_ROLLUP_RETENTION_MS, + 365, 36500, - ); - this.monthlyRollupRetentionMs = daysToRetentionMs( + ).ms; + this.monthlyRollupRetentionMs = daysToRetentionWindow( retention.monthlyRollupsDays, - DEFAULT_MONTHLY_ROLLUP_RETENTION_MS, + 5 * 365, 36500, - ); - this.vacuumIntervalMs = daysToRetentionMs( + ).ms; + this.vacuumIntervalMs = daysToRetentionWindow( retention.vacuumIntervalDays, - DEFAULT_VACUUM_INTERVAL_MS, + 7, 3650, - ); + ).ms; this.db = new Database(this.dbPath); applyPragmas(this.db); ensureSchema(this.db); @@ -1604,6 +1615,9 @@ export class ImmersionTrackerService { eventsRetentionMs: this.eventsRetentionMs, telemetryRetentionMs: this.telemetryRetentionMs, sessionsRetentionMs: this.sessionsRetentionMs, + eventsRetentionDays: this.eventsRetentionDays ?? undefined, + telemetryRetentionDays: this.telemetryRetentionDays ?? undefined, + sessionsRetentionDays: this.sessionsRetentionDays ?? undefined, }); } if ( diff --git a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts index 5b877967..dc78c125 100644 --- a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts @@ -50,6 +50,7 @@ import { updateAnimeAnilistInfo, upsertCoverArt, } from '../query-maintenance.js'; +import { getLocalEpochDay } from '../query-shared.js'; import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js'; function makeDbPath(): string { @@ -360,9 +361,6 @@ test('split library helpers return anime/media session and analytics rows', () = try { const now = new Date(); - const todayLocalDay = Math.floor( - new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000, - ); const animeId = getOrCreateAnimeRecord(db, { parsedTitle: 'Library Anime', canonicalTitle: 'Library Anime', @@ -398,6 +396,7 @@ test('split library helpers return anime/media session and analytics rows', () = 0, ).getTime(); const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; + const todayLocalDay = getLocalEpochDay(db, startedAtMs); finalizeSessionMetrics(db, sessionId, startedAtMs, { endedAtMs: startedAtMs + 55_000, totalWatchedMs: 55_000, diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index de56cec9..dc8bc45e 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -37,6 +37,11 @@ import { getWordOccurrences, upsertCoverArt, } from '../query.js'; +import { + getShiftedLocalDaySec, + getStartOfLocalDayTimestamp, + toDbTimestamp, +} from '../query-shared.js'; import { SOURCE_TYPE_LOCAL, SOURCE_TYPE_REMOTE, @@ -81,29 +86,13 @@ function cleanupDbPath(dbPath: string): void { } } -function withMockDate(fixedDate: Date, run: (realDate: typeof Date) => T): T { - const realDate = Date; - const fixedDateMs = fixedDate.getTime(); - - class MockDate extends Date { - constructor(...args: any[]) { - if (args.length === 0) { - super(fixedDateMs); - } else { - super(...(args as [any?, any?, any?, any?, any?, any?, any?])); - } - } - - static override now(): number { - return fixedDateMs; - } - } - - globalThis.Date = MockDate as DateConstructor; +function withMockNowMs(fixedDateMs: string | number, run: () => T): T { + const previousNowMs = globalThis.__subminerTestNowMs; + globalThis.__subminerTestNowMs = fixedDateMs; try { - return run(realDate); + return run(); } finally { - globalThis.Date = realDate; + globalThis.__subminerTestNowMs = previousNowMs; } } @@ -613,7 +602,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => { ] as const) { stmts.telemetryInsertStmt.run( sessionId, - startedAtMs + 60_000, + `${startedAtMs + 60_000}`, activeWatchedMs, activeWatchedMs, 10, @@ -626,8 +615,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => { 0, 0, 0, - startedAtMs + 60_000, - startedAtMs + 60_000, + `${startedAtMs + 60_000}`, + `${startedAtMs + 60_000}`, ); db.prepare( @@ -644,7 +633,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => { WHERE session_id = ? `, ).run( - startedAtMs + activeWatchedMs, + `${startedAtMs + activeWatchedMs}`, activeWatchedMs, activeWatchedMs, 10, @@ -687,8 +676,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => { '名詞', null, null, - Math.floor(dayOneStart / 1000), - Math.floor(dayTwoStart / 1000), + String(Math.floor(dayOneStart / 1000)), + String(Math.floor(dayTwoStart / 1000)), ); const dashboard = getTrendsDashboard(db, 'all', 'day'); @@ -743,18 +732,51 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => { parseMetadataJson: null, }); - const beforeMidnight = new Date(2026, 2, 1, 23, 30).getTime(); - const afterMidnight = new Date(2026, 2, 2, 0, 30).getTime(); - const firstSessionId = startSessionRecord(db, videoId, beforeMidnight).sessionId; - const secondSessionId = startSessionRecord(db, videoId, afterMidnight).sessionId; + const boundaryMs = BigInt(getStartOfLocalDayTimestamp(db, '1772436600000')); + const beforeMidnight = (boundaryMs - 1n).toString(); + const afterMidnight = (boundaryMs + 1n).toString(); + const firstSessionId = 1; + const secondSessionId = 2; + const insertSession = db.prepare( + ` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ); + insertSession.run( + firstSessionId, + '11111111-1111-1111-1111-111111111111', + videoId, + beforeMidnight, + 1, + beforeMidnight, + beforeMidnight, + ); + insertSession.run( + secondSessionId, + '22222222-2222-2222-2222-222222222222', + videoId, + afterMidnight, + 1, + afterMidnight, + afterMidnight, + ); for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [ [firstSessionId, beforeMidnight, 100, 4], [secondSessionId, afterMidnight, 120, 6], ] as const) { + const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString(); stmts.telemetryInsertStmt.run( sessionId, - startedAtMs + 60_000, + endedAtMs, 60_000, 60_000, 1, @@ -767,8 +789,8 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => { 0, 0, 0, - startedAtMs + 60_000, - startedAtMs + 60_000, + endedAtMs, + endedAtMs, ); db.prepare( ` @@ -787,7 +809,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => { WHERE session_id = ? `, ).run( - startedAtMs + 60_000, + endedAtMs, 60_000, 60_000, 1, @@ -795,7 +817,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => { lookupCount, lookupCount, lookupCount, - startedAtMs + 60_000, + endedAtMs, sessionId, ); } @@ -816,7 +838,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); - withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => { + withMockNowMs('1772395200000', () => { try { ensureSchema(db); const stmts = createTrackerPreparedStatements(db); @@ -862,18 +884,50 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k parseMetadataJson: null, }); - const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime(); - const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime(); - const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId; - const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId; + const febStartedAtMs = '1771214400000'; + const marStartedAtMs = '1772384400000'; + const febSessionId = 1; + const marSessionId = 2; + const insertSession = db.prepare( + ` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ); + insertSession.run( + febSessionId, + '33333333-3333-3333-3333-333333333333', + febVideoId, + febStartedAtMs, + 1, + febStartedAtMs, + febStartedAtMs, + ); + insertSession.run( + marSessionId, + '44444444-4444-4444-4444-444444444444', + marVideoId, + marStartedAtMs, + 1, + marStartedAtMs, + marStartedAtMs, + ); for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [ [febSessionId, febStartedAtMs, 100, 2, 3], [marSessionId, marStartedAtMs, 120, 4, 5], ] as const) { + const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString(); stmts.telemetryInsertStmt.run( sessionId, - startedAtMs + 60_000, + endedAtMs, 30 * 60_000, 30 * 60_000, 4, @@ -886,8 +940,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k 0, 0, 0, - startedAtMs + 60_000, - startedAtMs + 60_000, + endedAtMs, + endedAtMs, ); db.prepare( ` @@ -907,7 +961,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k WHERE session_id = ? `, ).run( - startedAtMs + 60_000, + endedAtMs, 30 * 60_000, 30 * 60_000, 4, @@ -916,7 +970,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k yomitanLookupCount, yomitanLookupCount, yomitanLookupCount, - startedAtMs + 60_000, + endedAtMs, sessionId, ); } @@ -937,10 +991,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, ); - const febEpochDay = Math.floor(febStartedAtMs / 86_400_000); - const marEpochDay = Math.floor(marStartedAtMs / 86_400_000); - insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); - insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); + insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); + insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); @@ -958,8 +1010,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k '名詞', '', '', - Math.floor(febStartedAtMs / 1000), - Math.floor(febStartedAtMs / 1000), + (BigInt(febStartedAtMs) / 1000n).toString(), + (BigInt(febStartedAtMs) / 1000n).toString(), 1, ); db.prepare( @@ -976,8 +1028,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k '名詞', '', '', - Math.floor(marStartedAtMs / 1000), - Math.floor(marStartedAtMs / 1000), + (BigInt(marStartedAtMs) / 1000n).toString(), + (BigInt(marStartedAtMs) / 1000n).toString(), 1, ); @@ -1077,7 +1129,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', () const dbPath = makeDbPath(); const db = new Database(dbPath); - withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => { + withMockNowMs('1773601200000', () => { try { ensureSchema(db); @@ -1088,12 +1140,9 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', () ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ); - const justBeforeWeekBoundary = Math.floor( - new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000, - ); - const justAfterWeekBoundary = Math.floor( - new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000, - ); + const weekBoundarySec = getShiftedLocalDaySec(db, '1773601200000', -7); + const justBeforeWeekBoundary = weekBoundarySec - 1; + const justAfterWeekBoundary = weekBoundarySec + 1; insertWord.run( '境界前', '境界前', @@ -1102,8 +1151,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', () '名詞', '', '', - justBeforeWeekBoundary, - justBeforeWeekBoundary, + String(justBeforeWeekBoundary), + String(justBeforeWeekBoundary), 1, ); insertWord.run( @@ -1114,8 +1163,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', () '名詞', '', '', - justAfterWeekBoundary, - justAfterWeekBoundary, + String(justAfterWeekBoundary), + String(justAfterWeekBoundary), 1, ); @@ -1134,38 +1183,70 @@ test('getQueryHints counts new words by distinct headword first-seen time', () = try { ensureSchema(db); + withMockNowMs('1773601200000', () => { + const todayStartSec = 1_773_558_000; + const oneHourAgo = todayStartSec + 3_600; + const twoDaysAgo = todayStartSec - 2 * 86_400; - const now = new Date(); - const todayStartSec = - new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000; - const oneHourAgo = todayStartSec + 3_600; - const twoDaysAgo = todayStartSec - 2 * 86_400; + db.prepare( + ` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + '知る', + '知った', + 'しった', + 'verb', + '動詞', + '', + '', + String(oneHourAgo), + String(oneHourAgo), + 1, + ); + db.prepare( + ` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + '知る', + '知っている', + 'しっている', + 'verb', + '動詞', + '', + '', + String(oneHourAgo), + String(oneHourAgo), + 1, + ); + db.prepare( + ` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + '猫', + '猫', + 'ねこ', + 'noun', + '名詞', + '', + '', + String(twoDaysAgo), + String(twoDaysAgo), + 1, + ); - db.prepare( - ` - INSERT INTO imm_words ( - headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1); - db.prepare( - ` - INSERT INTO imm_words ( - headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1); - db.prepare( - ` - INSERT INTO imm_words ( - headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1); - - const hints = getQueryHints(db); - assert.equal(hints.newWordsToday, 1); - assert.equal(hints.newWordsThisWeek, 2); + const hints = getQueryHints(db); + assert.equal(hints.newWordsToday, 1); + assert.equal(hints.newWordsThisWeek, 2); + }); } finally { db.close(); cleanupDbPath(dbPath); @@ -2020,7 +2101,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () => try { ensureSchema(db); const stmts = createTrackerPreparedStatements(db); - const startedAtMs = Date.UTC(2025, 0, 1, 12, 0, 0); + const startedAtMs = 1_735_732_800_000; const videoId = getOrCreateVideoRecord(db, '/tmp/session-words-by-line.mkv', { canonicalTitle: 'Episode', sourcePath: '/tmp/session-words-by-line.mkv', diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index 82c5c8ad..e0aac328 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from './sqlite'; import { finalizeSessionRecord } from './session'; import { nowMs } from './time'; +import { toDbTimestamp } from './query-shared'; import type { LifetimeRebuildSummary, SessionState } from './types'; interface TelemetryRow { @@ -41,8 +42,8 @@ interface LifetimeAnimeStateRow { interface RetainedSessionRow { sessionId: number; videoId: number; - startedAtMs: number; - endedAtMs: number; + startedAtMs: number | string; + endedAtMs: number | string; lastMediaMs: number | null; totalWatchedMs: number; activeWatchedMs: number; @@ -65,25 +66,29 @@ function hasRetainedPriorSession( startedAtMs: number, currentSessionId: number, ): boolean { - return ( - Number( - ( - db - .prepare( - ` - SELECT COUNT(*) AS count - FROM imm_sessions - WHERE video_id = ? - AND ( - started_at_ms < ? - OR (started_at_ms = ? AND session_id < ?) - ) - `, + const row = db + .prepare( + ` + SELECT 1 AS found + FROM imm_sessions + WHERE video_id = ? + AND ( + CAST(started_at_ms AS REAL) < CAST(? AS REAL) + OR ( + CAST(started_at_ms AS REAL) = CAST(? AS REAL) + AND session_id < ? ) - .get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null - )?.count ?? 0, - ) > 0 - ); + ) + LIMIT 1 + `, + ) + .get( + videoId, + toDbTimestamp(startedAtMs), + toDbTimestamp(startedAtMs), + currentSessionId, + ) as { found: number } | null; + return Boolean(row); } function isFirstSessionForLocalDay( @@ -91,23 +96,37 @@ function isFirstSessionForLocalDay( currentSessionId: number, startedAtMs: number, ): boolean { - return ( - ( - db - .prepare( - ` - SELECT COUNT(*) AS count + const row = db + .prepare( + ` + SELECT 1 AS found FROM imm_sessions - WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime') + WHERE session_id != ? + AND CAST( + julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 + AS INTEGER + ) = CAST( + julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 + AS INTEGER + ) AND ( - started_at_ms < ? - OR (started_at_ms = ? AND session_id < ?) + CAST(started_at_ms AS REAL) < CAST(? AS REAL) + OR ( + CAST(started_at_ms AS REAL) = CAST(? AS REAL) + AND session_id < ? + ) ) - `, - ) - .get(startedAtMs, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null - )?.count === 0 - ); + LIMIT 1 + `, + ) + .get( + currentSessionId, + toDbTimestamp(startedAtMs), + toDbTimestamp(startedAtMs), + toDbTimestamp(startedAtMs), + currentSessionId, + ) as { found: number } | null; + return !row; } function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { @@ -131,14 +150,14 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { LAST_UPDATE_DATE = ? WHERE global_id = 1 `, - ).run(nowMs, nowMs); + ).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs)); } function rebuildLifetimeSummariesInternal( db: DatabaseSync, rebuiltAtMs: number, ): LifetimeRebuildSummary { - const sessions = db + const rows = db .prepare( ` SELECT @@ -146,6 +165,7 @@ function rebuildLifetimeSummariesInternal( video_id AS videoId, started_at_ms AS startedAtMs, ended_at_ms AS endedAtMs, + ended_media_ms AS lastMediaMs, total_watched_ms AS totalWatchedMs, active_watched_ms AS activeWatchedMs, lines_seen AS linesSeen, @@ -164,7 +184,19 @@ function rebuildLifetimeSummariesInternal( ORDER BY started_at_ms ASC, session_id ASC `, ) - .all() as RetainedSessionRow[]; + .all() as Array< + Omit & { + startedAtMs: number | string; + endedAtMs: number | string; + lastMediaMs: number | string | null; + } + >; + const sessions = rows.map((row) => ({ + ...row, + startedAtMs: row.startedAtMs, + endedAtMs: row.endedAtMs, + lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs), + })) as RetainedSessionRow[]; resetLifetimeSummaries(db, rebuiltAtMs); for (const session of sessions) { @@ -181,9 +213,9 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState { return { sessionId: row.sessionId, videoId: row.videoId, - startedAtMs: row.startedAtMs, + startedAtMs: row.startedAtMs as number, currentLineIndex: 0, - lastWallClockMs: row.endedAtMs, + lastWallClockMs: row.endedAtMs as number, lastMediaMs: row.lastMediaMs, lastPauseStartMs: null, isPaused: false, @@ -206,7 +238,7 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState { } function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] { - return db + const rows = db .prepare( ` SELECT @@ -241,20 +273,32 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] ORDER BY s.started_at_ms ASC, s.session_id ASC `, ) - .all() as RetainedSessionRow[]; + .all() as Array< + Omit & { + startedAtMs: number | string; + endedAtMs: number | string; + lastMediaMs: number | string | null; + } + >; + return rows.map((row) => ({ + ...row, + startedAtMs: row.startedAtMs, + endedAtMs: row.endedAtMs, + lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs), + })) as RetainedSessionRow[]; } function upsertLifetimeMedia( db: DatabaseSync, videoId: number, - nowMs: number, + nowMs: number | string, activeMs: number, cardsMined: number, linesSeen: number, tokensSeen: number, completed: number, - startedAtMs: number, - endedAtMs: number, + startedAtMs: number | string, + endedAtMs: number | string, ): void { db.prepare( ` @@ -310,15 +354,15 @@ function upsertLifetimeMedia( function upsertLifetimeAnime( db: DatabaseSync, animeId: number, - nowMs: number, + nowMs: number | string, activeMs: number, cardsMined: number, linesSeen: number, tokensSeen: number, episodesStartedDelta: number, episodesCompletedDelta: number, - startedAtMs: number, - endedAtMs: number, + startedAtMs: number | string, + endedAtMs: number | string, ): void { db.prepare( ` @@ -377,8 +421,9 @@ function upsertLifetimeAnime( export function applySessionLifetimeSummary( db: DatabaseSync, session: SessionState, - endedAtMs: number, + endedAtMs: number | string, ): void { + const updatedAtMs = toDbTimestamp(nowMs()); const applyResult = db .prepare( ` @@ -393,7 +438,7 @@ export function applySessionLifetimeSummary( ON CONFLICT(session_id) DO NOTHING `, ) - .run(session.sessionId, endedAtMs, nowMs(), nowMs()); + .run(session.sessionId, endedAtMs, updatedAtMs, updatedAtMs); if ((applyResult.changes ?? 0) <= 0) { return; @@ -468,7 +513,6 @@ export function applySessionLifetimeSummary( ? 1 : 0; - const updatedAtMs = nowMs(); db.prepare( ` UPDATE imm_lifetime_global diff --git a/src/core/services/immersion-tracker/maintenance.test.ts b/src/core/services/immersion-tracker/maintenance.test.ts index cdb62258..ab9af881 100644 --- a/src/core/services/immersion-tracker/maintenance.test.ts +++ b/src/core/services/immersion-tracker/maintenance.test.ts @@ -11,6 +11,7 @@ import { toMonthKey, } from './maintenance'; import { ensureSchema } from './storage'; +import { toDbTimestamp } from './query-shared'; function makeDbPath(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-maintenance-test-')); @@ -39,18 +40,18 @@ test('pruneRawRetention uses session retention separately from telemetry retenti INSERT INTO imm_videos ( video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs} + 1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_sessions ( session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE ) VALUES - (1, 'session-1', 1, ${staleEndedAtMs - 1_000}, ${staleEndedAtMs}, 2, ${staleEndedAtMs}, ${staleEndedAtMs}), - (2, 'session-2', 1, ${keptEndedAtMs - 1_000}, ${keptEndedAtMs}, 2, ${keptEndedAtMs}, ${keptEndedAtMs}); + (1, 'session-1', 1, '${toDbTimestamp(staleEndedAtMs - 1_000)}', '${toDbTimestamp(staleEndedAtMs)}', 2, '${toDbTimestamp(staleEndedAtMs)}', '${toDbTimestamp(staleEndedAtMs)}'), + (2, 'session-2', 1, '${toDbTimestamp(keptEndedAtMs - 1_000)}', '${toDbTimestamp(keptEndedAtMs)}', 2, '${toDbTimestamp(keptEndedAtMs)}', '${toDbTimestamp(keptEndedAtMs)}'); INSERT INTO imm_session_telemetry ( session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES - (1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}), - (2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs}); + (1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'), + (2, '${toDbTimestamp(nowMs - 10_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'); `); const result = pruneRawRetention(db, nowMs, { @@ -94,22 +95,22 @@ test('pruneRawRetention skips disabled retention windows', () => { INSERT INTO imm_videos ( video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs} + 1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_sessions ( session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, 'session-1', 1, ${nowMs - 1_000}, ${nowMs - 500}, 2, ${nowMs}, ${nowMs} + 1, 'session-1', 1, '${toDbTimestamp(nowMs - 1_000)}', '${toDbTimestamp(nowMs - 500)}', 2, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_session_telemetry ( session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, ${nowMs - 2_000}, 0, 0, ${nowMs}, ${nowMs} + 1, '${toDbTimestamp(nowMs - 2_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_session_events ( session_id, event_type, ts_ms, payload_json, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, 1, ${nowMs - 3_000}, '{}', ${nowMs}, ${nowMs} + 1, 1, '${toDbTimestamp(nowMs - 3_000)}', '{}', '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); `); @@ -161,17 +162,17 @@ test('raw retention keeps rollups and rollup retention prunes them separately', INSERT INTO imm_videos ( video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs} + 1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_sessions ( session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, 'session-1', 1, ${nowMs - 200_000_000}, ${nowMs - 199_999_000}, 2, ${nowMs}, ${nowMs} + 1, 'session-1', 1, '${toDbTimestamp(nowMs - 200_000_000)}', '${toDbTimestamp(nowMs - 199_999_000)}', 2, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_session_telemetry ( session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - 1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs} + 1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); INSERT INTO imm_daily_rollups ( rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, @@ -183,7 +184,7 @@ test('raw retention keeps rollups and rollup retention prunes them separately', rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE ) VALUES ( - ${oldMonth}, 1, 1, 10, 1, 1, 1, ${nowMs}, ${nowMs} + ${oldMonth}, 1, 1, 10, 1, 1, 1, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}' ); `); diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts index 1ed9bc9c..d225f0d2 100644 --- a/src/core/services/immersion-tracker/maintenance.ts +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -1,13 +1,13 @@ import type { DatabaseSync } from './sqlite'; import { nowMs } from './time'; -import { toDbMs } from './query-shared'; +import { subtractDbTimestamp, toDbTimestamp } from './query-shared'; const ROLLUP_STATE_KEY = 'last_rollup_sample_ms'; const DAILY_MS = 86_400_000; const ZERO_ID = 0; interface RollupStateRow { - state_value: number; + state_value: string; } interface RollupGroupRow { @@ -51,12 +51,25 @@ export function pruneRawRetention( eventsRetentionMs: number; telemetryRetentionMs: number; sessionsRetentionMs: number; + eventsRetentionDays?: number; + telemetryRetentionDays?: number; + sessionsRetentionDays?: number; }, ): RawRetentionResult { + const resolveCutoff = ( + retentionMs: number, + retentionDays: number | undefined, + ): string => { + if (retentionDays !== undefined) { + return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n); + } + return subtractDbTimestamp(currentMs, retentionMs); + }; + const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs) ? ( db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run( - toDbMs(currentMs - policy.eventsRetentionMs), + resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays), ) as { changes: number } ).changes : 0; @@ -64,14 +77,18 @@ export function pruneRawRetention( ? ( db .prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`) - .run(toDbMs(currentMs - policy.telemetryRetentionMs)) as { changes: number } + .run(resolveCutoff(policy.telemetryRetentionMs, policy.telemetryRetentionDays)) as { + changes: number; + } ).changes : 0; const deletedEndedSessions = Number.isFinite(policy.sessionsRetentionMs) ? ( db .prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) - .run(toDbMs(currentMs - policy.sessionsRetentionMs)) as { changes: number } + .run(resolveCutoff(policy.sessionsRetentionMs, policy.sessionsRetentionDays)) as { + changes: number; + } ).changes : 0; @@ -115,14 +132,14 @@ export function pruneRollupRetention( }; } -function getLastRollupSampleMs(db: DatabaseSync): number { +function getLastRollupSampleMs(db: DatabaseSync): string { const row = db .prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`) .get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null; - return row ? Number(row.state_value) : ZERO_ID; + return row ? row.state_value : String(ZERO_ID); } -function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint): void { +function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint | string): void { db.prepare( `INSERT INTO imm_rollup_state (state_key, state_value) VALUES (?, ?) @@ -141,7 +158,7 @@ function resetRollups(db: DatabaseSync): void { function upsertDailyRollupsForGroups( db: DatabaseSync, groups: Array<{ rollupDay: number; videoId: number }>, - rollupNowMs: bigint, + rollupNowMs: number | string, ): void { if (groups.length === 0) { return; @@ -217,7 +234,7 @@ function upsertDailyRollupsForGroups( function upsertMonthlyRollupsForGroups( db: DatabaseSync, groups: Array<{ rollupMonth: number; videoId: number }>, - rollupNowMs: bigint, + rollupNowMs: number | string, ): void { if (groups.length === 0) { return; @@ -268,7 +285,7 @@ function upsertMonthlyRollupsForGroups( function getAffectedRollupGroups( db: DatabaseSync, - lastRollupSampleMs: number, + lastRollupSampleMs: number | string, ): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> { return ( db @@ -321,7 +338,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo return; } - const rollupNowMs = toDbMs(nowMs()); + const rollupNowMs = toDbTimestamp(nowMs()); const lastRollupSampleMs = getLastRollupSampleMs(db); const maxSampleRow = db @@ -356,7 +373,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo try { upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs); upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs); - setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID)); + setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID)); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); @@ -365,7 +382,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo } export function rebuildRollupsInTransaction(db: DatabaseSync): void { - const rollupNowMs = toDbMs(nowMs()); + const rollupNowMs = toDbTimestamp(nowMs()); const maxSampleRow = db .prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry') .get() as unknown as RollupTelemetryResult | null; @@ -377,7 +394,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void { const affectedGroups = getAffectedRollupGroups(db, ZERO_ID); if (affectedGroups.length === 0) { - setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID)); + setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID)); return; } @@ -396,7 +413,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void { upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs); upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs); - setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID)); + setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID)); } export function runOptimizeMaintenance(db: DatabaseSync): void { diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts index 004a7139..5e6ac68d 100644 --- a/src/core/services/immersion-tracker/query-lexical.ts +++ b/src/core/services/immersion-tracker/query-lexical.ts @@ -12,6 +12,7 @@ import type { WordDetailRow, WordOccurrenceRow, } from './types'; +import { fromDbTimestamp } from './query-shared'; export function getVocabularyStats( db: DatabaseSync, @@ -134,7 +135,11 @@ export function getSessionEvents( SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ? `); - return stmt.all(sessionId, limit) as SessionEventRow[]; + const rows = stmt.all(sessionId, limit) as Array; + return rows.map((row) => ({ + ...row, + tsMs: fromDbTimestamp(row.tsMs) ?? 0, + })); } const placeholders = eventTypes.map(() => '?').join(', '); @@ -145,7 +150,13 @@ export function getSessionEvents( ORDER BY ts_ms ASC LIMIT ? `); - return stmt.all(sessionId, ...eventTypes, limit) as SessionEventRow[]; + const rows = stmt.all(sessionId, ...eventTypes, limit) as Array; + return rows.map((row) => ({ + ...row, + tsMs: fromDbTimestamp(row.tsMs) ?? 0, + })); } export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null { diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts index cd03d6b6..13df7d1d 100644 --- a/src/core/services/immersion-tracker/query-library.ts +++ b/src/core/services/immersion-tracker/query-library.ts @@ -16,10 +16,10 @@ import type { StreakCalendarRow, WatchTimePerAnimeRow, } from './types'; -import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared'; +import { ACTIVE_SESSION_METRICS_CTE, fromDbTimestamp, resolvedCoverBlobExpr } from './query-shared'; export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { - return db + const rows = db .prepare( ` SELECT @@ -40,11 +40,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { ORDER BY totalActiveMs DESC, lm.last_watched_ms DESC, canonicalTitle ASC `, ) - .all() as unknown as AnimeLibraryRow[]; + .all() as Array; + return rows.map((row) => ({ + ...row, + lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0, + })); } export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null { - return db + const row = db .prepare( ` ${ACTIVE_SESSION_METRICS_CTE} @@ -75,7 +79,13 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo GROUP BY a.anime_id `, ) - .get(animeId) as unknown as AnimeDetailRow | null; + .get(animeId) as (AnimeDetailRow & { lastWatchedMs: number | string }) | null; + return row + ? { + ...row, + lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0, + } + : null; } export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] { @@ -98,7 +108,7 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime } export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] { - return db + const rows = db .prepare( ` ${ACTIVE_SESSION_METRICS_CTE} @@ -168,11 +178,21 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod v.video_id ASC `, ) - .all(animeId) as unknown as AnimeEpisodeRow[]; + .all(animeId) as Array< + AnimeEpisodeRow & { + endedMediaMs: number | string | null; + lastWatchedMs: number | string; + } + >; + return rows.map((row) => ({ + ...row, + endedMediaMs: fromDbTimestamp(row.endedMediaMs), + lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0, + })); } export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { - return db + const rows = db .prepare( ` SELECT @@ -205,7 +225,11 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { ORDER BY lm.last_watched_ms DESC `, ) - .all() as unknown as MediaLibraryRow[]; + .all() as Array; + return rows.map((row) => ({ + ...row, + lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0, + })); } export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null { @@ -253,7 +277,7 @@ export function getMediaSessions( videoId: number, limit = 100, ): SessionSummaryQueryRow[] { - return db + const rows = db .prepare( ` ${ACTIVE_SESSION_METRICS_CTE} @@ -279,7 +303,17 @@ export function getMediaSessions( LIMIT ? `, ) - .all(videoId, limit) as unknown as SessionSummaryQueryRow[]; + .all(videoId, limit) as Array< + SessionSummaryQueryRow & { + startedAtMs: number | string; + endedAtMs: number | string | null; + } + >; + return rows.map((row) => ({ + ...row, + startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0, + endedAtMs: fromDbTimestamp(row.endedAtMs), + })); } export function getMediaDailyRollups( @@ -351,7 +385,7 @@ export function getAnimeDailyRollups( export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null { const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab'); - return db + const row = db .prepare( ` SELECT @@ -372,12 +406,18 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow LIMIT 1 `, ) - .get(animeId) as unknown as MediaArtRow | null; + .get(animeId) as (MediaArtRow & { fetchedAtMs: number | string }) | null; + return row + ? { + ...row, + fetchedAtMs: fromDbTimestamp(row.fetchedAtMs) ?? 0, + } + : null; } export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null { const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab'); - return db + const row = db .prepare( ` SELECT @@ -394,7 +434,13 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu WHERE a.video_id = ? `, ) - .get(videoId) as unknown as MediaArtRow | null; + .get(videoId) as (MediaArtRow & { fetchedAtMs: number | string }) | null; + return row + ? { + ...row, + fetchedAtMs: fromDbTimestamp(row.fetchedAtMs) ?? 0, + } + : null; } export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] { @@ -510,7 +556,7 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): } export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] { - return db + const rows = db .prepare( ` ${ACTIVE_SESSION_METRICS_CTE} @@ -533,7 +579,17 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu ORDER BY s.started_at_ms DESC `, ) - .all(videoId) as SessionSummaryQueryRow[]; + .all(videoId) as Array< + SessionSummaryQueryRow & { + startedAtMs: number | string; + endedAtMs: number | string | null; + } + >; + return rows.map((row) => ({ + ...row, + startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0, + endedAtMs: fromDbTimestamp(row.endedAtMs), + })); } export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] { @@ -552,7 +608,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode .all(videoId) as Array<{ eventId: number; sessionId: number; - tsMs: number; + tsMs: number | string; cardsDelta: number; payloadJson: string | null; }>; @@ -568,7 +624,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode return { eventId: row.eventId, sessionId: row.sessionId, - tsMs: row.tsMs, + tsMs: fromDbTimestamp(row.tsMs) ?? 0, cardsDelta: row.cardsDelta, noteIds, }; diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index 022386fe..00b687c2 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -17,6 +17,7 @@ import { getAffectedWordIdsForVideo, refreshLexicalAggregates, toDbMs, + toDbTimestamp, } from './query-shared'; type CleanupVocabularyRow = { @@ -351,7 +352,7 @@ export function upsertCoverArt( ) .get(videoId) as { coverBlobHash: string | null } | undefined; const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl); - const fetchedAtMs = toDbMs(nowMs()); + const fetchedAtMs = toDbTimestamp(nowMs()); const coverBlob = normalizeCoverBlobBytes(art.coverBlob); const computedCoverBlobHash = coverBlob && coverBlob.length > 0 @@ -444,7 +445,7 @@ export function updateAnimeAnilistInfo( info.titleEnglish, info.titleNative, info.episodesTotal, - toDbMs(nowMs()), + toDbTimestamp(nowMs()), row.anime_id, ); } @@ -452,7 +453,7 @@ export function updateAnimeAnilistInfo( export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run( watched ? 1 : 0, - toDbMs(nowMs()), + toDbTimestamp(nowMs()), videoId, ); } diff --git a/src/core/services/immersion-tracker/query-sessions.ts b/src/core/services/immersion-tracker/query-sessions.ts index 50224bd8..2d068656 100644 --- a/src/core/services/immersion-tracker/query-sessions.ts +++ b/src/core/services/immersion-tracker/query-sessions.ts @@ -1,11 +1,17 @@ import type { DatabaseSync } from './sqlite'; -import { nowMs } from './time'; import type { ImmersionSessionRollupRow, SessionSummaryQueryRow, SessionTimelineRow, } from './types'; -import { ACTIVE_SESSION_METRICS_CTE } from './query-shared'; +import { + ACTIVE_SESSION_METRICS_CTE, + currentDbTimestamp, + fromDbTimestamp, + getLocalEpochDay, + getShiftedLocalDaySec, + toDbTimestamp, +} from './query-shared'; export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] { const prepared = db.prepare(` @@ -33,7 +39,15 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar ORDER BY s.started_at_ms DESC LIMIT ? `); - return prepared.all(limit) as unknown as SessionSummaryQueryRow[]; + const rows = prepared.all(limit) as Array; + return rows.map((row) => ({ + ...row, + startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0, + endedAtMs: fromDbTimestamp(row.endedAtMs), + })); } export function getSessionTimeline( @@ -55,11 +69,23 @@ export function getSessionTimeline( `; if (limit === undefined) { - return db.prepare(select).all(sessionId) as unknown as SessionTimelineRow[]; + const rows = db.prepare(select).all(sessionId) as Array; + return rows.map((row) => ({ + ...row, + sampleMs: fromDbTimestamp(row.sampleMs) ?? 0, + })); } - return db + const rows = db .prepare(`${select}\n LIMIT ?`) - .all(sessionId, limit) as unknown as SessionTimelineRow[]; + .all(sessionId, limit) as Array; + return rows.map((row) => ({ + ...row, + sampleMs: fromDbTimestamp(row.sampleMs) ?? 0, + })); } /** Returns all distinct headwords in the vocabulary table (global). */ @@ -129,35 +155,50 @@ export function getSessionWordsByLine( } function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } { - const now = new Date(); - const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000; - const weekAgoSec = - new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000; + const currentTimestamp = currentDbTimestamp(); + const todayStartSec = getShiftedLocalDaySec(db, currentTimestamp, 0); + const weekAgoSec = getShiftedLocalDaySec(db, currentTimestamp, -7); - const row = db + const rows = db .prepare( ` - WITH headword_first_seen AS ( - SELECT - headword, - MIN(first_seen) AS first_seen - FROM imm_words - WHERE first_seen IS NOT NULL - AND headword IS NOT NULL - AND headword != '' - GROUP BY headword - ) SELECT - COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today, - COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week - FROM headword_first_seen + headword, + first_seen AS firstSeen + FROM imm_words + WHERE first_seen IS NOT NULL + AND headword IS NOT NULL + AND headword != '' `, ) - .get(todayStartSec, weekAgoSec) as { today: number; week: number } | null; + .all() as Array<{ headword: string; firstSeen: number | string }>; + + const firstSeenByHeadword = new Map(); + for (const row of rows) { + const firstSeen = Number(row.firstSeen); + if (!Number.isFinite(firstSeen)) { + continue; + } + const previous = firstSeenByHeadword.get(row.headword); + if (previous === undefined || firstSeen < previous) { + firstSeenByHeadword.set(row.headword, firstSeen); + } + } + + let today = 0; + let week = 0; + for (const firstSeen of firstSeenByHeadword.values()) { + if (firstSeen >= todayStartSec) { + today += 1; + } + if (firstSeen >= weekAgoSec) { + week += 1; + } + } return { - newWordsToday: Number(row?.today ?? 0), - newWordsThisWeek: Number(row?.week ?? 0), + newWordsToday: today, + newWordsThisWeek: week, }; } @@ -203,10 +244,8 @@ export function getQueryHints(db: DatabaseSync): { animeCompleted: number; } | null; - const now = new Date(); - const todayLocal = Math.floor( - new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000, - ); + const currentTimestamp = currentDbTimestamp(); + const todayLocal = getLocalEpochDay(db, currentTimestamp); const episodesToday = ( @@ -215,13 +254,16 @@ export function getQueryHints(db: DatabaseSync): { ` SELECT COUNT(DISTINCT s.video_id) AS count FROM imm_sessions s - WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? + WHERE CAST( + julianday(CAST(s.started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 + AS INTEGER + ) = ? `, ) .get(todayLocal) as { count: number } )?.count ?? 0; - const thirtyDaysAgoMs = nowMs() - 30 * 86400000; + const thirtyDaysAgoMs = getShiftedLocalDaySec(db, currentTimestamp, -30).toString() + '000'; const activeAnimeCount = ( db diff --git a/src/core/services/immersion-tracker/query-shared.ts b/src/core/services/immersion-tracker/query-shared.ts index c5d8312b..2634ce6e 100644 --- a/src/core/services/immersion-tracker/query-shared.ts +++ b/src/core/services/immersion-tracker/query-shared.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from './sqlite'; +import { nowMs } from './time'; export const ACTIVE_SESSION_METRICS_CTE = ` WITH active_session_metrics AS ( @@ -280,3 +281,213 @@ export function toDbMs(ms: number | bigint): bigint { } return BigInt(Math.trunc(ms)); } + +function normalizeTimestampString(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new TypeError(`Invalid database timestamp: ${value}`); + } + + const integerLike = /^(-?)(\d+)(?:\.0+)?$/.exec(trimmed); + if (integerLike) { + const sign = integerLike[1] ?? ''; + const digits = (integerLike[2] ?? '0').replace(/^0+(?=\d)/, ''); + return `${sign}${digits || '0'}`; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + throw new TypeError(`Invalid database timestamp: ${value}`); + } + return JSON.stringify(Math.trunc(parsed)); +} + +export function toDbTimestamp(ms: number | bigint | string): string { + const normalizeParsed = (parsed: number): string => JSON.stringify(Math.trunc(parsed)); + + if (typeof ms === 'bigint') { + return ms.toString(); + } + if (typeof ms === 'string') { + return normalizeTimestampString(ms); + } + if (!Number.isFinite(ms)) { + throw new TypeError(`Invalid database timestamp: ${ms}`); + } + return normalizeParsed(ms); +} + +export function currentDbTimestamp(): string { + const testNowMs = globalThis.__subminerTestNowMs; + if (typeof testNowMs === 'string') { + return normalizeTimestampString(testNowMs); + } + if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) { + return toDbTimestamp(testNowMs); + } + return toDbTimestamp(nowMs()); +} + +export function subtractDbTimestamp( + timestampMs: number | bigint | string, + deltaMs: number | bigint, +): string { + return (BigInt(toDbTimestamp(timestampMs)) - BigInt(deltaMs)).toString(); +} + +export function fromDbTimestamp(ms: number | bigint | string | null | undefined): number | null { + if (ms === null || ms === undefined) { + return null; + } + if (typeof ms === 'number') { + return ms; + } + if (typeof ms === 'bigint') { + return Number(ms); + } + return Number(ms); +} + +function getNumericCalendarValue( + db: DatabaseSync, + sql: string, + timestampMs: number | bigint | string, +): number { + const row = db.prepare(sql).get(toDbTimestamp(timestampMs)) as + | { value: number | string | null } + | undefined; + return Number(row?.value ?? 0); +} + +export function getLocalEpochDay( + db: DatabaseSync, + timestampMs: number | bigint | string, +): number { + return getNumericCalendarValue( + db, + ` + SELECT CAST( + julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 + AS INTEGER + ) AS value + `, + timestampMs, + ); +} + +export function getLocalMonthKey( + db: DatabaseSync, + timestampMs: number | bigint | string, +): number { + return getNumericCalendarValue( + db, + ` + SELECT CAST( + strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') + AS INTEGER + ) AS value + `, + timestampMs, + ); +} + +export function getLocalDayOfWeek( + db: DatabaseSync, + timestampMs: number | bigint | string, +): number { + return getNumericCalendarValue( + db, + ` + SELECT CAST( + strftime('%w', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') + AS INTEGER + ) AS value + `, + timestampMs, + ); +} + +export function getLocalHourOfDay( + db: DatabaseSync, + timestampMs: number | bigint | string, +): number { + return getNumericCalendarValue( + db, + ` + SELECT CAST( + strftime('%H', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') + AS INTEGER + ) AS value + `, + timestampMs, + ); +} + +export function getStartOfLocalDaySec( + db: DatabaseSync, + timestampMs: number | bigint | string, +): number { + return getNumericCalendarValue( + db, + ` + SELECT CAST( + strftime( + '%s', + CAST(? AS REAL) / 1000, + 'unixepoch', + 'localtime', + 'start of day', + 'utc' + ) AS INTEGER + ) AS value + `, + timestampMs, + ); +} + +export function getStartOfLocalDayTimestamp( + db: DatabaseSync, + timestampMs: number | bigint | string, +): string { + return `${getStartOfLocalDaySec(db, timestampMs)}000`; +} + +export function getShiftedLocalDayTimestamp( + db: DatabaseSync, + timestampMs: number | bigint | string, + dayOffset: number, +): string { + const normalizedDayOffset = Math.trunc(dayOffset); + const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`; + const row = db + .prepare( + ` + SELECT strftime( + '%s', + CAST(? AS REAL) / 1000, + 'unixepoch', + 'localtime', + 'start of day', + '${modifier}', + 'utc' + ) AS value + `, + ) + .get(toDbTimestamp(timestampMs)) as { value: string | number | null } | undefined; + return `${row?.value ?? '0'}000`; +} + +export function getShiftedLocalDaySec( + db: DatabaseSync, + timestampMs: number | bigint | string, + dayOffset: number, +): number { + return Number(BigInt(getShiftedLocalDayTimestamp(db, timestampMs, dayOffset)) / 1000n); +} + +export function getStartOfLocalDayMs( + db: DatabaseSync, + timestampMs: number | bigint | string, +): number { + return getStartOfLocalDaySec(db, timestampMs) * 1000; +} diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts index c72fae4f..4e7d2dcb 100644 --- a/src/core/services/immersion-tracker/query-trends.ts +++ b/src/core/services/immersion-tracker/query-trends.ts @@ -1,6 +1,16 @@ import type { DatabaseSync } from './sqlite'; import type { ImmersionSessionRollupRow } from './types'; -import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared'; +import { + ACTIVE_SESSION_METRICS_CTE, + currentDbTimestamp, + getLocalDayOfWeek, + getLocalEpochDay, + getLocalHourOfDay, + getLocalMonthKey, + getShiftedLocalDayTimestamp, + makePlaceholders, + toDbTimestamp, +} from './query-shared'; import { getDailyRollups, getMonthlyRollups } from './query-sessions'; type TrendRange = '7d' | '30d' | '90d' | 'all'; @@ -19,6 +29,10 @@ interface TrendPerAnimePoint { interface TrendSessionMetricRow { startedAtMs: number; + epochDay: number; + monthKey: number; + dayOfWeek: number; + hourOfDay: number; videoId: number | null; canonicalTitle: string | null; animeTitle: string | null; @@ -73,64 +87,64 @@ const TREND_DAY_LIMITS: Record, number> = { '90d': 90, }; +const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function getTrendDayLimit(range: TrendRange): number { return range === 'all' ? 365 : TREND_DAY_LIMITS[range]; } -function getTrendMonthlyLimit(range: TrendRange): number { +function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number { if (range === 'all') { return 120; } - const now = new Date(); - const cutoff = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate() - (TREND_DAY_LIMITS[range] - 1), - ); - return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1); + const currentTimestamp = currentDbTimestamp(); + const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0); + const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1)); + const currentMonthKey = getLocalMonthKey(db, todayStartMs); + const cutoffMonthKey = getLocalMonthKey(db, cutoffMs); + const currentYear = Math.floor(currentMonthKey / 100); + const currentMonth = currentMonthKey % 100; + const cutoffYear = Math.floor(cutoffMonthKey / 100); + const cutoffMonth = cutoffMonthKey % 100; + return Math.max(1, (currentYear - cutoffYear) * 12 + currentMonth - cutoffMonth + 1); } -function getTrendCutoffMs(range: TrendRange): number | null { +function getTrendCutoffMs(db: DatabaseSync, range: TrendRange): string | null { if (range === 'all') { return null; } - const dayLimit = getTrendDayLimit(range); - const now = new Date(); - const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - return localMidnight - (dayLimit - 1) * 86_400_000; + return getShiftedLocalDayTimestamp(db, currentDbTimestamp(), -(getTrendDayLimit(range) - 1)); +} + +function dayPartsFromEpochDay(epochDay: number): { year: number; month: number; day: number } { + const z = epochDay + 719468; + const era = Math.floor(z / 146097); + const doe = z - era * 146097; + const yoe = Math.floor( + (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365, + ); + let year = yoe + era * 400; + const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); + const mp = Math.floor((5 * doy + 2) / 153); + const day = doy - Math.floor((153 * mp + 2) / 5) + 1; + const month = mp < 10 ? mp + 3 : mp - 9; + if (month <= 2) { + year += 1; + } + return { year, month, day }; } function makeTrendLabel(value: number): string { if (value > 100_000) { const year = Math.floor(value / 100); const month = value % 100; - return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, { - month: 'short', - year: '2-digit', - }); + return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`; } - return new Date(value * 86_400_000).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }); -} - -function getLocalEpochDay(timestampMs: number): number { - const date = new Date(timestampMs); - return Math.floor((timestampMs - date.getTimezoneOffset() * 60_000) / 86_400_000); -} - -function getLocalDateForEpochDay(epochDay: number): Date { - const utcDate = new Date(epochDay * 86_400_000); - return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000); -} - -function getLocalMonthKey(timestampMs: number): number { - const date = new Date(timestampMs); - return date.getFullYear() * 100 + date.getMonth() + 1; + const { month, day } = dayPartsFromEpochDay(value); + return `${MONTH_NAMES[month - 1]} ${day}`; } function getTrendSessionWordCount(session: Pick): number { @@ -189,7 +203,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) { function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { const totals = new Array(7).fill(0); for (const session of sessions) { - totals[new Date(session.startedAtMs).getDay()] += session.activeWatchedMs; + totals[session.dayOfWeek] += session.activeWatchedMs; } return DAY_NAMES.map((name, index) => ({ label: name, @@ -200,7 +214,7 @@ function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChar function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { const totals = new Array(24).fill(0); for (const session of sessions) { - totals[new Date(session.startedAtMs).getHours()] += session.activeWatchedMs; + totals[session.hourOfDay] += session.activeWatchedMs; } return totals.map((ms, index) => ({ label: `${String(index).padStart(2, '0')}:00`, @@ -209,10 +223,8 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin } function dayLabel(epochDay: number): string { - return getLocalDateForEpochDay(epochDay).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }); + const { month, day } = dayPartsFromEpochDay(epochDay); + return `${MONTH_NAMES[month - 1]} ${day}`; } function buildSessionSeriesByDay( @@ -221,8 +233,7 @@ function buildSessionSeriesByDay( ): TrendChartPoint[] { const byDay = new Map(); for (const session of sessions) { - const epochDay = getLocalEpochDay(session.startedAtMs); - byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session)); + byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session)); } return Array.from(byDay.entries()) .sort(([left], [right]) => left - right) @@ -235,8 +246,7 @@ function buildSessionSeriesByMonth( ): TrendChartPoint[] { const byMonth = new Map(); for (const session of sessions) { - const monthKey = getLocalMonthKey(session.startedAtMs); - byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session)); + byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session)); } return Array.from(byMonth.entries()) .sort(([left], [right]) => left - right) @@ -251,8 +261,7 @@ function buildLookupsPerHundredWords( const wordsByBucket = new Map(); for (const session of sessions) { - const bucketKey = - groupBy === 'month' ? getLocalMonthKey(session.startedAtMs) : getLocalEpochDay(session.startedAtMs); + const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay; lookupsByBucket.set( bucketKey, (lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount, @@ -282,7 +291,7 @@ function buildPerAnimeFromSessions( for (const session of sessions) { const animeTitle = resolveTrendAnimeTitle(session); - const epochDay = getLocalEpochDay(session.startedAtMs); + const epochDay = session.epochDay; const dayMap = byAnime.get(animeTitle) ?? new Map(); dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session)); byAnime.set(animeTitle, dayMap); @@ -303,7 +312,7 @@ function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): Tren for (const session of sessions) { const animeTitle = resolveTrendAnimeTitle(session); - const epochDay = getLocalEpochDay(session.startedAtMs); + const epochDay = session.epochDay; const lookupMap = lookups.get(animeTitle) ?? new Map(); lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount); @@ -498,9 +507,10 @@ function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): function getTrendSessionMetrics( db: DatabaseSync, - cutoffMs: number | null, + cutoffMs: string | null, ): TrendSessionMetricRow[] { const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?'; + const cutoffValue = cutoffMs === null ? null : toDbTimestamp(cutoffMs); const prepared = db.prepare(` ${ACTIVE_SESSION_METRICS_CTE} SELECT @@ -520,14 +530,27 @@ function getTrendSessionMetrics( ORDER BY s.started_at_ms ASC `); - return (cutoffMs === null ? prepared.all() : prepared.all(cutoffMs)) as TrendSessionMetricRow[]; + const rows = (cutoffValue === null ? prepared.all() : prepared.all(cutoffValue)) as Array< + TrendSessionMetricRow & { startedAtMs: number | string } + >; + return rows.map((row) => ({ + ...row, + startedAtMs: 0, + epochDay: getLocalEpochDay(db, row.startedAtMs), + monthKey: getLocalMonthKey(db, row.startedAtMs), + dayOfWeek: getLocalDayOfWeek(db, row.startedAtMs), + hourOfDay: getLocalHourOfDay(db, row.startedAtMs), + })); } -function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] { +function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] { const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const prepared = db.prepare(` SELECT - CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay, + CAST( + julianday(CAST(first_seen AS REAL), 'unixepoch', 'localtime') - 2440587.5 + AS INTEGER + ) AS epochDay, COUNT(*) AS wordCount FROM imm_words WHERE first_seen IS NOT NULL @@ -537,7 +560,7 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh `); const rows = ( - cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000)) + cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString()) ) as Array<{ epochDay: number; wordCount: number; @@ -549,11 +572,14 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh })); } -function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] { +function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] { const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const prepared = db.prepare(` SELECT - CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey, + CAST( + strftime('%Y%m', CAST(first_seen AS REAL), 'unixepoch', 'localtime') + AS INTEGER + ) AS monthKey, COUNT(*) AS wordCount FROM imm_words WHERE first_seen IS NOT NULL @@ -563,7 +589,7 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): Trend `); const rows = ( - cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000)) + cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString()) ) as Array<{ monthKey: number; wordCount: number; @@ -581,8 +607,8 @@ export function getTrendsDashboard( groupBy: TrendGroupBy = 'day', ): TrendsDashboardQueryResult { const dayLimit = getTrendDayLimit(range); - const monthlyLimit = getTrendMonthlyLimit(range); - const cutoffMs = getTrendCutoffMs(range); + const monthlyLimit = getTrendMonthlyLimit(db, range); + const cutoffMs = getTrendCutoffMs(db, range); const useMonthlyBuckets = groupBy === 'month'; const dailyRollups = getDailyRollups(db, dayLimit); const monthlyRollups = getMonthlyRollups(db, monthlyLimit); diff --git a/src/core/services/immersion-tracker/session.ts b/src/core/services/immersion-tracker/session.ts index b0484f65..787c246d 100644 --- a/src/core/services/immersion-tracker/session.ts +++ b/src/core/services/immersion-tracker/session.ts @@ -4,7 +4,7 @@ import { createInitialSessionState } from './reducer'; import { nowMs } from './time'; import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types'; import type { SessionState } from './types'; -import { toDbMs } from './query-shared'; +import { toDbMs, toDbTimestamp } from './query-shared'; export function startSessionRecord( db: DatabaseSync, @@ -25,10 +25,10 @@ export function startSessionRecord( .run( sessionUuid, videoId, - toDbMs(startedAtMs), + toDbTimestamp(startedAtMs), SESSION_STATUS_ACTIVE, - toDbMs(startedAtMs), - toDbMs(createdAtMs), + toDbTimestamp(startedAtMs), + toDbTimestamp(createdAtMs), ); const sessionId = Number(result.lastInsertRowid); return { @@ -40,7 +40,7 @@ export function startSessionRecord( export function finalizeSessionRecord( db: DatabaseSync, sessionState: SessionState, - endedAtMs = nowMs(), + endedAtMs: number | string = nowMs(), ): void { db.prepare( ` @@ -66,7 +66,7 @@ export function finalizeSessionRecord( WHERE session_id = ? `, ).run( - toDbMs(endedAtMs), + toDbTimestamp(endedAtMs), SESSION_STATUS_ENDED, sessionState.lastMediaMs === null ? null : toDbMs(sessionState.lastMediaMs), sessionState.totalWatchedMs, @@ -82,7 +82,7 @@ export function finalizeSessionRecord( sessionState.seekForwardCount, sessionState.seekBackwardCount, sessionState.mediaBufferEvents, - toDbMs(nowMs()), + toDbTimestamp(nowMs()), sessionState.sessionId, ); } diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index d00e09bd..d84a8496 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -143,10 +143,10 @@ test('ensureSchema creates immersion core tables', () => { const rollupStateRow = db .prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?') .get('last_rollup_sample_ms') as { - state_value: number; + state_value: string; } | null; assert.ok(rollupStateRow); - assert.equal(rollupStateRow?.state_value, 0); + assert.equal(Number(rollupStateRow?.state_value ?? 0), 0); } finally { db.close(); cleanupDbPath(dbPath); @@ -965,12 +965,12 @@ test('start/finalize session updates ended_at and status', () => { const row = db .prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?') .get(sessionId) as { - ended_at_ms: number | null; + ended_at_ms: string | null; status: number; } | null; assert.ok(row); - assert.equal(row?.ended_at_ms, endedAtMs); + assert.equal(Number(row?.ended_at_ms ?? 0), endedAtMs); assert.equal(row?.status, SESSION_STATUS_ENDED); } finally { db.close(); diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index ce8833cc..98496868 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -4,7 +4,7 @@ import type { DatabaseSync } from './sqlite'; import { nowMs } from './time'; import { SCHEMA_VERSION } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; -import { toDbMs } from './query-shared'; +import { toDbMs, toDbTimestamp } from './query-shared'; export interface TrackerPreparedStatements { telemetryInsertStmt: ReturnType; @@ -130,7 +130,7 @@ function deduplicateExistingCoverArtRows(db: DatabaseSync): void { return; } - const nowMsValue = toDbMs(nowMs()); + const nowMsValue = toDbTimestamp(nowMs()); const upsertBlobStmt = db.prepare(` INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE) VALUES (?, ?, ?, ?) @@ -275,7 +275,7 @@ function parseLegacyAnimeBackfillCandidate( } function ensureLifetimeSummaryTables(db: DatabaseSync): void { - const nowMsValue = toDbMs(nowMs()); + const nowMsValue = toDbTimestamp(nowMs()); db.exec(` CREATE TABLE IF NOT EXISTS imm_lifetime_global( @@ -287,9 +287,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { episodes_started INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0, anime_completed INTEGER NOT NULL DEFAULT 0, - last_rebuilt_ms INTEGER, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + last_rebuilt_ms TEXT, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ) `); @@ -332,10 +332,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { total_tokens_seen INTEGER NOT NULL DEFAULT 0, episodes_started INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0, - first_watched_ms INTEGER, - last_watched_ms INTEGER, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + first_watched_ms TEXT, + last_watched_ms TEXT, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE ) `); @@ -349,10 +349,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { total_lines_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0, - first_watched_ms INTEGER, - last_watched_ms INTEGER, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + first_watched_ms TEXT, + last_watched_ms TEXT, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE ) `); @@ -360,9 +360,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions( session_id INTEGER PRIMARY KEY, - applied_at_ms INTEGER NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + applied_at_ms TEXT NOT NULL, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE ) `); @@ -405,13 +405,13 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput input.titleEnglish, input.titleNative, input.metadataJson, - toDbMs(nowMs()), + toDbTimestamp(nowMs()), existing.anime_id, ); return existing.anime_id; } - const nowMsValue = toDbMs(nowMs()); + const nowMsValue = toDbTimestamp(nowMs()); const result = db .prepare( ` @@ -471,7 +471,7 @@ export function linkVideoToAnimeRecord( input.parserSource, input.parserConfidence, input.parseMetadataJson, - toDbMs(nowMs()), + toDbTimestamp(nowMs()), videoId, ); } @@ -562,13 +562,13 @@ export function ensureSchema(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_schema_version ( schema_version INTEGER PRIMARY KEY, - applied_at_ms INTEGER NOT NULL + applied_at_ms TEXT NOT NULL ); `); db.exec(` CREATE TABLE IF NOT EXISTS imm_rollup_state( state_key TEXT PRIMARY KEY, - state_value INTEGER NOT NULL + state_value TEXT NOT NULL ); `); db.exec(` @@ -597,8 +597,8 @@ export function ensureSchema(db: DatabaseSync): void { episodes_total INTEGER, description TEXT, metadata_json TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ); `); db.exec(` @@ -625,8 +625,8 @@ export function ensureSchema(db: DatabaseSync): void { bitrate_kbps INTEGER, audio_codec_id INTEGER, hash_sha256 TEXT, screenshot_path TEXT, metadata_json TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL ); `); @@ -635,7 +635,7 @@ export function ensureSchema(db: DatabaseSync): void { session_id INTEGER PRIMARY KEY AUTOINCREMENT, session_uuid TEXT NOT NULL UNIQUE, video_id INTEGER NOT NULL, - started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER, + started_at_ms TEXT NOT NULL, ended_at_ms TEXT, status INTEGER NOT NULL, locale_id INTEGER, target_lang_id INTEGER, difficulty_tier INTEGER, subtitle_mode INTEGER, @@ -653,8 +653,8 @@ export function ensureSchema(db: DatabaseSync): void { seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ); `); @@ -662,7 +662,7 @@ export function ensureSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS imm_session_telemetry( telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, - sample_ms INTEGER NOT NULL, + sample_ms TEXT NOT NULL, total_watched_ms INTEGER NOT NULL DEFAULT 0, active_watched_ms INTEGER NOT NULL DEFAULT 0, lines_seen INTEGER NOT NULL DEFAULT 0, @@ -676,8 +676,8 @@ export function ensureSchema(db: DatabaseSync): void { seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE ); `); @@ -693,8 +693,8 @@ export function ensureSchema(db: DatabaseSync): void { tokens_delta INTEGER NOT NULL DEFAULT 0, cards_delta INTEGER NOT NULL DEFAULT 0, payload_json TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE ); `); @@ -710,8 +710,8 @@ export function ensureSchema(db: DatabaseSync): void { cards_per_hour REAL, tokens_per_min REAL, lookup_hit_rate REAL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, PRIMARY KEY (rollup_day, video_id) ); `); @@ -724,8 +724,8 @@ export function ensureSchema(db: DatabaseSync): void { total_lines_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_cards INTEGER NOT NULL DEFAULT 0, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, PRIMARY KEY (rollup_month, video_id) ); `); @@ -806,9 +806,9 @@ export function ensureSchema(db: DatabaseSync): void { title_romaji TEXT, title_english TEXT, episodes_total INTEGER, - fetched_at_ms INTEGER NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + fetched_at_ms TEXT NOT NULL, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE ); `); @@ -827,9 +827,9 @@ export function ensureSchema(db: DatabaseSync): void { uploader_url TEXT, description TEXT, metadata_json TEXT, - fetched_at_ms INTEGER NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + fetched_at_ms TEXT NOT NULL, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE ); `); @@ -837,26 +837,26 @@ export function ensureSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS imm_cover_art_blobs( blob_hash TEXT PRIMARY KEY, cover_blob BLOB NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ); `); if (currentVersion?.schema_version === 1) { - addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE'); + addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE', 'TEXT'); - const migratedAtMs = toDbMs(nowMs()); + const migratedAtMs = toDbTimestamp(nowMs()); db.prepare( ` UPDATE imm_videos @@ -1243,7 +1243,7 @@ export function ensureSchema(db: DatabaseSync): void { db.exec(` INSERT INTO imm_schema_version(schema_version, applied_at_ms) - VALUES (${SCHEMA_VERSION}, ${toDbMs(nowMs())}) + VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())}) ON CONFLICT DO NOTHING `); } @@ -1401,7 +1401,7 @@ function incrementKanjiAggregate( } export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void { - const currentMs = toDbMs(nowMs()); + const currentMs = toDbTimestamp(nowMs()); if (write.kind === 'telemetry') { if ( write.totalWatchedMs === undefined || @@ -1420,7 +1420,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta ) { throw new Error('Incomplete telemetry write'); } - const telemetrySampleMs = toDbMs(write.sampleMs ?? Number(currentMs)); + const telemetrySampleMs = toDbTimestamp(write.sampleMs ?? Number(currentMs)); stmts.telemetryInsertStmt.run( write.sessionId, telemetrySampleMs, @@ -1495,7 +1495,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta stmts.eventInsertStmt.run( write.sessionId, - toDbMs(write.sampleMs ?? Number(currentMs)), + toDbTimestamp(write.sampleMs ?? Number(currentMs)), write.eventType ?? 0, write.lineIndex ?? null, write.segmentStartMs ?? null, @@ -1530,11 +1530,11 @@ export function getOrCreateVideoRecord( LAST_UPDATE_DATE = ? WHERE video_id = ? `, - ).run(details.canonicalTitle || 'unknown', toDbMs(nowMs()), existing.video_id); + ).run(details.canonicalTitle || 'unknown', toDbTimestamp(nowMs()), existing.video_id); return existing.video_id; } - const currentMs = toDbMs(nowMs()); + const currentMs = toDbTimestamp(nowMs()); const insert = db.prepare(` INSERT INTO imm_videos ( video_key, canonical_title, source_type, source_path, source_url, @@ -1604,7 +1604,7 @@ export function updateVideoMetadataRecord( metadata.hashSha256, metadata.screenshotPath, metadata.metadataJson, - toDbMs(nowMs()), + toDbTimestamp(nowMs()), videoId, ); } @@ -1622,7 +1622,7 @@ export function updateVideoTitleRecord( LAST_UPDATE_DATE = ? WHERE video_id = ? `, - ).run(canonicalTitle, toDbMs(nowMs()), videoId); + ).run(canonicalTitle, toDbTimestamp(nowMs()), videoId); } export function upsertYoutubeVideoMetadata( @@ -1630,7 +1630,7 @@ export function upsertYoutubeVideoMetadata( videoId: number, metadata: YoutubeVideoMetadata, ): void { - const currentMs = toDbMs(nowMs()); + const currentMs = toDbTimestamp(nowMs()); db.prepare( ` INSERT INTO imm_youtube_videos ( diff --git a/src/core/services/immersion-tracker/time.test.ts b/src/core/services/immersion-tracker/time.test.ts index 08c5f54a..75ff5e18 100644 --- a/src/core/services/immersion-tracker/time.test.ts +++ b/src/core/services/immersion-tracker/time.test.ts @@ -5,3 +5,25 @@ import { nowMs } from './time.js'; test('nowMs returns wall-clock epoch milliseconds', () => { assert.ok(nowMs() > 1_600_000_000_000); }); + +test('nowMs honors string-backed test clock values', () => { + const previousNowMs = globalThis.__subminerTestNowMs; + globalThis.__subminerTestNowMs = '123.9'; + + try { + assert.equal(nowMs(), 123); + } finally { + globalThis.__subminerTestNowMs = previousNowMs; + } +}); + +test('nowMs truncates negative numeric test clock values', () => { + const previousNowMs = globalThis.__subminerTestNowMs; + globalThis.__subminerTestNowMs = -1.9; + + try { + assert.equal(nowMs(), -1); + } finally { + globalThis.__subminerTestNowMs = previousNowMs; + } +}); diff --git a/src/core/services/immersion-tracker/time.ts b/src/core/services/immersion-tracker/time.ts index 8ea20816..8c8a94ca 100644 --- a/src/core/services/immersion-tracker/time.ts +++ b/src/core/services/immersion-tracker/time.ts @@ -1,4 +1,26 @@ +declare global { + var __subminerTestNowMs: number | string | undefined; +} + +function getMockNowMs(testNowMs: number | string | undefined): number | null { + if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) { + return Math.trunc(testNowMs); + } + if (typeof testNowMs === 'string') { + const parsed = Number(testNowMs.trim()); + if (Number.isFinite(parsed)) { + return Math.trunc(parsed); + } + } + return null; +} + export function nowMs(): number { + const mockedNowMs = getMockNowMs(globalThis.__subminerTestNowMs); + if (mockedNowMs !== null) { + return mockedNowMs; + } + const perf = globalThis.performance; if (perf && Number.isFinite(perf.timeOrigin)) { return Math.floor(perf.timeOrigin + perf.now()); diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index f9c63b2f..4888fdfc 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -16,6 +16,7 @@ function createOptions(overrides: Partial { calls.push('subsync'); @@ -26,6 +27,9 @@ function createOptions(overrides: Partial { calls.push('youtube-picker'); }, + openPlaylistBrowser: () => { + calls.push('playlist-browser'); + }, runtimeOptionsCycle: () => ({ ok: true }), showMpvOsd: (text) => { osd.push(text); @@ -110,6 +114,28 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', ( assert.deepEqual(osd, []); }); +test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => { + const { options, calls, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['__playlist-browser-open'], options); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ['playlist-browser']); + assert.deepEqual(sentCommands, []); + assert.deepEqual(osd, []); +}); + +test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => { + const { options, osd } = createOptions({ + openPlaylistBrowser: async () => { + throw new Error('overlay failed'); + }, + }); + + handleMpvCommandFromIpc(['__playlist-browser-open'], options); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(osd, ['Playlist browser failed: overlay failed']); +}); + test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { const { options, sentCommands, osd } = createOptions({ isMpvConnected: () => false, diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 166ac687..1a9e4144 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions { SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string; YOUTUBE_PICKER_OPEN: string; + PLAYLIST_BROWSER_OPEN: string; }; triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; openYoutubeTrackPicker: () => void | Promise; + openPlaylistBrowser: () => void | Promise; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; mpvReplaySubtitle: () => void; @@ -97,6 +99,16 @@ export function handleMpvCommandFromIpc( return; } + if (first === options.specialCommands.PLAYLIST_BROWSER_OPEN) { + Promise.resolve() + .then(() => options.openPlaylistBrowser()) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + options.showMpvOsd(`Playlist browser failed: ${message}`); + }); + return; + } + if ( first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START || first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index e5dae341..28ff2d47 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; -import type { SubtitleSidebarSnapshot } from '../../types'; +import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types'; interface FakeIpcRegistrar { on: Map void>; @@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), immersionTracker: null, ...overrides, @@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { return { ok: true, message: 'done' }; }, appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: '/tmp', + directoryAvailable: true, + directoryStatus: '/tmp', + directoryItems: [], + playlistItems: [], + playingIndex: 0, + currentFilePath: '/tmp/current.mkv', + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }); @@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { ok: true, message: 'done', }); + assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true); + assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), { + ok: true, + message: 'append', + }); + assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' }); + assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' }); + assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' }); assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); assert.equal(deps.getPlaybackPaused(), true); }); +test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: Array<[string, unknown[]]> = []; + registerIpcHandlers( + createRegisterIpcDeps({ + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: '/tmp/videos', + directoryAvailable: true, + directoryStatus: '/tmp/videos', + directoryItems: [], + playlistItems: [], + playingIndex: 1, + currentFilePath: '/tmp/videos/ep2.mkv', + }), + appendPlaylistBrowserFile: async (filePath) => { + calls.push(['append', [filePath]]); + return { ok: true, message: 'append-ok' }; + }, + playPlaylistBrowserIndex: async (index) => { + calls.push(['play', [index]]); + return { ok: true, message: 'play-ok' }; + }, + removePlaylistBrowserIndex: async (index) => { + calls.push(['remove', [index]]); + return { ok: true, message: 'remove-ok' }; + }, + movePlaylistBrowserIndex: async (index, direction) => { + calls.push(['move', [index, direction]]); + return { ok: true, message: 'move-ok' }; + }, + }), + registrar, + ); + + const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.( + {}, + )) as PlaylistBrowserSnapshot | undefined; + const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.( + {}, + '/tmp/videos/ep3.mkv', + ); + const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2); + const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.( + {}, + 2, + ); + const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.( + {}, + 2, + -1, + ); + + assert.equal(snapshot?.playingIndex, 1); + assert.deepEqual(append, { ok: true, message: 'append-ok' }); + assert.deepEqual(play, { ok: true, message: 'play-ok' }); + assert.deepEqual(remove, { ok: true, message: 'remove-ok' }); + assert.deepEqual(move, { ok: true, message: 'move-ok' }); + assert.deepEqual(calls, [ + ['append', ['/tmp/videos/ep3.mkv']], + ['play', [2]], + ['remove', [2]], + ['move', [2, -1]], + ]); +}); + test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: Array<{ id: string; value: unknown }> = []; @@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, @@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, @@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, @@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }), + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index a20374fa..ff781457 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -2,6 +2,8 @@ import electron from 'electron'; import type { IpcMainEvent } from 'electron'; import type { ControllerConfigUpdate, + PlaylistBrowserMutationResult, + PlaylistBrowserSnapshot, ControllerPreferenceUpdate, ResolvedControllerConfig, RuntimeOptionId, @@ -78,6 +80,14 @@ export interface IpcServiceDeps { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getPlaylistBrowserSnapshot: () => Promise; + appendPlaylistBrowserFile: (filePath: string) => Promise; + playPlaylistBrowserIndex: (index: number) => Promise; + removePlaylistBrowserIndex: (index: number) => Promise; + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ) => Promise; immersionTracker?: { recordYomitanLookup: () => void; getSessionSummaries: (limit?: number) => Promise; @@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getPlaylistBrowserSnapshot: () => Promise; + appendPlaylistBrowserFile: (filePath: string) => Promise; + playPlaylistBrowserIndex: (index: number) => Promise; + removePlaylistBrowserIndex: (index: number) => Promise; + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ) => Promise; getImmersionTracker?: () => IpcServiceDeps['immersionTracker']; } @@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getAnilistQueueStatus: options.getAnilistQueueStatus, retryAnilistQueueNow: options.retryAnilistQueueNow, appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, + getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, + appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, + playPlaylistBrowserIndex: options.playPlaylistBrowserIndex, + removePlaylistBrowserIndex: options.removePlaylistBrowserIndex, + movePlaylistBrowserIndex: options.movePlaylistBrowserIndex, get immersionTracker() { return options.getImmersionTracker?.() ?? null; }, @@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.appendClipboardVideoToQueue(); }); + ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => { + return await deps.getPlaylistBrowserSnapshot(); + }); + + ipc.handle(IPC_CHANNELS.request.appendPlaylistBrowserFile, async (_event, filePath: unknown) => { + if (typeof filePath !== 'string' || filePath.trim().length === 0) { + return { ok: false, message: 'Invalid playlist browser file path.' }; + } + return await deps.appendPlaylistBrowserFile(filePath); + }); + + ipc.handle(IPC_CHANNELS.request.playPlaylistBrowserIndex, async (_event, index: unknown) => { + if (!Number.isSafeInteger(index) || (index as number) < 0) { + return { ok: false, message: 'Invalid playlist browser index.' }; + } + return await deps.playPlaylistBrowserIndex(index as number); + }); + + ipc.handle(IPC_CHANNELS.request.removePlaylistBrowserIndex, async (_event, index: unknown) => { + if (!Number.isSafeInteger(index) || (index as number) < 0) { + return { ok: false, message: 'Invalid playlist browser index.' }; + } + return await deps.removePlaylistBrowserIndex(index as number); + }); + + ipc.handle( + IPC_CHANNELS.request.movePlaylistBrowserIndex, + async (_event, index: unknown, direction: unknown) => { + if (!Number.isSafeInteger(index) || (index as number) < 0) { + return { ok: false, message: 'Invalid playlist browser index.' }; + } + if (direction !== 1 && direction !== -1) { + return { ok: false, message: 'Invalid playlist browser move direction.' }; + } + return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1); + }, + ); + // Stats request handlers ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => { const tracker = deps.immersionTracker; diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index a9928280..b43d7d79 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -238,7 +238,7 @@ test('visible overlay stays hidden while a modal window is active', () => { assert.ok(!calls.includes('update-bounds')); }); -test('macOS tracked visible overlay stays visible without passively stealing focus', () => { +test('macOS tracked visible overlay stays click-through without passively stealing focus', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => true, @@ -270,7 +270,7 @@ test('macOS tracked visible overlay stays visible without passively stealing foc isWindowsPlatform: false, } as never); - assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('show')); assert.ok(!calls.includes('focus')); }); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 080b2322..c74e6bbc 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -37,7 +37,7 @@ export function updateVisibleOverlayVisibility(args: { const showPassiveVisibleOverlay = (): void => { const forceMousePassthrough = args.forceMousePassthrough === true; - if (args.isWindowsPlatform || forceMousePassthrough) { + if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) { mainWindow.setIgnoreMouseEvents(true, { forward: true }); } else { mainWindow.setIgnoreMouseEvents(false); diff --git a/src/main.ts b/src/main.ts index 78ce4b0f..a1645ded 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,7 @@ import { screen, } from 'electron'; 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 { mergeAiConfig } from './ai/config'; @@ -427,6 +428,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; +import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createFrequencyDictionaryRuntimeService, @@ -1929,6 +1931,23 @@ function openRuntimeOptionsPalette(): void { overlayVisibilityComposer.openRuntimeOptionsPalette(); } +function openPlaylistBrowser(): void { + if (!appState.mpvClient?.connected) { + showMpvOsd('Playlist browser requires active playback.'); + return; + } + const opened = openPlaylistBrowserRuntime({ + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => + ensureOverlayWindowsReadyForVisibilityActions(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }); + if (!opened) { + showMpvOsd('Playlist browser overlay unavailable.'); + } +} + function getResolvedConfig() { return configService.getConfig(); } @@ -4109,11 +4128,14 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen showMpvOsd: (text) => showMpvOsd(text), }); +const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient); + const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), + openPlaylistBrowser: () => openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => { if (!appState.runtimeOptionsManager) { return { ok: false, error: 'Runtime options manager unavailable' }; @@ -4290,6 +4312,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), + ...playlistBrowserMainDeps, getImmersionTracker: () => appState.immersionTracker, }, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index f5113bac..74a87a34 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams { getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; + getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; + appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; + playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex']; + removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex']; + movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex']; getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker']; } @@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams { triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; + openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; @@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps( getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, + getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, + appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, + playPlaylistBrowserIndex: params.playPlaylistBrowserIndex, + removePlaylistBrowserIndex: params.removePlaylistBrowserIndex, + movePlaylistBrowserIndex: params.movePlaylistBrowserIndex, getImmersionTracker: params.getImmersionTracker, }; } @@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps( triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, openYoutubeTrackPicker: params.openYoutubeTrackPicker, + openPlaylistBrowser: params.openPlaylistBrowser, runtimeOptionsCycle: params.runtimeOptionsCycle, showMpvOsd: params.showMpvOsd, mpvReplaySubtitle: params.mpvReplaySubtitle, diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts index aefea49d..4fa34174 100644 --- a/src/main/ipc-mpv-command.ts +++ b/src/main/ipc-mpv-command.ts @@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps { triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; openYoutubeTrackPicker: () => void | Promise; + openPlaylistBrowser: () => void | Promise; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; replayCurrentSubtitle: () => void; @@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime( triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openYoutubeTrackPicker: deps.openYoutubeTrackPicker, + openPlaylistBrowser: deps.openPlaylistBrowser, runtimeOptionsCycle: deps.cycleRuntimeOption, showMpvOsd: deps.showMpvOsd, mpvReplaySubtitle: deps.replayCurrentSubtitle, diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index b4461b55..94f2db6b 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -125,3 +125,54 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel await handler(); assert.deepEqual(calls, []); }); + +test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => { + const calls: string[] = []; + const attemptedKeys = new Set(); + const mediaKey = '/tmp/video.mkv'; + const attemptKey = buildAnilistAttemptKey(mediaKey, 1); + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => mediaKey, + hasMpvClient: () => true, + getTrackedMediaKey: () => mediaKey, + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => 1000, + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }), + hasAttemptedUpdateKey: (key) => attemptedKeys.has(key), + processNextAnilistRetryUpdate: async () => { + attemptedKeys.add(attemptKey); + calls.push('process-retry'); + return { ok: true, message: 'retry ok' }; + }, + refreshAnilistClientSecretState: async () => 'token', + enqueueRetry: () => calls.push('enqueue'), + markRetryFailure: () => calls.push('mark-failure'), + markRetrySuccess: () => calls.push('mark-success'), + refreshRetryQueueState: () => calls.push('refresh'), + updateAnilistPostWatchProgress: async () => { + calls.push('update'); + return { status: 'updated', message: 'updated ok' }; + }, + rememberAttemptedUpdateKey: (key) => { + attemptedKeys.add(key); + calls.push(`remember:${key}`); + }, + showMpvOsd: (message) => calls.push(`osd:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + minWatchSeconds: 600, + minWatchRatio: 0.85, + }); + + await handler(); + + assert.equal(calls.includes('update'), false); + assert.equal(calls.includes('enqueue'), false); + assert.equal(calls.includes('mark-failure'), false); + assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']); +}); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 74fa8495..89bc3cc1 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -165,6 +165,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { deps.setInFlight(true); try { await deps.processNextAnilistRetryUpdate(); + if (deps.hasAttemptedUpdateKey(attemptKey)) { + return; + } const accessToken = await deps.refreshAnilistClientSecretState(); if (!accessToken) { diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 6f404b25..9b3df025 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b triggerSubsyncFromConfig: async () => {}, openRuntimeOptionsPalette: () => {}, openYoutubeTrackPicker: () => {}, + openPlaylistBrowser: () => {}, cycleRuntimeOption: () => ({ ok: true }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, @@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getAnilistQueueStatus: () => ({}) as never, retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + getPlaylistBrowserSnapshot: async () => + ({ + directoryPath: null, + directoryAvailable: false, + directoryStatus: '', + directoryItems: [], + playlistItems: [], + playingIndex: null, + currentFilePath: null, + }) as never, + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, ankiJimakuDeps: { diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts index 7dd5665e..4d1a3d8a 100644 --- a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts +++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts @@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => { triggerSubsyncFromConfig: async () => {}, openRuntimeOptionsPalette: () => {}, openYoutubeTrackPicker: () => {}, + openPlaylistBrowser: () => {}, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts index d4e142ac..5c237f14 100644 --- a/src/main/runtime/ipc-bridge-actions.test.ts +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => { triggerSubsyncFromConfig: () => {}, openRuntimeOptionsPalette: () => {}, openYoutubeTrackPicker: () => {}, + openPlaylistBrowser: () => {}, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts index edb61865..ebd59a19 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => { openYoutubeTrackPicker: () => { calls.push('youtube-picker'); }, + openPlaylistBrowser: () => { + calls.push('playlist-browser'); + }, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: (text) => calls.push(`osd:${text}`), replayCurrentSubtitle: () => calls.push('replay'), @@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { deps.triggerSubsyncFromConfig(); deps.openRuntimeOptionsPalette(); void deps.openYoutubeTrackPicker(); + void deps.openPlaylistBrowser(); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); deps.showMpvOsd('hello'); deps.replayCurrentSubtitle(); @@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { 'subsync', 'palette', 'youtube-picker', + 'playlist-browser', 'osd:hello', 'replay', 'next', diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts index fafca8d3..fd373179 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), + openPlaylistBrowser: () => deps.openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), showMpvOsd: (text: string) => deps.showMpvOsd(text), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), diff --git a/src/main/runtime/playlist-browser-ipc.ts b/src/main/runtime/playlist-browser-ipc.ts new file mode 100644 index 00000000..3dbecf43 --- /dev/null +++ b/src/main/runtime/playlist-browser-ipc.ts @@ -0,0 +1,46 @@ +import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime'; +import { + appendPlaylistBrowserFileRuntime, + getPlaylistBrowserSnapshotRuntime, + movePlaylistBrowserIndexRuntime, + playPlaylistBrowserIndexRuntime, + removePlaylistBrowserIndexRuntime, + type PlaylistBrowserRuntimeDeps, +} from './playlist-browser-runtime'; + +type PlaylistBrowserMainDeps = Pick< + RegisterIpcRuntimeServicesParams['mainDeps'], + | 'getPlaylistBrowserSnapshot' + | 'appendPlaylistBrowserFile' + | 'playPlaylistBrowserIndex' + | 'removePlaylistBrowserIndex' + | 'movePlaylistBrowserIndex' +>; + +export type PlaylistBrowserIpcRuntime = { + playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps; + playlistBrowserMainDeps: PlaylistBrowserMainDeps; +}; + +export function createPlaylistBrowserIpcRuntime( + getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'], +): PlaylistBrowserIpcRuntime { + const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = { + getMpvClient, + }; + + return { + playlistBrowserRuntimeDeps, + playlistBrowserMainDeps: { + getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps), + appendPlaylistBrowserFile: (filePath: string) => + appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath), + playPlaylistBrowserIndex: (index: number) => + playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index), + removePlaylistBrowserIndex: (index: number) => + removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index), + movePlaylistBrowserIndex: (index: number, direction: 1 | -1) => + movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction), + }, + }; +} diff --git a/src/main/runtime/playlist-browser-open.test.ts b/src/main/runtime/playlist-browser-open.test.ts new file mode 100644 index 00000000..970d10e9 --- /dev/null +++ b/src/main/runtime/playlist-browser-open.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openPlaylistBrowser } from './playlist-browser-open'; + +test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => { + const calls: string[] = []; + + const opened = openPlaylistBrowser({ + ensureOverlayStartupPrereqs: () => { + calls.push('prereqs'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('windows'); + }, + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => { + calls.push(`send:${channel}`); + assert.equal(payload, undefined); + assert.deepEqual(runtimeOptions, { + restoreOnModalClose: 'playlist-browser', + }); + return true; + }, + }); + + assert.equal(opened, true); + assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]); +}); diff --git a/src/main/runtime/playlist-browser-open.ts b/src/main/runtime/playlist-browser-open.ts new file mode 100644 index 00000000..ba4ce1f8 --- /dev/null +++ b/src/main/runtime/playlist-browser-open.ts @@ -0,0 +1,23 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; + +const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser'; + +export function openPlaylistBrowser(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; +}): boolean { + deps.ensureOverlayStartupPrereqs(); + deps.ensureOverlayWindowsReadyForVisibilityActions(); + return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, { + restoreOnModalClose: PLAYLIST_BROWSER_MODAL, + }); +} diff --git a/src/main/runtime/playlist-browser-runtime.test.ts b/src/main/runtime/playlist-browser-runtime.test.ts new file mode 100644 index 00000000..6c0e2433 --- /dev/null +++ b/src/main/runtime/playlist-browser-runtime.test.ts @@ -0,0 +1,487 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; + +import type { PlaylistBrowserQueueItem } from '../../types'; +import { + appendPlaylistBrowserFileRuntime, + getPlaylistBrowserSnapshotRuntime, + movePlaylistBrowserIndexRuntime, + playPlaylistBrowserIndexRuntime, + removePlaylistBrowserIndexRuntime, +} from './playlist-browser-runtime'; + +type FakePlaylistEntry = { + current?: boolean; + playing?: boolean; + filename: string; + title?: string; + id?: number; +}; + +function createTempVideoDir(t: TestContext): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-')); + t.after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +function createFakeMpvClient(options: { + currentVideoPath: string; + playlist: FakePlaylistEntry[]; + connected?: boolean; +}) { + let playlist = options.playlist.map((item, index) => ({ + id: item.id ?? index + 1, + current: item.current ?? false, + playing: item.playing ?? item.current ?? false, + filename: item.filename, + title: item.title ?? null, + })); + const commands: Array<(string | number)[]> = []; + + const syncFlags = (): void => { + let playingIndex = playlist.findIndex((item) => item.current || item.playing); + if (playingIndex < 0 && playlist.length > 0) { + playingIndex = 0; + } + playlist = playlist.map((item, index) => ({ + ...item, + current: index === playingIndex, + playing: index === playingIndex, + })); + }; + + syncFlags(); + + return { + connected: options.connected ?? true, + currentVideoPath: options.currentVideoPath, + async requestProperty(name: string): Promise { + if (name === 'playlist') { + return playlist; + } + if (name === 'playlist-playing-pos') { + return playlist.findIndex((item) => item.current || item.playing); + } + if (name === 'path') { + return this.currentVideoPath; + } + throw new Error(`Unexpected property: ${name}`); + }, + send(payload: { command: unknown[] }): boolean { + const command = payload.command as (string | number)[]; + commands.push(command); + const [action, first, second] = command; + if (action === 'loadfile' && typeof first === 'string' && second === 'append') { + playlist.push({ + id: playlist.length + 1, + filename: first, + title: null, + current: false, + playing: false, + }); + syncFlags(); + return true; + } + if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) { + playlist = playlist.map((item, index) => ({ + ...item, + current: index === first, + playing: index === first, + })); + this.currentVideoPath = playlist[first]!.filename; + return true; + } + if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) { + const removingCurrent = playlist[first]!.current || playlist[first]!.playing; + playlist.splice(first, 1); + if (removingCurrent) { + syncFlags(); + this.currentVideoPath = + playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath; + } + return true; + } + if ( + action === 'playlist-move' && + typeof first === 'number' && + typeof second === 'number' && + playlist[first] + ) { + const [moved] = playlist.splice(first, 1); + playlist.splice(second, 0, moved!); + syncFlags(); + return true; + } + return true; + }, + getCommands(): Array<(string | number)[]> { + return commands; + }, + }; +} + +test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => { + const dir = createTempVideoDir(t); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const special = path.join(dir, 'Show - Special.mp4'); + const ignored = path.join(dir, 'notes.txt'); + fs.writeFileSync(episode2, ''); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(special, ''); + fs.writeFileSync(ignored, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode2, + playlist: [ + { filename: episode1, current: false, playing: false, title: 'Episode 1' }, + { filename: episode2, current: true, playing: true, title: 'Episode 2' }, + ], + }); + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + assert.equal(snapshot.directoryAvailable, true); + assert.equal(snapshot.directoryPath, dir); + assert.equal(snapshot.currentFilePath, episode2); + assert.equal(snapshot.playingIndex, 1); + assert.deepEqual( + snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]), + [ + ['Show - S01E01.mkv', false], + ['Show - S01E02.mkv', true], + ['Show - Special.mp4', false], + ], + ); + assert.deepEqual( + snapshot.playlistItems.map((item) => ({ + index: item.index, + displayLabel: item.displayLabel, + current: item.current, + })), + [ + { index: 0, displayLabel: 'Episode 1', current: false }, + { index: 1, displayLabel: 'Episode 2', current: true }, + ], + ); +}); + +test('getPlaylistBrowserSnapshotRuntime clamps stale playing index to the playlist bounds', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true, playing: true, title: 'Episode 1' }, + { filename: episode2, title: 'Episode 2' }, + ], + }); + const requestProperty = mpvClient.requestProperty.bind(mpvClient); + mpvClient.requestProperty = async (name: string): Promise => { + if (name === 'playlist-playing-pos') { + return 99; + } + return requestProperty(name); + }; + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + assert.equal(snapshot.playingIndex, 1); +}); + +test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => { + const mpvClient = createFakeMpvClient({ + currentVideoPath: 'https://example.com/video.m3u8', + playlist: [{ filename: 'https://example.com/video.m3u8', current: true }], + }); + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + assert.equal(snapshot.directoryAvailable, false); + assert.equal(snapshot.directoryItems.length, 0); + assert.match(snapshot.directoryStatus, /local filesystem/i); + assert.equal(snapshot.playlistItems.length, 1); +}); + +test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + const episode3 = path.join(dir, 'Show - S01E03.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + fs.writeFileSync(episode3, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true, title: 'Episode 1' }, + { filename: episode2, title: 'Episode 2' }, + ], + }); + + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + const deps = { + getMpvClient: () => mpvClient, + schedule: (callback: () => void, delayMs: number) => { + scheduled.push({ callback, delayMs }); + }, + }; + + const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3); + assert.equal(appendResult.ok, true); + assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']); + assert.deepEqual( + appendResult.snapshot?.playlistItems.map((item) => item.path), + [episode1, episode2, episode3], + ); + + const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1); + assert.equal(moveResult.ok, true); + assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]); + assert.deepEqual( + moveResult.snapshot?.playlistItems.map((item) => item.path), + [episode1, episode3, episode2], + ); + + const playResult = await playPlaylistBrowserIndexRuntime(deps, 1); + assert.equal(playResult.ok, true); + assert.deepEqual(mpvClient.getCommands().slice(-2), [ + ['set_property', 'sub-auto', 'fuzzy'], + ['playlist-play-index', 1], + ]); + assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]); + scheduled[0]?.callback(); + assert.deepEqual(mpvClient.getCommands().slice(-2), [ + ['set_property', 'sid', 'auto'], + ['set_property', 'secondary-sid', 'auto'], + ]); + assert.equal(playResult.snapshot?.playingIndex, 1); + + const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2); + assert.equal(removeResult.ok, true); + assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]); + assert.deepEqual( + removeResult.snapshot?.playlistItems.map((item) => item.path), + [episode1, episode3], + ); +}); + +test('playlist-browser mutation runtimes report MPV send rejection', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + const episode3 = path.join(dir, 'Show - S01E03.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + fs.writeFileSync(episode3, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true, title: 'Episode 1' }, + { filename: episode2, title: 'Episode 2' }, + { filename: episode3, title: 'Episode 3' }, + ], + }); + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + mpvClient.send = () => false; + const deps = { + getMpvClient: () => mpvClient, + schedule: (callback: () => void, delayMs: number) => { + scheduled.push({ callback, delayMs }); + }, + }; + + const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3); + assert.equal(appendResult.ok, false); + assert.equal(appendResult.snapshot, undefined); + + const playResult = await playPlaylistBrowserIndexRuntime(deps, 1); + assert.equal(playResult.ok, false); + assert.equal(playResult.snapshot, undefined); + assert.deepEqual(scheduled, []); + + const removeResult = await removePlaylistBrowserIndexRuntime(deps, 1); + assert.equal(removeResult.ok, false); + assert.equal(removeResult.snapshot, undefined); + + const moveResult = await movePlaylistBrowserIndexRuntime(deps, 1, 1); + assert.equal(moveResult.ok, false); + assert.equal(moveResult.snapshot, undefined); +}); + +test('appendPlaylistBrowserFileRuntime returns an error result when statSync throws', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + fs.writeFileSync(episode1, ''); + + const mutableFs = fs as typeof fs & { statSync: typeof fs.statSync }; + const originalStatSync = mutableFs.statSync; + mutableFs.statSync = ((targetPath: fs.PathLike) => { + if (path.resolve(String(targetPath)) === episode1) { + throw new Error('EACCES'); + } + return originalStatSync(targetPath); + }) as typeof fs.statSync; + + try { + const result = await appendPlaylistBrowserFileRuntime( + { + getMpvClient: () => + createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [{ filename: episode1, current: true }], + }), + }, + episode1, + ); + + assert.deepEqual(result, { + ok: false, + message: 'Playlist browser file is not readable.', + }); + } finally { + mutableFs.statSync = originalStatSync; + } +}); + +test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true }, + { filename: episode2 }, + ], + }); + + const deps = { + getMpvClient: () => mpvClient, + }; + + const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1); + assert.deepEqual(moveUp, { + ok: false, + message: 'Playlist item is already at the top.', + }); + + const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1); + assert.deepEqual(moveDown, { + ok: false, + message: 'Playlist item is already at the bottom.', + }); +}); + +test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + fs.writeFileSync(episode1, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [{ filename: episode1, current: true, title: '' }], + }); + + const snapshot = await getPlaylistBrowserSnapshotRuntime({ + getMpvClient: () => mpvClient, + }); + + const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem; + assert.equal(item.displayLabel, 'Show - S01E01.mkv'); + assert.equal(item.path, episode1); +}); + +test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => { + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + const mpvClient = createFakeMpvClient({ + currentVideoPath: 'https://example.com/video-1.m3u8', + playlist: [ + { filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' }, + { filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' }, + ], + }); + + const result = await playPlaylistBrowserIndexRuntime( + { + getMpvClient: () => mpvClient, + schedule: (callback, delayMs) => { + scheduled.push({ callback, delayMs }); + }, + }, + 1, + ); + + assert.equal(result.ok, true); + assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]); + assert.equal(scheduled.length, 0); +}); + +test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm callbacks', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + const episode3 = path.join(dir, 'Show - S01E03.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + fs.writeFileSync(episode3, ''); + + const scheduled: Array<() => void> = []; + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true, title: 'Episode 1' }, + { filename: episode2, title: 'Episode 2' }, + { filename: episode3, title: 'Episode 3' }, + ], + }); + + const deps = { + getMpvClient: () => mpvClient, + schedule: (callback: () => void) => { + scheduled.push(callback); + }, + }; + + const firstPlay = await playPlaylistBrowserIndexRuntime(deps, 1); + const secondPlay = await playPlaylistBrowserIndexRuntime(deps, 2); + + assert.equal(firstPlay.ok, true); + assert.equal(secondPlay.ok, true); + assert.equal(scheduled.length, 2); + + scheduled[0]?.(); + scheduled[1]?.(); + + assert.deepEqual( + mpvClient.getCommands().slice(-6), + [ + ['set_property', 'sub-auto', 'fuzzy'], + ['playlist-play-index', 1], + ['set_property', 'sub-auto', 'fuzzy'], + ['playlist-play-index', 2], + ['set_property', 'sid', 'auto'], + ['set_property', 'secondary-sid', 'auto'], + ], + ); +}); diff --git a/src/main/runtime/playlist-browser-runtime.ts b/src/main/runtime/playlist-browser-runtime.ts new file mode 100644 index 00000000..2a9324a2 --- /dev/null +++ b/src/main/runtime/playlist-browser-runtime.ts @@ -0,0 +1,361 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { + PlaylistBrowserDirectoryItem, + PlaylistBrowserMutationResult, + PlaylistBrowserQueueItem, + PlaylistBrowserSnapshot, +} from '../../types'; +import { isRemoteMediaPath } from '../../jimaku/utils'; +import { hasVideoExtension } from '../../shared/video-extensions'; +import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort'; + +type PlaylistLike = { + filename?: unknown; + title?: unknown; + id?: unknown; + current?: unknown; + playing?: unknown; +}; + +type MpvPlaylistBrowserClientLike = { + connected: boolean; + currentVideoPath?: string | null; + requestProperty?: (name: string) => Promise; + send: (payload: { command: unknown[]; request_id?: number }) => boolean; +}; + +export type PlaylistBrowserRuntimeDeps = { + getMpvClient: () => MpvPlaylistBrowserClientLike | null; + schedule?: (callback: () => void, delayMs: number) => void; +}; + +const pendingLocalSubtitleSelectionRearms = new WeakMap(); + +function trimToNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readProperty( + client: MpvPlaylistBrowserClientLike | null, + name: string, +): Promise { + if (!client?.requestProperty) return null; + try { + return await client.requestProperty(name); + } catch { + return null; + } +} + +async function resolveCurrentFilePath( + client: MpvPlaylistBrowserClientLike | null, +): Promise { + const currentVideoPath = trimToNull(client?.currentVideoPath); + if (currentVideoPath) return currentVideoPath; + return trimToNull(await readProperty(client, 'path')); +} + +function resolveDirectorySnapshot( + currentFilePath: string | null, +): Pick { + if (!currentFilePath) { + return { + directoryAvailable: false, + directoryItems: [], + directoryPath: null, + directoryStatus: 'Current media path is unavailable.', + }; + } + + if (isRemoteMediaPath(currentFilePath)) { + return { + directoryAvailable: false, + directoryItems: [], + directoryPath: null, + directoryStatus: 'Directory browser requires a local filesystem video.', + }; + } + + const resolvedPath = path.resolve(currentFilePath); + const directoryPath = path.dirname(resolvedPath); + try { + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + const videoPaths = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => hasVideoExtension(path.extname(name))) + .map((name) => path.join(directoryPath, name)); + + const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems( + videoPaths, + ).map((item) => ({ + ...item, + isCurrentFile: item.path === resolvedPath, + })); + + return { + directoryAvailable: true, + directoryItems, + directoryPath, + directoryStatus: directoryPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + directoryAvailable: false, + directoryItems: [], + directoryPath, + directoryStatus: `Could not read parent directory: ${message}`, + }; + } +} + +function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry, index) => { + const item = (entry ?? {}) as PlaylistLike; + const filename = trimToNull(item.filename) ?? ''; + const title = trimToNull(item.title); + const normalizedPath = + filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename); + return { + index, + id: typeof item.id === 'number' ? item.id : null, + filename, + title, + displayLabel: + title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`), + current: item.current === true, + playing: item.playing === true, + path: normalizedPath, + }; + }); +} + +function ensureConnectedClient( + deps: PlaylistBrowserRuntimeDeps, +): MpvPlaylistBrowserClientLike | { ok: false; message: string } { + const client = deps.getMpvClient(); + if (!client?.connected) { + return { + ok: false, + message: 'MPV is not connected.', + }; + } + return client; +} + +function buildRejectedCommandResult(): PlaylistBrowserMutationResult { + return { + ok: false, + message: 'Could not send command to MPV.', + }; +} + +async function getPlaylistItemsFromClient( + client: MpvPlaylistBrowserClientLike | null, +): Promise { + return normalizePlaylistItems(await readProperty(client, 'playlist')); +} + +function resolvePlayingIndex( + playlistItems: PlaylistBrowserQueueItem[], + playingPosValue: unknown, +): number | null { + if (playlistItems.length === 0) { + return null; + } + if (typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)) { + return Math.min(Math.max(playingPosValue, 0), playlistItems.length - 1); + } + const playingIndex = playlistItems.findIndex((item) => item.current || item.playing); + return playingIndex >= 0 ? playingIndex : null; +} + +export async function getPlaylistBrowserSnapshotRuntime( + deps: PlaylistBrowserRuntimeDeps, +): Promise { + const client = deps.getMpvClient(); + const currentFilePath = await resolveCurrentFilePath(client); + const [playlistItems, playingPosValue] = await Promise.all([ + getPlaylistItemsFromClient(client), + readProperty(client, 'playlist-playing-pos'), + ]); + + return { + ...resolveDirectorySnapshot(currentFilePath), + playlistItems, + playingIndex: resolvePlayingIndex(playlistItems, playingPosValue), + currentFilePath, + }; +} + +async function validatePlaylistIndex( + deps: PlaylistBrowserRuntimeDeps, + index: number, +): Promise< + | { ok: false; message: string } + | { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] } +> { + const client = ensureConnectedClient(deps); + if ('ok' in client) { + return client; + } + const playlistItems = await getPlaylistItemsFromClient(client); + if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) { + return { + ok: false, + message: 'Playlist item not found.', + }; + } + return { + ok: true, + client, + playlistItems, + }; +} + +async function buildMutationResult( + message: string, + deps: PlaylistBrowserRuntimeDeps, +): Promise { + return { + ok: true, + message, + snapshot: await getPlaylistBrowserSnapshotRuntime(deps), + }; +} + +function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void { + client.send({ command: ['set_property', 'sid', 'auto'] }); + client.send({ command: ['set_property', 'secondary-sid', 'auto'] }); +} + +function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void { + client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] }); +} + +function isLocalPlaylistItem( + item: PlaylistBrowserQueueItem | null | undefined, +): item is PlaylistBrowserQueueItem & { path: string } { + return Boolean(item?.path && !isRemoteMediaPath(item.path)); +} + +function scheduleLocalSubtitleSelectionRearm( + deps: PlaylistBrowserRuntimeDeps, + client: MpvPlaylistBrowserClientLike, + expectedPath: string, +): void { + const nextToken = (pendingLocalSubtitleSelectionRearms.get(client) ?? 0) + 1; + pendingLocalSubtitleSelectionRearms.set(client, nextToken); + (deps.schedule ?? setTimeout)(() => { + if (pendingLocalSubtitleSelectionRearms.get(client) !== nextToken) return; + pendingLocalSubtitleSelectionRearms.delete(client); + const currentPath = trimToNull(client.currentVideoPath); + if (currentPath && path.resolve(currentPath) !== expectedPath) { + return; + } + rearmLocalSubtitleSelection(client); + }, 400); +} + +export async function appendPlaylistBrowserFileRuntime( + deps: PlaylistBrowserRuntimeDeps, + filePath: string, +): Promise { + const client = ensureConnectedClient(deps); + if ('ok' in client) { + return client; + } + const resolvedPath = path.resolve(filePath); + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedPath); + } catch { + return { + ok: false, + message: 'Playlist browser file is not readable.', + }; + } + if (!stats.isFile()) { + return { + ok: false, + message: 'Playlist browser file is not readable.', + }; + } + + if (!client.send({ command: ['loadfile', resolvedPath, 'append'] })) { + return buildRejectedCommandResult(); + } + return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps); +} + +export async function playPlaylistBrowserIndexRuntime( + deps: PlaylistBrowserRuntimeDeps, + index: number, +): Promise { + const result = await validatePlaylistIndex(deps, index); + if (!result.ok) { + return result; + } + + const targetItem = result.playlistItems[index] ?? null; + if (isLocalPlaylistItem(targetItem)) { + prepareLocalSubtitleAutoload(result.client); + } + if (!result.client.send({ command: ['playlist-play-index', index] })) { + return buildRejectedCommandResult(); + } + if (isLocalPlaylistItem(targetItem)) { + scheduleLocalSubtitleSelectionRearm(deps, result.client, path.resolve(targetItem.path)); + } + return buildMutationResult(`Playing playlist item ${index + 1}`, deps); +} + +export async function removePlaylistBrowserIndexRuntime( + deps: PlaylistBrowserRuntimeDeps, + index: number, +): Promise { + const result = await validatePlaylistIndex(deps, index); + if (!result.ok) { + return result; + } + + if (!result.client.send({ command: ['playlist-remove', index] })) { + return buildRejectedCommandResult(); + } + return buildMutationResult(`Removed playlist item ${index + 1}`, deps); +} + +export async function movePlaylistBrowserIndexRuntime( + deps: PlaylistBrowserRuntimeDeps, + index: number, + direction: -1 | 1, +): Promise { + const result = await validatePlaylistIndex(deps, index); + if (!result.ok) { + return result; + } + + const targetIndex = index + direction; + if (targetIndex < 0) { + return { + ok: false, + message: 'Playlist item is already at the top.', + }; + } + if (targetIndex >= result.playlistItems.length) { + return { + ok: false, + message: 'Playlist item is already at the bottom.', + }; + } + + if (!result.client.send({ command: ['playlist-move', index, targetIndex] })) { + return buildRejectedCommandResult(); + } + return buildMutationResult(`Moved playlist item ${index + 1}`, deps); +} diff --git a/src/main/runtime/playlist-browser-sort.test.ts b/src/main/runtime/playlist-browser-sort.test.ts new file mode 100644 index 00000000..f84cc8cd --- /dev/null +++ b/src/main/runtime/playlist-browser-sort.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort'; + +test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => { + const root = '/library/show'; + const items = sortPlaylistBrowserDirectoryItems([ + path.join(root, 'Show - S01E10.mkv'), + path.join(root, 'Show - S01E02.mkv'), + path.join(root, 'Show - S01E01.mkv'), + path.join(root, 'Show - Episode 7.mkv'), + path.join(root, 'Show - 01x03.mkv'), + ]); + + assert.deepEqual( + items.map((item) => item.basename), + [ + 'Show - S01E01.mkv', + 'Show - S01E02.mkv', + 'Show - 01x03.mkv', + 'Show - Episode 7.mkv', + 'Show - S01E10.mkv', + ], + ); + assert.deepEqual( + items.map((item) => item.episodeLabel), + ['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'], + ); +}); + +test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => { + const root = '/library/show'; + const items = sortPlaylistBrowserDirectoryItems([ + path.join(root, 'Show Part 10.mkv'), + path.join(root, 'Show Part 2.mkv'), + path.join(root, 'Show Part 1.mkv'), + path.join(root, 'Show Special.mkv'), + ]); + + assert.deepEqual( + items.map((item) => item.basename), + ['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'], + ); + assert.deepEqual( + items.map((item) => item.episodeLabel), + [null, null, null, null], + ); +}); diff --git a/src/main/runtime/playlist-browser-sort.ts b/src/main/runtime/playlist-browser-sort.ts new file mode 100644 index 00000000..b254a475 --- /dev/null +++ b/src/main/runtime/playlist-browser-sort.ts @@ -0,0 +1,129 @@ +import path from 'node:path'; + +type ParsedEpisodeKey = { + season: number | null; + episode: number; +}; + +type SortToken = string | number; + +export type PlaylistBrowserSortedDirectoryItem = { + path: string; + basename: string; + episodeLabel: string | null; +}; + +const COLLATOR = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +function parseEpisodeKey(basename: string): ParsedEpisodeKey | null { + const name = basename.replace(/\.[^.]+$/, ''); + const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i); + if (seasonEpisode) { + return { + season: Number(seasonEpisode[1]), + episode: Number(seasonEpisode[2]), + }; + } + + const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i); + if (seasonByX) { + return { + season: Number(seasonByX[1]), + episode: Number(seasonByX[2]), + }; + } + + const namedEpisode = name.match( + /(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i, + ); + if (namedEpisode) { + return { + season: null, + episode: Number(namedEpisode[1]), + }; + } + + return null; +} + +function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null { + if (!parsed) return null; + if (parsed.season !== null) { + return `S${parsed.season}E${parsed.episode}`; + } + return `E${parsed.episode}`; +} + +function tokenizeNaturalSort(basename: string): SortToken[] { + return basename + .toLowerCase() + .split(/(\d+)/) + .filter((token) => token.length > 0) + .map((token) => (/^\d+$/.test(token) ? Number(token) : token)); +} + +function compareNaturalTokens(left: SortToken[], right: SortToken[]): number { + const maxLength = Math.max(left.length, right.length); + for (let index = 0; index < maxLength; index += 1) { + const a = left[index]; + const b = right[index]; + if (a === undefined) return -1; + if (b === undefined) return 1; + if (typeof a === 'number' && typeof b === 'number') { + if (a !== b) return a - b; + continue; + } + const comparison = COLLATOR.compare(String(a), String(b)); + if (comparison !== 0) return comparison; + } + return 0; +} + +export function sortPlaylistBrowserDirectoryItems( + paths: string[], +): PlaylistBrowserSortedDirectoryItem[] { + return paths + .map((pathValue) => { + const basename = path.basename(pathValue); + const parsed = parseEpisodeKey(basename); + return { + path: pathValue, + basename, + parsed, + episodeLabel: buildEpisodeLabel(parsed), + naturalTokens: tokenizeNaturalSort(basename), + }; + }) + .sort((left, right) => { + if (left.parsed && right.parsed) { + if ( + left.parsed.season !== null && + right.parsed.season !== null && + left.parsed.season !== right.parsed.season + ) { + return left.parsed.season - right.parsed.season; + } + if (left.parsed.episode !== right.parsed.episode) { + return left.parsed.episode - right.parsed.episode; + } + } else if (left.parsed && !right.parsed) { + return -1; + } else if (!left.parsed && right.parsed) { + return 1; + } + + const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens); + if (naturalComparison !== 0) { + return naturalComparison; + } + return COLLATOR.compare(left.basename, right.basename); + }) + .map(({ path: itemPath, basename, episodeLabel }) => ({ + path: itemPath, + basename, + episodeLabel, + })); +} diff --git a/src/preload.ts b/src/preload.ts index 8d0299d8..bc112f6a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -38,6 +38,8 @@ import type { SubsyncManualRunRequest, SubsyncResult, ClipboardAppendResult, + PlaylistBrowserMutationResult, + PlaylistBrowserSnapshot, KikuFieldGroupingRequestData, KikuFieldGroupingChoice, KikuMergePreviewRequest, @@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload payload as YoutubePickerOpenPayload, ); +const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen); const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( IPC_CHANNELS.event.youtubePickerCancel, ); @@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = { onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, + onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, appendClipboardVideoToQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), + getPlaylistBrowserSnapshot: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot), + appendPlaylistBrowserFile: (pathValue: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.appendPlaylistBrowserFile, pathValue), + playPlaylistBrowserIndex: (index: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.playPlaylistBrowserIndex, index), + removePlaylistBrowserIndex: (index: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.removePlaylistBrowserIndex, index), + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.movePlaylistBrowserIndex, index, direction), youtubePickerResolve: ( request: YoutubePickerResolveRequest, ): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index a197098e..d81f1cbe 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() { let controllerSelectOpenCount = 0; let controllerDebugOpenCount = 0; let controllerSelectKeydownCount = 0; + let playlistBrowserKeydownCount = 0; const createWordNode = (left: number) => ({ classList: createClassList(), @@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() { }, handleControllerDebugKeydown: () => false, handleYoutubePickerKeydown: () => false, + handlePlaylistBrowserKeydown: () => { + playlistBrowserKeydownCount += 1; + return true; + }, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, @@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() { controllerSelectOpenCount: () => controllerSelectOpenCount, controllerDebugOpenCount: () => controllerDebugOpenCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount, + playlistBrowserKeydownCount: () => playlistBrowserKeydownCount, setWordCount: (count: number) => { wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); }, @@ -623,6 +629,49 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p } }); +test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => { + const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } = + createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + ctx.state.playlistBrowserModalOpen = true; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); + + assert.equal(playlistBrowserKeydownCount(), 1); + assert.equal( + testGlobals.commandEvents.some( + (event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown', + ), + false, + ); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: playlist browser modal handles h before lookup controls', async () => { + const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } = + createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + ctx.state.playlistBrowserModalOpen = true; + ctx.state.keyboardSelectedWordIndex = 2; + + testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' }); + + assert.equal(playlistBrowserKeydownCount(), 1); + assert.equal(ctx.state.keyboardSelectedWordIndex, 2); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: configured stats toggle works even while popup is open', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index ac4b294e..a98bcea9 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -16,6 +16,7 @@ export function createKeyboardHandlers( handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; + handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; @@ -815,6 +816,12 @@ export function createKeyboardHandlers( return; } + if (ctx.state.playlistBrowserModalOpen) { + if (options.handlePlaylistBrowserKeydown(e)) { + return; + } + } + if (handleKeyboardDrivenModeLookupControls(e)) { e.preventDefault(); return; diff --git a/src/renderer/index.html b/src/renderer/index.html index c16b8edf..2221946a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -320,6 +320,35 @@ + diff --git a/src/renderer/modals/playlist-browser-renderer.ts b/src/renderer/modals/playlist-browser-renderer.ts new file mode 100644 index 00000000..d3e9d24d --- /dev/null +++ b/src/renderer/modals/playlist-browser-renderer.ts @@ -0,0 +1,144 @@ +import type { + PlaylistBrowserDirectoryItem, + PlaylistBrowserQueueItem, +} from '../../types'; +import type { RendererContext } from '../context'; + +type PlaylistBrowserRowRenderActions = { + appendDirectoryItem: (filePath: string) => void; + movePlaylistItem: (index: number, direction: 1 | -1) => void; + playPlaylistItem: (index: number) => void; + removePlaylistItem: (index: number) => void; + render: () => void; +}; + +function createActionButton(label: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.className = 'playlist-browser-action'; + button.addEventListener('click', (event) => { + event.stopPropagation(); + onClick(); + }); + button.addEventListener('dblclick', (event) => { + event.preventDefault?.(); + event.stopPropagation(); + }); + return button; +} + +export function renderPlaylistBrowserDirectoryRow( + ctx: RendererContext, + item: PlaylistBrowserDirectoryItem, + index: number, + actions: PlaylistBrowserRowRenderActions, +): HTMLElement { + const row = document.createElement('li'); + row.className = 'playlist-browser-row'; + if (item.isCurrentFile) row.classList.add('current'); + if ( + ctx.state.playlistBrowserActivePane === 'directory' && + ctx.state.playlistBrowserSelectedDirectoryIndex === index + ) { + row.classList.add('active'); + } + + const main = document.createElement('div'); + main.className = 'playlist-browser-row-main'; + const label = document.createElement('div'); + label.className = 'playlist-browser-row-label'; + label.textContent = item.basename; + const meta = document.createElement('div'); + meta.className = 'playlist-browser-row-meta'; + meta.textContent = item.isCurrentFile + ? item.episodeLabel + ? `${item.episodeLabel} · Current file` + : 'Current file' + : item.episodeLabel ?? 'Video file'; + main.append(label, meta); + + const trailing = document.createElement('div'); + trailing.className = 'playlist-browser-row-trailing'; + if (item.episodeLabel) { + const badge = document.createElement('div'); + badge.className = 'playlist-browser-chip'; + badge.textContent = item.episodeLabel; + trailing.appendChild(badge); + } + trailing.appendChild( + createActionButton('Add', () => { + void actions.appendDirectoryItem(item.path); + }), + ); + + row.append(main, trailing); + row.addEventListener('click', () => { + ctx.state.playlistBrowserActivePane = 'directory'; + ctx.state.playlistBrowserSelectedDirectoryIndex = index; + actions.render(); + }); + row.addEventListener('dblclick', () => { + ctx.state.playlistBrowserSelectedDirectoryIndex = index; + void actions.appendDirectoryItem(item.path); + }); + return row; +} + +export function renderPlaylistBrowserPlaylistRow( + ctx: RendererContext, + item: PlaylistBrowserQueueItem, + index: number, + actions: PlaylistBrowserRowRenderActions, +): HTMLElement { + const row = document.createElement('li'); + row.className = 'playlist-browser-row'; + if (item.current || item.playing) row.classList.add('current'); + if ( + ctx.state.playlistBrowserActivePane === 'playlist' && + ctx.state.playlistBrowserSelectedPlaylistIndex === index + ) { + row.classList.add('active'); + } + + const main = document.createElement('div'); + main.className = 'playlist-browser-row-main'; + const label = document.createElement('div'); + label.className = 'playlist-browser-row-label'; + label.textContent = `${index + 1}. ${item.displayLabel}`; + const meta = document.createElement('div'); + meta.className = 'playlist-browser-row-meta'; + meta.textContent = item.current || item.playing ? 'Playing now' : 'Queued'; + const submeta = document.createElement('div'); + submeta.className = 'playlist-browser-row-submeta'; + submeta.textContent = item.filename; + main.append(label, meta, submeta); + + const trailing = document.createElement('div'); + trailing.className = 'playlist-browser-row-actions'; + trailing.append( + createActionButton('Play', () => { + void actions.playPlaylistItem(item.index); + }), + createActionButton('Up', () => { + void actions.movePlaylistItem(item.index, -1); + }), + createActionButton('Down', () => { + void actions.movePlaylistItem(item.index, 1); + }), + createActionButton('Remove', () => { + void actions.removePlaylistItem(item.index); + }), + ); + row.append(main, trailing); + row.addEventListener('click', () => { + ctx.state.playlistBrowserActivePane = 'playlist'; + ctx.state.playlistBrowserSelectedPlaylistIndex = index; + actions.render(); + }); + row.addEventListener('dblclick', () => { + ctx.state.playlistBrowserSelectedPlaylistIndex = index; + void actions.playPlaylistItem(item.index); + }); + return row; +} diff --git a/src/renderer/modals/playlist-browser.test.ts b/src/renderer/modals/playlist-browser.test.ts new file mode 100644 index 00000000..28623001 --- /dev/null +++ b/src/renderer/modals/playlist-browser.test.ts @@ -0,0 +1,659 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ElectronAPI, PlaylistBrowserSnapshot } from '../../types'; +import { createRendererState } from '../state.js'; +import { createPlaylistBrowserModal } from './playlist-browser.js'; + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + toggle: (entry: string, force?: boolean) => { + if (force === true) tokens.add(entry); + else if (force === false) tokens.delete(entry); + else if (tokens.has(entry)) tokens.delete(entry); + else tokens.add(entry); + }, + }; +} + +function createFakeElement() { + const attributes = new Map(); + return { + textContent: '', + innerHTML: '', + children: [] as unknown[], + listeners: new Map void>>(), + classList: createClassList(['hidden']), + appendChild(child: unknown) { + this.children.push(child); + return child; + }, + append(...children: unknown[]) { + this.children.push(...children); + }, + replaceChildren(...children: unknown[]) { + this.children = [...children]; + }, + addEventListener(type: string, listener: (event?: unknown) => void) { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + }, + setAttribute(name: string, value: string) { + attributes.set(name, value); + }, + getAttribute(name: string) { + return attributes.get(name) ?? null; + }, + focus() {}, + }; +} + +function createPlaylistRow() { + return { + className: '', + classList: createClassList(), + dataset: {} as Record, + textContent: '', + children: [] as unknown[], + listeners: new Map void>>(), + append(...children: unknown[]) { + this.children.push(...children); + }, + appendChild(child: unknown) { + this.children.push(child); + return child; + }, + addEventListener(type: string, listener: (event?: unknown) => void) { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + }, + setAttribute() {}, + }; +} + +function createListStub() { + return { + innerHTML: '', + children: [] as ReturnType[], + appendChild(child: ReturnType) { + this.children.push(child); + return child; + }, + replaceChildren(...children: ReturnType[]) { + this.children = [...children]; + }, + }; +} + +function createSnapshot(): PlaylistBrowserSnapshot { + return { + directoryPath: '/tmp/show', + directoryAvailable: true, + directoryStatus: '/tmp/show', + currentFilePath: '/tmp/show/Show - S01E02.mkv', + playingIndex: 1, + directoryItems: [ + { + path: '/tmp/show/Show - S01E01.mkv', + basename: 'Show - S01E01.mkv', + episodeLabel: 'S1E1', + isCurrentFile: false, + }, + { + path: '/tmp/show/Show - S01E02.mkv', + basename: 'Show - S01E02.mkv', + episodeLabel: 'S1E2', + isCurrentFile: true, + }, + ], + playlistItems: [ + { + index: 0, + id: 1, + filename: '/tmp/show/Show - S01E01.mkv', + title: 'Episode 1', + displayLabel: 'Episode 1', + current: false, + playing: false, + path: '/tmp/show/Show - S01E01.mkv', + }, + { + index: 1, + id: 2, + filename: '/tmp/show/Show - S01E02.mkv', + title: 'Episode 2', + displayLabel: 'Episode 2', + current: true, + playing: true, + path: '/tmp/show/Show - S01E02.mkv', + }, + ], + }; +} + +function createMutationSnapshot(): PlaylistBrowserSnapshot { + return { + directoryPath: '/tmp/show', + directoryAvailable: true, + directoryStatus: '/tmp/show', + currentFilePath: '/tmp/show/Show - S01E02.mkv', + playingIndex: 0, + directoryItems: [ + { + path: '/tmp/show/Show - S01E01.mkv', + basename: 'Show - S01E01.mkv', + episodeLabel: 'S1E1', + isCurrentFile: false, + }, + { + path: '/tmp/show/Show - S01E02.mkv', + basename: 'Show - S01E02.mkv', + episodeLabel: 'S1E2', + isCurrentFile: true, + }, + { + path: '/tmp/show/Show - S01E03.mkv', + basename: 'Show - S01E03.mkv', + episodeLabel: 'S1E3', + isCurrentFile: false, + }, + ], + playlistItems: [ + { + index: 1, + id: 2, + filename: '/tmp/show/Show - S01E02.mkv', + title: 'Episode 2', + displayLabel: 'Episode 2', + current: true, + playing: true, + path: '/tmp/show/Show - S01E02.mkv', + }, + { + index: 2, + id: 3, + filename: '/tmp/show/Show - S01E03.mkv', + title: 'Episode 3', + displayLabel: 'Episode 3', + current: false, + playing: false, + path: '/tmp/show/Show - S01E03.mkv', + }, + { + index: 0, + id: 1, + filename: '/tmp/show/Show - S01E01.mkv', + title: 'Episode 1', + displayLabel: 'Episode 1', + current: false, + playing: false, + path: '/tmp/show/Show - S01E01.mkv', + }, + ], + }; +} + +function restoreGlobalDescriptor( + key: K, + descriptor: PropertyDescriptor | undefined, +) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + return; + } + Reflect.deleteProperty(globalThis, key); +} + +function createPlaylistBrowserDomFixture() { + return { + overlay: { + classList: createClassList(), + focus: () => {}, + }, + playlistBrowserModal: createFakeElement(), + playlistBrowserTitle: createFakeElement(), + playlistBrowserStatus: createFakeElement(), + playlistBrowserDirectoryList: createListStub(), + playlistBrowserPlaylistList: createListStub(), + playlistBrowserClose: createFakeElement(), + }; +} + +function createPlaylistBrowserElectronApi(overrides?: Partial): ElectronAPI { + return { + getPlaylistBrowserSnapshot: async () => createSnapshot(), + notifyOverlayModalOpened: () => {}, + notifyOverlayModalClosed: () => {}, + focusMainWindow: async () => {}, + setIgnoreMouseEvents: () => {}, + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + ...overrides, + } as ElectronAPI; +} + +function setupPlaylistBrowserModalTest(options?: { + electronApi?: Partial; + shouldToggleMouseIgnore?: boolean; +}) { + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const state = createRendererState(); + const dom = createPlaylistBrowserDomFixture(); + const ctx = { + state, + platform: { + shouldToggleMouseIgnore: options?.shouldToggleMouseIgnore ?? false, + }, + dom, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: createPlaylistBrowserElectronApi(options?.electronApi), + focus: () => {}, + } satisfies { electronAPI: ElectronAPI; focus: () => void }, + writable: true, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createPlaylistRow(), + }, + writable: true, + }); + + return { + state, + dom, + createModal(overrides: Partial[1]> = {}) { + return createPlaylistBrowserModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + ...overrides, + }); + }, + restore() { + restoreGlobalDescriptor('window', previousWindowDescriptor); + restoreGlobalDescriptor('document', previousDocumentDescriptor); + }, + }; +} + +test('playlist browser test cleanup must delete injected globals that were originally absent', () => { + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false); + + const env = setupPlaylistBrowserModalTest(); + + try { + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true); + } finally { + env.restore(); + } + + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false); + assert.equal(typeof globalThis.window, 'undefined'); + assert.equal(typeof globalThis.document, 'undefined'); +}); + +test('playlist browser modal opens with playlist-focused current item selection', async () => { + const notifications: string[] = []; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + }, + }); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + + assert.equal(env.state.playlistBrowserModalOpen, true); + assert.equal(env.state.playlistBrowserActivePane, 'playlist'); + assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 1); + assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 1); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2); + assert.equal(env.dom.playlistBrowserDirectoryList.children[0]?.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children[0]?.children.length, 2); + assert.deepEqual(notifications, ['open:playlist-browser']); + } finally { + env.restore(); + } +}); + +test('playlist browser modal action buttons stop double-click propagation', async () => { + const env = setupPlaylistBrowserModalTest(); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + + const row = + env.dom.playlistBrowserDirectoryList.children[0] as + | ReturnType + | undefined; + const trailing = row?.children?.[1] as ReturnType | undefined; + const button = + trailing?.children?.at(-1) as + | { listeners?: Map void>> } + | undefined; + const dblclickHandler = button?.listeners?.get('dblclick')?.[0]; + + assert.equal(typeof dblclickHandler, 'function'); + let stopped = false; + dblclickHandler?.({ + stopPropagation: () => { + stopped = true; + }, + }); + + assert.equal(stopped, true); + } finally { + env.restore(); + } +}); + +test('playlist browser preserves prior selection across mutation snapshots', async () => { + const env = setupPlaylistBrowserModalTest({ + electronApi: { + getPlaylistBrowserSnapshot: async () => ({ + ...createSnapshot(), + directoryItems: [ + ...createSnapshot().directoryItems, + { + path: '/tmp/show/Show - S01E03.mkv', + basename: 'Show - S01E03.mkv', + episodeLabel: 'S1E3', + isCurrentFile: false, + }, + ], + playlistItems: [ + ...createSnapshot().playlistItems, + { + index: 2, + id: 3, + filename: '/tmp/show/Show - S01E03.mkv', + title: 'Episode 3', + displayLabel: 'Episode 3', + current: false, + playing: false, + path: '/tmp/show/Show - S01E03.mkv', + }, + ], + }), + appendPlaylistBrowserFile: async () => ({ + ok: true, + message: 'Queued file', + snapshot: createMutationSnapshot(), + }), + }, + }); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + env.state.playlistBrowserActivePane = 'directory'; + env.state.playlistBrowserSelectedDirectoryIndex = 2; + env.state.playlistBrowserSelectedPlaylistIndex = 0; + + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault: () => {}, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 2); + assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 2); + } finally { + env.restore(); + } +}); + +test('playlist browser modal keydown routes append, remove, reorder, tab switch, and play', async () => { + const calls: Array<[string, unknown[]]> = []; + const notifications: string[] = []; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + appendPlaylistBrowserFile: async (filePath: string) => { + calls.push(['append', [filePath]]); + return { ok: true, message: 'append-ok', snapshot: createMutationSnapshot() }; + }, + playPlaylistBrowserIndex: async (index: number) => { + calls.push(['play', [index]]); + return { ok: true, message: 'play-ok', snapshot: createSnapshot() }; + }, + removePlaylistBrowserIndex: async (index: number) => { + calls.push(['remove', [index]]); + return { ok: true, message: 'remove-ok', snapshot: createSnapshot() }; + }, + movePlaylistBrowserIndex: async (index: number, direction: -1 | 1) => { + calls.push(['move', [index, direction]]); + return { ok: true, message: 'move-ok', snapshot: createSnapshot() }; + }, + }, + }); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + + const preventDefault = () => {}; + env.state.playlistBrowserActivePane = 'directory'; + env.state.playlistBrowserSelectedDirectoryIndex = 0; + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Tab', + code: 'Tab', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + assert.equal(env.state.playlistBrowserActivePane, 'playlist'); + + await modal.handlePlaylistBrowserKeydown({ + key: 'ArrowDown', + code: 'ArrowDown', + preventDefault, + ctrlKey: true, + metaKey: false, + shiftKey: false, + } as never); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Delete', + code: 'Delete', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + assert.deepEqual(calls, [ + ['append', ['/tmp/show/Show - S01E01.mkv']], + ['move', [1, 1]], + ['remove', [1]], + ['play', [1]], + ]); + assert.equal(env.state.playlistBrowserModalOpen, false); + assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']); + } finally { + env.restore(); + } +}); + +test('playlist browser keeps modal open when playing selected queue item fails', async () => { + const notifications: string[] = []; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }), + }, + }); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + assert.equal(env.state.playlistBrowserModalOpen, true); + + await modal.handlePlaylistBrowserKeydown({ + key: 'Enter', + code: 'Enter', + preventDefault: () => {}, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as never); + + assert.equal(env.state.playlistBrowserModalOpen, true); + assert.equal(env.dom.playlistBrowserStatus.textContent, 'play failed'); + assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true); + assert.deepEqual(notifications, ['open:playlist-browser']); + } finally { + env.restore(); + } +}); + +test('playlist browser refresh failure clears stale rendered rows and reports the error', async () => { + const notifications: string[] = []; + let refreshShouldFail = false; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + getPlaylistBrowserSnapshot: async () => { + if (refreshShouldFail) { + throw new Error('snapshot failed'); + } + return createSnapshot(); + }, + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + }, + }); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2); + + refreshShouldFail = true; + await modal.refreshSnapshot(); + + assert.equal(env.state.playlistBrowserSnapshot, null); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0); + assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser'); + assert.equal(env.dom.playlistBrowserStatus.textContent, 'snapshot failed'); + assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true); + assert.deepEqual(notifications, ['open:playlist-browser']); + } finally { + env.restore(); + } +}); + +test('playlist browser close clears rendered snapshot ui', async () => { + const notifications: string[] = []; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + }, + }); + + try { + const modal = env.createModal(); + + await modal.openPlaylistBrowserModal(); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2); + + modal.closePlaylistBrowserModal(); + + assert.equal(env.state.playlistBrowserSnapshot, null); + assert.equal(env.state.playlistBrowserStatus, ''); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0); + assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser'); + assert.equal(env.dom.playlistBrowserStatus.textContent, ''); + assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']); + } finally { + env.restore(); + } +}); + +test('playlist browser open is ignored while another modal is already open', async () => { + const notifications: string[] = []; + let snapshotCalls = 0; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + getPlaylistBrowserSnapshot: async () => { + snapshotCalls += 1; + return createSnapshot(); + }, + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + }, + }); + + try { + const modal = env.createModal({ + modalStateReader: { isAnyModalOpen: () => true }, + }); + + await modal.openPlaylistBrowserModal(); + + assert.equal(env.state.playlistBrowserModalOpen, false); + assert.equal(snapshotCalls, 0); + assert.equal(env.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(notifications, []); + } finally { + env.restore(); + } +}); diff --git a/src/renderer/modals/playlist-browser.ts b/src/renderer/modals/playlist-browser.ts new file mode 100644 index 00000000..0fc3a81b --- /dev/null +++ b/src/renderer/modals/playlist-browser.ts @@ -0,0 +1,407 @@ +import type { + PlaylistBrowserDirectoryItem, + PlaylistBrowserMutationResult, + PlaylistBrowserQueueItem, + PlaylistBrowserSnapshot, +} from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; +import { + renderPlaylistBrowserDirectoryRow, + renderPlaylistBrowserPlaylistRow, +} from './playlist-browser-renderer.js'; + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.min(Math.max(index, 0), length - 1); +} + +function buildDefaultStatus(snapshot: PlaylistBrowserSnapshot): string { + const directoryCount = snapshot.directoryItems.length; + const playlistCount = snapshot.playlistItems.length; + if (!snapshot.directoryAvailable) { + return `${snapshot.directoryStatus} ${playlistCount > 0 ? `· ${playlistCount} queued` : ''}`.trim(); + } + return `${directoryCount} sibling videos · ${playlistCount} queued`; +} + +function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): number { + const directoryIndex = snapshot.directoryItems.findIndex((item) => item.isCurrentFile); + return clampIndex(directoryIndex >= 0 ? directoryIndex : 0, snapshot.directoryItems.length); +} + +function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number { + const playlistIndex = + snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing); + return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length); +} + +function resolvePreservedIndex( + previousIndex: number, + previousItems: T[], + nextItems: T[], + matchIndex: (previousItem: T) => number, +): number { + if (nextItems.length <= 0) return 0; + if (previousItems.length <= 0) return clampIndex(previousIndex, nextItems.length); + + const normalizedPreviousIndex = clampIndex(previousIndex, previousItems.length); + const previousItem = previousItems[normalizedPreviousIndex]; + const matchedIndex = previousItem ? matchIndex(previousItem) : -1; + return clampIndex(matchedIndex >= 0 ? matchedIndex : normalizedPreviousIndex, nextItems.length); +} + +function resolveDirectorySelectionIndex( + snapshot: PlaylistBrowserSnapshot, + previousSnapshot: PlaylistBrowserSnapshot, + previousIndex: number, +): number { + return resolvePreservedIndex( + previousIndex, + previousSnapshot.directoryItems, + snapshot.directoryItems, + (previousItem: PlaylistBrowserDirectoryItem) => + snapshot.directoryItems.findIndex((item) => item.path === previousItem.path), + ); +} + +function resolvePlaylistSelectionIndex( + snapshot: PlaylistBrowserSnapshot, + previousSnapshot: PlaylistBrowserSnapshot, + previousIndex: number, +): number { + return resolvePreservedIndex( + previousIndex, + previousSnapshot.playlistItems, + snapshot.playlistItems, + (previousItem: PlaylistBrowserQueueItem) => { + if (previousItem.id !== null) { + const byId = snapshot.playlistItems.findIndex((item) => item.id === previousItem.id); + if (byId >= 0) return byId; + } + if (previousItem.path) { + return snapshot.playlistItems.findIndex((item) => item.path === previousItem.path); + } + return -1; + }, + ); +} + +export function createPlaylistBrowserModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function setStatus(message: string, isError = false): void { + ctx.state.playlistBrowserStatus = message; + ctx.dom.playlistBrowserStatus.textContent = message; + ctx.dom.playlistBrowserStatus.classList.toggle('error', isError); + } + + function getSnapshot(): PlaylistBrowserSnapshot | null { + return ctx.state.playlistBrowserSnapshot; + } + + function resetSnapshotUi(): void { + ctx.state.playlistBrowserSnapshot = null; + ctx.state.playlistBrowserStatus = ''; + ctx.state.playlistBrowserSelectedDirectoryIndex = 0; + ctx.state.playlistBrowserSelectedPlaylistIndex = 0; + ctx.dom.playlistBrowserTitle.textContent = 'Playlist Browser'; + ctx.dom.playlistBrowserDirectoryList.replaceChildren(); + ctx.dom.playlistBrowserPlaylistList.replaceChildren(); + ctx.dom.playlistBrowserStatus.textContent = ''; + ctx.dom.playlistBrowserStatus.classList.remove('error'); + } + + function syncSelection( + snapshot: PlaylistBrowserSnapshot, + previousSnapshot: PlaylistBrowserSnapshot | null, + ): void { + if (!previousSnapshot) { + ctx.state.playlistBrowserSelectedDirectoryIndex = getDefaultDirectorySelectionIndex(snapshot); + ctx.state.playlistBrowserSelectedPlaylistIndex = getDefaultPlaylistSelectionIndex(snapshot); + return; + } + + ctx.state.playlistBrowserSelectedDirectoryIndex = resolveDirectorySelectionIndex( + snapshot, + previousSnapshot, + ctx.state.playlistBrowserSelectedDirectoryIndex, + ); + ctx.state.playlistBrowserSelectedPlaylistIndex = resolvePlaylistSelectionIndex( + snapshot, + previousSnapshot, + ctx.state.playlistBrowserSelectedPlaylistIndex, + ); + } + + function render(): void { + const snapshot = getSnapshot(); + if (!snapshot) { + ctx.dom.playlistBrowserDirectoryList.replaceChildren(); + ctx.dom.playlistBrowserPlaylistList.replaceChildren(); + return; + } + + ctx.dom.playlistBrowserTitle.textContent = snapshot.directoryPath ?? 'Playlist Browser'; + ctx.dom.playlistBrowserStatus.textContent = + ctx.state.playlistBrowserStatus || buildDefaultStatus(snapshot); + ctx.dom.playlistBrowserDirectoryList.replaceChildren( + ...snapshot.directoryItems.map((item, index) => + renderPlaylistBrowserDirectoryRow(ctx, item, index, { + appendDirectoryItem, + movePlaylistItem, + playPlaylistItem, + removePlaylistItem, + render, + }), + ), + ); + ctx.dom.playlistBrowserPlaylistList.replaceChildren( + ...snapshot.playlistItems.map((item, index) => + renderPlaylistBrowserPlaylistRow(ctx, item, index, { + appendDirectoryItem, + movePlaylistItem, + playPlaylistItem, + removePlaylistItem, + render, + }), + ), + ); + } + + function applySnapshot(snapshot: PlaylistBrowserSnapshot): void { + const previousSnapshot = ctx.state.playlistBrowserSnapshot; + ctx.state.playlistBrowserSnapshot = snapshot; + syncSelection(snapshot, previousSnapshot); + render(); + } + + async function refreshSnapshot(): Promise { + try { + const snapshot = await window.electronAPI.getPlaylistBrowserSnapshot(); + ctx.state.playlistBrowserStatus = ''; + applySnapshot(snapshot); + setStatus( + buildDefaultStatus(snapshot), + !snapshot.directoryAvailable && snapshot.directoryStatus.length > 0, + ); + } catch (error) { + resetSnapshotUi(); + setStatus(error instanceof Error ? error.message : String(error), true); + } + } + + async function handleMutation( + action: Promise, + fallbackMessage: string, + ): Promise { + const result = await action; + if (!result.ok) { + setStatus(result.message, true); + return; + } + setStatus(result.message || fallbackMessage, false); + if (result.snapshot) { + applySnapshot(result.snapshot); + return; + } + await refreshSnapshot(); + } + + async function appendDirectoryItem(filePath: string): Promise { + await handleMutation(window.electronAPI.appendPlaylistBrowserFile(filePath), 'Queued file'); + } + + async function playPlaylistItem(index: number): Promise { + const result = await window.electronAPI.playPlaylistBrowserIndex(index); + if (!result.ok) { + setStatus(result.message, true); + return; + } + closePlaylistBrowserModal(); + } + + async function removePlaylistItem(index: number): Promise { + await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item'); + } + + async function movePlaylistItem(index: number, direction: 1 | -1): Promise { + await handleMutation( + window.electronAPI.movePlaylistBrowserIndex(index, direction), + 'Moved queue item', + ); + } + + async function openPlaylistBrowserModal(): Promise { + if (ctx.state.playlistBrowserModalOpen) { + await refreshSnapshot(); + return; + } + if (options.modalStateReader.isAnyModalOpen()) { + return; + } + + ctx.state.playlistBrowserModalOpen = true; + ctx.state.playlistBrowserActivePane = 'playlist'; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.playlistBrowserModal.classList.remove('hidden'); + ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'false'); + window.electronAPI.notifyOverlayModalOpened('playlist-browser'); + await refreshSnapshot(); + } + + function closePlaylistBrowserModal(): void { + if (!ctx.state.playlistBrowserModalOpen) return; + ctx.state.playlistBrowserModalOpen = false; + resetSnapshotUi(); + ctx.dom.playlistBrowserModal.classList.add('hidden'); + ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('playlist-browser'); + options.syncSettingsModalSubtitleSuppression(); + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + } + + function moveSelection(delta: number): void { + const snapshot = getSnapshot(); + if (!snapshot) return; + if (ctx.state.playlistBrowserActivePane === 'directory') { + ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex( + ctx.state.playlistBrowserSelectedDirectoryIndex + delta, + snapshot.directoryItems.length, + ); + } else { + ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex( + ctx.state.playlistBrowserSelectedPlaylistIndex + delta, + snapshot.playlistItems.length, + ); + } + render(); + } + + function jumpSelection(target: 'start' | 'end'): void { + const snapshot = getSnapshot(); + if (!snapshot) return; + const length = + ctx.state.playlistBrowserActivePane === 'directory' + ? snapshot.directoryItems.length + : snapshot.playlistItems.length; + const nextIndex = target === 'start' ? 0 : Math.max(0, length - 1); + if (ctx.state.playlistBrowserActivePane === 'directory') { + ctx.state.playlistBrowserSelectedDirectoryIndex = nextIndex; + } else { + ctx.state.playlistBrowserSelectedPlaylistIndex = nextIndex; + } + render(); + } + + function activateSelection(): void { + const snapshot = getSnapshot(); + if (!snapshot) return; + if (ctx.state.playlistBrowserActivePane === 'directory') { + const item = snapshot.directoryItems[ctx.state.playlistBrowserSelectedDirectoryIndex]; + if (item) { + void appendDirectoryItem(item.path); + } + return; + } + + const item = snapshot.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void playPlaylistItem(item.index); + } + } + + function handlePlaylistBrowserKeydown(event: KeyboardEvent): boolean { + if (!ctx.state.playlistBrowserModalOpen) return false; + + if (event.key === 'Escape') { + event.preventDefault(); + closePlaylistBrowserModal(); + return true; + } + if (event.key === 'Tab') { + event.preventDefault(); + ctx.state.playlistBrowserActivePane = + ctx.state.playlistBrowserActivePane === 'directory' ? 'playlist' : 'directory'; + render(); + return true; + } + if (event.key === 'Home') { + event.preventDefault(); + jumpSelection('start'); + return true; + } + if (event.key === 'End') { + event.preventDefault(); + jumpSelection('end'); + return true; + } + if (event.key === 'ArrowUp' && (event.ctrlKey || event.metaKey)) { + if (ctx.state.playlistBrowserActivePane === 'playlist') { + event.preventDefault(); + const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void movePlaylistItem(item.index, -1); + } + return true; + } + } + if (event.key === 'ArrowDown' && (event.ctrlKey || event.metaKey)) { + if (ctx.state.playlistBrowserActivePane === 'playlist') { + event.preventDefault(); + const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void movePlaylistItem(item.index, 1); + } + return true; + } + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveSelection(-1); + return true; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveSelection(1); + return true; + } + if (event.key === 'Enter') { + event.preventDefault(); + activateSelection(); + return true; + } + if (event.key === 'Delete' || event.key === 'Backspace') { + if (ctx.state.playlistBrowserActivePane === 'playlist') { + event.preventDefault(); + const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex]; + if (item) { + void removePlaylistItem(item.index); + } + return true; + } + } + + return false; + } + + function wireDomEvents(): void { + ctx.dom.playlistBrowserClose.addEventListener('click', () => { + closePlaylistBrowserModal(); + }); + } + + return { + openPlaylistBrowserModal, + closePlaylistBrowserModal, + handlePlaylistBrowserKeydown, + refreshSnapshot, + wireDomEvents, + }; +} diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index f5c8177a..30a7982c 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string { } if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; + if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { @@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string { if ( first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || + first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN || first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) ) { return 'Runtime settings'; diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 48a445a0..4672e0c0 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js'; import { createControllerSelectModal } from './modals/controller-select.js'; import { createJimakuModal } from './modals/jimaku.js'; import { createKikuModal } from './modals/kiku.js'; +import { createPlaylistBrowserModal } from './modals/playlist-browser.js'; import { createSessionHelpModal } from './modals/session-help.js'; import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js'; import { isControllerInteractionBlocked } from './controller-interaction-blocking.js'; @@ -71,7 +72,8 @@ function isAnySettingsModalOpen(): boolean { ctx.state.kikuModalOpen || ctx.state.jimakuModalOpen || ctx.state.youtubePickerModalOpen || - ctx.state.sessionHelpModalOpen + ctx.state.sessionHelpModalOpen || + ctx.state.playlistBrowserModalOpen ); } @@ -85,6 +87,7 @@ function isAnyModalOpen(): boolean { ctx.state.subsyncModalOpen || ctx.state.youtubePickerModalOpen || ctx.state.sessionHelpModalOpen || + ctx.state.playlistBrowserModalOpen || ctx.state.subtitleSidebarModalOpen ); } @@ -153,12 +156,17 @@ const youtubePickerModal = createYoutubeTrackPickerModal(ctx, { restorePointerInteractionState: mouseHandlers.restorePointerInteractionState, syncSettingsModalSubtitleSuppression, }); +const playlistBrowserModal = createPlaylistBrowserModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); const keyboardHandlers = createKeyboardHandlers(ctx, { handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, + handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, @@ -209,6 +217,7 @@ function getActiveModal(): string | null { if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar'; if (ctx.state.jimakuModalOpen) return 'jimaku'; if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker'; + if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser'; if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.subsyncModalOpen) return 'subsync'; @@ -232,6 +241,9 @@ function dismissActiveUiAfterError(): void { if (ctx.state.youtubePickerModalOpen) { youtubePickerModal.closeYoutubePickerModal(); } + if (ctx.state.playlistBrowserModalOpen) { + playlistBrowserModal.closePlaylistBrowserModal(); + } if (ctx.state.runtimeOptionsModalOpen) { runtimeOptionsModal.closeRuntimeOptionsModal(); } @@ -439,6 +451,11 @@ function registerModalOpenHandlers(): void { youtubePickerModal.openYoutubePickerModal(payload); }); }); + window.electronAPI.onOpenPlaylistBrowser(() => { + runGuardedAsync('playlist-browser:open', async () => { + await playlistBrowserModal.openPlaylistBrowserModal(); + }); + }); window.electronAPI.onCancelYoutubeTrackPicker(() => { runGuarded('youtube:picker-cancel', () => { youtubePickerModal.closeYoutubePickerModal(); @@ -518,6 +535,11 @@ async function init(): Promise { runGuarded('subtitle-position:update', () => { positioning.applyStoredSubtitlePosition(position, 'media-change'); measurementReporter.schedule(); + if (ctx.state.playlistBrowserModalOpen) { + runGuardedAsync('playlist-browser:refresh-on-media-change', async () => { + await playlistBrowserModal.refreshSnapshot(); + }); + } }); }); @@ -572,6 +594,7 @@ async function init(): Promise { jimakuModal.wireDomEvents(); youtubePickerModal.wireDomEvents(); + playlistBrowserModal.wireDomEvents(); kikuModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents(); subsyncModal.wireDomEvents(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index c81f8e45..40ddff59 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -1,4 +1,5 @@ import type { + PlaylistBrowserSnapshot, ControllerButtonSnapshot, ControllerDeviceInfo, ResolvedControllerConfig, @@ -78,6 +79,12 @@ export type RendererState = { sessionHelpModalOpen: boolean; sessionHelpSelectedIndex: number; + playlistBrowserModalOpen: boolean; + playlistBrowserSnapshot: PlaylistBrowserSnapshot | null; + playlistBrowserStatus: string; + playlistBrowserActivePane: 'directory' | 'playlist'; + playlistBrowserSelectedDirectoryIndex: number; + playlistBrowserSelectedPlaylistIndex: number; subtitleSidebarCues: SubtitleCue[]; subtitleSidebarActiveCueIndex: number; subtitleSidebarToggleKey: string; @@ -175,6 +182,12 @@ export function createRendererState(): RendererState { sessionHelpModalOpen: false, sessionHelpSelectedIndex: 0, + playlistBrowserModalOpen: false, + playlistBrowserSnapshot: null, + playlistBrowserStatus: '', + playlistBrowserActivePane: 'playlist', + playlistBrowserSelectedDirectoryIndex: 0, + playlistBrowserSelectedPlaylistIndex: 0, subtitleSidebarCues: [], subtitleSidebarActiveCueIndex: -1, subtitleSidebarToggleKey: 'Backslash', diff --git a/src/renderer/style.css b/src/renderer/style.css index 79d4d776..0e6fb3b1 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -42,6 +42,34 @@ body { :root { --subtitle-sidebar-reserved-width: 0px; + + /* Catppuccin Macchiato */ + --ctp-rosewater: #f4dbd6; + --ctp-flamingo: #f0c6c6; + --ctp-pink: #f5bde6; + --ctp-mauve: #c6a0f6; + --ctp-red: #ed8796; + --ctp-maroon: #ee99a0; + --ctp-peach: #f5a97f; + --ctp-yellow: #eed49f; + --ctp-green: #a6da95; + --ctp-teal: #8bd5ca; + --ctp-sky: #91d7e3; + --ctp-sapphire: #7dc4e4; + --ctp-blue: #8aadf4; + --ctp-lavender: #b7bdf8; + --ctp-text: #cad3f5; + --ctp-subtext1: #b8c0e0; + --ctp-subtext0: #a5adcb; + --ctp-overlay2: #939ab7; + --ctp-overlay1: #8087a2; + --ctp-overlay0: #6e738d; + --ctp-surface2: #5b6078; + --ctp-surface1: #494d64; + --ctp-surface0: #363a4f; + --ctp-base: #24273a; + --ctp-mantle: #1e2030; + --ctp-crust: #181926; } #overlay { @@ -146,7 +174,7 @@ body { border: 1px solid rgba(110, 115, 141, 0.18); border-radius: 12px; padding: 16px; - color: #cad3f5; + color: var(--ctp-text); display: flex; flex-direction: column; gap: 12px; @@ -166,7 +194,7 @@ body { .modal-close { background: rgba(73, 77, 100, 0.5); - color: #a5adcb; + color: var(--ctp-subtext0); border: 1px solid rgba(110, 115, 141, 0.2); border-radius: 6px; padding: 6px 10px; @@ -175,7 +203,7 @@ body { .modal-close:hover { background: rgba(91, 96, 120, 0.6); - color: #cad3f5; + color: var(--ctp-text); } .modal-body { @@ -197,14 +225,14 @@ body { flex-direction: column; gap: 6px; font-size: 12px; - color: rgba(255, 255, 255, 0.7); + color: var(--ctp-subtext0); } .jimaku-field input { - background: rgba(0, 0, 0, 0.5); - border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(24, 25, 38, 0.85); + border: 1px solid rgba(110, 115, 141, 0.2); border-radius: 6px; - color: #fff; + color: var(--ctp-text); padding: 6px 8px; } @@ -212,20 +240,20 @@ body { height: 36px; padding: 0 14px; border-radius: 6px; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.15); - color: #fff; + border: 1px solid rgba(110, 115, 141, 0.35); + background: rgba(54, 58, 79, 0.6); + color: var(--ctp-text); cursor: pointer; } .jimaku-button:hover { - background: rgba(255, 255, 255, 0.25); + background: rgba(91, 96, 120, 0.7); } .jimaku-status { min-height: 20px; font-size: 13px; - color: rgba(255, 255, 255, 0.8); + color: var(--ctp-subtext1); } .jimaku-section { @@ -244,7 +272,7 @@ body { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.6); + color: var(--ctp-overlay2); } .jimaku-list { @@ -252,14 +280,14 @@ body { padding: 0; margin: 0; overflow-y: auto; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(110, 115, 141, 0.14); border-radius: 8px; max-height: 180px; } .jimaku-list li { padding: 8px 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: 1px solid rgba(110, 115, 141, 0.1); cursor: pointer; display: flex; flex-direction: column; @@ -271,18 +299,18 @@ body { } .jimaku-list li.active { - background: rgba(255, 255, 255, 0.15); + background: rgba(138, 173, 244, 0.15); } .jimaku-list .jimaku-subtext { font-size: 12px; - color: rgba(255, 255, 255, 0.6); + color: var(--ctp-overlay2); } .jimaku-link { align-self: flex-start; background: transparent; - color: rgba(255, 255, 255, 0.8); + color: var(--ctp-blue); border: none; padding: 0; cursor: pointer; @@ -307,7 +335,7 @@ body { .youtube-picker-title { font-size: 13px; - color: #b8c0e0; + color: var(--ctp-subtext1); } .youtube-picker-grid { @@ -321,7 +349,7 @@ body { flex-direction: column; gap: 6px; font-size: 12px; - color: #a5adcb; + color: var(--ctp-subtext0); } .youtube-picker-field select { @@ -329,14 +357,14 @@ body { border-radius: 8px; border: 1px solid rgba(110, 115, 141, 0.28); background: rgba(24, 25, 38, 0.92); - color: #cad3f5; + color: var(--ctp-text); padding: 6px 10px; } .youtube-picker-status { min-height: 20px; font-size: 13px; - color: #a5adcb; + color: var(--ctp-subtext0); } .youtube-picker-tracks { @@ -355,7 +383,7 @@ body { gap: 12px; padding: 10px 12px; border-bottom: 1px solid rgba(110, 115, 141, 0.08); - color: #cad3f5; + color: var(--ctp-text); } .youtube-picker-tracks li:last-child { @@ -363,7 +391,7 @@ body { } .youtube-picker-track-meta { - color: #6e738d; + color: var(--ctp-overlay0); font-size: 12px; } @@ -372,10 +400,214 @@ body { justify-content: flex-end; } +.playlist-browser-content { + width: min(1480px, calc(100vw - 40px)); + max-width: none; + min-height: min(760px, calc(100vh - 56px)); + max-height: calc(100vh - 40px); + padding: 18px 18px 16px; + background: + radial-gradient(circle at top left, rgba(138, 173, 244, 0.13), transparent 28%), + linear-gradient(180deg, rgba(36, 39, 58, 0.985), rgba(30, 32, 48, 0.985)); + border-color: rgba(138, 173, 244, 0.24); + box-shadow: + 0 28px 80px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(18px); +} + +.playlist-browser-body { + flex: 1; + min-height: 0; + gap: 16px; +} + +.playlist-browser-title { + font-size: 14px; + font-weight: 700; + color: var(--ctp-lavender); + overflow-wrap: anywhere; +} + +.playlist-browser-status { + min-height: 20px; + font-size: 12px; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--ctp-overlay2); +} + +.playlist-browser-grid { + display: grid; + flex: 1; + min-height: 0; + grid-template-columns: minmax(0, 1.15fr) minmax(420px, 0.85fr); + gap: 18px; +} + +.playlist-browser-pane { + display: flex; + flex-direction: column; + min-height: 0; + gap: 10px; + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(110, 115, 141, 0.16); + background: + linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.playlist-browser-pane-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--ctp-blue); +} + +.playlist-browser-list { + flex: 1; + min-height: 0; + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + border-radius: 12px; + border: 1px solid rgba(110, 115, 141, 0.14); + background: rgba(30, 32, 48, 0.5); +} + +.playlist-browser-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-bottom: 1px solid rgba(110, 115, 141, 0.09); + color: var(--ctp-text); +} + +.playlist-browser-row:last-child { + border-bottom: none; +} + +.playlist-browser-row.active { + background: rgba(138, 173, 244, 0.14); +} + +.playlist-browser-row.current { + background: + linear-gradient(90deg, rgba(138, 173, 244, 0.12), rgba(138, 173, 244, 0.03) 28%, transparent); + box-shadow: inset 3px 0 0 #8aadf4; +} + +.playlist-browser-row-main { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 5px; +} + +.playlist-browser-row-label { + font-size: 15px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.playlist-browser-row-meta { + font-size: 12px; + color: var(--ctp-blue); + line-height: 1.35; +} + +.playlist-browser-row-submeta { + font-size: 12px; + color: var(--ctp-subtext0); + line-height: 1.45; + overflow-wrap: anywhere; +} + +.playlist-browser-row-trailing { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 10px; +} + +.playlist-browser-chip { + border: 1px solid rgba(138, 173, 244, 0.24); + border-radius: 999px; + padding: 4px 9px; + background: rgba(138, 173, 244, 0.1); + color: var(--ctp-lavender); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.playlist-browser-row-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + flex-shrink: 0; + max-width: 240px; +} + +.playlist-browser-action { + border: 1px solid rgba(138, 173, 244, 0.24); + background: rgba(49, 54, 83, 0.78); + color: var(--ctp-text); + border-radius: 9px; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; +} + +.playlist-browser-action:hover { + background: rgba(70, 78, 117, 0.88); + color: var(--ctp-lavender); +} + +.playlist-browser-footer { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding-top: 2px; + font-size: 12px; + color: var(--ctp-overlay0); +} + @media (max-width: 700px) { .youtube-picker-grid { grid-template-columns: 1fr; } + + .playlist-browser-content { + width: calc(100vw - 20px); + min-height: calc(100vh - 24px); + max-height: calc(100vh - 20px); + padding: 14px; + border-radius: 14px; + } + + .playlist-browser-grid { + grid-template-columns: 1fr; + } + + .playlist-browser-row, + .playlist-browser-row-trailing { + flex-direction: column; + align-items: flex-start; + } + + .playlist-browser-row-actions { + max-width: none; + justify-content: flex-start; + } } @media (max-width: 640px) { @@ -923,7 +1155,7 @@ iframe[id^='yomitan-popup'] { .kiku-info-text { font-size: 14px; - color: rgba(255, 255, 255, 0.8); + color: var(--ctp-subtext1); line-height: 1.5; } @@ -934,8 +1166,8 @@ iframe[id^='yomitan-popup'] { } .kiku-card { - background: rgba(40, 40, 40, 0.8); - border: 2px solid rgba(255, 255, 255, 0.1); + background: rgba(54, 58, 79, 0.85); + border: 2px solid rgba(110, 115, 141, 0.2); border-radius: 10px; padding: 14px; display: flex; @@ -947,35 +1179,35 @@ iframe[id^='yomitan-popup'] { } .kiku-card:hover { - border-color: rgba(255, 255, 255, 0.3); + border-color: rgba(110, 115, 141, 0.45); } .kiku-card.active { - border-color: rgba(100, 180, 255, 0.8); - background: rgba(40, 60, 90, 0.5); + border-color: rgba(138, 173, 244, 0.85); + background: rgba(138, 173, 244, 0.12); } .kiku-card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.5); + color: var(--ctp-overlay1); font-weight: 600; } .kiku-card.active .kiku-card-label { - color: rgba(100, 180, 255, 0.9); + color: var(--ctp-blue); } .kiku-card-expression { font-size: 22px; font-weight: 600; - color: #fff; + color: var(--ctp-text); } .kiku-card-sentence { font-size: 14px; - color: rgba(255, 255, 255, 0.75); + color: var(--ctp-subtext0); max-height: 52px; overflow: hidden; text-overflow: ellipsis; @@ -984,7 +1216,7 @@ iframe[id^='yomitan-popup'] { .kiku-card-meta { font-size: 12px; - color: rgba(255, 255, 255, 0.45); + color: var(--ctp-overlay1); display: flex; gap: 10px; } @@ -996,7 +1228,7 @@ iframe[id^='yomitan-popup'] { align-items: center; gap: 10px; padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(110, 115, 141, 0.14); } .kiku-preview-header { @@ -1010,7 +1242,7 @@ iframe[id^='yomitan-popup'] { .kiku-preview-title { font-size: 13px; font-weight: 600; - color: rgba(255, 255, 255, 0.88); + color: var(--ctp-text); } .kiku-preview-toggle { @@ -1021,17 +1253,17 @@ iframe[id^='yomitan-popup'] { .kiku-preview-toggle button { padding: 5px 10px; border-radius: 5px; - border: 1px solid rgba(255, 255, 255, 0.15); - background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(110, 115, 141, 0.2); + background: rgba(54, 58, 79, 0.4); + color: var(--ctp-subtext0); cursor: pointer; font-size: 12px; } .kiku-preview-toggle button.active { - border-color: rgba(100, 180, 255, 0.45); - background: rgba(100, 180, 255, 0.16); - color: rgba(100, 180, 255, 0.95); + border-color: rgba(138, 173, 244, 0.45); + background: rgba(138, 173, 244, 0.16); + color: var(--ctp-blue); } .kiku-preview-json { @@ -1041,19 +1273,19 @@ iframe[id^='yomitan-popup'] { overflow: auto; white-space: pre-wrap; word-break: break-word; - border: 1px solid rgba(255, 255, 255, 0.12); + border: 1px solid rgba(110, 115, 141, 0.2); border-radius: 8px; - background: rgba(0, 0, 0, 0.34); + background: rgba(24, 25, 38, 0.8); padding: 10px; font-size: 11px; line-height: 1.45; - color: rgba(255, 255, 255, 0.88); + color: var(--ctp-text); } .kiku-preview-error { margin: 0 0 10px; font-size: 12px; - color: #ff8f8f; + color: var(--ctp-red); } .kiku-delete-toggle { @@ -1061,12 +1293,12 @@ iframe[id^='yomitan-popup'] { align-items: center; gap: 8px; font-size: 12px; - color: rgba(255, 255, 255, 0.8); + color: var(--ctp-subtext1); user-select: none; } .kiku-delete-toggle input { - accent-color: rgba(100, 180, 255, 0.9); + accent-color: var(--ctp-blue); } .kiku-confirm-button { @@ -1074,7 +1306,7 @@ iframe[id^='yomitan-popup'] { border-radius: 6px; border: 1px solid rgba(138, 173, 244, 0.4); background: rgba(138, 173, 244, 0.15); - color: #8aadf4; + color: var(--ctp-blue); font-weight: 600; cursor: pointer; } @@ -1098,7 +1330,7 @@ iframe[id^='yomitan-popup'] { flex-direction: column; gap: 6px; font-size: 13px; - color: rgba(255, 255, 255, 0.85); + color: var(--ctp-subtext1); } .subsync-radio { @@ -1107,14 +1339,14 @@ iframe[id^='yomitan-popup'] { gap: 8px; margin-right: 14px; font-size: 13px; - color: rgba(255, 255, 255, 0.8); + color: var(--ctp-subtext1); } .subsync-field select { - background: rgba(0, 0, 0, 0.5); - border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(24, 25, 38, 0.9); + border: 1px solid rgba(110, 115, 141, 0.2); border-radius: 6px; - color: #fff; + color: var(--ctp-text); padding: 8px 10px; } @@ -1126,21 +1358,21 @@ iframe[id^='yomitan-popup'] { .kiku-cancel-button { padding: 8px 16px; border-radius: 6px; - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid rgba(110, 115, 141, 0.25); background: transparent; - color: rgba(255, 255, 255, 0.6); + color: var(--ctp-overlay2); cursor: pointer; } .kiku-cancel-button:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; + background: rgba(54, 58, 79, 0.5); + color: var(--ctp-text); } .kiku-hint { text-align: center; font-size: 11px; - color: rgba(255, 255, 255, 0.35); + color: var(--ctp-overlay0); } .runtime-modal-content { @@ -1149,14 +1381,14 @@ iframe[id^='yomitan-popup'] { .runtime-options-hint { font-size: 12px; - color: rgba(255, 255, 255, 0.65); + color: var(--ctp-subtext0); } .runtime-options-list { list-style: none; margin: 0; padding: 0; - border: 1px solid rgba(255, 255, 255, 0.12); + border: 1px solid rgba(110, 115, 141, 0.2); border-radius: 8px; max-height: 320px; overflow-y: auto; @@ -1171,7 +1403,7 @@ iframe[id^='yomitan-popup'] { flex-direction: column; gap: 4px; padding: 10px 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(110, 115, 141, 0.1); cursor: pointer; } @@ -1188,37 +1420,37 @@ iframe[id^='yomitan-popup'] { } .runtime-options-item.active { - background: rgba(100, 180, 255, 0.15); + background: rgba(138, 173, 244, 0.15); } .runtime-options-item-button:focus-visible { - outline: 2px solid rgba(100, 180, 255, 0.85); + outline: 2px solid rgba(138, 173, 244, 0.85); outline-offset: -2px; } .runtime-options-label { font-size: 14px; - color: #fff; + color: var(--ctp-text); } .runtime-options-value { font-size: 13px; - color: rgba(100, 180, 255, 0.9); + color: var(--ctp-blue); } .runtime-options-allowed { font-size: 11px; - color: rgba(255, 255, 255, 0.55); + color: var(--ctp-overlay1); } .runtime-options-status { min-height: 18px; font-size: 12px; - color: rgba(255, 255, 255, 0.75); + color: var(--ctp-subtext0); } .runtime-options-status.error { - color: #ff8f8f; + color: var(--ctp-red); } .controller-select-field { @@ -1227,22 +1459,22 @@ iframe[id^='yomitan-popup'] { gap: 6px; margin-bottom: 12px; font-size: 13px; - color: rgba(255, 255, 255, 0.88); + color: var(--ctp-text); } .controller-select-field select { min-height: 38px; padding: 8px 10px; - border: 1px solid rgba(255, 255, 255, 0.14); + border: 1px solid rgba(110, 115, 141, 0.2); border-radius: 8px; - background: rgba(10, 14, 20, 0.9); - color: rgba(255, 255, 255, 0.94); + background: rgba(24, 25, 38, 0.9); + color: var(--ctp-text); } .controller-select-summary { margin-bottom: 12px; font-size: 12px; - color: rgba(255, 255, 255, 0.7); + color: var(--ctp-subtext0); } .controller-config-list { @@ -1252,18 +1484,18 @@ iframe[id^='yomitan-popup'] { overflow-y: auto; margin-bottom: 12px; scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + scrollbar-color: rgba(110, 115, 141, 0.25) transparent; } .controller-config-group { margin-top: 14px; padding-bottom: 6px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(110, 115, 141, 0.12); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - color: rgba(120, 190, 255, 0.9); + color: var(--ctp-blue); } .controller-config-group:first-child { @@ -1274,7 +1506,7 @@ iframe[id^='yomitan-popup'] { display: flex; align-items: center; padding: 8px 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: 1px solid rgba(110, 115, 141, 0.1); background: transparent; cursor: pointer; transition: background 120ms ease; @@ -1285,19 +1517,19 @@ iframe[id^='yomitan-popup'] { } .controller-config-row:hover { - background: rgba(255, 255, 255, 0.04); + background: rgba(54, 58, 79, 0.4); } .controller-config-row.expanded { - background: rgba(100, 180, 255, 0.06); - border-color: rgba(100, 180, 255, 0.15); + background: rgba(138, 173, 244, 0.08); + border-color: rgba(138, 173, 244, 0.2); } .controller-config-label { flex: 1; min-width: 0; font-size: 13px; - color: rgba(255, 255, 255, 0.95); + color: var(--ctp-text); } .controller-config-right { @@ -1314,31 +1546,31 @@ iframe[id^='yomitan-popup'] { font-size: 11px; font-weight: 600; letter-spacing: 0.02em; - background: rgba(100, 180, 255, 0.12); - color: rgba(100, 180, 255, 0.95); + background: rgba(138, 173, 244, 0.12); + color: var(--ctp-blue); white-space: nowrap; } .controller-config-badge.disabled { - background: rgba(255, 255, 255, 0.06); - color: rgba(255, 255, 255, 0.4); + background: rgba(110, 115, 141, 0.1); + color: var(--ctp-overlay0); } .controller-config-edit-icon { font-size: 14px; - color: rgba(255, 255, 255, 0.3); + color: var(--ctp-overlay0); transition: color 120ms ease; } .controller-config-row:hover .controller-config-edit-icon { - color: rgba(255, 255, 255, 0.6); + color: var(--ctp-overlay2); } .controller-config-edit-panel { overflow: hidden; animation: configEditSlideIn 180ms ease-out; - border-bottom: 1px solid rgba(100, 180, 255, 0.12); - background: rgba(100, 180, 255, 0.04); + border-bottom: 1px solid rgba(138, 173, 244, 0.15); + background: rgba(138, 173, 244, 0.04); } @keyframes configEditSlideIn { @@ -1362,11 +1594,11 @@ iframe[id^='yomitan-popup'] { .controller-config-edit-hint { font-size: 12px; - color: rgba(255, 255, 255, 0.6); + color: var(--ctp-overlay2); } .controller-config-edit-hint.learning { - color: rgba(100, 180, 255, 0.95); + color: var(--ctp-blue); animation: configLearnPulse 1.2s ease-in-out infinite; } @@ -1391,7 +1623,7 @@ iframe[id^='yomitan-popup'] { border-radius: 5px; border: 1px solid rgba(138, 173, 244, 0.4); background: rgba(138, 173, 244, 0.15); - color: #8aadf4; + color: var(--ctp-blue); font-size: 12px; font-weight: 600; cursor: pointer; @@ -1412,7 +1644,7 @@ iframe[id^='yomitan-popup'] { border-radius: 5px; border: 1px solid rgba(110, 115, 141, 0.2); background: transparent; - color: #6e738d; + color: var(--ctp-overlay0); font-size: 12px; cursor: pointer; transition: @@ -1422,7 +1654,7 @@ iframe[id^='yomitan-popup'] { .btn-secondary:hover { background: rgba(73, 77, 100, 0.4); - color: #a5adcb; + color: var(--ctp-subtext0); } .controller-debug-content { @@ -1455,7 +1687,7 @@ iframe[id^='yomitan-popup'] { .controller-debug-summary { min-height: 18px; font-size: 13px; - color: rgba(255, 255, 255, 0.86); + color: var(--ctp-text); line-height: 1.45; } @@ -1482,9 +1714,9 @@ iframe[id^='yomitan-popup'] { margin: 0; padding: 12px; border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(0, 0, 0, 0.38); - color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(110, 115, 141, 0.2); + background: rgba(24, 25, 38, 0.85); + color: var(--ctp-text); font-size: 12px; line-height: 1.5; overflow: auto; @@ -1519,7 +1751,7 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal { ); font-size: var(--subtitle-sidebar-font-size, 16px); background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9)); - color: var(--subtitle-sidebar-text-color, #cad3f5); + color: var(--subtitle-sidebar-text-color, var(--ctp-text)); border: 1px solid rgba(110, 115, 141, 0.18); border-radius: 10px; box-shadow: @@ -1542,7 +1774,7 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal { font-size: 13px; font-weight: 600; letter-spacing: 0.04em; - color: #b8c0e0; + color: var(--ctp-subtext1); text-transform: uppercase; } @@ -1552,13 +1784,13 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal { border-radius: 6px; background: rgba(73, 77, 100, 0.5); border: 1px solid rgba(110, 115, 141, 0.2); - color: #a5adcb; + color: var(--ctp-subtext0); transition: all 140ms ease; } .subtitle-sidebar-content .modal-close:hover { background: rgba(91, 96, 120, 0.6); - color: #cad3f5; + color: var(--ctp-text); border-color: rgba(110, 115, 141, 0.35); } @@ -1586,7 +1818,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { .subtitle-sidebar-content .runtime-options-status { font-size: 11px; padding: 4px 14px; - color: #6e738d; + color: var(--ctp-overlay0); letter-spacing: 0.02em; } @@ -1666,34 +1898,34 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: 0.03em; - color: var(--subtitle-sidebar-timestamp-color, #6e738d); + color: var(--subtitle-sidebar-timestamp-color, var(--ctp-overlay0)); padding-top: 2px; } .subtitle-sidebar-item:hover .subtitle-sidebar-timestamp { - color: var(--subtitle-sidebar-timestamp-color, #a5adcb); + color: var(--subtitle-sidebar-timestamp-color, var(--ctp-subtext0)); } .subtitle-sidebar-item.active .subtitle-sidebar-timestamp { - color: var(--subtitle-sidebar-active-line-color, #f5bde6); + color: var(--subtitle-sidebar-active-line-color, var(--ctp-pink)); opacity: 0.75; } .subtitle-sidebar-item.active .subtitle-sidebar-text { - color: var(--subtitle-sidebar-active-line-color, #f5bde6); + color: var(--subtitle-sidebar-active-line-color, var(--ctp-pink)); } .subtitle-sidebar-text { white-space: pre-wrap; line-height: 1.5; font-size: 1em; - color: var(--subtitle-sidebar-text-color, #cad3f5); + color: var(--subtitle-sidebar-text-color, var(--ctp-text)); } .session-help-content { width: min(760px, 92%); max-height: 84%; - color: rgba(255, 255, 255, 0.95); + color: var(--ctp-text); } @media (max-width: 720px) { @@ -1707,17 +1939,17 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { .session-help-status { min-height: 18px; font-size: 13px; - color: rgba(255, 255, 255, 0.8); + color: var(--ctp-subtext1); line-height: 1.45; } .session-help-shortcut { font-weight: 600; - color: rgba(255, 255, 255, 0.97); + color: var(--ctp-text); } .session-help-warning { - color: #f8a100; + color: var(--ctp-yellow); } .session-help-content-list { @@ -1732,27 +1964,27 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { .session-help-filter { width: 100%; min-height: 32px; - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid rgba(110, 115, 141, 0.3); border-radius: 8px; padding: 8px 10px; - background: rgba(0, 0, 0, 0.45); - color: #fff; + background: rgba(24, 25, 38, 0.9); + color: var(--ctp-text); font-size: 13px; line-height: 1.2; } .session-help-filter::placeholder { - color: rgba(255, 255, 255, 0.45); + color: var(--ctp-overlay0); } .session-help-filter:focus { outline: none; - border-color: rgba(137, 180, 255, 0.6); - box-shadow: 0 0 0 2px rgba(137, 180, 255, 0.2); + border-color: rgba(138, 173, 244, 0.6); + box-shadow: 0 0 0 2px rgba(138, 173, 244, 0.2); } .session-help-content-no-results { - color: rgba(255, 255, 255, 0.75); + color: var(--ctp-subtext0); padding: 12px; font-size: 13px; } @@ -1762,9 +1994,9 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { flex-direction: column; gap: 8px; padding: 8px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(110, 115, 141, 0.15); border-radius: 10px; - background: rgba(255, 255, 255, 0.02); + background: rgba(54, 58, 79, 0.15); backdrop-filter: blur(1px); } @@ -1773,7 +2005,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { letter-spacing: 0.08em; text-transform: uppercase; font-weight: 800; - color: rgba(255, 255, 255, 0.55); + color: var(--ctp-overlay1); display: flex; align-items: center; gap: 6px; @@ -1784,7 +2016,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { .session-help-item-list { display: flex; flex-direction: column; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(110, 115, 141, 0.15); border-radius: 8px; overflow: hidden; } @@ -1799,9 +2031,9 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { gap: 12px; text-align: left; border: none; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(110, 115, 141, 0.1); background: transparent; - color: #fff; + color: var(--ctp-text); cursor: pointer; } @@ -1812,12 +2044,12 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { .session-help-item:hover, .session-help-item:focus-visible, .session-help-item.active { - background: rgba(137, 180, 255, 0.2); + background: rgba(138, 173, 244, 0.2); outline: none; } .session-help-item.active { - box-shadow: inset 3px 0 0 0 rgba(137, 180, 255, 0.9); + box-shadow: inset 3px 0 0 0 var(--ctp-blue); } .session-help-item-left { @@ -1838,21 +2070,21 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { .session-help-key { font-size: 12px; font-weight: 700; - color: rgba(255, 255, 255, 0.95); + color: var(--ctp-text); white-space: nowrap; padding: 4px 9px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; border-radius: 999px; - background: rgba(137, 180, 255, 0.16); - border: 1px solid rgba(137, 180, 255, 0.35); + background: rgba(138, 173, 244, 0.16); + border: 1px solid rgba(138, 173, 244, 0.35); letter-spacing: 0.01em; } .session-help-action { font-size: 13px; - color: rgba(255, 255, 255, 0.84); + color: var(--ctp-subtext1); white-space: normal; overflow: hidden; text-overflow: ellipsis; @@ -1863,7 +2095,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { width: 10px; height: 10px; border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid rgba(110, 115, 141, 0.4); flex: 0 0 auto; } diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 002aad5d..7ae03319 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -96,6 +96,13 @@ export type RendererDom = { sessionHelpStatus: HTMLDivElement; sessionHelpFilter: HTMLInputElement; sessionHelpContent: HTMLDivElement; + + playlistBrowserModal: HTMLDivElement; + playlistBrowserTitle: HTMLDivElement; + playlistBrowserStatus: HTMLDivElement; + playlistBrowserDirectoryList: HTMLUListElement; + playlistBrowserPlaylistList: HTMLUListElement; + playlistBrowserClose: HTMLButtonElement; }; function getRequiredElement(id: string): T { @@ -211,5 +218,12 @@ export function resolveRendererDom(): RendererDom { sessionHelpStatus: getRequiredElement('sessionHelpStatus'), sessionHelpFilter: getRequiredElement('sessionHelpFilter'), sessionHelpContent: getRequiredElement('sessionHelpContent'), + + playlistBrowserModal: getRequiredElement('playlistBrowserModal'), + playlistBrowserTitle: getRequiredElement('playlistBrowserTitle'), + playlistBrowserStatus: getRequiredElement('playlistBrowserStatus'), + playlistBrowserDirectoryList: getRequiredElement('playlistBrowserDirectoryList'), + playlistBrowserPlaylistList: getRequiredElement('playlistBrowserPlaylistList'), + playlistBrowserClose: getRequiredElement('playlistBrowserClose'), }; } diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 468b7d51..30615b4a 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -6,6 +6,7 @@ export const OVERLAY_HOSTED_MODALS = [ 'subsync', 'jimaku', 'youtube-track-picker', + 'playlist-browser', 'kiku', 'controller-select', 'controller-debug', @@ -67,6 +68,11 @@ export const IPC_CHANNELS = { getAnilistQueueStatus: 'anilist:get-queue-status', retryAnilistNow: 'anilist:retry-now', appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', + getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot', + appendPlaylistBrowserFile: 'playlist-browser:append-file', + playPlaylistBrowserIndex: 'playlist-browser:play-index', + removePlaylistBrowserIndex: 'playlist-browser:remove-index', + movePlaylistBrowserIndex: 'playlist-browser:move-index', jimakuGetMediaInfo: 'jimaku:get-media-info', jimakuSearchEntries: 'jimaku:search-entries', jimakuListFiles: 'jimaku:list-files', @@ -100,6 +106,7 @@ export const IPC_CHANNELS = { jimakuOpen: 'jimaku:open', youtubePickerOpen: 'youtube:picker-open', youtubePickerCancel: 'youtube:picker-cancel', + playlistBrowserOpen: 'playlist-browser:open', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', configHotReload: 'config:hot-reload', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index bf555552..b9949f7a 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -76,6 +76,40 @@ export interface SubsyncResult { message: string; } +export interface PlaylistBrowserDirectoryItem { + path: string; + basename: string; + episodeLabel?: string | null; + isCurrentFile: boolean; +} + +export interface PlaylistBrowserQueueItem { + index: number; + id: number | null; + filename: string; + title: string | null; + displayLabel: string; + current: boolean; + playing: boolean; + path: string | null; +} + +export interface PlaylistBrowserSnapshot { + directoryPath: string | null; + directoryAvailable: boolean; + directoryStatus: string; + directoryItems: PlaylistBrowserDirectoryItem[]; + playlistItems: PlaylistBrowserQueueItem[]; + playingIndex: number | null; + currentFilePath: string | null; +} + +export interface PlaylistBrowserMutationResult { + ok: boolean; + message: string; + snapshot?: PlaylistBrowserSnapshot; +} + export type ControllerButtonBinding = | 'none' | 'select' @@ -354,10 +388,19 @@ export interface ElectronAPI { onOpenRuntimeOptions: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; + onOpenPlaylistBrowser: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise; + getPlaylistBrowserSnapshot: () => Promise; + appendPlaylistBrowserFile: (path: string) => Promise; + playPlaylistBrowserIndex: (index: number) => Promise; + removePlaylistBrowserIndex: (index: number) => Promise; + movePlaylistBrowserIndex: ( + index: number, + direction: 1 | -1, + ) => Promise; youtubePickerResolve: ( request: YoutubePickerResolveRequest, ) => Promise; @@ -367,6 +410,7 @@ export interface ElectronAPI { | 'subsync' | 'jimaku' | 'youtube-track-picker' + | 'playlist-browser' | 'kiku' | 'controller-select' | 'controller-debug' @@ -378,6 +422,7 @@ export interface ElectronAPI { | 'subsync' | 'jimaku' | 'youtube-track-picker' + | 'playlist-browser' | 'kiku' | 'controller-select' | 'controller-debug'