diff --git a/apps/web/src/app/services/external-playback.service.spec.ts b/apps/web/src/app/services/external-playback.service.spec.ts index 043bb1a3f..499ab5a9b 100644 --- a/apps/web/src/app/services/external-playback.service.spec.ts +++ b/apps/web/src/app/services/external-playback.service.spec.ts @@ -50,7 +50,7 @@ describe('ExternalPlaybackService', () => { it('commits deferred history once the external player has opened', () => { const commit = jest.fn(); TestBed.inject(PlaybackHistoryGate).defer( - ['https://example.com/video.m3u8'], + { streamUrls: ['https://example.com/video.m3u8'] }, commit ); @@ -64,7 +64,7 @@ describe('ExternalPlaybackService', () => { it('does not commit history for a launch that failed', () => { const commit = jest.fn(); TestBed.inject(PlaybackHistoryGate).defer( - ['https://example.com/video.m3u8'], + { streamUrls: ['https://example.com/video.m3u8'] }, commit ); diff --git a/apps/web/src/app/services/external-playback.service.ts b/apps/web/src/app/services/external-playback.service.ts index 4e389ee8b..a99f1e0c8 100644 --- a/apps/web/src/app/services/external-playback.service.ts +++ b/apps/web/src/app/services/external-playback.service.ts @@ -107,7 +107,7 @@ export class ExternalPlaybackService { // MPV/VLC cannot report whether a live stream really plays, so a // successful launch is what commits the deferred history write. if (session.status === 'opened' || session.status === 'playing') { - this.historyGate.confirm([session.streamUrl]); + this.historyGate.confirm({ streamUrls: [session.streamUrl] }); } const current = this.activeSession(); diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 68bd585ea..3de409f16 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1137,26 +1137,36 @@ its stream has really played, so a stream that fails straight away never reaches history. - Writers do not persist on selection. They hand the write to - `PlaybackHistoryGate` (`@iptvnator/services`) with `defer(keys, commit)`, - keyed by what the playback will be known by: the stream URL and, for M3U, - the host's `playbackSessionKey`. The Stalker resolver defers by the - resolved (possibly temporary) link; the persisted row still stores the - portal `cmd`, never that link. Writers capture the item and its playlist - when they defer, so navigating meanwhile cannot misfile it. + `PlaybackHistoryGate` (`@iptvnator/services`) with `defer(target, commit)`, + where the target is what the playback will be known by: the host's + `playbackSessionKey` (M3U) and/or stream URLs. The Stalker resolver defers + by the resolved (possibly temporary) link; the persisted row still stores + the portal `cmd`, never that link. Writers capture the item and its + playlist when they defer, so navigating meanwhile cannot misfile it; an + Xtream write confirmed after a playlist switch refreshes the store's recent + list only if its playlist is still current. +- Matching: when both the write and the confirmation carry a session key, + only the session key is compared — the same URL in two playlists must not + let playback in one record a failed attempt in the other. Stream URLs are + the fallback when either side has none. - `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl` and `playback.streamUrl` once the owned engine's reported position has - advanced by 2 seconds (`PlaybackProgressConfirmation`). Steps above - 3 seconds (seeks, a resume jump, live-edge catch-up), stalls, pauses and - backwards jumps do not count; a report of a new stream never adds to the - progress of the previous one; an engine or format swap of the same stream - keeps its progress. The radio `AudioPlayerComponent` confirms its URL the - same way. + advanced by 2 seconds while playing (`PlaybackProgressConfirmation`). + Engines report `playing` (not paused, not seeking) with each time update, + so seeks of paused media do not count; neither do steps above 3 seconds + (seeks, a resume jump, live-edge catch-up), stalls or backwards jumps. A + report of a new stream never adds to the progress of the previous one; an + engine or format swap of the same stream keeps its progress. The radio + `AudioPlayerComponent` confirms the same way (with the host's session key + when given). - MPV/VLC cannot report whether a live stream plays, so the Electron `ExternalPlaybackService` confirms a session's `streamUrl` once it is `opened` or `playing`; a launch that ends in `error` is not recorded. M3U keeps recording on selection when MPV/VLC is the configured player. -- A confirmation commits every write deferred under any of its keys, once. - Unconfirmed writes are bounded (oldest dropped) and simply never commit. +- A confirmation commits every write matching it, once. Unconfirmed writes + are bounded (oldest dropped) and simply never commit. The global live tab + moves a confirmed row to the top of an open Recently Viewed list even if + another row was selected meanwhile. - A committed write updates the source while the item keeps playing. Hosts must not hand the player a new but identical playback for it: the M3U host's `embeddedPlayback` also reads the playlist meta, so it is compared diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts index a94714dc5..d697236a1 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts @@ -177,7 +177,7 @@ describe('VideoPlayerComponent — recently viewed history', () => { playlistsServiceMock.addM3uRecentlyViewed ).not.toHaveBeenCalled(); - gate.confirm([component.playbackSessionKey()]); + gate.confirm({ sessionKey: component.playbackSessionKey() }); expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( 'playlist-1', @@ -215,7 +215,7 @@ describe('VideoPlayerComponent — recently viewed history', () => { name: 'Second TV', }); - gate.confirm(['http://localhost/second.m3u8']); + gate.confirm({ streamUrls: ['http://localhost/second.m3u8'] }); expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( 1 @@ -234,7 +234,7 @@ describe('VideoPlayerComponent — recently viewed history', () => { playlistsServiceMock.addM3uRecentlyViewed ).not.toHaveBeenCalled(); - gate.confirm([radio.url]); + gate.confirm({ streamUrls: [radio.url] }); expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( 1 diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index aa4710be4..1580b5338 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -106,10 +106,13 @@ } @else { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 832c8572c..5f43c5023 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -1230,7 +1230,10 @@ export class VideoPlayerComponent } this.historyGate.defer( - [this.playbackSessionKey(), channel.url], + { + sessionKey: this.playbackSessionKey(), + streamUrls: [channel.url], + }, record ); } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index 607e35535..f405f92d5 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -97,6 +97,7 @@ export class StubAudioPlayerComponent { readonly channelName = input(''); readonly channelLogo = input(''); readonly volume = input(null); + readonly playbackSessionKey = input(null); readonly volumeChange = output(); } 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 ac43d9ab1..c403dff03 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 @@ -121,13 +121,20 @@ export function createUnifiedLiveSelection(options: { }); }; + /** Set by `dispose`: the host is gone and must not be notified. */ + let disposed = false; + + /** + * Runs once the row has really played. That is seconds after selection, + * so the user may have moved on — the write still happened, and an open + * Recently Viewed list must still move the row to the top. + */ const recordLivePlayback = async ( - item: UnifiedCollectionItem, - generation: number + item: UnifiedCollectionItem ): Promise => { try { const updatedItem = await recentData.recordLivePlayback(item); - if (generation === options.generation.current()) { + if (!disposed) { options.onItemPlayed(updatedItem); } } catch { @@ -242,8 +249,8 @@ 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. historyGate.defer( - [detail.playback.streamUrl], - () => void recordLivePlayback(item, generation) + { streamUrls: [detail.playback.streamUrl] }, + () => void recordLivePlayback(item) ); if ( @@ -278,6 +285,7 @@ export function createUnifiedLiveSelection(options: { activate, close, dispose(): void { + disposed = true; // Invalidate a playback continuation still awaiting its header // IPC and drop any radio credentials owned by this tab. options.generation.next(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 5c6844f4d..b960be88d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -163,7 +163,9 @@ describe('UnifiedLiveTabComponent', () => { /** What the mounted player reports once the stream has really played. */ async function playStream(streamUrl: string): Promise { - TestBed.inject(PlaybackHistoryGate).confirm([streamUrl]); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [streamUrl], + }); await fixture.whenStable(); } 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 9c32c3d7a..c5cd55394 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 @@ -9,7 +9,11 @@ import { } from '@iptvnator/ui/playback'; import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg'; import { ResizableDirective } from '@iptvnator/ui/components'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + PlaybackHistoryGate, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { PORTAL_PLAYER, @@ -173,6 +177,49 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { } ); + it('moves a row that played to the top of Recently Viewed after another row was selected', async () => { + const first = buildM3uLiveItem(); + const second: UnifiedCollectionItem = { + ...first, + uid: 'm3u::pl-1::m3u-channel-2', + name: 'M3U Live 2', + channelId: 'm3u-channel-2', + tvgId: 'm3u-channel-2', + streamUrl: 'https://example.com/m3u-2.m3u8', + }; + streamResolver.resolveM3uPlaybackDetail.mockImplementation( + async (item: UnifiedCollectionItem) => ({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }) + ); + recentData.recordLivePlayback.mockImplementation( + async (item: UnifiedCollectionItem) => item + ); + const played = jest.fn(); + component.itemPlayed.subscribe(played); + fixture.componentRef.setInput('items', [first, second]); + fixture.componentRef.setInput('mode', 'recent'); + fixture.detectChanges(); + await fixture.whenStable(); + const row = (name: string) => + component + .channelsForList() + .find((channel) => channel.name === name)!; + + await component.onChannelSelected(row('M3U Live')); + await component.onChannelSelected(row('M3U Live 2')); + // The first row's playback is confirmed only now, after the switch. + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [first.streamUrl], + }); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(first); + expect(played).toHaveBeenCalledWith(first); + }); + it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => { const first = buildM3uLiveItem(); const second: UnifiedCollectionItem = { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 97c68a535..4dcc94a33 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -19,7 +19,9 @@ import { withStalkerPlayer } from './with-stalker-player.feature'; /** What the player does once the resolved stream has really played. */ function confirmPlayback(playback: { streamUrl: string }): void { - TestBed.inject(PlaybackHistoryGate).confirm([playback.streamUrl]); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [playback.streamUrl], + }); } jest.mock('@iptvnator/portal/shared/util', () => ({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 9f5dd9f8d..9409d33e5 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -152,7 +152,7 @@ export function withStalkerPlayer() { cover, title ); - historyGate.defer([streamUrl], () => + historyGate.defer({ streamUrls: [streamUrl] }, () => persistRecentlyViewed(playlistId, { ...recentItem, added_at: Date.now(), diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index 7486245ad..bc1bc54bc 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -1,6 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { signalStore } from '@ngrx/signals'; +import { signalStore, withState } from '@ngrx/signals'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; @@ -16,6 +16,11 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ })); const TestRecentItemsStore = signalStore(withRecentItems()); +/** Like `XtreamStore`: `withPortal` (current playlist) precedes the feature. */ +const TestCurrentPlaylistStore = signalStore( + withState({ currentPlaylist: { id: 'playlist-2' } }), + withRecentItems() +); describe('withRecentItems', () => { const originalElectron = window.electron; @@ -95,6 +100,7 @@ describe('withRecentItems', () => { TestBed.configureTestingModule({ providers: [ TestRecentItemsStore, + TestCurrentPlaylistStore, { provide: DatabaseService, useValue: databaseService, @@ -157,6 +163,32 @@ describe('withRecentItems', () => { ]); }); + it('saves a late write to its playlist without replacing the current playlist list', async () => { + // Playback confirmed after the user moved on to playlist-2. + const currentStore = TestBed.inject(TestCurrentPlaylistStore); + dataSource.getContentByXtreamId.mockResolvedValue({ + id: 3941697, + title: 'Krypton', + type: 'series', + xtream_id: 290, + }); + + currentStore.addRecentItem({ + xtreamId: 290, + contentType: 'series', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1', + undefined + ); + expect(dataSource.getRecentItems).not.toHaveBeenCalled(); + expect(currentStore.recentItems()).toEqual([]); + }); + it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => { Object.defineProperty(window, 'electron', { value: undefined, diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index c420bdce1..37f70a2e2 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -50,6 +50,21 @@ function mapDbRecentItem( }; } +/** + * Whether `playlistId` is the store's current playlist. `withPortal`, composed + * before this feature in `XtreamStore`, owns it; a store without one (a + * feature-only test store) treats every playlist as current. + */ +function isCurrentPlaylist(store: object, playlistId: string): boolean { + const currentPlaylist = ( + store as { + currentPlaylist?: () => { id?: string } | null | undefined; + } + ).currentPlaylist; + const currentId = currentPlaylist?.()?.id; + return !currentId || currentId === playlistId; +} + export const withRecentItems = function () { const logger = createLogger('withRecentItems'); return signalStoreFeature( @@ -123,6 +138,12 @@ export const withRecentItems = function () { backdropUrl ); + // A write confirmed after the user switched + // playlists is saved to its own playlist, + // but the list in state is the current one's. + if (!isCurrentPlaylist(store, playlistId)) { + return; + } // Reload after add/update so re-watched items // immediately move to the top in recently-viewed. const items = diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 47510bac3..37454f74e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -569,9 +569,9 @@ describe('SerialDetailsComponent', () => { ); // The series is a recent view only once the episode has played. expect(addRecentItem).not.toHaveBeenCalled(); - TestBed.inject(PlaybackHistoryGate).confirm([ - 'http://xtream.example/series/1001.mp4', - ]); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['http://xtream.example/series/1001.mp4'], + }); expect(addRecentItem).toHaveBeenCalledWith({ xtreamId: '103', contentType: 'series', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 31586ddda..5b23f0f94 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -163,9 +163,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(addRecentItem).not.toHaveBeenCalled(); - TestBed.inject(PlaybackHistoryGate).confirm([ - 'https://example.com/route.mkv', - ]); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/route.mkv'], + }); expect(addRecentItem).toHaveBeenCalledTimes(1); const [recentItem] = addRecentItem.mock.calls[0]; @@ -372,9 +372,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => { contentType: 'vod', }, }); - TestBed.inject(PlaybackHistoryGate).confirm([ - 'https://example.com/alt.mkv', - ]); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/alt.mkv'], + }); expect(addRecentItem).toHaveBeenCalled(); }); diff --git a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts index 5a182f6b1..f5fe56e7f 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -26,7 +26,7 @@ export function injectXtreamRecentHistory(): ( return (streamUrl, request) => { const playlist = signal(store.currentPlaylist()).asReadonly(); - gate.defer([streamUrl], () => + gate.defer({ streamUrls: [streamUrl] }, () => store.addRecentItem({ ...request, playlist }) ); }; diff --git a/libs/services/src/lib/playback-history-gate.service.spec.ts b/libs/services/src/lib/playback-history-gate.service.spec.ts index 84b7b109c..c599b2a86 100644 --- a/libs/services/src/lib/playback-history-gate.service.spec.ts +++ b/libs/services/src/lib/playback-history-gate.service.spec.ts @@ -7,13 +7,16 @@ describe('PlaybackHistoryGate', () => { gate = new PlaybackHistoryGate(); }); - it('holds a write until one of its keys is confirmed', () => { + it('holds a write until its playback is confirmed', () => { const commit = jest.fn(); - gate.defer(['http://stream/1', 'live:p1:c1'], commit); + gate.defer( + { sessionKey: 'live:p1:c1', streamUrls: ['http://stream/1'] }, + commit + ); expect(commit).not.toHaveBeenCalled(); - gate.confirm(['live:p1:c1']); + gate.confirm({ sessionKey: 'live:p1:c1' }); expect(commit).toHaveBeenCalledTimes(1); }); @@ -21,62 +24,104 @@ describe('PlaybackHistoryGate', () => { it('never commits a write whose stream is not confirmed', () => { const failed = jest.fn(); const played = jest.fn(); - gate.defer(['http://stream/failed'], failed); - gate.defer(['http://stream/played'], played); + gate.defer({ streamUrls: ['http://stream/failed'] }, failed); + gate.defer({ streamUrls: ['http://stream/played'] }, played); - gate.confirm(['http://stream/played']); + gate.confirm({ streamUrls: ['http://stream/played'] }); expect(failed).not.toHaveBeenCalled(); expect(played).toHaveBeenCalledTimes(1); }); + it('does not let the same URL played in another playlist confirm a write', () => { + // Playlist A's attempt failed; the same stream then plays in B. + const failedInA = jest.fn(); + const playedInB = jest.fn(); + gate.defer( + { sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] }, + failedInA + ); + gate.defer( + { sessionKey: 'live:b:c9', streamUrls: ['http://shared/1'] }, + playedInB + ); + + gate.confirm({ + sessionKey: 'live:b:c9', + streamUrls: ['http://shared/1'], + }); + + expect(failedInA).not.toHaveBeenCalled(); + expect(playedInB).toHaveBeenCalledTimes(1); + }); + + it('falls back to the stream URL when either side has no session key', () => { + const portalWrite = jest.fn(); + const radioWrite = jest.fn(); + gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite); + gate.defer( + { sessionKey: 'live:p1:radio', streamUrls: ['http://radio/1'] }, + radioWrite + ); + + // A player with a session key; a player (MPV/VLC session) without. + gate.confirm({ + sessionKey: 'live:portal:9', + streamUrls: ['http://portal/tmp'], + }); + gate.confirm({ streamUrls: ['http://radio/1'] }); + + expect(portalWrite).toHaveBeenCalledTimes(1); + expect(radioWrite).toHaveBeenCalledTimes(1); + }); + it('commits each confirmed write once', () => { const commit = jest.fn(); - gate.defer(['http://stream/1'], commit); + gate.defer({ streamUrls: ['http://stream/1'] }, commit); - gate.confirm(['http://stream/1']); - gate.confirm(['http://stream/1']); + gate.confirm({ streamUrls: ['http://stream/1'] }); + gate.confirm({ streamUrls: ['http://stream/1'] }); expect(commit).toHaveBeenCalledTimes(1); }); - it('commits every writer deferred under the same key', () => { + it('commits every writer deferred for the same playback', () => { const first = jest.fn(); const second = jest.fn(); - gate.defer(['http://stream/1'], first); - gate.defer(['http://stream/1'], second); + gate.defer({ streamUrls: ['http://stream/1'] }, first); + gate.defer({ streamUrls: ['http://stream/1'] }, second); - gate.confirm(['http://stream/1']); + gate.confirm({ streamUrls: ['http://stream/1'] }); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); }); - it('commits immediately when a write has no usable key', () => { + it('commits immediately when a write has nothing to match on', () => { const commit = jest.fn(); - gate.defer([undefined, null, ' '], commit); + gate.defer({ sessionKey: ' ', streamUrls: [undefined, null] }, commit); expect(commit).toHaveBeenCalledTimes(1); }); - it('ignores confirmations without usable keys', () => { + it('ignores confirmations with nothing to match on', () => { const commit = jest.fn(); - gate.defer(['http://stream/1'], commit); + gate.defer({ streamUrls: ['http://stream/1'] }, commit); - gate.confirm([undefined, '']); + gate.confirm({ streamUrls: [undefined, ''] }); expect(commit).not.toHaveBeenCalled(); }); it('drops the oldest unconfirmed writes beyond its bound', () => { const oldest = jest.fn(); - gate.defer(['http://stream/oldest'], oldest); + gate.defer({ streamUrls: ['http://stream/oldest'] }, oldest); for (let index = 0; index < 20; index += 1) { - gate.defer([`http://stream/${index}`], jest.fn()); + gate.defer({ streamUrls: [`http://stream/${index}`] }, jest.fn()); } - gate.confirm(['http://stream/oldest']); + gate.confirm({ streamUrls: ['http://stream/oldest'] }); expect(oldest).not.toHaveBeenCalled(); }); @@ -89,10 +134,10 @@ describe('PlaybackHistoryGate', () => { throw new Error('db down'); }); const healthy = jest.fn(); - gate.defer(['http://stream/1'], failing); - gate.defer(['http://stream/1'], healthy); + gate.defer({ streamUrls: ['http://stream/1'] }, failing); + gate.defer({ streamUrls: ['http://stream/1'] }, healthy); - gate.confirm(['http://stream/1']); + gate.confirm({ streamUrls: ['http://stream/1'] }); expect(healthy).toHaveBeenCalledTimes(1); consoleError.mockRestore(); diff --git a/libs/services/src/lib/playback-history-gate.service.ts b/libs/services/src/lib/playback-history-gate.service.ts index a304d0bd7..0df771504 100644 --- a/libs/services/src/lib/playback-history-gate.service.ts +++ b/libs/services/src/lib/playback-history-gate.service.ts @@ -1,10 +1,23 @@ import { Injectable } from '@angular/core'; -/** Keys a history write can be confirmed by: stream URLs or session keys. */ -export type PlaybackHistoryKeys = readonly (string | null | undefined)[]; +/** What a playback is known by, for correlating a write with its playback. */ +export interface PlaybackHistoryTarget { + /** + * The playing host's `playbackSessionKey` (source and content scoped). + * When both sides carry one, it is the only thing that is compared. + */ + readonly sessionKey?: string | null; + /** Stream URLs; the fallback when either side has no session key. */ + readonly streamUrls?: readonly (string | null | undefined)[]; +} + +interface NormalizedTarget { + readonly sessionKey: string | null; + readonly streamUrls: ReadonlySet; +} interface PendingHistoryWrite { - readonly keys: ReadonlySet; + readonly target: NormalizedTarget; readonly commit: () => void; } @@ -20,56 +33,74 @@ const MAX_PENDING_HISTORY_WRITES = 20; * The code that resolves a channel or movie is not the code that plays it: * a Stalker link is resolved in the store, played by whichever view mounts * the player, or handed to MPV/VLC. Writers therefore `defer` the write under - * the keys the playback will be known by (its stream URL, or a playback - * session key), and whoever observes the playback `confirm`s those keys: - * the inline players once the stream has advanced for a couple of seconds, - * and the external-player session as soon as MPV/VLC is launched. A stream - * that fails before that point never reaches history or the dashboard hero. + * what the playback will be known by, and whoever observes the playback + * `confirm`s it: the inline players once the stream has advanced for a + * couple of seconds, and the external-player session as soon as MPV/VLC is + * launched. A stream that fails before that point never reaches history or + * the dashboard hero. * - * Several writers may defer under the same key; one confirmation commits - * all of them. A write whose keys are all empty cannot be confirmed and is + * A session key is the stronger correlation: the same stream URL can sit in + * two playlists, and playing it in one must not record a failed attempt in + * the other. Stream URLs only match when either side has no session key. + * Several writers may defer for the same playback; one confirmation commits + * all of them. A write with nothing to match on cannot be confirmed and is * committed immediately, as before this gate existed. */ @Injectable({ providedIn: 'root' }) export class PlaybackHistoryGate { private pending: PendingHistoryWrite[] = []; - defer(keys: PlaybackHistoryKeys, commit: () => void): void { - const normalized = normalizeKeys(keys); - if (normalized.size === 0) { + defer(target: PlaybackHistoryTarget, commit: () => void): void { + const normalized = normalizeTarget(target); + if (!normalized.sessionKey && normalized.streamUrls.size === 0) { runCommit(commit); return; } - this.pending.push({ keys: normalized, commit }); + this.pending.push({ target: normalized, commit }); if (this.pending.length > MAX_PENDING_HISTORY_WRITES) { this.pending.shift(); } } - confirm(keys: PlaybackHistoryKeys): void { - const normalized = normalizeKeys(keys); - if (normalized.size === 0 || this.pending.length === 0) { - return; - } - + confirm(target: PlaybackHistoryTarget): void { + const confirmed = normalizeTarget(target); const matched: PendingHistoryWrite[] = []; const remaining: PendingHistoryWrite[] = []; for (const write of this.pending) { - const matches = [...write.keys].some((key) => normalized.has(key)); - (matches ? matched : remaining).push(write); + (matchesTarget(write.target, confirmed) ? matched : remaining).push( + write + ); } this.pending = remaining; matched.forEach((write) => runCommit(write.commit)); } } -function normalizeKeys(keys: PlaybackHistoryKeys): ReadonlySet { - return new Set( - keys - .map((key) => key?.trim() ?? '') - .filter((key): key is string => key.length > 0) - ); +function matchesTarget( + write: NormalizedTarget, + confirmed: NormalizedTarget +): boolean { + if (write.sessionKey && confirmed.sessionKey) { + return write.sessionKey === confirmed.sessionKey; + } + return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url)); +} + +function normalizeTarget(target: PlaybackHistoryTarget): NormalizedTarget { + return { + sessionKey: normalizeKey(target.sessionKey), + streamUrls: new Set( + (target.streamUrls ?? []) + .map(normalizeKey) + .filter((url): url is string => url !== null) + ), + }; +} + +function normalizeKey(key: string | null | undefined): string | null { + const trimmed = key?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; } function runCommit(commit: () => void): void { diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts index da0897083..156e3c223 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts @@ -29,7 +29,9 @@ describe('ArtPlayerVideoSession', () => { player.currentTime = 45; player.duration = 120; player.emit('video:timeupdate'); - expect(times).toEqual([{ currentTime: 45, duration: 120 }]); + expect(times).toEqual([ + { currentTime: 45, duration: 120, playing: false }, + ]); Object.defineProperty(player.video, 'error', { configurable: true, @@ -70,7 +72,7 @@ describe('ArtPlayerVideoSession', () => { it('uses the source-resolved duration for time updates', () => { const player = createPlayer(); - const times: Array<{ currentTime: number; duration: number }> = []; + const times: unknown[] = []; player.currentTime = 45; player.duration = Number.POSITIVE_INFINITY; const session = new ArtPlayerVideoSession({ @@ -87,7 +89,9 @@ describe('ArtPlayerVideoSession', () => { player.emit('video:timeupdate'); - expect(times).toEqual([{ currentTime: 45, duration: 135 }]); + expect(times).toEqual([ + { currentTime: 45, duration: 135, playing: false }, + ]); }); it('removes native and ArtPlayer listeners exactly on destroy', () => { diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts index 86570eb31..b3d2a852b 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts @@ -5,6 +5,7 @@ import { classifyNativePlaybackIssue, createPlaybackSourceMetadata, } from '@iptvnator/playback/util'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; export interface ArtPlayerVideoSessionConfig { player: Artplayer; @@ -13,7 +14,7 @@ export interface ArtPlayerVideoSessionConfig { getDuration: () => number; persistSharedVolume: boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; - emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; + emitTimeUpdate: (value: PlayerTimeUpdate) => void; emitPlaybackEnded: () => void; emitPlaybackStarted?: () => void; } @@ -71,6 +72,7 @@ export class ArtPlayerVideoSession { this.config.emitTimeUpdate({ currentTime: player.currentTime, duration: this.config.getDuration(), + playing: !player.video.paused && !player.video.seeking, }); }; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 1dc49464a..6d3eef50b 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -34,6 +34,7 @@ import { } from './art-player-setup'; import { ArtPlayerSourceSession } from './art-player-source-session'; import { ArtPlayerVideoSession } from './art-player-video-session'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugArtPlayer = createDevLogger('ArtPlayer'); @@ -62,10 +63,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly playbackStarted = output(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index a0fbee4bf..89723e4fe 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -181,9 +181,13 @@ describe('AudioPlayerComponent', () => { const audio = createComponent('https://example.com/station.mp3'); const commit = jest.fn(); TestBed.inject(PlaybackHistoryGate).defer( - ['https://example.com/station.mp3'], + { streamUrls: ['https://example.com/station.mp3'] }, commit ); + Object.defineProperty(audio, 'paused', { + configurable: true, + value: false, + }); const playTo = (position: number) => { Object.defineProperty(audio, 'currentTime', { configurable: true, @@ -204,7 +208,7 @@ describe('AudioPlayerComponent', () => { const audio = createComponent('https://example.com/dead.mp3'); const commit = jest.fn(); TestBed.inject(PlaybackHistoryGate).defer( - ['https://example.com/dead.mp3'], + { streamUrls: ['https://example.com/dead.mp3'] }, commit ); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 116c2563e..8658fced8 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -183,6 +183,8 @@ export class AudioPlayerComponent { readonly channelName = input(''); readonly externalVolume = input(null, { alias: 'volume' }); readonly dispatchAdjacentChannelAction = input(true); + /** The host's playback session key, for the history confirmation. */ + readonly playbackSessionKey = input(null); readonly channelSwitchRequested = output<'next' | 'previous'>(); readonly volumeChange = output(); @@ -208,7 +210,10 @@ export class AudioPlayerComponent { /** Commits the deferred "recently viewed" write once the station plays. */ private readonly historyConfirmation = new PlaybackHistoryConfirmation({ gate: inject(PlaybackHistoryGate), - keys: () => [this.url()], + target: () => ({ + sessionKey: this.playbackSessionKey(), + streamUrls: [this.url()], + }), }); constructor() { @@ -282,7 +287,12 @@ export class AudioPlayerComponent { onTimeUpdate(): void { const audio = this.audioRef()?.nativeElement; - if (audio) this.historyConfirmation.record(audio.currentTime); + if (audio) { + this.historyConfirmation.record( + audio.currentTime, + !audio.paused && !audio.seeking + ); + } } play() { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index ed2fef2da..bab319b90 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -53,6 +53,7 @@ import { subtitleTrackLabel, volumeIcon, } from './embedded-mpv-format.utils'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; @@ -85,10 +86,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -563,6 +561,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.timeUpdate.emit({ currentTime: session.positionSeconds, duration: session.durationSeconds ?? 0, + playing: session.status === 'playing', }); this.legacyInteractions.scheduleControlsHide(); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts index d1a44277b..cc0352fcb 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts @@ -4,8 +4,11 @@ describe('HtmlVideoElementSession', () => { it('owns native video events and detaches them idempotently', () => { const video = document.createElement('video'); const playbackIssues: unknown[] = []; - const timeUpdates: Array<{ currentTime: number; duration: number }> = - []; + const timeUpdates: Array<{ + currentTime: number; + duration: number; + playing?: boolean; + }> = []; const playbackEnded = jest.fn(); const removeEventListener = jest.spyOn(video, 'removeEventListener'); const session = new HtmlVideoElementSession({ @@ -40,7 +43,20 @@ describe('HtmlVideoElementSession', () => { video.dispatchEvent(new Event('ended')); expect(video.currentTime).toBe(18); - expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]); + // A paused element's position moves too (a seek): not playing. + expect(timeUpdates).toEqual([ + { currentTime: 18, duration: 90, playing: false }, + ]); + Object.defineProperty(video, 'paused', { + configurable: true, + value: false, + }); + video.dispatchEvent(new Event('timeupdate')); + expect(timeUpdates.at(-1)).toEqual({ + currentTime: 18, + duration: 90, + playing: true, + }); expect(playbackIssues[0]).toEqual( expect.objectContaining({ code: 'unsupported-container', diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 190fce46c..a443da253 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -5,6 +5,7 @@ import { classifyNativePlaybackIssue, createPlaybackSourceMetadata, } from '@iptvnator/playback/util'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -13,7 +14,7 @@ export interface HtmlVideoElementSessionConfig { getChannelUrl: () => string | undefined; getStartTime: () => number; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; - emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; + emitTimeUpdate: (value: PlayerTimeUpdate) => void; emitPlaybackEnded: () => void; emitPlaybackStarted?: () => void; } @@ -62,6 +63,7 @@ export class HtmlVideoElementSession { this.config.emitTimeUpdate({ currentTime: video.currentTime, duration: video.duration, + playing: !video.paused && !video.seeking, }); }; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 9cbb37f06..1e3a0ba03 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -53,6 +53,7 @@ import { HtmlVideoPlayerControlsBridge, type HtmlVideoControlsSource, } from './html-video-player-controls.bridge'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -83,10 +84,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { readonly mediaTitle = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - @Output() timeUpdate = new EventEmitter<{ - currentTime: number; - duration: number; - }>(); + @Output() timeUpdate = new EventEmitter(); @Output() playbackIssue = new EventEmitter(); @Output() playbackEnded = new EventEmitter(); @Output() playbackStarted = new EventEmitter(); diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts index 8dd568a78..3a0175146 100644 --- a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts @@ -1,8 +1,16 @@ -import type { PlaybackHistoryKeys } from '@iptvnator/services'; +import type { PlaybackHistoryTarget } from '@iptvnator/services'; import { PlaybackHistoryConfirmation } from './playback-history-confirmation'; describe('PlaybackHistoryConfirmation', () => { - let keys: PlaybackHistoryKeys; + const streamA: PlaybackHistoryTarget = { + sessionKey: 'live:p1:a', + streamUrls: ['http://stream/a'], + }; + const streamB: PlaybackHistoryTarget = { + sessionKey: 'live:p1:b', + streamUrls: ['http://stream/b'], + }; + let target: PlaybackHistoryTarget; let revision: symbol; let confirm: jest.Mock; let confirmation: PlaybackHistoryConfirmation; @@ -11,39 +19,39 @@ describe('PlaybackHistoryConfirmation', () => { positions.forEach((position) => confirmation.record(position)); beforeEach(() => { - keys = ['live:p1:a', 'http://stream/a']; + target = streamA; revision = Symbol('first'); confirm = jest.fn(); confirmation = new PlaybackHistoryConfirmation({ gate: { confirm }, - keys: () => keys, + target: () => target, sourceRevision: () => revision, }); }); - it('confirms the keys of the stream that played', () => { + it('confirms what played', () => { play(0, 1, 2); - expect(confirm).toHaveBeenCalledWith(['live:p1:a', 'http://stream/a']); + expect(confirm).toHaveBeenCalledWith(streamA); }); it('does not credit a new stream with the previous stream progress', () => { play(0, 1.5); - keys = ['live:p1:b', 'http://stream/b']; + target = streamB; play(0, 0.5); expect(confirm).not.toHaveBeenCalled(); play(1, 2); expect(confirm).toHaveBeenCalledTimes(1); - expect(confirm).toHaveBeenCalledWith(['live:p1:b', 'http://stream/b']); + expect(confirm).toHaveBeenCalledWith(streamB); }); it('confirms a returning stream again after another one played', () => { play(0, 1, 2); - keys = ['live:p1:b', 'http://stream/b']; + target = streamB; play(0, 1, 2); - keys = ['live:p1:a', 'http://stream/a']; + target = streamA; play(0, 1, 2); expect(confirm).toHaveBeenCalledTimes(3); @@ -56,4 +64,12 @@ describe('PlaybackHistoryConfirmation', () => { expect(confirm).toHaveBeenCalledTimes(1); }); + + it('ignores position changes reported while not playing', () => { + confirmation.record(0, false); + confirmation.record(1, false); + confirmation.record(2, false); + + expect(confirm).not.toHaveBeenCalled(); + }); }); diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts index 4819b6156..abfd0686e 100644 --- a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts @@ -1,13 +1,13 @@ import type { PlaybackHistoryGate, - PlaybackHistoryKeys, + PlaybackHistoryTarget, } from '@iptvnator/services'; import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; export interface PlaybackHistoryConfirmationOptions { readonly gate: Pick; - /** Keys of what is playing now: session key and/or stream URLs. */ - readonly keys: () => PlaybackHistoryKeys; + /** What is playing now: its session key and/or stream URLs. */ + readonly target: () => PlaybackHistoryTarget; /** * Engine/source generation within the same keys (engine switch, live * format fallback, reload). A change restarts the position clock but @@ -26,13 +26,14 @@ export class PlaybackHistoryConfirmation { private identity: string | null = null; private sourceRevision: unknown = null; private readonly progress = new PlaybackProgressConfirmation(() => - this.options.gate.confirm(this.options.keys()) + this.options.gate.confirm(this.options.target()) ); constructor(private readonly options: PlaybackHistoryConfirmationOptions) {} - record(position: number): void { - const identity = JSON.stringify(this.options.keys()); + /** @param playing see {@link PlaybackProgressConfirmation.record}. */ + record(position: number, playing?: boolean): void { + const identity = JSON.stringify(this.options.target()); const sourceRevision = this.options.sourceRevision?.() ?? null; if (identity !== this.identity) { this.identity = identity; @@ -43,6 +44,6 @@ export class PlaybackHistoryConfirmation { this.progress.rebase(); } - this.progress.record(position); + this.progress.record(position, playing); } } diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts index 3a8351a7f..991b0fc6d 100644 --- a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts @@ -45,6 +45,28 @@ describe('PlaybackProgressConfirmation', () => { expect(onConfirmed).not.toHaveBeenCalled(); }); + it('does not count short seeks of paused media', () => { + confirmation.record(0, false); + confirmation.record(1, false); + confirmation.record(2, false); + confirmation.record(2.5, false); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('counts only the steps reported while playing', () => { + confirmation.record(0, true); + confirmation.record(1.5, true); + // Paused, then seeked a second ahead: not watched. + confirmation.record(2.5, false); + confirmation.record(2.9, true); + + expect(onConfirmed).not.toHaveBeenCalled(); + + confirmation.record(3.5, true); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + it('does not count backwards jumps', () => { play(5, 4, 3, 2, 1); diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts index c8ad01ade..a6397013f 100644 --- a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts @@ -12,7 +12,8 @@ const MAX_PLAYBACK_STEP_SECONDS = 3; * Turns an engine's position reports into a single "this stream plays" * signal. `playing` alone is not enough: a broken stream can fire it and * stall or error a moment later, so only position that really advanced - * counts. Pauses, stalls, seeks and backwards jumps add nothing. + * counts. Pauses, stalls, seeks and backwards jumps add nothing — including + * short seeks of paused media, which engines report as `playing: false`. */ export class PlaybackProgressConfirmation { private lastPosition: number | null = null; @@ -36,14 +37,18 @@ export class PlaybackProgressConfirmation { this.lastPosition = null; } - record(position: number): void { + /** + * @param playing whether the media was actually playing (not paused, not + * seeking) at this report; engines that cannot tell leave it undefined. + */ + record(position: number, playing?: boolean): void { if (this.confirmed || !Number.isFinite(position)) { return; } const previous = this.lastPosition; this.lastPosition = position; - if (previous === null) { + if (previous === null || playing === false) { return; } diff --git a/libs/ui/playback/src/lib/playback-history/player-time-update.ts b/libs/ui/playback/src/lib/playback-history/player-time-update.ts new file mode 100644 index 000000000..10b4a4000 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/player-time-update.ts @@ -0,0 +1,12 @@ +/** Position report an engine emits on every media time update. */ +export interface PlayerTimeUpdate { + readonly currentTime: number; + readonly duration: number; + /** + * Whether the media was actually playing — not paused and not seeking. + * Seeks of paused media also move the position; only playing reports + * count towards the "really played" history confirmation. Left undefined + * by an engine that cannot tell. + */ + readonly playing?: boolean; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index ca208b60e..7b59a2530 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -50,6 +50,7 @@ import { getVideoJsTechVideo, } from './vjs-player.types'; import { VjsVideoElementSession } from './vjs-video-element-session'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugVjsPlayer = createDevLogger('VjsPlayer'); @@ -80,10 +81,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly playbackStarted = output(); @@ -280,6 +278,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.timeUpdate.emit({ currentTime: this.player.currentTime() ?? 0, duration: this.player.duration() ?? 0, + playing: !this.player.paused() && !this.player.seeking(), }); }; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts index f0e5a098d..9ac9cc4ac 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts @@ -118,7 +118,7 @@ describe('WebPlayerViewComponent playback history', () => { it('records a channel once its stream has played for two seconds', () => { const commit = jest.fn(); - gate.defer(['live:p1:a'], commit); + gate.defer({ sessionKey: 'live:p1:a' }, commit); playTo(0, 1); expect(commit).not.toHaveBeenCalled(); @@ -129,7 +129,7 @@ describe('WebPlayerViewComponent playback history', () => { it('confirms by stream URL for writers that only know the link', () => { const commit = jest.fn(); - gate.defer(['https://example.com/a'], commit); + gate.defer({ streamUrls: ['https://example.com/a'] }, commit); playTo(0, 1, 2); @@ -138,7 +138,7 @@ describe('WebPlayerViewComponent playback history', () => { it('does not record a channel whose stream fails before playing', () => { const commit = jest.fn(); - gate.defer(['live:p1:a'], commit); + gate.defer({ sessionKey: 'live:p1:a' }, commit); playTo(0); vjs().playbackIssue.emit({ @@ -156,13 +156,24 @@ describe('WebPlayerViewComponent playback history', () => { expect(commit).not.toHaveBeenCalled(); }); + it('does not count seeks of paused media as watched time', () => { + const commit = jest.fn(); + gate.defer({ sessionKey: 'live:p1:a' }, commit); + + [0, 1, 2, 3].forEach((currentTime) => + vjs().timeUpdate.emit({ currentTime, duration: 0, playing: false }) + ); + + expect(commit).not.toHaveBeenCalled(); + }); + it('does not credit the next channel with the previous one', async () => { const next = jest.fn(); playTo(0, 1.5); fixture.componentRef.setInput('streamUrl', 'https://example.com/b'); fixture.componentRef.setInput('playbackSessionKey', 'live:p1:b'); - gate.defer(['live:p1:b'], next); + gate.defer({ sessionKey: 'live:p1:b' }, next); await render(); playTo(0, 0.5); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index c55853208..597be7455 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -41,6 +41,7 @@ import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/ful import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; import { type PlayerMediaTitle, WEB_PLAYER_SHARED_CONTROLS, @@ -221,11 +222,10 @@ export class WebPlayerViewComponent implements OnDestroy { /** Commits deferred "recently viewed" writes once this stream plays. */ private readonly historyConfirmation = new PlaybackHistoryConfirmation({ gate: inject(PlaybackHistoryGate), - keys: () => [ - this.playbackSessionKey(), - this.streamUrl(), - this.playback()?.streamUrl, - ], + target: () => ({ + sessionKey: this.playbackSessionKey(), + streamUrls: [this.streamUrl(), this.playback()?.streamUrl], + }), sourceRevision: () => this.playbackSourceRevisionToken(), }); readonly playbackExternallyTransferable = computed(() => @@ -370,7 +370,7 @@ export class WebPlayerViewComponent implements OnDestroy { } handleTimeUpdate( - event: { currentTime: number; duration: number }, + event: PlayerTimeUpdate, ownership: PlaybackApplicationOwnership ): void { if ( @@ -392,7 +392,7 @@ export class WebPlayerViewComponent implements OnDestroy { } this.recoverySession.recordTimeUpdate(event, ownership.isLive); - this.historyConfirmation.record(event.currentTime); + this.historyConfirmation.record(event.currentTime, event.playing); this.timeUpdate.emit(event); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index fb57beae0..488a67959 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -5,6 +5,7 @@ import type { RecordingStartMetadata, RecordingStoppedEvent, } from '@iptvnator/shared/interfaces'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; /** * Player stand-ins for WebPlayerViewComponent specs. They mirror the real @@ -25,7 +26,7 @@ export class StubVjsPlayerComponent { readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -47,7 +48,7 @@ export class StubHtmlVideoPlayerComponent { readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -69,7 +70,7 @@ export class StubArtPlayerComponent { readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -98,7 +99,7 @@ export class StubEmbeddedMpvPlayerComponent { readonly recordingFolder = input(''); readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output();