From ba1b7a8d73370a8aa32fd28afe893aa77b043507 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 28 Sep 2026 08:25:23 +0200 Subject: [PATCH] fix(playback): keep MPV/VLC rows of the live tab confirmable by URL The live tab's session key can only be confirmed by its own inline players; MPV/VLC confirm the launched URL alone. A row that goes to an external player (also later, after a double-click) now defers by URL, and only rows played inline carry the session key. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/embedded-inline-playback.md | 7 ++--- .../unified-live-selection.ts | 17 ++++++++---- .../unified-live-tab.fullscreen-panel.spec.ts | 26 ++++++++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 29d42cc03..22753815b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1149,9 +1149,10 @@ reaches history. - Matching: a write deferred with a session key is confirmed only by that same key — the same URL in two playlists must not let playback in one (inline, or in MPV/VLC) record a failed attempt in the other. Writes - without one (portal resolvers, collection tabs) match any confirmation of - their stream URL. The global live tab defers with its own - playlist-scoped session key, which also survives a switch to catch-up. + without one (portal resolvers) match any confirmation of their stream URL. + The global live tab defers with its own playlist-scoped session key, which + also survives a switch to catch-up, when the row plays inline; a row that + goes to MPV/VLC defers by URL, the only thing that launch confirms. - `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl` and `playback.streamUrl` once the owned engine's reported position has advanced by 2 seconds while playing (`PlaybackProgressConfirmation`). diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts index 2ea781c19..f3fce3fd1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -248,13 +248,20 @@ export function createUnifiedLiveSelection(options: { } // Selecting a channel is not watching it: the row moves to the - // top of Recently Viewed once its stream has really played. The - // tab's playlist-scoped session key — the one its players confirm - // with — survives a switch to catch-up and does not match the - // same URL listed in another playlist. + // top of Recently Viewed once its stream has really played. Played + // inline, the tab's playlist-scoped session key — the one its + // players confirm with — survives a switch to catch-up and does + // not match the same URL in another playlist. MPV/VLC can only + // confirm the launched URL, so an external row defers by URL. + const playsInline = !options.shouldOpenExternalPlayback( + detail, + true + ); historyGate.defer( { - sessionKey: createUnifiedLivePlaybackSessionKey(item), + sessionKey: playsInline + ? createUnifiedLivePlaybackSessionKey(item) + : null, streamUrls: [detail.playback.streamUrl], }, () => void recordLivePlayback(item) diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts index 837b88f35..4e8d81360 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts @@ -175,7 +175,8 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { } ); - it('correlates a row with its session key, not its stream URL', async () => { + it('correlates an inline row with its session key, not its stream URL', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); const item = buildM3uLiveItem(); streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ epgMode: 'm3u', @@ -206,6 +207,29 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); }); + it('records a row played in MPV/VLC from the URL its launch confirms', async () => { + const item = buildM3uLiveItem(); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + await component.onChannelSelected(component.channelsForList()[0]); + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalled(); + + // The external session update carries only the launched URL. + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [item.streamUrl], + }); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + }); + it('moves a row that played to the top of Recently Viewed after another row was selected', async () => { const first = buildM3uLiveItem(); const second: UnifiedCollectionItem = {