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();