mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(playback): tighten recently viewed confirmation per review
- Correlate by session key first: when both the deferred write and the confirmation carry a playbackSessionKey, only that is compared, so the same stream URL played in another playlist no longer records a failed attempt. URLs remain the fallback (portal writes, MPV/VLC sessions). The M3U radio player now receives the host's session key. - Count only playing progress: engines report `playing` (not paused, not seeking) with each time update, so short seeks of paused media no longer confirm a view. - Xtream: a write confirmed after a playlist switch still saves to its own playlist but no longer replaces the current playlist's recent list. - Global live tab: a row confirmed after another row was selected still moves to the top of an open Recently Viewed list (only a disposed tab skips the notification). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b7a2449a5
commit
96f1707b2d
37 files changed
+462
-157
No files matched your search
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -106,10 +106,13 @@
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
[icon]="
|
||||
$safeNavigationMigration(activeChannel?.tvg?.logo)!
|
||||
$safeNavigationMigration(
|
||||
activeChannel?.tvg?.logo
|
||||
)!
|
||||
"
|
||||
[channelName]="displayChannelName()"
|
||||
[volume]="volume()"
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
(volumeChange)="onInlineVolumeChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@@ -1230,7 +1230,10 @@ export class VideoPlayerComponent
|
||||
}
|
||||
|
||||
this.historyGate.defer(
|
||||
[this.playbackSessionKey(), channel.url],
|
||||
{
|
||||
sessionKey: this.playbackSessionKey(),
|
||||
streamUrls: [channel.url],
|
||||
},
|
||||
record
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export class StubAudioPlayerComponent {
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
readonly volume = input<number | null>(null);
|
||||
readonly playbackSessionKey = input<string | null>(null);
|
||||
readonly volumeChange = output<number>();
|
||||
}
|
||||
|
||||
|
||||
+13
-5
@@ -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<void> => {
|
||||
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();
|
||||
|
||||
+3
-1
@@ -163,7 +163,9 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
|
||||
/** What the mounted player reports once the stream has really played. */
|
||||
async function playStream(streamUrl: string): Promise<void> {
|
||||
TestBed.inject(PlaybackHistoryGate).confirm([streamUrl]);
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: [streamUrl],
|
||||
});
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
|
||||
+48
-1
@@ -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 = {
|
||||
|
||||
+3
-1
@@ -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', () => ({
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ export function withStalkerPlayer() {
|
||||
cover,
|
||||
title
|
||||
);
|
||||
historyGate.defer([streamUrl], () =>
|
||||
historyGate.defer({ streamUrls: [streamUrl] }, () =>
|
||||
persistRecentlyViewed(playlistId, {
|
||||
...recentItem,
|
||||
added_at: Date.now(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
interface PendingHistoryWrite {
|
||||
readonly keys: ReadonlySet<string>;
|
||||
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<string> {
|
||||
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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -183,6 +183,8 @@ export class AudioPlayerComponent {
|
||||
readonly channelName = input<string>('');
|
||||
readonly externalVolume = input<number | null>(null, { alias: 'volume' });
|
||||
readonly dispatchAdjacentChannelAction = input(true);
|
||||
/** The host's playback session key, for the history confirmation. */
|
||||
readonly playbackSessionKey = input<string | null>(null);
|
||||
readonly channelSwitchRequested = output<'next' | 'previous'>();
|
||||
readonly volumeChange = output<number>();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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<PlayerMediaTitle | null>(null);
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
@Output() timeUpdate = new EventEmitter<PlayerTimeUpdate>();
|
||||
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
|
||||
@Output() playbackEnded = new EventEmitter<void>();
|
||||
@Output() playbackStarted = new EventEmitter<void>();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import type {
|
||||
PlaybackHistoryGate,
|
||||
PlaybackHistoryKeys,
|
||||
PlaybackHistoryTarget,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackProgressConfirmation } from './playback-progress-confirmation';
|
||||
|
||||
export interface PlaybackHistoryConfirmationOptions {
|
||||
readonly gate: Pick<PlaybackHistoryGate, 'confirm'>;
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -47,7 +48,7 @@ export class StubHtmlVideoPlayerComponent {
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -69,7 +70,7 @@ export class StubArtPlayerComponent {
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -98,7 +99,7 @@ export class StubEmbeddedMpvPlayerComponent {
|
||||
readonly recordingFolder = input('');
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
Reference in new issue
Block a user