fix(playback): correlate global live-tab history by its session key

The unified Favorites/Recent live tab deferred its history write by stream
URL only, so the same URL played from another playlist could confirm a
failed selection, and a switch to catch-up before confirmation could never
match. It now defers with the tab's playlist-scoped playbackSessionKey (the
key its players confirm with), and the tab's radio player receives it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-28 08:24:09 +02:00
1 parent ce92603981
commit b0467791a4
6 files changed
+47 -3

No files matched your search

@@ -1150,7 +1150,8 @@ reaches history.
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.
their stream URL. The global live tab defers with its own
playlist-scoped session key, which also survives a switch to catch-up.
- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl`
and `playback.streamUrl` once the owned engine's reported position has
advanced by 2 seconds while playing (`PlaybackProgressConfirmation`).
@@ -11,6 +11,7 @@ import {
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
import { UnifiedLiveTimeshift } from './unified-live-catchup';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
export interface UnifiedLiveSelection {
@@ -247,9 +248,15 @@ 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.
// 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.
historyGate.defer(
{ streamUrls: [detail.playback.streamUrl] },
{
sessionKey: createUnifiedLivePlaybackSessionKey(item),
streamUrls: [detail.playback.streamUrl],
},
() => void recordLivePlayback(item)
);
@@ -58,6 +58,7 @@
activeDetail()?.playback?.title ||
''
"
[playbackSessionKey]="playbackSessionKey()"
/>
} @else {
<app-web-player-view
@@ -161,6 +161,7 @@ describe('UnifiedLiveTabComponent', () => {
/** What the mounted player reports once the stream has really played. */
async function playStream(streamUrl: string): Promise<void> {
TestBed.inject(PlaybackHistoryGate).confirm({
sessionKey: component.playbackSessionKey(),
streamUrls: [streamUrl],
});
await fixture.whenStable();
@@ -22,6 +22,7 @@ import {
} from '@iptvnator/portal/shared/data-access';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
import {
StubAudioPlayerComponent,
StubEpgTimelineComponent,
@@ -174,6 +175,37 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
}
);
it('correlates a row with its session key, not its stream URL', 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]);
const gate = TestBed.inject(PlaybackHistoryGate);
// The same URL played from another playlist does not confirm it.
gate.confirm({
sessionKey: 'live:another-playlist:m3u-channel',
streamUrls: [item.streamUrl],
});
await fixture.whenStable();
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
// The row's own player does, even after switching to catch-up.
gate.confirm({
sessionKey: component.playbackSessionKey(),
streamUrls: ['https://example.com/archive.m3u8'],
});
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 = {
@@ -209,6 +241,7 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
await component.onChannelSelected(row('M3U Live 2'));
// The first row's playback is confirmed only now, after the switch.
TestBed.inject(PlaybackHistoryGate).confirm({
sessionKey: createUnifiedLivePlaybackSessionKey(first),
streamUrls: [first.streamUrl],
});
await fixture.whenStable();
@@ -101,6 +101,7 @@ export class StubAudioPlayerComponent {
readonly icon = input('');
readonly url = input.required<string>();
readonly channelName = input('');
readonly playbackSessionKey = input<string | null>(null);
}
@Component({