fix(playback): confirm session-keyed history only by its own session

A write deferred with a playback session key (M3U) is now confirmed only
by that key. The app-wide MPV/VLC session confirmation carries just the
URL, so opening the same stream externally from another playlist could
still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is
instead confirmed by the WebPlayerViewComponent that requested it, under
its own session key, once the launch has opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:56:23 +02:00
1 parent 96f1707b2d
commit 671a0cb1ec
7 files changed
+111 -27

No files matched your search

@@ -83,6 +83,38 @@ describe('ExternalPlaybackRecoveryCoordinator', () => {
coordinator.destroy();
});
it.each([
['an opened launch', session({ status: 'opened' }), 1],
['a launch that failed', session({ status: 'error' }), 0],
['a launch of another player', session({ player: 'vlc' }), 0],
])(
'reports %s to the view',
async (_label, launched: ExternalPlayerSession, calls: number) => {
const activeSession = signal<ExternalPlayerSession | null>(null);
const onLaunched = jest.fn();
const coordinator = new ExternalPlaybackRecoveryCoordinator(
{
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
},
onLaunched
);
coordinator.syncSession('content-a');
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
trackLaunch(Promise.resolve(launched));
return true;
});
await Promise.resolve();
await Promise.resolve();
expect(onLaunched).toHaveBeenCalledTimes(calls);
coordinator.destroy();
}
);
it('closes a closable error before retrying another external target', async () => {
const previous = session({
id: 'uncertain-process',
@@ -21,8 +21,16 @@ export class ExternalPlaybackRecoveryCoordinator {
readonly states = this.recovery.states;
readonly pending = this.recovery.pending;
/**
* @param onLaunched runs once a launch this view requested has opened in
* MPV/VLC — still owned by the current intent — e.g. to record it as
* recently viewed under the view's own playback session.
*/
constructor(
private readonly externalPlayback: PortalExternalPlayback | null
private readonly externalPlayback: PortalExternalPlayback | null,
private readonly onLaunched: (
session: ExternalPlayerSession
) => void = () => undefined
) {}
observe(session: ExternalPlayerSession | null): void {
@@ -93,7 +101,13 @@ export class ExternalPlaybackRecoveryCoordinator {
void launch.then(
(session) => {
if (session) {
this.recovery.confirm(intent, session);
if (
this.recovery.confirm(intent, session) &&
(session.status === 'opened' ||
session.status === 'playing')
) {
this.onLaunched(session);
}
} else {
this.recovery.fail(intent);
}
@@ -122,8 +122,16 @@ export class WebPlayerViewComponent implements OnDestroy {
optional: true,
});
private readonly recoverySession = new PlaybackRecoverySession();
private readonly historyGate = inject(PlaybackHistoryGate);
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
this.externalPlayback
this.externalPlayback,
// "Open in MPV/VLC" after an inline failure is still this session's
// view; the app-wide session confirmation carries only the URL.
(session) =>
this.historyGate.confirm({
sessionKey: this.playbackSessionKey(),
streamUrls: [session.streamUrl],
})
);
private readonly applicationHandoff =
new WebPlayerApplicationHandoffCoordinator(
@@ -221,7 +229,7 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly playbackApplicationToken = this.applicationState.token;
/** Commits deferred "recently viewed" writes once this stream plays. */
private readonly historyConfirmation = new PlaybackHistoryConfirmation({
gate: inject(PlaybackHistoryGate),
gate: this.historyGate,
target: () => ({
sessionKey: this.playbackSessionKey(),
streamUrls: [this.streamUrl(), this.playback()?.streamUrl],