mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
feat(anime): open Anime Browser in player modal
- Add the Ctrl+Alt+A shortcut and dedicated overlay modal - Share queue, sources, and watch history while isolating browser state
This commit is contained in:
@@ -29,3 +29,4 @@ area: anime
|
|||||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||||
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
||||||
|
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. It stays within the player bounds and shares playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state.
|
||||||
|
|||||||
@@ -341,6 +341,12 @@
|
|||||||
"__playlist-browser-open"
|
"__playlist-browser-open"
|
||||||
] // Command setting.
|
] // Command setting.
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "Ctrl+Alt+KeyA", // Key setting.
|
||||||
|
"command": [
|
||||||
|
"__anime-browser-open"
|
||||||
|
] // Command setting.
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||||
"command": [
|
"command": [
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
|
|||||||
**Browse Anime** in the tray menu. The window stays open while you watch, so you
|
**Browse Anime** in the tray menu. The window stays open while you watch, so you
|
||||||
can queue the next episode without reopening it.
|
can queue the next episode without reopening it.
|
||||||
|
|
||||||
|
During playback, `Ctrl+Alt+A` opens the same browser as a modal inside the mpv
|
||||||
|
player bounds. It uses a dedicated modal surface, so it stays above fullscreen
|
||||||
|
playback and closes like the other in-player tools. The standalone window and
|
||||||
|
the modal keep their own search, selected source, tab, and scroll state, so using
|
||||||
|
one does not replace or cancel what you were doing in the other.
|
||||||
|
|
||||||
|
Both surfaces use the same playback queue, source configuration, and stats
|
||||||
|
history. Queue changes appear in both immediately, and watched marks come from
|
||||||
|
the same history that playback and the stats window update. Closing and reopening
|
||||||
|
the modal therefore picks up progress made from either browser surface.
|
||||||
|
|
||||||
While the window is open, SubMiner shows a tray icon and — on macOS — appears
|
While the window is open, SubMiner shows a tray icon and — on macOS — appears
|
||||||
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
|
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
|
||||||
flip between the browser and mpv. SubMiner normally hides itself from the Dock
|
flip between the browser and mpv. SubMiner normally hides itself from the Dock
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ src/
|
|||||||
shared/ipc/ # Cross-process IPC channel constants + payload validators
|
shared/ipc/ # Cross-process IPC channel constants + payload validators
|
||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
modals/ # Modal flows (Anime Browser, Jimaku, Kiku, subsync, runtime options,
|
||||||
# changelog, character dictionary, playlist browser, subtitle
|
# session help, changelog, character dictionary, playlist browser, subtitle
|
||||||
# sidebar, YouTube track picker, controller config/debug/select)
|
# sidebar, YouTube track picker, controller config/debug/select)
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
settings/ # Settings window UI (model, controls, markup)
|
settings/ # Settings window UI (model, controls, markup)
|
||||||
|
|||||||
@@ -593,6 +593,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||||
|
| `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Open Anime Browser in the player |
|
||||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||||
@@ -634,7 +635,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
{ "key": "Space", "command": null }
|
{ "key": "Space", "command": null }
|
||||||
```
|
```
|
||||||
|
|
||||||
**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. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[: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. `__anime-browser-open` opens the Anime Browser inside the player bounds. `__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. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||||
|
|
||||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,12 @@
|
|||||||
"__playlist-browser-open"
|
"__playlist-browser-open"
|
||||||
] // Command setting.
|
] // Command setting.
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "Ctrl+Alt+KeyA", // Key setting.
|
||||||
|
"command": [
|
||||||
|
"__anime-browser-open"
|
||||||
|
] // Command setting.
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||||
"command": [
|
"command": [
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| `J` | Cycle primary subtitle track |
|
| `J` | Cycle primary subtitle track |
|
||||||
| `Shift+J` | Cycle secondary subtitle track |
|
| `Shift+J` | Cycle secondary subtitle track |
|
||||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||||
|
| `Ctrl+Alt+A` | Open Anime Browser inside the player |
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
| `ArrowRight` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
| `ArrowLeft` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | Seek forward 60 seconds |
|
| `ArrowUp` | Seek forward 60 seconds |
|
||||||
@@ -67,7 +68,7 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||||
|
|
||||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut opens a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent.
|
||||||
|
|
||||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ function M.create(ctx)
|
|||||||
return { "--open-controller-debug" }
|
return { "--open-controller-debug" }
|
||||||
elseif action_id == "openPlaylistBrowser" then
|
elseif action_id == "openPlaylistBrowser" then
|
||||||
return { "--open-playlist-browser" }
|
return { "--open-playlist-browser" }
|
||||||
|
elseif action_id == "openAnimeBrowser" then
|
||||||
|
return { "--session-action", '{"actionId":"openAnimeBrowser"}' }
|
||||||
elseif action_id == "replayCurrentSubtitle" then
|
elseif action_id == "replayCurrentSubtitle" then
|
||||||
return { "--replay-current-subtitle" }
|
return { "--replay-current-subtitle" }
|
||||||
elseif action_id == "playNextSubtitle" then
|
elseif action_id == "playNextSubtitle" then
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ test('build:syncui bundles the sandboxed preload and keeps Electron external', (
|
|||||||
|
|
||||||
test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
|
test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
|
||||||
const command = buildScript('build:animeui');
|
const command = buildScript('build:animeui');
|
||||||
|
const sharedPreloadSource = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '..', 'src', 'preload-anime-browser-api.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
// The preload imports IPC_CHANNELS, so it must be bundled rather than
|
// The preload imports IPC_CHANNELS, so it must be bundled rather than
|
||||||
// emitted by plain tsc with a relative runtime require.
|
// emitted by plain tsc with a relative runtime require.
|
||||||
@@ -28,6 +32,9 @@ test('build:animeui bundles the sandboxed preload and keeps Electron external',
|
|||||||
assert.match(command, /--bundle/);
|
assert.match(command, /--bundle/);
|
||||||
assert.match(command, /--external:electron/);
|
assert.match(command, /--external:electron/);
|
||||||
assert.match(command, /--outfile=dist\/preload-animeui\.js/);
|
assert.match(command, /--outfile=dist\/preload-animeui\.js/);
|
||||||
|
// The standalone Anime window uses Electron's sandboxed preload runtime,
|
||||||
|
// which exposes `electron` but cannot require arbitrary Node built-ins.
|
||||||
|
assert.doesNotMatch(sharedPreloadSource, /from ['"]node:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('build:animeui runs as part of the top-level build', () => {
|
test('build:animeui runs as part of the top-level build', () => {
|
||||||
|
|||||||
+24
-4
@@ -22,13 +22,25 @@ import type {
|
|||||||
AnimeBrowserSource,
|
AnimeBrowserSource,
|
||||||
} from '../types/anime-browser';
|
} from '../types/anime-browser';
|
||||||
|
|
||||||
declare global {
|
const embeddedInOverlay =
|
||||||
interface Window {
|
new URLSearchParams(window.location.search).get('embedded') === 'overlay-modal';
|
||||||
animeBrowserAPI: AnimeBrowserAPI;
|
if (embeddedInOverlay) document.documentElement.dataset.embedded = 'overlay-modal';
|
||||||
|
|
||||||
|
function resolveAnimeBrowserAPI(): AnimeBrowserAPI {
|
||||||
|
const local = (window as Window & { animeBrowserAPI?: AnimeBrowserAPI }).animeBrowserAPI;
|
||||||
|
if (local) return local;
|
||||||
|
try {
|
||||||
|
const parent = (window.parent as Window & { animeBrowserAPI?: AnimeBrowserAPI })
|
||||||
|
.animeBrowserAPI;
|
||||||
|
if (parent) return parent;
|
||||||
|
} catch {
|
||||||
|
// The embedded document is expected to be same-origin. Fall through to a
|
||||||
|
// direct error if Chromium blocks that relationship unexpectedly.
|
||||||
}
|
}
|
||||||
|
throw new Error('Anime Browser API is unavailable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = window.animeBrowserAPI;
|
const api = resolveAnimeBrowserAPI();
|
||||||
|
|
||||||
const searchForm = el<HTMLFormElement>('search-form');
|
const searchForm = el<HTMLFormElement>('search-form');
|
||||||
const searchInput = el<HTMLInputElement>('search-input');
|
const searchInput = el<HTMLInputElement>('search-input');
|
||||||
@@ -82,6 +94,14 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
|
|||||||
|
|
||||||
const detailPanel = createDetailPanel({ api, setStatus });
|
const detailPanel = createDetailPanel({ api, setStatus });
|
||||||
|
|
||||||
|
if (embeddedInOverlay) {
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
window.parent.postMessage('subminer:anime-browser-close', '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
|
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
|
||||||
idle: 'Starting the extension bridge',
|
idle: 'Starting the extension bridge',
|
||||||
locating: 'Looking up the extension bridge release',
|
locating: 'Looking up the extension bridge release',
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
|
|||||||
|
|
||||||
detailBack.addEventListener('click', close);
|
detailBack.addEventListener('click', close);
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape' && !detail.classList.contains('hidden')) close();
|
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -52,6 +52,23 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-embedded='overlay-modal'] .topbar {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-embedded='overlay-modal'] .brand-block {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-embedded='overlay-modal'] .bridge-banner {
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-embedded='overlay-modal'] .statusbar {
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const SPECIAL_COMMANDS = {
|
|||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||||
|
ANIME_BROWSER_OPEN: '__anime-browser-open',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||||
@@ -81,6 +82,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
|||||||
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
|
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
|
||||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||||
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
||||||
|
{ key: 'Ctrl+Alt+KeyA', command: [SPECIAL_COMMANDS.ANIME_BROWSER_OPEN] },
|
||||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||||
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||||
{ key: 'KeyQ', command: ['quit'] },
|
{ key: 'KeyQ', command: ['quit'] },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||||
|
ANIME_BROWSER_OPEN: '__anime-browser-open',
|
||||||
},
|
},
|
||||||
triggerSubsyncFromConfig: () => {
|
triggerSubsyncFromConfig: () => {
|
||||||
calls.push('subsync');
|
calls.push('subsync');
|
||||||
@@ -38,6 +39,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
openPlaylistBrowser: () => {
|
openPlaylistBrowser: () => {
|
||||||
calls.push('playlist-browser');
|
calls.push('playlist-browser');
|
||||||
},
|
},
|
||||||
|
openAnimeBrowser: () => {
|
||||||
|
calls.push('anime-browser');
|
||||||
|
},
|
||||||
runtimeOptionsCycle: () => ({ ok: true }),
|
runtimeOptionsCycle: () => ({ ok: true }),
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
osd.push(text);
|
osd.push(text);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
PLAY_NEXT_SUBTITLE: string;
|
PLAY_NEXT_SUBTITLE: string;
|
||||||
YOUTUBE_PICKER_OPEN: string;
|
YOUTUBE_PICKER_OPEN: string;
|
||||||
PLAYLIST_BROWSER_OPEN: string;
|
PLAYLIST_BROWSER_OPEN: string;
|
||||||
|
ANIME_BROWSER_OPEN: string;
|
||||||
};
|
};
|
||||||
triggerSubsyncFromConfig: () => void;
|
triggerSubsyncFromConfig: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
@@ -27,6 +28,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
openTsukihime: () => void;
|
openTsukihime: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
openPlaylistBrowser: () => void | Promise<void>;
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
|
openAnimeBrowser: () => void | Promise<void>;
|
||||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
showRawMpvOsd?: (text: string) => void;
|
showRawMpvOsd?: (text: string) => void;
|
||||||
@@ -141,6 +143,16 @@ export function handleMpvCommandFromIpc(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === options.specialCommands.ANIME_BROWSER_OPEN) {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => options.openAnimeBrowser())
|
||||||
|
.catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
options.showMpvOsd(`Anime Browser failed: ${message}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
if (!options.hasRuntimeOptionsManager()) return;
|
if (!options.hasRuntimeOptionsManager()) return;
|
||||||
const [, idToken, directionToken] = first.split(':');
|
const [, idToken, directionToken] = first.split(':');
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
openPlaylistBrowser: () => {
|
openPlaylistBrowser: () => {
|
||||||
calls.push('playlist');
|
calls.push('playlist');
|
||||||
},
|
},
|
||||||
|
openAnimeBrowser: () => {
|
||||||
|
calls.push('anime-browser');
|
||||||
|
},
|
||||||
replayCurrentSubtitle: () => calls.push('replay'),
|
replayCurrentSubtitle: () => calls.push('replay'),
|
||||||
playNextSubtitle: () => calls.push('play-next'),
|
playNextSubtitle: () => calls.push('play-next'),
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface SessionActionExecutorDeps {
|
|||||||
openTsukihime: () => void;
|
openTsukihime: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||||
|
openAnimeBrowser: () => boolean | void | Promise<boolean | void>;
|
||||||
replayCurrentSubtitle: () => void;
|
replayCurrentSubtitle: () => void;
|
||||||
playNextSubtitle: () => void;
|
playNextSubtitle: () => void;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
@@ -125,6 +126,9 @@ export async function dispatchSessionAction(
|
|||||||
case 'openPlaylistBrowser':
|
case 'openPlaylistBrowser':
|
||||||
await deps.openPlaylistBrowser();
|
await deps.openPlaylistBrowser();
|
||||||
return;
|
return;
|
||||||
|
case 'openAnimeBrowser':
|
||||||
|
await deps.openAnimeBrowser();
|
||||||
|
return;
|
||||||
case 'replayCurrentSubtitle':
|
case 'replayCurrentSubtitle':
|
||||||
deps.replayCurrentSubtitle();
|
deps.replayCurrentSubtitle();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
|
|||||||
const expectedSpecialActions: Record<string, string> = {
|
const expectedSpecialActions: Record<string, string> = {
|
||||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||||
|
[SPECIAL_COMMANDS.ANIME_BROWSER_OPEN]: 'openAnimeBrowser',
|
||||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
||||||
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
|
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ function resolveCommandBinding(
|
|||||||
if (command.length !== 1) return null;
|
if (command.length !== 1) return null;
|
||||||
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
|
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
|
||||||
}
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN) {
|
||||||
|
if (command.length !== 1) return null;
|
||||||
|
return { actionType: 'session-action', actionId: 'openAnimeBrowser' };
|
||||||
|
}
|
||||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
|
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
|
||||||
if (command.length !== 1) return null;
|
if (command.length !== 1) return null;
|
||||||
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
|
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
|
||||||
|
|||||||
+55
-14
@@ -544,7 +544,11 @@ import {
|
|||||||
createCreateAnimeBrowserWindowHandler,
|
createCreateAnimeBrowserWindowHandler,
|
||||||
} from './main/runtime/setup-window-factory';
|
} from './main/runtime/setup-window-factory';
|
||||||
import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime';
|
import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime';
|
||||||
import { registerAnimeBrowserIpcHandlers } from './main/runtime/anime-browser-ipc-handlers';
|
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
|
||||||
|
import {
|
||||||
|
registerAnimeBrowserIpcHandlers,
|
||||||
|
type AnimeBrowserIpcSender,
|
||||||
|
} from './main/runtime/anime-browser-ipc-handlers';
|
||||||
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
|
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
|
||||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||||
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
|
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
|
||||||
@@ -2965,6 +2969,24 @@ function openPlaylistBrowser(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAnimeBrowserModal(): void {
|
||||||
|
if (!appState.mpvClient?.connected) {
|
||||||
|
overlayNotificationsRuntime.showConfiguredStatusNotification(
|
||||||
|
'The in-player Anime Browser requires active playback.',
|
||||||
|
{
|
||||||
|
title: 'Anime Browser',
|
||||||
|
variant: 'warning',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openOverlayHostedModalWithOsd(
|
||||||
|
openAnimeBrowserModalRuntime,
|
||||||
|
'Anime Browser overlay unavailable.',
|
||||||
|
'Failed to open Anime Browser overlay.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getRuntimeBooleanOption(
|
function getRuntimeBooleanOption(
|
||||||
id:
|
id:
|
||||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||||
@@ -3303,6 +3325,17 @@ const {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
|
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
|
||||||
|
const animeBrowserSessions = new Map<string, AnimeBrowserIpcSender>();
|
||||||
|
|
||||||
|
function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
|
||||||
|
const targets = new Set<AnimeBrowserIpcSender>();
|
||||||
|
const standalone = appState.animeBrowserWindow;
|
||||||
|
if (standalone && !standalone.isDestroyed()) targets.add(standalone.webContents);
|
||||||
|
for (const sender of animeBrowserSessions.values()) {
|
||||||
|
if (!sender.isDestroyed()) targets.add(sender);
|
||||||
|
}
|
||||||
|
for (const sender of targets) sender.send(channel, payload);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAnimeExtensionsDir(): string {
|
function resolveAnimeExtensionsDir(): string {
|
||||||
const configured = configService.getConfig().anime?.extensionsDir?.trim();
|
const configured = configService.getConfig().anime?.extensionsDir?.trim();
|
||||||
@@ -3392,27 +3425,33 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onBridgeState: (state) => {
|
onBridgeState: (state) => {
|
||||||
const window = appState.animeBrowserWindow;
|
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserBridgeState, state);
|
||||||
if (window && !window.isDestroyed()) {
|
|
||||||
window.webContents.send(IPC_CHANNELS.event.animeBrowserBridgeState, state);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSearchUpdate: (update) => {
|
onSearchUpdate: (update, sessionId) => {
|
||||||
const window = appState.animeBrowserWindow;
|
const sender = animeBrowserSessions.get(sessionId);
|
||||||
if (window && !window.isDestroyed()) {
|
if (sender && !sender.isDestroyed()) {
|
||||||
window.webContents.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
sender.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onQueueState: (state) => {
|
onQueueState: (state) => {
|
||||||
const window = appState.animeBrowserWindow;
|
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserQueueState, state);
|
||||||
if (window && !window.isDestroyed()) {
|
|
||||||
window.webContents.send(IPC_CHANNELS.event.animeBrowserQueueState, state);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
log: (message) => logger.info(message),
|
log: (message) => logger.info(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime });
|
registerAnimeBrowserIpcHandlers({
|
||||||
|
ipcMain,
|
||||||
|
runtime: animeBrowserRuntime,
|
||||||
|
registerSession: (sessionId, sender) => {
|
||||||
|
if (animeBrowserSessions.get(sessionId) === sender) return;
|
||||||
|
animeBrowserSessions.set(sessionId, sender);
|
||||||
|
sender.once('destroyed', () => {
|
||||||
|
if (animeBrowserSessions.get(sessionId) !== sender) return;
|
||||||
|
animeBrowserSessions.delete(sessionId);
|
||||||
|
animeBrowserRuntime.releaseSession(sessionId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
let animeBrowserDockIconRetained = false;
|
let animeBrowserDockIconRetained = false;
|
||||||
const releaseAnimeBrowserDockIcon = (): void => {
|
const releaseAnimeBrowserDockIcon = (): void => {
|
||||||
@@ -5590,6 +5629,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
openControllerDebug: () => openControllerDebugOverlay(),
|
openControllerDebug: () => openControllerDebugOverlay(),
|
||||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||||
|
openAnimeBrowser: () => openAnimeBrowserModal(),
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
cycleRuntimeOption: (id, direction) => {
|
cycleRuntimeOption: (id, direction) => {
|
||||||
@@ -5620,6 +5660,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
openTsukihime: () => openTsukihimeOverlay(),
|
openTsukihime: () => openTsukihimeOverlay(),
|
||||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||||
|
openAnimeBrowser: () => openAnimeBrowserModal(),
|
||||||
cycleRuntimeOption: (id, direction) => {
|
cycleRuntimeOption: (id, direction) => {
|
||||||
if (!appState.runtimeOptionsManager) {
|
if (!appState.runtimeOptionsManager) {
|
||||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
|||||||
openTsukihime: HandleMpvCommandFromIpcOptions['openTsukihime'];
|
openTsukihime: HandleMpvCommandFromIpcOptions['openTsukihime'];
|
||||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||||
|
openAnimeBrowser: HandleMpvCommandFromIpcOptions['openAnimeBrowser'];
|
||||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||||
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
|
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
|
||||||
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
||||||
@@ -444,6 +445,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
|||||||
openTsukihime: params.openTsukihime,
|
openTsukihime: params.openTsukihime,
|
||||||
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
||||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||||
|
openAnimeBrowser: params.openAnimeBrowser,
|
||||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||||
showMpvOsd: params.showMpvOsd,
|
showMpvOsd: params.showMpvOsd,
|
||||||
showRawMpvOsd: params.showRawMpvOsd,
|
showRawMpvOsd: params.showRawMpvOsd,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
|||||||
openTsukihime: () => void;
|
openTsukihime: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
openPlaylistBrowser: () => void | Promise<void>;
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
|
openAnimeBrowser: () => void | Promise<void>;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
showRawMpvOsd?: (text: string) => void;
|
showRawMpvOsd?: (text: string) => void;
|
||||||
@@ -42,6 +43,7 @@ export function handleMpvCommandFromIpcRuntime(
|
|||||||
openTsukihime: deps.openTsukihime,
|
openTsukihime: deps.openTsukihime,
|
||||||
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
||||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||||
|
openAnimeBrowser: deps.openAnimeBrowser,
|
||||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||||
showMpvOsd: deps.showMpvOsd,
|
showMpvOsd: deps.showMpvOsd,
|
||||||
showRawMpvOsd: deps.showRawMpvOsd,
|
showRawMpvOsd: deps.showRawMpvOsd,
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ export interface AnimeBrowserIpcDeps {
|
|||||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||||
};
|
};
|
||||||
runtime: AnimeBrowserRuntime;
|
runtime: AnimeBrowserRuntime;
|
||||||
|
registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimeBrowserIpcSender {
|
||||||
|
send(channel: string, ...args: unknown[]): void;
|
||||||
|
isDestroyed(): boolean;
|
||||||
|
once(event: 'destroyed', listener: () => void): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimeBrowserIpcEvent {
|
||||||
|
sender: AnimeBrowserIpcSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,20 +39,36 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
|||||||
deps.ipcMain.handle(channel, listener);
|
deps.ipcMain.handle(channel, listener);
|
||||||
};
|
};
|
||||||
|
|
||||||
handle(channels.animeBrowserGetSnapshot, () => runtime.getSnapshot());
|
handle(channels.animeBrowserGetSnapshot, (event, sessionId) => {
|
||||||
handle(channels.animeBrowserEnsureBridge, () => runtime.ensureBridge());
|
const session = registerSession(deps, event, sessionId);
|
||||||
handle(channels.animeBrowserSelectSource, (_event, sourceId) =>
|
return runtime.getSnapshot(session);
|
||||||
runtime.selectSource(String(sourceId)),
|
});
|
||||||
|
handle(channels.animeBrowserEnsureBridge, (event, sessionId) => {
|
||||||
|
registerSession(deps, event, sessionId);
|
||||||
|
return runtime.ensureBridge();
|
||||||
|
});
|
||||||
|
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
|
||||||
|
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
|
||||||
);
|
);
|
||||||
handle(channels.animeBrowserSearch, (_event, query, page) =>
|
handle(channels.animeBrowserSearch, (event, sessionId, query, page) =>
|
||||||
runtime.search(String(query ?? ''), toPage(page)),
|
runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)),
|
||||||
);
|
);
|
||||||
handle(channels.animeBrowserGetPopular, (_event, page) => runtime.getPopular(toPage(page)));
|
handle(channels.animeBrowserGetPopular, (event, sessionId, page) =>
|
||||||
handle(channels.animeBrowserGetDetails, (_event, animeUrl, sourceId) =>
|
runtime.getPopular(toPage(page), registerSession(deps, event, sessionId)),
|
||||||
runtime.getDetails(String(animeUrl), toOptionalId(sourceId)),
|
|
||||||
);
|
);
|
||||||
handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
|
handle(channels.animeBrowserGetDetails, (event, sessionId, animeUrl, sourceId) =>
|
||||||
runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)),
|
runtime.getDetails(
|
||||||
|
String(animeUrl),
|
||||||
|
toOptionalId(sourceId),
|
||||||
|
registerSession(deps, event, sessionId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
handle(channels.animeBrowserGetEpisodes, (event, sessionId, animeUrl, sourceId) =>
|
||||||
|
runtime.getEpisodes(
|
||||||
|
String(animeUrl),
|
||||||
|
toOptionalId(sourceId),
|
||||||
|
registerSession(deps, event, sessionId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
handle(channels.animeBrowserGetWatchState, (_event, request) =>
|
handle(channels.animeBrowserGetWatchState, (_event, request) =>
|
||||||
runtime.getWatchState(toWatchStateRequest(request)),
|
runtime.getWatchState(toWatchStateRequest(request)),
|
||||||
@@ -79,6 +106,13 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerSession(deps: AnimeBrowserIpcDeps, event: unknown, sessionId: unknown): string {
|
||||||
|
const normalized = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : 'default';
|
||||||
|
const sender = (event as AnimeBrowserIpcEvent | undefined)?.sender;
|
||||||
|
if (sender) deps.registerSession?.(normalized, sender);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
/** Coerce a play (or queue) request at the renderer trust boundary. */
|
/** Coerce a play (or queue) request at the renderer trust boundary. */
|
||||||
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
|
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
|
||||||
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
|
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import { openAnimeBrowserModal } from './anime-browser-open';
|
||||||
|
|
||||||
|
test('anime browser open uses a dedicated player-bounded modal window', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const opened = await openAnimeBrowserModal({
|
||||||
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => calls.push('windows'),
|
||||||
|
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
||||||
|
calls.push(`send:${channel}`);
|
||||||
|
assert.equal(payload, undefined);
|
||||||
|
assert.deepEqual(runtimeOptions, {
|
||||||
|
restoreOnModalClose: 'anime-browser',
|
||||||
|
preferModalWindow: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
waitForModalOpen: async () => true,
|
||||||
|
logWarn: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(opened, true);
|
||||||
|
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.animeBrowserOpen}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anime browser open retries on a fresh modal window after a missed acknowledgement', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const opened = await openAnimeBrowserModal({
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||||
|
sendToActiveOverlayWindow: () => true,
|
||||||
|
waitForModalOpen: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
return attempts === 2;
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(opened, true);
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||||
|
|
||||||
|
const ANIME_BROWSER_MODAL: OverlayHostedModal = 'anime-browser';
|
||||||
|
const ANIME_BROWSER_OPEN_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
export async function openAnimeBrowserModal(deps: {
|
||||||
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||||
|
sendToActiveOverlayWindow: (
|
||||||
|
channel: string,
|
||||||
|
payload?: unknown,
|
||||||
|
runtimeOptions?: {
|
||||||
|
restoreOnModalClose?: OverlayHostedModal;
|
||||||
|
preferModalWindow?: boolean;
|
||||||
|
},
|
||||||
|
) => boolean;
|
||||||
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return await retryOverlayModalOpen(
|
||||||
|
{
|
||||||
|
waitForModalOpen: deps.waitForModalOpen,
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modal: ANIME_BROWSER_MODAL,
|
||||||
|
timeoutMs: ANIME_BROWSER_OPEN_TIMEOUT_MS,
|
||||||
|
retryWarning:
|
||||||
|
'Anime Browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||||
|
sendOpen: () =>
|
||||||
|
openOverlayHostedModal(
|
||||||
|
{
|
||||||
|
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions:
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.event.animeBrowserOpen,
|
||||||
|
modal: ANIME_BROWSER_MODAL,
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ export interface AnimeBrowserRuntimeDeps {
|
|||||||
/** Pushes the play queue to the browser window, advances included. */
|
/** Pushes the play queue to the browser window, advances included. */
|
||||||
onQueueState?: (state: AnimeBrowserQueueState) => void;
|
onQueueState?: (state: AnimeBrowserQueueState) => void;
|
||||||
/** Streams per-source progress while a search invoke is pending. */
|
/** Streams per-source progress while a search invoke is pending. */
|
||||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void;
|
||||||
preferredQuality?: () => string | undefined;
|
preferredQuality?: () => string | undefined;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
/** Overrides process startup in focused runtime tests. */
|
/** Overrides process startup in focused runtime tests. */
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { BridgePreference } from '../../anime-bridge/types';
|
import type { BridgePreference } from '../../anime-bridge/types';
|
||||||
|
import type { BridgeAnimePage } from '../../anime-bridge/types';
|
||||||
|
import type { AnimeBrowserSearchUpdate } from '../../types/anime-browser';
|
||||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ async function setupRuntime(
|
|||||||
client: Record<string, unknown>,
|
client: Record<string, unknown>,
|
||||||
packages: Record<string, string>,
|
packages: Record<string, string>,
|
||||||
storedPreferences?: Record<string, BridgePreference[]>,
|
storedPreferences?: Record<string, BridgePreference[]>,
|
||||||
|
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void,
|
||||||
) {
|
) {
|
||||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-runtime-'));
|
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-runtime-'));
|
||||||
for (const [pkg, contents] of Object.entries(packages)) {
|
for (const [pkg, contents] of Object.entries(packages)) {
|
||||||
@@ -31,6 +34,7 @@ async function setupRuntime(
|
|||||||
sendMpvCommand: () => undefined,
|
sendMpvCommand: () => undefined,
|
||||||
ensureMpvConnected: async () => true,
|
ensureMpvConnected: async () => true,
|
||||||
onBridgeState: () => undefined,
|
onBridgeState: () => undefined,
|
||||||
|
onSearchUpdate,
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
startSidecar: async () => ({
|
startSidecar: async () => ({
|
||||||
client: client as unknown as AnimeBridgeClient,
|
client: client as unknown as AnimeBridgeClient,
|
||||||
@@ -136,3 +140,72 @@ test('colliding bridge ids keep package preferences isolated and uninstall clear
|
|||||||
assert.deepEqual(persisted['pkg.two:shared'], [two]);
|
assert.deepEqual(persisted['pkg.two:shared'], [two]);
|
||||||
await runtime.dispose();
|
await runtime.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('standalone and modal browser sessions keep source selection and searches isolated', async () => {
|
||||||
|
const client = {
|
||||||
|
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||||
|
searchAnime: async (source: { fingerprint: string }, query: string) => ({
|
||||||
|
animes: [{ url: `${source.fingerprint}/${query}`, title: source.fingerprint }],
|
||||||
|
hasNextPage: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { runtime } = await setupRuntime(client, { 'pkg.one': 'one', 'pkg.two': 'two' });
|
||||||
|
const sourceIds = runtime.getSnapshot('standalone').sources.map((source) => source.id);
|
||||||
|
runtime.selectSource(sourceIds[0]!, 'standalone');
|
||||||
|
runtime.selectSource(sourceIds[1]!, 'modal');
|
||||||
|
|
||||||
|
const [standalone, modal] = await Promise.all([
|
||||||
|
runtime.search('one', 1, 'standalone'),
|
||||||
|
runtime.search('two', 1, 'modal'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(runtime.getSnapshot('standalone').selectedSourceId, sourceIds[0]);
|
||||||
|
assert.equal(runtime.getSnapshot('modal').selectedSourceId, sourceIds[1]);
|
||||||
|
assert.equal(standalone.entries[0]?.sourceId, sourceIds[0]);
|
||||||
|
assert.equal(modal.entries[0]?.sourceId, sourceIds[1]);
|
||||||
|
await runtime.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releasing a browser session suppresses its pending search updates', async () => {
|
||||||
|
let resolveOldSearch: (page: BridgeAnimePage) => void = () => undefined;
|
||||||
|
let notifyOldSearchStarted: () => void = () => undefined;
|
||||||
|
const oldSearchStarted = new Promise<void>((resolve) => {
|
||||||
|
notifyOldSearchStarted = resolve;
|
||||||
|
});
|
||||||
|
const updates: Array<{ sessionId: string; update: AnimeBrowserSearchUpdate }> = [];
|
||||||
|
const client = {
|
||||||
|
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||||
|
searchAnime: async (_source: unknown, query: string): Promise<BridgeAnimePage> => {
|
||||||
|
if (query !== 'old') {
|
||||||
|
return { animes: [{ url: `/${query}`, title: query }], hasNextPage: false };
|
||||||
|
}
|
||||||
|
notifyOldSearchStarted();
|
||||||
|
return await new Promise<BridgeAnimePage>((resolve) => {
|
||||||
|
resolveOldSearch = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { runtime } = await setupRuntime(
|
||||||
|
client,
|
||||||
|
{ 'pkg.one': 'one' },
|
||||||
|
undefined,
|
||||||
|
(update, sessionId) => {
|
||||||
|
updates.push({ sessionId, update });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldSearch = runtime.search('old', 1, 'reused');
|
||||||
|
await oldSearchStarted;
|
||||||
|
runtime.releaseSession('reused');
|
||||||
|
await runtime.search('new', 1, 'reused');
|
||||||
|
resolveOldSearch({ animes: [{ url: '/old', title: 'old' }], hasNextPage: false });
|
||||||
|
await oldSearch;
|
||||||
|
|
||||||
|
const resultUrls = updates.flatMap(({ sessionId, update }) =>
|
||||||
|
sessionId === 'reused' && update.kind === 'result'
|
||||||
|
? update.entries.map((entry) => entry.url)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
assert.deepEqual(resultUrls, ['/new']);
|
||||||
|
await runtime.dispose();
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,12 +63,21 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
let starting: Promise<SidecarHandle> | null = null;
|
let starting: Promise<SidecarHandle> | null = null;
|
||||||
let extensions: InstalledExtension[] = [];
|
let extensions: InstalledExtension[] = [];
|
||||||
let sources: ExtensionSource[] = [];
|
let sources: ExtensionSource[] = [];
|
||||||
let selectedSourceId: string | null = null;
|
|
||||||
let loadFailures: ExtensionLoadFailure[] = [];
|
let loadFailures: ExtensionLoadFailure[] = [];
|
||||||
// Monotonic; identifies the newest browse so stale ones stop emitting.
|
const browserSessions = new Map<
|
||||||
let searchToken = 0;
|
string,
|
||||||
|
{ selectedSourceId: string | null; searchToken: number }
|
||||||
|
>();
|
||||||
const preferenceStore = new PreferenceStore(deps.preferencesFile);
|
const preferenceStore = new PreferenceStore(deps.preferencesFile);
|
||||||
|
|
||||||
|
function getBrowserSession(sessionId = 'default') {
|
||||||
|
const existing = browserSessions.get(sessionId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = { selectedSourceId: sources[0]?.id ?? null, searchToken: 0 };
|
||||||
|
browserSessions.set(sessionId, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
function setState(state: AnimeBrowserBridgeState): void {
|
function setState(state: AnimeBrowserBridgeState): void {
|
||||||
bridgeState = state;
|
bridgeState = state;
|
||||||
deps.onBridgeState(state);
|
deps.onBridgeState(state);
|
||||||
@@ -175,11 +184,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
deps.log(`[anime-bridge] extension ${extension.fallbackName} failed to load: ${message}`);
|
deps.log(`[anime-bridge] extension ${extension.fallbackName} failed to load: ${message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the current selection if it survived the rescan. "All sources"
|
// Each open browser keeps its own source selection. Reconcile all of them
|
||||||
// survives as long as anything is installed.
|
// after a rescan without making one renderer change another renderer's UI.
|
||||||
const keptAll = selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
|
for (const session of browserSessions.values()) {
|
||||||
if (!keptAll && !sources.some((source) => source.id === selectedSourceId)) {
|
const keptAll = session.selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
|
||||||
selectedSourceId = sources[0]?.id ?? null;
|
if (!keptAll && !sources.some((source) => source.id === session.selectedSourceId)) {
|
||||||
|
session.selectedSourceId = sources[0]?.id ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
@@ -252,23 +263,27 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
*/
|
*/
|
||||||
async function browse(
|
async function browse(
|
||||||
page: number,
|
page: number,
|
||||||
|
sessionId: string,
|
||||||
fetchPage: (
|
fetchPage: (
|
||||||
source: Awaited<ReturnType<typeof sourceFor>>,
|
source: Awaited<ReturnType<typeof sourceFor>>,
|
||||||
page: number,
|
page: number,
|
||||||
) => Promise<BridgeAnimePage>,
|
) => Promise<BridgeAnimePage>,
|
||||||
): Promise<AnimeBrowserSearchResult> {
|
): Promise<AnimeBrowserSearchResult> {
|
||||||
const { baseUrl } = await bridge();
|
const { baseUrl } = await bridge();
|
||||||
|
const session = getBrowserSession(sessionId);
|
||||||
const targets =
|
const targets =
|
||||||
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
|
session.selectedSourceId === ALL_SOURCES_ID
|
||||||
|
? sources
|
||||||
|
: [requireSource(session.selectedSourceId)];
|
||||||
if (targets.length === 0) throw new Error('No sources are installed.');
|
if (targets.length === 0) throw new Error('No sources are installed.');
|
||||||
|
|
||||||
// Each source's answer is pushed the moment it lands, so a fast source is
|
// Each source's answer is pushed the moment it lands, so a fast source is
|
||||||
// on screen while a slow one is still resolving. Guarded by the token: a
|
// on screen while a slow one is still resolving. Guarded by the token: a
|
||||||
// superseded search stops emitting, and its remaining sources run out
|
// superseded search stops emitting, and its remaining sources run out
|
||||||
// quietly.
|
// quietly.
|
||||||
const token = ++searchToken;
|
const token = ++session.searchToken;
|
||||||
const emit = (update: AnimeBrowserSearchUpdate): void => {
|
const emit = (update: AnimeBrowserSearchUpdate): void => {
|
||||||
if (token === searchToken) deps.onSearchUpdate?.(update);
|
if (token === session.searchToken) deps.onSearchUpdate?.(update, sessionId);
|
||||||
};
|
};
|
||||||
emit({ kind: 'start', token, sourceCount: targets.length });
|
emit({ kind: 'start', token, sourceCount: targets.length });
|
||||||
|
|
||||||
@@ -370,7 +385,8 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getSnapshot(): AnimeBrowserSnapshot {
|
getSnapshot(sessionId = 'default'): AnimeBrowserSnapshot {
|
||||||
|
const session = getBrowserSession(sessionId);
|
||||||
return {
|
return {
|
||||||
bridge: bridgeState,
|
bridge: bridgeState,
|
||||||
sources: sources.map((source) => ({
|
sources: sources.map((source) => ({
|
||||||
@@ -379,7 +395,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
lang: source.lang,
|
lang: source.lang,
|
||||||
pkg: source.pkg,
|
pkg: source.pkg,
|
||||||
})),
|
})),
|
||||||
selectedSourceId,
|
selectedSourceId: session.selectedSourceId,
|
||||||
loadFailures,
|
loadFailures,
|
||||||
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
|
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
|
||||||
extensionsDir: deps.extensionsDir(),
|
extensionsDir: deps.extensionsDir(),
|
||||||
@@ -458,12 +474,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
if (sidecar) await scanExtensions(sidecar);
|
if (sidecar) await scanExtensions(sidecar);
|
||||||
},
|
},
|
||||||
|
|
||||||
selectSource(sourceId: string): void {
|
selectSource(sourceId: string, sessionId = 'default'): void {
|
||||||
|
const session = getBrowserSession(sessionId);
|
||||||
if (sourceId === ALL_SOURCES_ID && sources.length > 0) {
|
if (sourceId === ALL_SOURCES_ID && sources.length > 0) {
|
||||||
selectedSourceId = ALL_SOURCES_ID;
|
session.selectedSourceId = ALL_SOURCES_ID;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sources.some((source) => source.id === sourceId)) selectedSourceId = sourceId;
|
if (sources.some((source) => source.id === sourceId)) session.selectedSourceId = sourceId;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -507,21 +524,31 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
|
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
|
async search(
|
||||||
|
query: string,
|
||||||
|
page = 1,
|
||||||
|
sessionId = 'default',
|
||||||
|
): Promise<AnimeBrowserSearchResult> {
|
||||||
const { client } = await bridge();
|
const { client } = await bridge();
|
||||||
return browse(page, (source, requestedPage) =>
|
return browse(page, sessionId, (source, requestedPage) =>
|
||||||
client.searchAnime(source, query, requestedPage),
|
client.searchAnime(source, query, requestedPage),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
|
async getPopular(page = 1, sessionId = 'default'): Promise<AnimeBrowserSearchResult> {
|
||||||
const { client } = await bridge();
|
const { client } = await bridge();
|
||||||
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
|
return browse(page, sessionId, (source, requestedPage) =>
|
||||||
|
client.getPopularAnime(source, requestedPage),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
|
async getDetails(
|
||||||
|
animeUrl: string,
|
||||||
|
sourceId?: string,
|
||||||
|
sessionId = 'default',
|
||||||
|
): Promise<AnimeBrowserDetails> {
|
||||||
const { client, baseUrl } = await bridge();
|
const { client, baseUrl } = await bridge();
|
||||||
const source = requireSource(sourceId ?? selectedSourceId);
|
const source = requireSource(sourceId ?? getBrowserSession(sessionId).selectedSourceId);
|
||||||
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
|
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
|
||||||
return {
|
return {
|
||||||
...toEntry(baseUrl, { ...details, url: details.url ?? animeUrl }, source),
|
...toEntry(baseUrl, { ...details, url: details.url ?? animeUrl }, source),
|
||||||
@@ -532,9 +559,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
|
async getEpisodes(
|
||||||
|
animeUrl: string,
|
||||||
|
sourceId?: string,
|
||||||
|
sessionId = 'default',
|
||||||
|
): Promise<AnimeBrowserEpisode[]> {
|
||||||
const { client } = await bridge();
|
const { client } = await bridge();
|
||||||
const source = requireSource(sourceId ?? selectedSourceId);
|
const source = requireSource(sourceId ?? getBrowserSession(sessionId).selectedSourceId);
|
||||||
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
|
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
|
||||||
return episodes.map((episode) => ({
|
return episodes.map((episode) => ({
|
||||||
url: episode.url ?? '',
|
url: episode.url ?? '',
|
||||||
@@ -632,6 +663,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
releaseSession(sessionId: string): void {
|
||||||
|
const session = browserSessions.get(sessionId);
|
||||||
|
if (session) session.searchToken += 1;
|
||||||
|
browserSessions.delete(sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
const handle = sidecar;
|
const handle = sidecar;
|
||||||
const proxy = stripProxy;
|
const proxy = stripProxy;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
openTsukihime: () => {},
|
openTsukihime: () => {},
|
||||||
openYoutubeTrackPicker: () => {},
|
openYoutubeTrackPicker: () => {},
|
||||||
openPlaylistBrowser: () => {},
|
openPlaylistBrowser: () => {},
|
||||||
|
openAnimeBrowser: () => {},
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
|||||||
openTsukihime: () => {},
|
openTsukihime: () => {},
|
||||||
openYoutubeTrackPicker: () => {},
|
openYoutubeTrackPicker: () => {},
|
||||||
openPlaylistBrowser: () => {},
|
openPlaylistBrowser: () => {},
|
||||||
|
openAnimeBrowser: () => {},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
|||||||
openTsukihime: () => {},
|
openTsukihime: () => {},
|
||||||
openYoutubeTrackPicker: () => {},
|
openYoutubeTrackPicker: () => {},
|
||||||
openPlaylistBrowser: () => {},
|
openPlaylistBrowser: () => {},
|
||||||
|
openAnimeBrowser: () => {},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
openPlaylistBrowser: () => {
|
openPlaylistBrowser: () => {
|
||||||
calls.push('playlist-browser');
|
calls.push('playlist-browser');
|
||||||
},
|
},
|
||||||
|
openAnimeBrowser: () => {
|
||||||
|
calls.push('anime-browser');
|
||||||
|
},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
|
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
|
||||||
@@ -33,6 +36,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
deps.openTsukihime();
|
deps.openTsukihime();
|
||||||
void deps.openYoutubeTrackPicker();
|
void deps.openYoutubeTrackPicker();
|
||||||
void deps.openPlaylistBrowser();
|
void deps.openPlaylistBrowser();
|
||||||
|
void deps.openAnimeBrowser();
|
||||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||||
deps.showMpvOsd('hello');
|
deps.showMpvOsd('hello');
|
||||||
deps.showRawMpvOsd?.('delay');
|
deps.showRawMpvOsd?.('delay');
|
||||||
@@ -50,6 +54,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
'tsukihime',
|
'tsukihime',
|
||||||
'youtube-picker',
|
'youtube-picker',
|
||||||
'playlist-browser',
|
'playlist-browser',
|
||||||
|
'anime-browser',
|
||||||
'osd:hello',
|
'osd:hello',
|
||||||
'raw-osd:delay',
|
'raw-osd:delay',
|
||||||
'feedback:primary',
|
'feedback:primary',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
|||||||
openTsukihime: () => deps.openTsukihime(),
|
openTsukihime: () => deps.openTsukihime(),
|
||||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||||
|
openAnimeBrowser: () => deps.openAnimeBrowser(),
|
||||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}),
|
...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
import { createAnimeBrowserAPI } from './preload-anime-browser-api';
|
||||||
|
|
||||||
|
function createIpcRecorder() {
|
||||||
|
const calls: Array<{ channel: string; args: unknown[] }> = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
ipc: {
|
||||||
|
invoke: async (channel: string, ...args: unknown[]) => {
|
||||||
|
calls.push({ channel, args });
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
on: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('anime browser API keeps one opaque session per renderer bridge', async () => {
|
||||||
|
const first = createIpcRecorder();
|
||||||
|
const second = createIpcRecorder();
|
||||||
|
const firstApi = createAnimeBrowserAPI(first.ipc as never);
|
||||||
|
const secondApi = createAnimeBrowserAPI(second.ipc as never);
|
||||||
|
|
||||||
|
await firstApi.getSnapshot();
|
||||||
|
await firstApi.selectSource('source.one');
|
||||||
|
await firstApi.search('frieren', 2);
|
||||||
|
await firstApi.getDetails('/frieren');
|
||||||
|
await firstApi.getEpisodes('/frieren', 'source.one');
|
||||||
|
await secondApi.getPopular(3);
|
||||||
|
|
||||||
|
const firstSessionId = first.calls[0]?.args[0];
|
||||||
|
const secondSessionId = second.calls[0]?.args[0];
|
||||||
|
assert.equal(typeof firstSessionId, 'string');
|
||||||
|
assert.notEqual(firstSessionId, secondSessionId);
|
||||||
|
assert.deepEqual(first.calls, [
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.request.animeBrowserGetSnapshot,
|
||||||
|
args: [firstSessionId],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.request.animeBrowserSelectSource,
|
||||||
|
args: [firstSessionId, 'source.one'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.request.animeBrowserSearch,
|
||||||
|
args: [firstSessionId, 'frieren', 2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.request.animeBrowserGetDetails,
|
||||||
|
args: [firstSessionId, '/frieren', undefined],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.request.animeBrowserGetEpisodes,
|
||||||
|
args: [firstSessionId, '/frieren', 'source.one'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(second.calls, [
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.request.animeBrowserGetPopular,
|
||||||
|
args: [secondSessionId, 3],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { IpcRenderer } from 'electron';
|
||||||
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
import type {
|
||||||
|
AnimeBrowserAPI,
|
||||||
|
AnimeBrowserBridgeState,
|
||||||
|
AnimeBrowserDetails,
|
||||||
|
AnimeBrowserEpisode,
|
||||||
|
AnimeBrowserEpisodeWatchState,
|
||||||
|
AnimeBrowserPlayRequest,
|
||||||
|
AnimeBrowserPlayResult,
|
||||||
|
AnimeBrowserQueueState,
|
||||||
|
AnimeBrowserSearchResult,
|
||||||
|
AnimeBrowserSearchUpdate,
|
||||||
|
AnimeBrowserSetWatchedRequest,
|
||||||
|
AnimeBrowserSnapshot,
|
||||||
|
AnimeBrowserWatchStateRequest,
|
||||||
|
AvailableExtensionsResult,
|
||||||
|
SourcePreferenceView,
|
||||||
|
} from './types/anime-browser';
|
||||||
|
|
||||||
|
type AnimeBrowserIpcRenderer = Pick<IpcRenderer, 'invoke' | 'on' | 'removeListener'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the browser bridge for one renderer. The opaque session id keeps
|
||||||
|
* source selection and in-flight searches independent between the standalone
|
||||||
|
* window and the player-hosted modal.
|
||||||
|
*/
|
||||||
|
export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): AnimeBrowserAPI {
|
||||||
|
const request = IPC_CHANNELS.request;
|
||||||
|
// Sandboxed Electron preloads cannot require arbitrary Node built-ins.
|
||||||
|
// Web Crypto is available in the isolated renderer world; the fallback only
|
||||||
|
// covers test or legacy contexts where randomUUID is absent.
|
||||||
|
const sessionId =
|
||||||
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
|
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSnapshot: (): Promise<AnimeBrowserSnapshot> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetSnapshot, sessionId),
|
||||||
|
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserEnsureBridge, sessionId),
|
||||||
|
selectSource: (sourceId: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
|
||||||
|
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserSearch, sessionId, query, page),
|
||||||
|
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetPopular, sessionId, page),
|
||||||
|
getDetails: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetDetails, sessionId, animeUrl, sourceId),
|
||||||
|
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetEpisodes, sessionId, animeUrl, sourceId),
|
||||||
|
getWatchState: (
|
||||||
|
watchRequest: AnimeBrowserWatchStateRequest,
|
||||||
|
): Promise<AnimeBrowserEpisodeWatchState[]> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetWatchState, watchRequest),
|
||||||
|
setWatched: (
|
||||||
|
watchedRequest: AnimeBrowserSetWatchedRequest,
|
||||||
|
): Promise<AnimeBrowserEpisodeWatchState[]> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
|
||||||
|
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
|
||||||
|
queueEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserQueueEpisode, playRequest),
|
||||||
|
dequeueEpisode: (sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserDequeueEpisode, sourceId, episodeUrl),
|
||||||
|
clearQueue: (): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserClearQueue),
|
||||||
|
getQueue: (): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetQueue),
|
||||||
|
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying),
|
||||||
|
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
|
||||||
|
setPreference: (
|
||||||
|
sourceId: string,
|
||||||
|
key: string,
|
||||||
|
value: string | string[] | boolean,
|
||||||
|
): Promise<SourcePreferenceView[]> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserSetPreference, sourceId, key, value),
|
||||||
|
listAvailableExtensions: (): Promise<AvailableExtensionsResult> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
|
||||||
|
installExtension: (pkg: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
|
||||||
|
removeExtension: (pkg: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
|
||||||
|
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
|
||||||
|
addRepo: (url: string): Promise<void> => ipcRenderer.invoke(request.animeBrowserAddRepo, url),
|
||||||
|
removeRepo: (url: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserRemoveRepo, url),
|
||||||
|
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void): (() => void) => {
|
||||||
|
const handler = (_event: unknown, state: AnimeBrowserBridgeState): void => listener(state);
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
|
||||||
|
},
|
||||||
|
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void): (() => void) => {
|
||||||
|
const handler = (_event: unknown, update: AnimeBrowserSearchUpdate): void => listener(update);
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
||||||
|
},
|
||||||
|
onQueueState: (listener: (state: AnimeBrowserQueueState) => void): (() => void) => {
|
||||||
|
const handler = (_event: unknown, state: AnimeBrowserQueueState): void => listener(state);
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-89
@@ -1,91 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { createAnimeBrowserAPI } from './preload-anime-browser-api';
|
||||||
import type {
|
|
||||||
AnimeBrowserAPI,
|
|
||||||
AnimeBrowserBridgeState,
|
|
||||||
AnimeBrowserDetails,
|
|
||||||
AnimeBrowserEpisode,
|
|
||||||
AnimeBrowserEpisodeWatchState,
|
|
||||||
AnimeBrowserWatchStateRequest,
|
|
||||||
AnimeBrowserSetWatchedRequest,
|
|
||||||
AnimeBrowserPlayRequest,
|
|
||||||
AnimeBrowserPlayResult,
|
|
||||||
AnimeBrowserQueueState,
|
|
||||||
AnimeBrowserSearchResult,
|
|
||||||
AnimeBrowserSearchUpdate,
|
|
||||||
AnimeBrowserSnapshot,
|
|
||||||
AvailableExtensionsResult,
|
|
||||||
SourcePreferenceView,
|
|
||||||
} from './types/anime-browser';
|
|
||||||
|
|
||||||
const request = IPC_CHANNELS.request;
|
contextBridge.exposeInMainWorld('animeBrowserAPI', createAnimeBrowserAPI(ipcRenderer));
|
||||||
|
|
||||||
const animeBrowserAPI: AnimeBrowserAPI = {
|
|
||||||
getSnapshot: (): Promise<AnimeBrowserSnapshot> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserGetSnapshot),
|
|
||||||
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserEnsureBridge),
|
|
||||||
selectSource: (sourceId: string): Promise<void> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserSelectSource, sourceId),
|
|
||||||
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserSearch, query, page),
|
|
||||||
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserGetPopular, page),
|
|
||||||
getDetails: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId),
|
|
||||||
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserGetEpisodes, animeUrl, sourceId),
|
|
||||||
getWatchState: (
|
|
||||||
watchRequest: AnimeBrowserWatchStateRequest,
|
|
||||||
): Promise<AnimeBrowserEpisodeWatchState[]> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserGetWatchState, watchRequest),
|
|
||||||
setWatched: (
|
|
||||||
watchedRequest: AnimeBrowserSetWatchedRequest,
|
|
||||||
): Promise<AnimeBrowserEpisodeWatchState[]> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
|
|
||||||
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
|
|
||||||
queueEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserQueueEpisode, playRequest),
|
|
||||||
dequeueEpisode: (sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserDequeueEpisode, sourceId, episodeUrl),
|
|
||||||
clearQueue: (): Promise<AnimeBrowserQueueState> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserClearQueue),
|
|
||||||
getQueue: (): Promise<AnimeBrowserQueueState> => ipcRenderer.invoke(request.animeBrowserGetQueue),
|
|
||||||
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying),
|
|
||||||
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
|
|
||||||
setPreference: (
|
|
||||||
sourceId: string,
|
|
||||||
key: string,
|
|
||||||
value: string | string[] | boolean,
|
|
||||||
): Promise<SourcePreferenceView[]> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserSetPreference, sourceId, key, value),
|
|
||||||
listAvailableExtensions: (): Promise<AvailableExtensionsResult> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
|
|
||||||
installExtension: (pkg: string): Promise<void> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
|
|
||||||
removeExtension: (pkg: string): Promise<void> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
|
|
||||||
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
|
|
||||||
addRepo: (url: string): Promise<void> => ipcRenderer.invoke(request.animeBrowserAddRepo, url),
|
|
||||||
removeRepo: (url: string): Promise<void> =>
|
|
||||||
ipcRenderer.invoke(request.animeBrowserRemoveRepo, url),
|
|
||||||
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void): (() => void) => {
|
|
||||||
const handler = (_event: unknown, state: AnimeBrowserBridgeState): void => listener(state);
|
|
||||||
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
|
|
||||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
|
|
||||||
},
|
|
||||||
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void): (() => void) => {
|
|
||||||
const handler = (_event: unknown, update: AnimeBrowserSearchUpdate): void => listener(update);
|
|
||||||
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
|
||||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
|
||||||
},
|
|
||||||
onQueueState: (listener: (state: AnimeBrowserQueueState) => void): (() => void) => {
|
|
||||||
const handler = (_event: unknown, state: AnimeBrowserQueueState): void => listener(state);
|
|
||||||
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
|
|
||||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('animeBrowserAPI', animeBrowserAPI);
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import type {
|
|||||||
ChangelogSnapshot,
|
ChangelogSnapshot,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
import { createAnimeBrowserAPI } from './preload-anime-browser-api';
|
||||||
|
|
||||||
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ function createLatestValueIpcListenerWithPayload<T>(
|
|||||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||||
|
const onOpenAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserOpen);
|
||||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||||
);
|
);
|
||||||
@@ -451,6 +453,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||||
onOpenSessionHelp: onOpenSessionHelpEvent,
|
onOpenSessionHelp: onOpenSessionHelpEvent,
|
||||||
onOpenChangelog: onOpenChangelogEvent,
|
onOpenChangelog: onOpenChangelogEvent,
|
||||||
|
onOpenAnimeBrowser: onOpenAnimeBrowserEvent,
|
||||||
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
||||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||||
@@ -528,3 +531,4 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
|
contextBridge.exposeInMainWorld('animeBrowserAPI', createAnimeBrowserAPI(ipcRenderer));
|
||||||
|
|||||||
@@ -990,6 +990,7 @@ test('default keybindings dispatch through overlay keyboard handling', async ()
|
|||||||
const specialActionIds: Record<string, string> = {
|
const specialActionIds: Record<string, string> = {
|
||||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||||
|
[SPECIAL_COMMANDS.ANIME_BROWSER_OPEN]: 'openAnimeBrowser',
|
||||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
||||||
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
|
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function createKeyboardHandlers(
|
|||||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleAnimeBrowserKeydown?: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -1096,6 +1097,12 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.state.animeBrowserModalOpen) {
|
||||||
|
if (options.handleAnimeBrowserKeydown?.(e)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
|
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
|
||||||
// arrows/H/L for folding, and those would otherwise move the subtitle word
|
// arrows/H/L for folding, and those would otherwise move the subtitle word
|
||||||
// selection (and seek mpv) behind the open modal.
|
// selection (and seek mpv) behind the open modal.
|
||||||
|
|||||||
@@ -520,6 +520,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="animeBrowserModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-content anime-browser-modal-content">
|
||||||
|
<div class="modal-header anime-browser-modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="modal-title">Anime Browser</div>
|
||||||
|
<div class="anime-browser-modal-kicker">Playing inside mpv</div>
|
||||||
|
</div>
|
||||||
|
<button id="animeBrowserClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body anime-browser-modal-body">
|
||||||
|
<iframe
|
||||||
|
id="animeBrowserFrame"
|
||||||
|
class="anime-browser-frame"
|
||||||
|
title="Anime Browser"
|
||||||
|
data-src="../animeui/index.html?embedded=overlay-modal"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="renderer.js"></script>
|
<script type="module" src="renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createAnimeBrowserModal } from './anime-browser';
|
||||||
|
|
||||||
|
function createClassList(initial: string[] = []) {
|
||||||
|
const classes = new Set(initial);
|
||||||
|
return {
|
||||||
|
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
|
||||||
|
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
|
||||||
|
contains: (name: string) => classes.has(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('embedded Anime Browser closes only for its own close message', () => {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
const messages: Array<(event: MessageEvent) => void> = [];
|
||||||
|
const notifications: string[] = [];
|
||||||
|
const frameWindow = {};
|
||||||
|
const closeListeners: Array<() => void> = [];
|
||||||
|
const removedCloseListeners: Array<() => void> = [];
|
||||||
|
const overlay = { classList: createClassList() };
|
||||||
|
const modalElement = {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
attributes: new Map<string, string>(),
|
||||||
|
setAttribute(name: string, value: string) {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const frame = {
|
||||||
|
src: '',
|
||||||
|
dataset: { src: '../animeui/index.html?embedded=overlay-modal' },
|
||||||
|
contentWindow: frameWindow,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: (name: string) => notifications.push(`open:${name}`),
|
||||||
|
notifyOverlayModalClosed: (name: string) => notifications.push(`close:${name}`),
|
||||||
|
},
|
||||||
|
addEventListener: (name: string, listener: (event: MessageEvent) => void) => {
|
||||||
|
if (name === 'message') messages.push(listener);
|
||||||
|
},
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = { animeBrowserModalOpen: false, isOverSubtitle: false };
|
||||||
|
const modal = createAnimeBrowserModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay,
|
||||||
|
animeBrowserModal: modalElement,
|
||||||
|
animeBrowserFrame: frame,
|
||||||
|
animeBrowserClose: {
|
||||||
|
addEventListener: (_name: string, listener: () => void) =>
|
||||||
|
closeListeners.push(listener),
|
||||||
|
removeEventListener: (_name: string, listener: () => void) =>
|
||||||
|
removedCloseListeners.push(listener),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => state.animeBrowserModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openAnimeBrowserModal();
|
||||||
|
assert.equal(state.animeBrowserModalOpen, true);
|
||||||
|
assert.equal(frame.src, frame.dataset.src);
|
||||||
|
assert.deepEqual(notifications, ['open:anime-browser']);
|
||||||
|
|
||||||
|
messages[0]?.({ source: {}, data: 'subminer:anime-browser-close' } as MessageEvent);
|
||||||
|
messages[0]?.({ source: frameWindow, data: 'not-the-close-message' } as MessageEvent);
|
||||||
|
assert.equal(state.animeBrowserModalOpen, true);
|
||||||
|
|
||||||
|
messages[0]?.({ source: frameWindow, data: 'subminer:anime-browser-close' } as MessageEvent);
|
||||||
|
assert.equal(state.animeBrowserModalOpen, false);
|
||||||
|
assert.equal(modalElement.classList.contains('hidden'), true);
|
||||||
|
assert.deepEqual(notifications, ['open:anime-browser', 'close:anime-browser']);
|
||||||
|
modal.disposeDomEvents();
|
||||||
|
assert.deepEqual(removedCloseListeners, closeListeners);
|
||||||
|
} finally {
|
||||||
|
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||||
|
else Reflect.deleteProperty(globalThis, 'window');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
|
||||||
|
const CLOSE_MESSAGE = 'subminer:anime-browser-close';
|
||||||
|
|
||||||
|
export function createAnimeBrowserModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
function ensureFrameLoaded(): void {
|
||||||
|
if (ctx.dom.animeBrowserFrame.src) return;
|
||||||
|
const source = ctx.dom.animeBrowserFrame.dataset.src;
|
||||||
|
if (!source) throw new Error('Anime Browser modal is missing its embedded source.');
|
||||||
|
ctx.dom.animeBrowserFrame.src = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAnimeBrowserModal(): void {
|
||||||
|
if (ctx.state.animeBrowserModalOpen || options.modalStateReader.isAnyModalOpen()) return;
|
||||||
|
|
||||||
|
ctx.state.animeBrowserModalOpen = true;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
|
ctx.dom.animeBrowserModal.classList.remove('hidden');
|
||||||
|
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false');
|
||||||
|
ensureFrameLoaded();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('anime-browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAnimeBrowserModal(): void {
|
||||||
|
if (!ctx.state.animeBrowserModalOpen) return;
|
||||||
|
ctx.state.animeBrowserModalOpen = false;
|
||||||
|
ctx.dom.animeBrowserModal.classList.add('hidden');
|
||||||
|
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'true');
|
||||||
|
window.electronAPI.notifyOverlayModalClosed('anime-browser');
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAnimeBrowserKeydown(event: KeyboardEvent): boolean {
|
||||||
|
if (!ctx.state.animeBrowserModalOpen || event.key !== 'Escape') return false;
|
||||||
|
event.preventDefault();
|
||||||
|
closeAnimeBrowserModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFrameMessage(event: MessageEvent): void {
|
||||||
|
if (event.source === ctx.dom.animeBrowserFrame.contentWindow && event.data === CLOSE_MESSAGE) {
|
||||||
|
closeAnimeBrowserModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
ctx.dom.animeBrowserClose.addEventListener('click', closeAnimeBrowserModal);
|
||||||
|
window.addEventListener('message', handleFrameMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeDomEvents(): void {
|
||||||
|
ctx.dom.animeBrowserClose.removeEventListener('click', closeAnimeBrowserModal);
|
||||||
|
window.removeEventListener('message', handleFrameMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openAnimeBrowserModal,
|
||||||
|
closeAnimeBrowserModal,
|
||||||
|
handleAnimeBrowserKeydown,
|
||||||
|
wireDomEvents,
|
||||||
|
disposeDomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ function describeCommand(command: (string | number)[]): string {
|
|||||||
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
||||||
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime';
|
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime';
|
||||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||||
|
if (first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN) return 'Open Anime Browser';
|
||||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
@@ -151,6 +152,7 @@ function sectionForCommand(command: (string | number)[]): string {
|
|||||||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
|
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
|
||||||
first === SPECIAL_COMMANDS.TSUKIHIME_OPEN ||
|
first === SPECIAL_COMMANDS.TSUKIHIME_OPEN ||
|
||||||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||||
|
first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN ||
|
||||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||||
) {
|
) {
|
||||||
return 'Runtime settings';
|
return 'Runtime settings';
|
||||||
@@ -229,6 +231,8 @@ function describeSessionAction(
|
|||||||
return 'Open YouTube subtitle picker';
|
return 'Open YouTube subtitle picker';
|
||||||
case 'openPlaylistBrowser':
|
case 'openPlaylistBrowser':
|
||||||
return 'Open playlist browser';
|
return 'Open playlist browser';
|
||||||
|
case 'openAnimeBrowser':
|
||||||
|
return 'Open Anime Browser';
|
||||||
case 'replayCurrentSubtitle':
|
case 'replayCurrentSubtitle':
|
||||||
return 'Replay current subtitle';
|
return 'Replay current subtitle';
|
||||||
case 'playNextSubtitle':
|
case 'playNextSubtitle':
|
||||||
@@ -270,6 +274,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
|||||||
case 'openControllerDebug':
|
case 'openControllerDebug':
|
||||||
case 'openYoutubePicker':
|
case 'openYoutubePicker':
|
||||||
case 'openPlaylistBrowser':
|
case 'openPlaylistBrowser':
|
||||||
|
case 'openAnimeBrowser':
|
||||||
case 'openSessionHelp':
|
case 'openSessionHelp':
|
||||||
return 'Modals and tools';
|
return 'Modals and tools';
|
||||||
case 'replayCurrentSubtitle':
|
case 'replayCurrentSubtitle':
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { createTsukihimeModal } from './modals/tsukihime.js';
|
|||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||||
|
import { createAnimeBrowserModal } from './modals/anime-browser.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
import { createChangelogModal } from './modals/changelog.js';
|
import { createChangelogModal } from './modals/changelog.js';
|
||||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
@@ -120,6 +121,12 @@ const modalDescriptors = [
|
|||||||
close: () => playlistBrowserModal.closePlaylistBrowserModal(),
|
close: () => playlistBrowserModal.closePlaylistBrowserModal(),
|
||||||
suppressesSubtitles: true,
|
suppressesSubtitles: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'anime-browser',
|
||||||
|
isOpen: () => ctx.state.animeBrowserModalOpen,
|
||||||
|
close: () => animeBrowserModal.closeAnimeBrowserModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'kiku',
|
id: 'kiku',
|
||||||
isOpen: () => ctx.state.kikuModalOpen,
|
isOpen: () => ctx.state.kikuModalOpen,
|
||||||
@@ -265,6 +272,10 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const animeBrowserModal = createAnimeBrowserModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||||
@@ -274,6 +285,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||||
|
handleAnimeBrowserKeydown: animeBrowserModal.handleAnimeBrowserKeydown,
|
||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
@@ -579,6 +591,11 @@ function registerModalOpenHandlers(): void {
|
|||||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onOpenAnimeBrowser(() => {
|
||||||
|
runGuarded('anime-browser:open', () => {
|
||||||
|
animeBrowserModal.openAnimeBrowserModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
window.electronAPI.onCancelYoutubeTrackPicker(() => {
|
window.electronAPI.onCancelYoutubeTrackPicker(() => {
|
||||||
runGuarded('youtube:picker-cancel', () => {
|
runGuarded('youtube:picker-cancel', () => {
|
||||||
youtubePickerModal.closeYoutubePickerModal();
|
youtubePickerModal.closeYoutubePickerModal();
|
||||||
@@ -806,6 +823,7 @@ async function init(): Promise<void> {
|
|||||||
tsukihimeModal.wireDomEvents();
|
tsukihimeModal.wireDomEvents();
|
||||||
youtubePickerModal.wireDomEvents();
|
youtubePickerModal.wireDomEvents();
|
||||||
playlistBrowserModal.wireDomEvents();
|
playlistBrowserModal.wireDomEvents();
|
||||||
|
animeBrowserModal.wireDomEvents();
|
||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
@@ -817,6 +835,7 @@ async function init(): Promise<void> {
|
|||||||
characterDictionaryModal.wireDomEvents();
|
characterDictionaryModal.wireDomEvents();
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
subtitleSidebarModal.disposeDomEvents();
|
subtitleSidebarModal.disposeDomEvents();
|
||||||
|
animeBrowserModal.disposeDomEvents();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export type RendererState = {
|
|||||||
changelogModalOpen: boolean;
|
changelogModalOpen: boolean;
|
||||||
changelogSelectedIndex: number;
|
changelogSelectedIndex: number;
|
||||||
playlistBrowserModalOpen: boolean;
|
playlistBrowserModalOpen: boolean;
|
||||||
|
animeBrowserModalOpen: boolean;
|
||||||
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
||||||
playlistBrowserStatus: string;
|
playlistBrowserStatus: string;
|
||||||
playlistBrowserActivePane: 'directory' | 'playlist';
|
playlistBrowserActivePane: 'directory' | 'playlist';
|
||||||
@@ -233,6 +234,7 @@ export function createRendererState(): RendererState {
|
|||||||
changelogModalOpen: false,
|
changelogModalOpen: false,
|
||||||
changelogSelectedIndex: 0,
|
changelogSelectedIndex: 0,
|
||||||
playlistBrowserModalOpen: false,
|
playlistBrowserModalOpen: false,
|
||||||
|
animeBrowserModalOpen: false,
|
||||||
playlistBrowserSnapshot: null,
|
playlistBrowserSnapshot: null,
|
||||||
playlistBrowserStatus: '',
|
playlistBrowserStatus: '',
|
||||||
playlistBrowserActivePane: 'playlist',
|
playlistBrowserActivePane: 'playlist',
|
||||||
|
|||||||
@@ -1116,6 +1116,54 @@ body:focus-visible,
|
|||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anime-browser-modal-content {
|
||||||
|
width: min(1500px, calc(100vw - 32px));
|
||||||
|
max-width: none;
|
||||||
|
height: min(920px, calc(100vh - 32px));
|
||||||
|
max-height: none;
|
||||||
|
padding: 14px;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 0%, rgba(138, 173, 244, 0.14), transparent 30%),
|
||||||
|
linear-gradient(180deg, rgba(36, 39, 58, 0.99), rgba(24, 25, 38, 0.99));
|
||||||
|
border-color: rgba(138, 173, 244, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 90px rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-browser-modal-header {
|
||||||
|
padding-inline: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-browser-modal-kicker {
|
||||||
|
margin-top: 1px;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-browser-modal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.22);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-browser-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-browser-body {
|
.playlist-browser-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1297,6 +1345,13 @@ body:focus-visible,
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anime-browser-modal-content {
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
height: calc(100vh - 16px);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-browser-grid {
|
.playlist-browser-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ export type RendererDom = {
|
|||||||
playlistBrowserDirectoryList: HTMLUListElement;
|
playlistBrowserDirectoryList: HTMLUListElement;
|
||||||
playlistBrowserPlaylistList: HTMLUListElement;
|
playlistBrowserPlaylistList: HTMLUListElement;
|
||||||
playlistBrowserClose: HTMLButtonElement;
|
playlistBrowserClose: HTMLButtonElement;
|
||||||
|
|
||||||
|
animeBrowserModal: HTMLDivElement;
|
||||||
|
animeBrowserClose: HTMLButtonElement;
|
||||||
|
animeBrowserFrame: HTMLIFrameElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||||
@@ -326,5 +330,9 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
'playlistBrowserPlaylistList',
|
'playlistBrowserPlaylistList',
|
||||||
),
|
),
|
||||||
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
|
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
|
||||||
|
|
||||||
|
animeBrowserModal: getRequiredElement<HTMLDivElement>('animeBrowserModal'),
|
||||||
|
animeBrowserClose: getRequiredElement<HTMLButtonElement>('animeBrowserClose'),
|
||||||
|
animeBrowserFrame: getRequiredElement<HTMLIFrameElement>('animeBrowserFrame'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'session-help',
|
'session-help',
|
||||||
'character-dictionary',
|
'character-dictionary',
|
||||||
'changelog',
|
'changelog',
|
||||||
|
'anime-browser',
|
||||||
] as const;
|
] as const;
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
@@ -172,6 +173,7 @@ export const IPC_CHANNELS = {
|
|||||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
sessionHelpOpen: 'session-help:open',
|
sessionHelpOpen: 'session-help:open',
|
||||||
changelogOpen: 'changelog:open',
|
changelogOpen: 'changelog:open',
|
||||||
|
animeBrowserOpen: 'anime-browser:open',
|
||||||
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
||||||
controllerSelectOpen: 'controller-select:open',
|
controllerSelectOpen: 'controller-select:open',
|
||||||
controllerDebugOpen: 'controller-debug:open',
|
controllerDebugOpen: 'controller-debug:open',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
|||||||
'openTsukihime',
|
'openTsukihime',
|
||||||
'openYoutubePicker',
|
'openYoutubePicker',
|
||||||
'openPlaylistBrowser',
|
'openPlaylistBrowser',
|
||||||
|
'openAnimeBrowser',
|
||||||
'replayCurrentSubtitle',
|
'replayCurrentSubtitle',
|
||||||
'playNextSubtitle',
|
'playNextSubtitle',
|
||||||
'cycleRuntimeOption',
|
'cycleRuntimeOption',
|
||||||
|
|||||||
+4
-32
@@ -5,6 +5,7 @@ import type {
|
|||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
} from './anki';
|
} from './anki';
|
||||||
import type { ChangelogSnapshot } from './changelog';
|
import type { ChangelogSnapshot } from './changelog';
|
||||||
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||||
import type {
|
import type {
|
||||||
CompiledSessionBinding,
|
CompiledSessionBinding,
|
||||||
@@ -510,6 +511,7 @@ export interface ElectronAPI {
|
|||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenSessionHelp: (callback: () => void) => void;
|
onOpenSessionHelp: (callback: () => void) => void;
|
||||||
onOpenChangelog: (callback: () => void) => void;
|
onOpenChangelog: (callback: () => void) => void;
|
||||||
|
onOpenAnimeBrowser: (callback: () => void) => void;
|
||||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
onOpenControllerSelect: (callback: () => void) => void;
|
onOpenControllerSelect: (callback: () => void) => void;
|
||||||
onOpenControllerDebug: (callback: () => void) => void;
|
onOpenControllerDebug: (callback: () => void) => void;
|
||||||
@@ -554,38 +556,8 @@ export interface ElectronAPI {
|
|||||||
mediaId: number,
|
mediaId: number,
|
||||||
direction: 1 | -1,
|
direction: 1 | -1,
|
||||||
) => Promise<CharacterDictionaryManagerMutationResult>;
|
) => Promise<CharacterDictionaryManagerMutationResult>;
|
||||||
notifyOverlayModalClosed: (
|
notifyOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
modal:
|
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||||
| 'runtime-options'
|
|
||||||
| 'subsync'
|
|
||||||
| 'jimaku'
|
|
||||||
| 'tsukihime'
|
|
||||||
| 'youtube-track-picker'
|
|
||||||
| 'playlist-browser'
|
|
||||||
| 'kiku'
|
|
||||||
| 'controller-select'
|
|
||||||
| 'controller-debug'
|
|
||||||
| 'subtitle-sidebar'
|
|
||||||
| 'session-help'
|
|
||||||
| 'character-dictionary'
|
|
||||||
| 'changelog',
|
|
||||||
) => void;
|
|
||||||
notifyOverlayModalOpened: (
|
|
||||||
modal:
|
|
||||||
| 'runtime-options'
|
|
||||||
| 'subsync'
|
|
||||||
| 'jimaku'
|
|
||||||
| 'tsukihime'
|
|
||||||
| 'youtube-track-picker'
|
|
||||||
| 'playlist-browser'
|
|
||||||
| 'kiku'
|
|
||||||
| 'controller-select'
|
|
||||||
| 'controller-debug'
|
|
||||||
| 'subtitle-sidebar'
|
|
||||||
| 'session-help'
|
|
||||||
| 'character-dictionary'
|
|
||||||
| 'changelog',
|
|
||||||
) => void;
|
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type SessionActionId =
|
|||||||
| 'openTsukihime'
|
| 'openTsukihime'
|
||||||
| 'openYoutubePicker'
|
| 'openYoutubePicker'
|
||||||
| 'openPlaylistBrowser'
|
| 'openPlaylistBrowser'
|
||||||
|
| 'openAnimeBrowser'
|
||||||
| 'replayCurrentSubtitle'
|
| 'replayCurrentSubtitle'
|
||||||
| 'playNextSubtitle'
|
| 'playNextSubtitle'
|
||||||
| 'cycleRuntimeOption';
|
| 'cycleRuntimeOption';
|
||||||
|
|||||||
Reference in New Issue
Block a user