mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
fix(embedded-mpv): preserve newer session snapshots
This commit is contained in:
1 parent
a12d5c059c
commit
594ee6f35b
5 files changed
+84
-3
No files matched your search
@@ -144,7 +144,9 @@ Key files:
|
||||
surface, modal overlays gate playback shortcuts, fullscreen still triggers
|
||||
bounds sync, and a playback/session transition key prevents engine or session
|
||||
handoff from presenting stale recording feedback while timers and pending
|
||||
commands are cancelled.
|
||||
commands are cancelled. Same-session IPC replies also yield to a broadcast
|
||||
snapshot received while the command was pending, preventing a successful
|
||||
recording acknowledgement from being rolled back by a stale reply.
|
||||
- HTML5/hls.js, Video.js, and ArtPlayer are not wired yet. Their existing skins
|
||||
remain active and the web rollout token remains default-off.
|
||||
- Canonical docs: `docs/architecture/player-controls-contract.md` and
|
||||
|
||||
@@ -618,7 +618,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. HTML5/hls.js, Video.js, and ArtPlayer remain unwired with their existing skins. Contract: `docs/architecture/player-controls-contract.md`.
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. HTML5/hls.js, Video.js, and ArtPlayer remain unwired with their existing skins. Contract: `docs/architecture/player-controls-contract.md`.
|
||||
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
|
||||
@@ -311,7 +311,10 @@ adapter therefore treats snapshots as observations rather than acknowledgments
|
||||
by themselves: it accepts only fresh same-session transitions, permits only one
|
||||
pending toggle, waits for command settlement and the expected state, preserves
|
||||
addon error text, and cancels pending state/feedback when playback, session, or
|
||||
engine ownership changes.
|
||||
engine ownership changes. Command replies are reconciled by snapshot freshness:
|
||||
a same-session broadcast that arrived while IPC was pending wins over an older
|
||||
or same-timestamp reply, so a latched recording acknowledgement cannot be rolled
|
||||
back to the command's stale baseline.
|
||||
|
||||
### Native-view engine
|
||||
|
||||
|
||||
@@ -203,6 +203,49 @@ describe('EmbeddedMpvCommandRunner', () => {
|
||||
expect(session()).toBe(replacement);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['older', '2026-06-02T00:00:02.000Z'],
|
||||
['same-timestamp', '2026-06-02T00:00:03.000Z'],
|
||||
])(
|
||||
'keeps a newer same-session snapshot when a %s command reply settles',
|
||||
async (_, replyUpdatedAt) => {
|
||||
let resolveCommand:
|
||||
| ((value: EmbeddedMpvSession | null) => void)
|
||||
| null = null;
|
||||
electron.startEmbeddedMpvRecording.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EmbeddedMpvSession | null>((resolve) => {
|
||||
resolveCommand = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const pendingRecording = runner.startRecording(
|
||||
'/custom',
|
||||
'My Show'
|
||||
);
|
||||
const acknowledged = createSession({
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath: '/tmp/rec.ts',
|
||||
startedAt: '2026-06-02T00:00:02.000Z',
|
||||
},
|
||||
updatedAt: '2026-06-02T00:00:03.000Z',
|
||||
});
|
||||
session.set(acknowledged);
|
||||
resolveCommand?.(
|
||||
createSession({
|
||||
recording: { active: false },
|
||||
updatedAt: replyUpdatedAt,
|
||||
})
|
||||
);
|
||||
|
||||
const recording = await pendingRecording;
|
||||
|
||||
expect(session()).toBe(acknowledged);
|
||||
expect(recording).toEqual(acknowledged.recording);
|
||||
}
|
||||
);
|
||||
|
||||
it('startRecording resolves the default folder for blank directories', async () => {
|
||||
const recording = await runner.startRecording(' ', 'My Show');
|
||||
expect(
|
||||
|
||||
@@ -152,6 +152,7 @@ export class EmbeddedMpvCommandRunner {
|
||||
expectedSessionId: string,
|
||||
call: () => Promise<EmbeddedMpvSession | null>
|
||||
): Promise<EmbeddedMpvSession | null> {
|
||||
const sessionBeforeCall = this.ctx.session();
|
||||
const updated = await this.guardIpc(call);
|
||||
if (
|
||||
!updated ||
|
||||
@@ -160,10 +161,42 @@ export class EmbeddedMpvCommandRunner {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const current = this.ctx.session();
|
||||
if (
|
||||
current?.id === expectedSessionId &&
|
||||
this.shouldKeepCurrentSnapshot(current, updated, sessionBeforeCall)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
this.ctx.session.set(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session broadcasts and invoke replies can cross in flight. Preserve a
|
||||
* broadcast observed during this command when it is newer than the reply;
|
||||
* for equal timestamps, renderer arrival order breaks the tie.
|
||||
*/
|
||||
private shouldKeepCurrentSnapshot(
|
||||
current: EmbeddedMpvSession,
|
||||
candidate: EmbeddedMpvSession,
|
||||
sessionBeforeCall: EmbeddedMpvSession | null
|
||||
): boolean {
|
||||
const currentUpdatedAt = Date.parse(current.updatedAt);
|
||||
const candidateUpdatedAt = Date.parse(candidate.updatedAt);
|
||||
if (
|
||||
!Number.isFinite(currentUpdatedAt) ||
|
||||
!Number.isFinite(candidateUpdatedAt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
currentUpdatedAt > candidateUpdatedAt ||
|
||||
(currentUpdatedAt === candidateUpdatedAt &&
|
||||
current !== sessionBeforeCall)
|
||||
);
|
||||
}
|
||||
|
||||
private async guardIpc<T>(call: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await call();
|
||||
|
||||
Reference in new issue
Block a user