18 KiB
External Playback Launch Feedback 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: Keep MPV/VLC recovery actions visible while reporting exact launch/session state in the diagnostic panel and global dock.
Architecture: Add a component-local, credential-free external recovery state machine beside PlaybackRecoverySession. The existing host output still owns source-specific launch payloads and binds its returned launch promise to the fieldless current intent; only that exact result supplies the initial session ID. PORTAL_EXTERNAL_PLAYBACK then supplies exact-ID status observation and close-before-switch. Attempts rerank but never remove external targets, while stale promises, sessions, timers, and diagnostic owners are rejected.
Tech Stack: Angular signals and control flow, Angular Material icons/spinner, Jest, Nx, Playwright Electron E2E, ngx-translate.
Task 1: External recovery state machine
Files:
-
Create:
libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts -
Create:
libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts -
Step 1: Write the failing state-machine tests
Cover the exact public contract:
const state = new ExternalPlaybackRecovery();
state.syncSession('content-a');
const intent = state.begin('mpv');
expect(intent).not.toBeNull();
expect(state.pending()).toBe(true);
expect(state.target('mpv')).toMatchObject({
status: 'launching',
attempts: 1,
sessionId: null,
});
expect(state.begin('vlc')).toBeNull();
expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe(
false
);
expect(state.observe(session({ id: 'new-session', player: 'vlc' }))).toBe(
false
);
expect(state.observe(session({ id: 'new-session', player: 'mpv' }))).toBe(
false
);
expect(
state.confirm(intent!, session({ id: 'new-session', player: 'mpv' }))
).toBe(true);
expect(state.target('mpv').status).toBe('started');
Add separate tests for playing, error, closed → idle, stale exact-ID
updates, timeout-to-error, stale timeout after session reset, retry attempt
count, and destroy() timer cleanup.
- Step 2: Run RED
Run:
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts --skip-nx-cache
Expected: FAIL because external-playback-recovery.ts does not exist.
- Step 3: Implement the minimal signal state machine
Use these exported shapes:
export type ExternalRecoveryStatus =
'idle' | 'launching' | 'started' | 'playing' | 'error';
export interface ExternalRecoveryTargetState {
readonly status: ExternalRecoveryStatus;
readonly attempts: number;
readonly sessionId: string | null;
}
export interface ExternalRecoveryIntent {
readonly token: symbol;
readonly target: ExternalPlayerName;
}
export class ExternalPlaybackRecovery {
readonly states: Signal<
Readonly<Record<ExternalPlayerName, ExternalRecoveryTargetState>>
>;
readonly pending: Signal<boolean>;
syncSession(key: string): boolean;
begin(target: ExternalPlayerName): ExternalRecoveryIntent | null;
owns(intent: ExternalRecoveryIntent): boolean;
confirm(
intent: ExternalRecoveryIntent,
session: ExternalPlayerSession
): boolean;
cancel(intent: ExternalRecoveryIntent): boolean;
close(target: ExternalPlayerName, sessionId: string): boolean;
observe(session: ExternalPlayerSession | null): boolean;
fail(intent: ExternalRecoveryIntent): boolean;
target(target: ExternalPlayerName): ExternalRecoveryTargetState;
destroy(): void;
}
The internal launch timer is 10 seconds, and all ownership tokens are fieldless
Symbol() values. Store only target, attempt count, status, and the exact
session ID returned through the owned host launch promise.
- Step 4: Run GREEN and refactor
Run the Task 1 command again. Expected: all state-machine tests PASS.
- Step 5: Commit
git add libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts
git commit -m "feat(playback): track external recovery launches"
Task 2: Preserve and rerank external recommendations
Files:
-
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts -
Test:
libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts -
Step 1: Write failing ranking tests
Add a focused spec for a helper with this contract:
const result = createWebPlayerRecommendations({
...baseOptions,
attemptedTargets: new Set(['videojs', 'mpv']),
externalStates: {
mpv: { status: 'error', attempts: 1, sessionId: 'mpv-1' },
vlc: { status: 'idle', attempts: 0, sessionId: null },
},
});
expect(
result.map((item) => (item.action === 'player' ? item.target : item.action))
).toEqual(['vlc', 'mpv', 'alternative-source']);
expect(result.map((item) => item.priority)).toEqual([
'primary',
'secondary',
'secondary',
]);
Also prove that an attempted inline family remains excluded, ties preserve MPV before VLC, input arrays/sets/states are not mutated, and output stays capped at three.
- Step 2: Run RED
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts --skip-nx-cache
Expected: FAIL because external state is not accepted and attempted MPV is removed.
- Step 3: Implement filtered policy attempts and stable reranking
Pass only inline attempted targets into recommendPlaybackRecovery(). Stable
sort adjacent external recommendations by attempts, using their original
policy index as tie-breaker, then regenerate primary/secondary priority without
mutating inputs.
- Step 4: Update old integration expectations
Change old assertions that expected MPV/VLC to disappear. They must now assert that both targets remain mounted and the less-attempted sibling is promoted.
- Step 5: Run GREEN and commit
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --skip-nx-cache
git add libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts
git commit -m "fix(playback): keep external recovery actions available"
Task 3: Wire launch ownership and close-before-switch
Files:
-
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.html -
Modify:
libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts -
Modify:
libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts -
Step 1: Add failing integration tests
Provide a fake PORTAL_EXTERNAL_PLAYBACK with activeSession: signal(null),
visibleSession, and closeSession. Prove:
mpvButton.click();
mpvButton.click();
expect(fallbackRequests).toHaveLength(1);
expect(component.externalRecoveryPending()).toBe(true);
activeSession.set(
externalSession({ id: 'mpv-1', player: 'mpv', status: 'opened' })
);
fixture.detectChanges();
expect(component.externalRecoveryState().mpv.status).toBe('started');
expect(playerActionIds()).toContain('playback-fallback-mpv');
Add independent tests for close-before-VLC, refusing an unclosable active
session, stale session after playbackSessionKey change, no duplicate output,
and no URL/header fields in serialized coordinator state.
- Step 2: Run RED
Run the recovery integration spec. Expected: new assertions FAIL because the component does not observe external sessions.
- Step 3: Wire the coordinator
Inject PORTAL_EXTERNAL_PLAYBACK optionally, observe its active signal in an
effect, reset external state from syncRecoverySession(), and destroy it from
the existing component cleanup. For external targets:
- validate the recommendation and current diagnostic;
- begin one owned external intent;
- if a nonterminal active external session exists, require
canClose, awaitcloseSession, and confirm it is no longer live; - recheck intent and diagnostic ownership;
- record the external attempt and emit the existing exact
PlaybackFallbackRequestonce.
- Step 4: Run GREEN and commit
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --skip-nx-cache
git add libs/ui/playback/src/lib/web-player-view
git commit -m "feat(playback): synchronize external launch feedback"
Task 4: Render accessible per-target feedback
Files:
-
Modify:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts -
Modify:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html -
Modify:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss -
Modify:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts -
Modify:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts -
Modify:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts -
Step 1: Write failing view/component tests
Prove state-aware keys and labels:
expect(getRecommendationLabelKey(mpvRecommendation, launchingState)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV'
);
expect(getRecommendationLabelKey(mpvRecommendation, errorState)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_MPV'
);
The component test must assert that the same MPV HTMLButtonElement remains in
the DOM through state changes, keeps focus, contains a 16px spinner during
launch, exposes aria-busy="true" and aria-disabled="true", and does not emit
when aria-disabled. Assert a visible polite status for started/playing/error.
- Step 2: Run RED
Run both panel specs. Expected: FAIL because the panel has no external-state input or spinner.
- Step 3: Implement the view
Import the existing Angular Material progress spinner. Keep external buttons
mounted and use aria-disabled plus handler guards instead of native
disabled for external handshake feedback, preserving keyboard focus. Keep
the existing native disabled behaviour for inline switch/retry pending.
Add only transform/opacity/color transitions under 180ms and a
prefers-reduced-motion rule that stops spinner animation when appropriate.
- Step 4: Run GREEN and commit
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts --skip-nx-cache
git add libs/ui/playback/src/lib/playback-diagnostic-panel
git commit -m "feat(playback): show external launch action states"
Task 5: Keep dock errors visible and use exact statuses
Files:
-
Modify:
apps/web/src/app/services/external-playback.service.ts -
Modify:
apps/web/src/app/services/external-playback.service.spec.ts -
Modify:
libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts -
Modify:
libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html -
Modify:
libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss -
Modify:
libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts -
Modify:
libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts -
Step 1: Write failing service and dock tests
Change the error-session expectation from hidden to visible, then prove
dismissActiveSession() hides it. Add dock cases for:
expect(statusText('launching')).toContain('Opening player');
expect(statusText('opened')).toContain('Player started');
expect(statusText('playing')).toContain('Playing');
expect(errorDock.getAttribute('aria-live')).toBe('polite');
expect(dismissButton.textContent).toContain('Dismiss');
- Step 2: Run RED
pnpm nx test web --runTestsByPath apps/web/src/app/services/external-playback.service.spec.ts --skip-nx-cache
pnpm nx test components --runTestsByPath libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts --skip-nx-cache
Expected: service error visibility and dock status assertions FAIL.
- Step 3: Implement exact dock semantics
Hide only closed in visibleSession. Translate launching/opened/playing and
generic error states. Keep detailed existing session.error for the visible
error copy. Show “Close player” only for a closable live session and “Dismiss”
for error. Keep the existing output so workspace shell routing does not change.
- Step 4: Run GREEN and commit
Run both commands plus the workspace-shell focused spec, then commit:
git add apps/web/src/app/services/external-playback.service.ts apps/web/src/app/services/external-playback.service.spec.ts libs/ui/components/src/lib/external-playback-dock libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts
git commit -m "fix(playback): keep external launch errors visible"
Task 6: Translation, documentation, and release note
Files:
-
Modify:
apps/web/src/assets/i18n/*.json -
Modify:
docs/architecture/embedded-inline-playback.md -
Modify:
AGENTS.md -
Modify:
CLAUDE.md -
Create:
.changes/playback-external-launch-feedback.md -
Step 1: Add user-facing translation keys
Add the same complete key set to every locale, translated where practical and English fallback otherwise:
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error"
Add workspace dock keys for opening, started, playing, failed, and dismiss.
- Step 2: Update canonical docs
Replace the v1 “external target disappears after attempt” contract with the
new per-target state/reranking and exact external session correlation. Mirror
the shared-player summary byte-for-meaning between AGENTS.md and CLAUDE.md.
- Step 3: Add release note
---
type: fix
area: playback
---
MPV and VLC recovery buttons now stay available after an attempt and show launch progress, player-started, playback, and failure feedback. External-player errors remain in the bottom status bar until dismissed.
- Step 4: Validate and commit
pnpm run i18n:validate
pnpm run release:notes:validate
git diff --check
git add apps/web/src/assets/i18n docs/architecture/embedded-inline-playback.md AGENTS.md CLAUDE.md .changes/playback-external-launch-feedback.md
git commit -m "docs(playback): document external launch feedback"
Task 7: Electron regression flow
Files:
-
Modify:
apps/electron-backend-e2e/src/dash-clearkey.e2e.ts -
Step 1: Change the existing E2E expectation before production code is considered complete
The fixture IPC handler must send repository-owned launching, opened, and
failure session updates through EXTERNAL_PLAYER_SESSION_UPDATE, and implement
the close handler without starting real players.
Assert:
-
MPV remains visible during and after the held handshake;
-
its spinner/status is visible while launching;
-
VLC cannot launch during the handshake;
-
after MPV starts, MPV remains available and VLC is primary;
-
the dock reads “Player started”;
-
a failed VLC session remains in the dock with a Dismiss action;
-
dismiss hides the dock while both diagnostic action buttons remain.
-
Step 2: Run the exact Electron target
pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts --skip-nx-cache
Expected: PASS with the repository-owned fixture and stubbed IPC only.
- Step 3: Commit
git add apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
git commit -m "test(playback): cover external launch feedback"
Task 8: Full validation, review, and PR
Files:
-
Review all changed files from
origin/master...HEAD. -
Step 1: Run affected unit, lint, build, and policy validation
pnpm nx test ui-playback --skip-nx-cache
pnpm nx test components --skip-nx-cache
pnpm nx test web --skip-nx-cache
pnpm nx affected -t lint --base=origin/master --head=HEAD --skip-nx-cache
pnpm nx build web --configuration=production --skip-nx-cache
pnpm nx build electron-backend --configuration=production --skip-nx-cache
pnpm run i18n:validate
pnpm run release:notes:validate
pnpm run coverage:policy:check
git diff --check origin/master...HEAD
- Step 2: Run the targeted Electron E2E again from the final tree
pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts --skip-nx-cache
- Step 3: Perform local Codex P1/P2 review
Run the local Codex CLI against origin/master, inspect every finding, add a
failing regression test before each valid fix, and rerun the affected and full
validation commands. Do not create the PR while an actionable P1/P2 remains.
- Step 4: Push and create a ready PR
Use title:
fix(playback): clarify external player launch feedback
The PR body must summarize persistent recovery actions, exact session status, accessibility/privacy ownership, dock dismissal, docs/release note, and every validation command. Do not merge.
- Step 5: Run the review loop
Wait for all latest-head CI, Greptile 5/5, and Codex review. Fix actionable feedback with regression coverage, push, retrigger both reviewers, and repeat until the ready PR is merge-ready.