Files
iptvnator/docs/superpowers/plans/2026-07-16-frame-copy-shared-controls.md
T
4gray f611ea3d7c feat(embedded-mpv): use shared controls for frame-copy (#1193)
* 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
2026-07-16 18:47:32 +02:00

355 lines
11 KiB
Markdown

# 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:
```ts
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:
```bash
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()`:
```ts
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:
```ts
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:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPattern=embedded-mpv-controls.adapter
```
Expected: both adapter suites pass.
- [ ] **Step 7: Commit the adapter**
```bash
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:
```ts
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:
```bash
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:
```ts
readonly sharedControls = inject(EmbeddedMpvControlsAdapter);
readonly playerSurface = computed(
() => this.playerRoot()?.nativeElement ?? null
);
this.sharedControls.configure({
playback: this.playback,
seriesNavigation: this.seriesNavigation,
recordingFolder: this.recordingFolder,
});
```
Render:
```html
@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:
```ts
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:
```bash
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**
```bash
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:
```bash
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:
```bash
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**
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand
```
Expected: all suites pass.
- [ ] **Step 2: Run lint, typecheck, and i18n validation**
```bash
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**
```bash
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**
```bash
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.