* docs(embedded-mpv): plan frame-copy shared controls * feat(embedded-mpv): adapt frame-copy sessions to shared controls * fix(embedded-mpv): correlate recording control updates * fix(embedded-mpv): accept recording ack before command resolve * fix(embedded-mpv): serialize delayed recording commands * fix(embedded-mpv): latch buffered recording outcomes * feat(embedded-mpv): use shared controls for frame-copy * fix(embedded-mpv): isolate recording ticks by engine * fix(embedded-mpv): reset controls on engine handoff * docs(embedded-mpv): document frame-copy shared controls * docs(embedded-mpv): normalize shared-controls plans * fix(embedded-mpv): isolate legacy feedback on handoff * fix(player-controls): block toggles while stalled * refactor(embedded-mpv): isolate controls timing * fix(player-controls): reset recording feedback on handoff * fix(embedded-mpv): preserve newer session snapshots
11 KiB
Frame-Copy Shared Controls Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Mount the shared player controls over embedded MPV's frame-copy canvas while preserving the native-view controls dock unchanged.
Architecture: A component-scoped EmbeddedMpvControlsAdapter maps EmbeddedMpvSessionController into the existing PlayerController contract. EmbeddedMpvPlayerComponent renders shared controls only for support.engine === 'frame-copy' and disables its legacy interaction handlers on that path.
Tech Stack: Angular standalone components, signals, ngx-translate, Jest/TestBed, Nx, Electron frame-copy playback.
Task 1: Add the embedded-MPV controls adapter
Files:
-
Create:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts -
Create:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts -
Create:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts -
Step 1: Write failing state and command tests
Cover a supported VOD session, live session, optional support capabilities,
audio/subtitle labels, series navigation, missing/unsupported/loading/error
states, and every PlayerControlsCommands method.
Use a fake controller with writable signals:
const controller = {
support: signal<EmbeddedMpvSupport | null>(supported()),
session: signal<EmbeddedMpvSession | null>(baseSession()),
stalled: signal(false),
togglePaused: jest.fn().mockResolvedValue(undefined),
seekTo: jest.fn().mockResolvedValue(undefined),
seekBy: jest.fn().mockResolvedValue(true),
applyVolume: jest.fn().mockResolvedValue(undefined),
setAudioTrack: jest.fn().mockResolvedValue(undefined),
setSubtitleTrack: jest.fn().mockResolvedValue(undefined),
setSpeed: jest.fn().mockResolvedValue(undefined),
setAspect: jest.fn().mockResolvedValue(undefined),
startRecording: jest.fn(),
stopRecording: jest.fn(),
};
- Step 2: Verify the adapter tests fail
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPattern=embedded-mpv-controls.adapter
Expected: failure because EmbeddedMpvControlsAdapter does not exist.
- Step 3: Implement the adapter state and commands
Create an injectable class that implements PlayerController, accepts the host
context once, and derives state with computed():
export interface EmbeddedMpvControlsContext {
playback: Signal<ResolvedPortalPlayback>;
seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
recordingFolder: Signal<string>;
}
@Injectable()
export class EmbeddedMpvControlsAdapter implements PlayerController {
private context: EmbeddedMpvControlsContext | null = null;
configure(context: EmbeddedMpvControlsContext): void {
this.context = context;
}
readonly commands: PlayerControlsCommands = {
togglePlay: () => void this.controller.togglePaused(),
seekTo: (seconds) => void this.controller.seekTo(seconds),
seekBy: (delta) => void this.controller.seekBy(delta),
setVolume: (value) => void this.controller.applyVolume(value),
setAudioTrack: (id) => void this.controller.setAudioTrack(id),
setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id),
setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed),
setAspectRatio: (value) => void this.controller.setAspect(value),
toggleRecording: () => void this.toggleRecording(),
};
}
Use the existing audioTrackLabel, subtitleTrackLabel, and
readStoredVolume helpers from embedded-mpv-format.utils.ts.
- Step 4: Add failing recording and translation tests
Cover all three ngx-translate event sources, successful start, successful stop with saved path, detailed and generic failures, elapsed time, replacement of an auto-dismissed message, and destroy-time timer cleanup.
- Step 5: Implement message reactivity and cleanup
Use:
private readonly translationsTick = toSignal(
merge(
this.translate.onLangChange,
this.translate.onTranslationChange,
this.translate.onDefaultLangChange
),
{ initialValue: null }
);
Register DestroyRef.onDestroy() to clear the recording-message timeout. Use an
effect() cleanup for the one-second elapsed-time interval.
- Step 6: Run focused adapter tests
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPattern=embedded-mpv-controls.adapter
Expected: both adapter suites pass.
- Step 7: Commit the adapter
git add libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter*
git commit -m "feat(embedded-mpv): adapt frame-copy sessions to shared controls"
Task 2: Mount shared controls for frame-copy
Files:
-
Modify:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts -
Modify:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html -
Modify:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss -
Create:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts -
Modify:
libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts -
Step 1: Write failing engine-selection tests
Assert:
expect(query(By.directive(PlayerControlsComponent))).not.toBeNull();
expect(query(By.css('.embedded-mpv-player__controls'))).toBeNull();
expect(query(By.css('[data-embedded-mpv-frame]'))).not.toBeNull();
for engine: 'frame-copy', and the inverse controls assertions for
engine: 'native'.
- Step 2: Verify the host integration test fails
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPattern=embedded-mpv-player.component.shared-controls
Expected: failure because the component does not import or render
PlayerControlsComponent.
- Step 3: Add the component-scoped adapter and template branch
Add PlayerControlsComponent to component imports and
EmbeddedMpvControlsAdapter to providers. Configure it with the component input
signals and expose the player root element:
readonly sharedControls = inject(EmbeddedMpvControlsAdapter);
readonly playerSurface = computed(
() => this.playerRoot()?.nativeElement ?? null
);
this.sharedControls.configure({
playback: this.playback,
seriesNavigation: this.seriesNavigation,
recordingFolder: this.recordingFolder,
});
Render:
@if (isFrameCopyEngine() && isSupported()) {
<app-player-controls
[controller]="sharedControls"
[playerSurface]="playerSurface()"
[showControls]="showControls()"
(previousEpisodeRequested)="requestPreviousEpisode()"
(nextEpisodeRequested)="requestNextEpisode()"
/>
}
Render the existing .embedded-mpv-player__controls only when the engine is not
frame-copy. Apply .embedded-mpv-player--controls-enabled only to that legacy
path so the frame-copy canvas fills the player.
- Step 4: Add failing exactly-once interaction tests
Cover click-to-pause, double-click fullscreen, keyboard play/pause, disabled shared controls, and previous/next outputs. Spy on controller methods and assert one invocation after the 250 ms single-click grace period.
- Step 5: Disable legacy interaction ownership for frame-copy
Make the old shortcut availability predicate require
!this.isFrameCopyEngine(). Return early from legacy player interaction,
viewport click, double-click, and document pointer handlers on frame-copy.
Keep the fullscreen listener's bounds-sync call for both engines, but reveal
legacy controls only for native-view.
- Step 6: Preserve native regression fixtures
Make existing component specs explicitly set:
engine: 'native',
so every legacy dock, timeline, popover, and shortcut assertion remains pinned to the native-view path.
- Step 7: Run component and shared-controls tests
Run:
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPattern='embedded-mpv-player.component|player-controls'
Expected: all selected suites pass.
- Step 8: Commit host integration
git add libs/ui/playback/src/lib/embedded-mpv-player/
git commit -m "feat(embedded-mpv): use shared controls for frame-copy"
Task 3: Update canonical architecture documentation
Files:
-
Modify:
docs/architecture/player-controls-contract.md -
Modify:
docs/architecture/embedded-mpv-native.md -
Modify:
AGENTS.md -
Modify:
CLAUDE.md -
Step 1: Update player-controls ownership
Change the current-status section from “no existing player consumes this layer” to state that frame-copy embedded MPV is the first consumer. Keep web engines default-off and native-view on its dock.
- Step 2: Update embedded-MPV renderer architecture
Add embedded-mpv-controls.adapter.ts to the renderer file map. Document that
frame-copy mounts shared controls over the canvas and native-view retains
compositor workarounds.
- Step 3: Keep agent guidance synchronized
Update the matching embedded-MPV/shared-controls statements in AGENTS.md and
CLAUDE.md with the same ownership boundary.
- Step 4: Verify Markdown and commit
Run:
git diff --check
rg -n "no existing player consumes|not wired" \
docs/architecture/player-controls-contract.md AGENTS.md CLAUDE.md
Expected: no stale claim that every player is unwired.
Commit:
git add docs/architecture/player-controls-contract.md \
docs/architecture/embedded-mpv-native.md AGENTS.md CLAUDE.md
git commit -m "docs(embedded-mpv): document frame-copy shared controls"
Task 4: Full validation and PR preparation
Files:
-
Verify all files changed by Tasks 1-3.
-
Step 1: Run UI playback tests
pnpm nx test ui-playback --skip-nx-cache --runInBand
Expected: all suites pass.
- Step 2: Run lint, typecheck, and i18n validation
pnpm nx lint ui-playback --skip-nx-cache
pnpm run typecheck:ci
pnpm run i18n:check
Expected: all commands pass; the existing identical-English i18n warning is acceptable.
- Step 3: Run Electron smoke coverage
pnpm nx run electron-backend-e2e:e2e-ci--src/smoke.e2e.ts --skip-nx-cache
Expected: smoke tests pass. If the local frame-copy runtime is absent, record that native frame rendering could not be exercised locally and rely on cross-platform PR CI for the packaged runtime gate.
- Step 4: Run repository hygiene checks
pnpm exec prettier --check \
libs/ui/playback/src/lib/embedded-mpv-player \
docs/architecture/player-controls-contract.md \
docs/architecture/embedded-mpv-native.md \
AGENTS.md CLAUDE.md
git diff --check origin/master..HEAD
git status --short
Expected: formatting passes, no whitespace errors, and the worktree is clean.
- Step 5: Request review and merge only after the exact-head gate
Push the branch, open a focused replacement PR, request Greptile and Codex reviews, address actionable findings with regression tests, and merge only when all exact-head CI checks pass, Greptile reports 5/5, Codex is clean, and no review thread remains unresolved.
The replacement PR is complete only after this exact-head gate passes.