fix(player-controls): reset recording feedback on handoff

This commit is contained in:
4gray committed 2026-07-16 18:08:09 +02:00
1 parent 051c7f9ff4
commit a12d5c059c
10 files changed
+175 -11

No files matched your search

+3 -2
View File
@@ -142,8 +142,9 @@ Key files:
- Frame-copy shared controls own DOM surface interactions, shortcuts,
fullscreen, and recording feedback. `showControls=false` detaches the shared
surface, modal overlays gate playback shortcuts, fullscreen still triggers
bounds sync, and engine/session handoff cancels stale recording/timer
ownership.
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 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
+1 -1
View File
@@ -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 engine/session handoff cancels stale recording/timer ownership. 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. 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)**:
@@ -127,6 +127,10 @@ is rendered; state such as `canSeek`, `canPreviousEpisode`, and
Adapters translate engine types into this model. The controls component must not
import Video.js, hls.js, ArtPlayer, libmpv, Electron IPC, or native-view types.
Recording state may expose a `transitionKey` that identifies its current
playback/session owner. When that key changes, shared feedback adopts the new
active baseline without flashing a start or saved transition from the previous
owner.
### Commands
@@ -214,6 +218,11 @@ a modal/backdrop overlay is active, so transport, seek, volume, and fullscreen
actions cannot leak through it. Escape keeps the shared component's generic
popover-dismissal behavior.
Frame-copy recording transitions use the adapter's playback/session identity as
their `transitionKey`. Session disposal, retry, channel changes, and engine
handoff therefore clear stale recording ownership without showing a false
`RECORDING_SAVED` confirmation.
### Timeline scrubbing
Timeline input is previewed locally while the user drags. The slider value,
@@ -155,4 +155,45 @@ describe('EmbeddedMpvControlsAdapter engine handoff', () => {
expect(controller.startRecording).toHaveBeenCalledTimes(2);
});
it('changes the recording transition key across session handoff', () => {
const controller = createController();
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
EmbeddedMpvControlsAdapter,
{
provide: EmbeddedMpvSessionController,
useValue: controller,
},
],
});
const adapter = TestBed.inject(EmbeddedMpvControlsAdapter);
adapter.configure({
playback: signal(PLAYBACK),
seriesNavigation: signal(null),
recordingFolder: signal('/recordings'),
});
controller.session.set(
session({
recording: {
active: true,
startedAt: '2026-07-16T10:00:00.000Z',
},
})
);
TestBed.tick();
const activeKey = adapter.state().recording.transitionKey;
controller.session.set(null);
TestBed.tick();
expect(adapter.state().recording).toMatchObject({
active: false,
transitionKey: null,
});
controller.session.set(session({ id: 'session-2' }));
TestBed.tick();
expect(adapter.state().recording.transitionKey).not.toBe(activeKey);
});
});
@@ -87,6 +87,13 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
private readonly activeSessionId = computed(
() => this.controller.session()?.id ?? null
);
private readonly recordingTransitionKey = computed(() => {
const playbackIdentity = this.recordingPlaybackIdentity();
const sessionId = this.activeSessionId();
return playbackIdentity === null || sessionId === null
? null
: JSON.stringify([playbackIdentity, sessionId]);
});
private readonly recordingTick = signal(Date.now());
readonly capabilities = computed<PlayerControlsCapabilities>(() => {
@@ -181,6 +188,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
this.recordingControls.feedback(),
this.translate
),
transitionKey: this.recordingTransitionKey(),
},
canPreviousEpisode:
hasSeriesNavigation && seriesNavigation?.canPrevious === true,
@@ -56,4 +56,20 @@ describe('ControlsFeedback', () => {
feedback.flashRecordingTransition(false, labels);
expect(feedback.current()?.label).toBe('Aufnahme gespeichert');
});
it('clears only recording-owned feedback across owner handoff', () => {
const feedback = new ControlsFeedback();
const labels = {
active: 'Recording',
inactive: 'Recording saved',
};
feedback.flashRecordingTransition(true, labels, 'session-1');
feedback.flashRecordingTransition(false, labels, 'session-2');
expect(feedback.current()).toBeNull();
feedback.flash('volume_up', '60%');
feedback.flashRecordingTransition(false, labels, 'session-3');
expect(feedback.current()?.label).toBe('60%');
});
});
@@ -1,4 +1,5 @@
import { signal } from '@angular/core';
import type { PlayerRecordingState } from './player-controls.model';
export interface RecordingFeedbackLabels {
active: string;
@@ -20,15 +21,19 @@ export class ControlsFeedback {
private timer: number | null = null;
private nextKey = 0;
private lastRecordingActive = false;
private recordingFeedbackKey: number | null = null;
private recordingTransitionKey: string | null | undefined;
flash(icon: string, label: string, durationMs = 700): void {
if (this.timer !== null) {
clearTimeout(this.timer);
}
this.recordingFeedbackKey = null;
this.nextKey += 1;
this.current.set({ icon, label, key: this.nextKey });
this.timer = window.setTimeout(() => {
this.current.set(null);
this.recordingFeedbackKey = null;
this.timer = null;
}, durationMs);
}
@@ -37,10 +42,34 @@ export class ControlsFeedback {
* Flash a transient overlay on recording start/stop transitions, ignoring
* repeated states. Tracks the last known active state internally.
*/
flashRecordingTransition(
active: boolean,
flashRecordingState(
recording: PlayerRecordingState,
labels: RecordingFeedbackLabels
): void {
this.flashRecordingTransition(
recording.active,
{
...labels,
inactive: recording.message || labels.inactive,
},
recording.transitionKey ?? null
);
}
flashRecordingTransition(
active: boolean,
labels: RecordingFeedbackLabels,
transitionKey: string | null = null
): void {
const ownerChanged =
this.recordingTransitionKey !== undefined &&
this.recordingTransitionKey !== transitionKey;
this.recordingTransitionKey = transitionKey;
if (ownerChanged) {
this.clearRecordingFeedback();
this.lastRecordingActive = active;
return;
}
if (active === this.lastRecordingActive) {
return;
}
@@ -50,6 +79,7 @@ export class ControlsFeedback {
} else {
this.flash('stop_circle', labels.inactive, 900);
}
this.recordingFeedbackKey = this.current()?.key ?? null;
}
dispose(): void {
@@ -58,4 +88,19 @@ export class ControlsFeedback {
this.timer = null;
}
}
private clearRecordingFeedback(): void {
if (
this.recordingFeedbackKey === null ||
this.current()?.key !== this.recordingFeedbackKey
) {
return;
}
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
this.recordingFeedbackKey = null;
this.current.set(null);
}
}
@@ -269,4 +269,48 @@ describe('PlayerControlsComponent capability contract', () => {
?.textContent
).toContain('REC 0:12');
});
it('does not flash a saved recording across owner handoff', () => {
const fake = createFakeController();
const fixture = createControls(fake);
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
recording: true,
});
fake.state.set({
...createEmptyControlsState(),
recording: {
active: false,
elapsedSeconds: 0,
message: null,
transitionKey: 'session-1',
},
});
fixture.detectChanges();
fake.state.update((state) => ({
...state,
recording: {
...state.recording,
active: true,
elapsedSeconds: 12,
},
}));
fixture.detectChanges();
expect(fixture.componentInstance.feedback.current()?.label).toBe(
'EMBEDDED_MPV.PLAYER.RECORDING'
);
fake.state.set({
...createEmptyControlsState(),
recording: {
active: false,
elapsedSeconds: 0,
message: null,
transitionKey: 'session-2',
},
});
fixture.detectChanges();
expect(fixture.componentInstance.feedback.current()).toBeNull();
});
});
@@ -205,15 +205,13 @@ export class PlayerControlsComponent implements OnDestroy {
state
);
this.visibility.scheduleHide();
this.feedback.flashRecordingTransition(state.recording.active, {
this.feedback.flashRecordingState(state.recording, {
active: this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING'
),
inactive:
state.recording.message ||
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_SAVED'
),
inactive: this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_SAVED'
),
});
});
});
@@ -38,6 +38,8 @@ export interface PlayerRecordingState {
elapsedSeconds: number;
/** Persistent status text (e.g. "Saved to …" / error). null when none. */
message: string | null;
/** Changes when recording ownership moves to another playback/session. */
transitionKey?: string | null;
}
export interface PlayerControlsState {