mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
perf(playback): keep the history gate off the initial bundle
The `@iptvnator/services` barrel ships in the initial bundle, so adding PlaybackHistoryGate there (and subscribing to it from the app-wide ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes. - Move the gate to a new lazy-only `playback-data-access` project (`@iptvnator/playback/data-access`; scope:shared, domain:playback, type:data-access) and register it in the coverage policy. - The gate subscribes to MPV/VLC session updates itself; it is created by the first deferred write, which precedes the launch it waits for. ExternalPlaybackService is back to master. - The Xtream "playlist switched before confirmation" check moves to the lazy helper; the initial-path store only takes a `skipListRefresh` flag. Net effect on this branch: +27 bytes over master (master itself is 108 bytes over the ratchet baseline already). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
671a0cb1ec
commit
e41bcb9f65
34 files changed
+250
-128
No files matched your search
@@ -1,5 +1,3 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/services';
|
||||
import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
|
||||
import { ExternalPlaybackService } from './external-playback.service';
|
||||
|
||||
@@ -42,36 +40,7 @@ describe('ExternalPlaybackService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
service = TestBed.runInInjectionContext(
|
||||
() => new ExternalPlaybackService()
|
||||
);
|
||||
});
|
||||
|
||||
it('commits deferred history once the external player has opened', () => {
|
||||
const commit = jest.fn();
|
||||
TestBed.inject(PlaybackHistoryGate).defer(
|
||||
{ streamUrls: ['https://example.com/video.m3u8'] },
|
||||
commit
|
||||
);
|
||||
|
||||
listener?.(createSession({ status: 'launching' }));
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
listener?.(createSession({ status: 'opened' }));
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not commit history for a launch that failed', () => {
|
||||
const commit = jest.fn();
|
||||
TestBed.inject(PlaybackHistoryGate).defer(
|
||||
{ streamUrls: ['https://example.com/video.m3u8'] },
|
||||
commit
|
||||
);
|
||||
|
||||
listener?.(createSession({ status: 'launching' }));
|
||||
listener?.(createSession({ status: 'error', error: 'not found' }));
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
service = new ExternalPlaybackService();
|
||||
});
|
||||
|
||||
it('tracks the latest launch and hides dismissed sessions until the next launch', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/services';
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ExternalPlaybackService {
|
||||
private readonly historyGate = inject(PlaybackHistoryGate);
|
||||
readonly activeSession = signal<ExternalPlayerSession | null>(null);
|
||||
private readonly dismissedSessionId = signal<string | null>(null);
|
||||
|
||||
@@ -104,12 +102,6 @@ export class ExternalPlaybackService {
|
||||
}
|
||||
|
||||
private handleSessionUpdate(session: ExternalPlayerSession): void {
|
||||
// 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({ streamUrls: [session.streamUrl] });
|
||||
}
|
||||
|
||||
const current = this.activeSession();
|
||||
const restoresCurrentReplacement =
|
||||
current?.id === session.restoredFromSessionId;
|
||||
|
||||
@@ -1137,9 +1137,9 @@ 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(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
|
||||
`PlaybackHistoryGate` (`@iptvnator/playback/data-access`) 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
|
||||
@@ -1160,9 +1160,11 @@ reaches history.
|
||||
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. That
|
||||
- MPV/VLC cannot report whether a live stream plays, so the gate itself
|
||||
subscribes to the Electron external-player session updates and confirms a
|
||||
session's `streamUrl` once it is `opened` or `playing`; a launch that ends
|
||||
in `error` is not recorded. (Subscribing in the gate, which the first
|
||||
deferred write creates, keeps it off the initial bundle.) That
|
||||
confirmation carries no session key, so an "Open in MPV/VLC" recovery
|
||||
launch is also confirmed by the `WebPlayerViewComponent` that requested
|
||||
it, under its own session key, once the launch has opened. M3U keeps
|
||||
|
||||
@@ -189,6 +189,14 @@ other utility projects, including shared interface contracts, while
|
||||
`ui-playback` and feature hosts may depend on it to render and execute
|
||||
session-local recovery actions.
|
||||
|
||||
`libs/playback/data-access` is the `playback-data-access` Nx project,
|
||||
imported through `@iptvnator/playback/data-access` (tags `scope:shared`,
|
||||
`domain:playback`, `type:data-access`). It owns renderer playback state that
|
||||
store, feature and player layers share — currently `PlaybackHistoryGate`,
|
||||
which holds "recently viewed" writes until playback is confirmed. It is kept
|
||||
out of `@iptvnator/services` on purpose: that barrel ships in the initial
|
||||
bundle, and this project is only reached from lazy player and portal code.
|
||||
|
||||
## Project Tags
|
||||
|
||||
Every Nx project keeps one tag from each family in `project.json`:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'playback-data-access',
|
||||
preset: '../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../coverage/libs/playback/data-access',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "playback-data-access",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playback/data-access/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:data-access"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/playback/data-access/jest.config.ts",
|
||||
"tsConfig": "libs/playback/data-access/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/playback-history-gate.service';
|
||||
+53
@@ -1,3 +1,4 @@
|
||||
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackHistoryGate } from './playback-history-gate.service';
|
||||
|
||||
describe('PlaybackHistoryGate', () => {
|
||||
@@ -137,6 +138,58 @@ describe('PlaybackHistoryGate', () => {
|
||||
expect(oldest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('MPV/VLC sessions', () => {
|
||||
const originalElectron = window.electron;
|
||||
let emit: (session: Partial<ExternalPlayerSession>) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onExternalPlayerSessionUpdate: (
|
||||
callback: (session: ExternalPlayerSession) => void
|
||||
) => {
|
||||
emit = (session) =>
|
||||
callback({
|
||||
streamUrl: 'http://vod/1.mkv',
|
||||
...session,
|
||||
} as ExternalPlayerSession);
|
||||
return () => undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
gate = new PlaybackHistoryGate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: originalElectron,
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms a stream once its external player has opened', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit);
|
||||
|
||||
emit({ status: 'launching' });
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
emit({ status: 'opened' });
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not confirm a launch that failed', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit);
|
||||
|
||||
emit({ status: 'launching' });
|
||||
emit({ status: 'error' });
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps committing the other writes when one throws', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
+13
@@ -53,6 +53,19 @@ const MAX_PENDING_HISTORY_WRITES = 20;
|
||||
export class PlaybackHistoryGate {
|
||||
private pending: PendingHistoryWrite[] = [];
|
||||
|
||||
constructor() {
|
||||
// MPV/VLC cannot report whether a live stream plays, so a launch
|
||||
// that opened is the confirmation. Subscribed here rather than in
|
||||
// the app-wide external-playback service so the gate stays off the
|
||||
// initial bundle: it is created by the first deferred write, which
|
||||
// always precedes the launch it waits for.
|
||||
window.electron?.onExternalPlayerSessionUpdate?.((session) => {
|
||||
if (session.status === 'opened' || session.status === 'playing') {
|
||||
this.confirm({ streamUrls: [session.streamUrl] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
defer(target: PlaybackHistoryTarget, commit: () => void): void {
|
||||
const normalized = normalizeTarget(target);
|
||||
if (!normalized.sessionKey && normalized.streamUrls.size === 0) {
|
||||
@@ -0,0 +1,9 @@
|
||||
// Keep Zone's fakeAsync helpers while preserving the Angular 21 test scheduler.
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless';
|
||||
|
||||
setupZonelessTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve",
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -10,12 +10,12 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaybackHistoryGate,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
Settings,
|
||||
|
||||
@@ -114,13 +114,13 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DataService,
|
||||
PlaybackHistoryGate,
|
||||
PlaylistsService,
|
||||
RecordingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
Channel,
|
||||
createDevLogger,
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
PORTAL_PLAYER,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
|
||||
import { UnifiedLiveTimeshift } from './unified-live-catchup';
|
||||
import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
|
||||
|
||||
+2
-5
@@ -26,11 +26,8 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
PlaybackHistoryGate,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
|
||||
+2
-5
@@ -9,11 +9,8 @@ import {
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
PlaybackHistoryGate,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
|
||||
+2
-5
@@ -4,11 +4,8 @@ import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaybackHistoryGate,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
|
||||
+2
-5
@@ -4,11 +4,8 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaybackHistoryGate,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
ResolvedPortalPlayback,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import { signalStore } 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,11 +16,6 @@ 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;
|
||||
@@ -100,7 +95,6 @@ describe('withRecentItems', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestRecentItemsStore,
|
||||
TestCurrentPlaylistStore,
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
@@ -163,9 +157,7 @@ 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);
|
||||
it('saves without replacing the list when the playlist is no longer current', async () => {
|
||||
dataSource.getContentByXtreamId.mockResolvedValue({
|
||||
id: 3941697,
|
||||
title: 'Krypton',
|
||||
@@ -173,10 +165,11 @@ describe('withRecentItems', () => {
|
||||
xtream_id: 290,
|
||||
});
|
||||
|
||||
currentStore.addRecentItem({
|
||||
store.addRecentItem({
|
||||
xtreamId: 290,
|
||||
contentType: 'series',
|
||||
playlist: signal({ id: 'playlist-1' }),
|
||||
skipListRefresh: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -186,7 +179,7 @@ describe('withRecentItems', () => {
|
||||
undefined
|
||||
);
|
||||
expect(dataSource.getRecentItems).not.toHaveBeenCalled();
|
||||
expect(currentStore.recentItems()).toEqual([]);
|
||||
expect(store.recentItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => {
|
||||
|
||||
@@ -50,21 +50,6 @@ 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(
|
||||
@@ -100,6 +85,8 @@ export const withRecentItems = function () {
|
||||
contentType: 'live' | 'movie' | 'series';
|
||||
playlist: Signal<{ id: string } | null | undefined>;
|
||||
backdropUrl?: string;
|
||||
/** Save only: `playlist` is no longer the one in state. */
|
||||
skipListRefresh?: boolean;
|
||||
}>(
|
||||
pipe(
|
||||
switchMap(
|
||||
@@ -108,6 +95,7 @@ export const withRecentItems = function () {
|
||||
contentType,
|
||||
playlist,
|
||||
backdropUrl,
|
||||
skipListRefresh,
|
||||
}) => {
|
||||
const playlistId = playlist()?.id;
|
||||
const normalizedXtreamId = Number(xtreamId);
|
||||
@@ -137,13 +125,10 @@ export const withRecentItems = function () {
|
||||
playlistId,
|
||||
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)) {
|
||||
if (skipListRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload after add/update so re-watched items
|
||||
// immediately move to the top in recently-viewed.
|
||||
const items =
|
||||
|
||||
@@ -25,10 +25,8 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
PlaybackHistoryGate,
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
@@ -577,6 +575,7 @@ describe('SerialDetailsComponent', () => {
|
||||
contentType: 'series',
|
||||
playlist: expect.any(Function),
|
||||
backdropUrl: undefined,
|
||||
skipListRefresh: false,
|
||||
});
|
||||
expect(addRecentItem.mock.calls[0][0].playlist()).toEqual(
|
||||
currentPlaylist()
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
PlaybackHistoryGate,
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
@@ -173,6 +171,8 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
expect.objectContaining({
|
||||
xtreamId: ROUTE_VOD_ID,
|
||||
contentType: 'movie',
|
||||
// Saved to its own playlist; the store's list is now another's.
|
||||
skipListRefresh: true,
|
||||
})
|
||||
);
|
||||
expect(recentItem.playlist()).toEqual({ id: ROUTE_PLAYLIST });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject, signal } from '@angular/core';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
|
||||
export interface XtreamRecentItemRequest {
|
||||
readonly xtreamId: number | string;
|
||||
@@ -13,7 +13,8 @@ export interface XtreamRecentItemRequest {
|
||||
* played — inline for a couple of seconds, or launched in MPV/VLC — so a
|
||||
* source that fails straight away never reaches history or the dashboard
|
||||
* hero. The playlist is captured when playback starts, so navigating to
|
||||
* another one meanwhile cannot misfile the item.
|
||||
* another one meanwhile cannot misfile the item — nor replace the recent
|
||||
* list the store now holds for the playlist the user switched to.
|
||||
*
|
||||
* Must run in an injection context.
|
||||
*/
|
||||
@@ -27,7 +28,11 @@ export function injectXtreamRecentHistory(): (
|
||||
return (streamUrl, request) => {
|
||||
const playlist = signal(store.currentPlaylist()).asReadonly();
|
||||
gate.defer({ streamUrls: [streamUrl] }, () =>
|
||||
store.addRecentItem({ ...request, playlist })
|
||||
store.addRecentItem({
|
||||
...request,
|
||||
playlist,
|
||||
skipListRefresh: store.currentPlaylist()?.id !== playlist()?.id,
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ export * from './lib/database-electron.service';
|
||||
export * from './lib/downloads.service';
|
||||
export * from './lib/recordings.service';
|
||||
export * from './lib/playback-position-runtime-bridge.service';
|
||||
export * from './lib/playback-history-gate.service';
|
||||
export * from './lib/playback-position.service';
|
||||
export * from './lib/playlist-delete-cleanup.token';
|
||||
export * from './lib/playlist-delete-action.service';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { AudioPlayerComponent } from './audio-player.component';
|
||||
|
||||
describe('AudioPlayerComponent', () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlaybackHistoryTarget } from '@iptvnator/services';
|
||||
import type { PlaybackHistoryTarget } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackHistoryConfirmation } from './playback-history-confirmation';
|
||||
|
||||
describe('PlaybackHistoryConfirmation', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
PlaybackHistoryGate,
|
||||
PlaybackHistoryTarget,
|
||||
} from '@iptvnator/services';
|
||||
} from '@iptvnator/playback/data-access';
|
||||
import { PlaybackProgressConfirmation } from './playback-progress-confirmation';
|
||||
|
||||
export interface PlaybackHistoryConfirmationOptions {
|
||||
|
||||
@@ -11,11 +11,8 @@ import {
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '@iptvnator/playback/util';
|
||||
import {
|
||||
PlaybackHistoryGate,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
|
||||
@@ -22,11 +22,8 @@ import {
|
||||
type PlaybackRecommendationTarget,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackHistoryGate,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type Channel,
|
||||
|
||||
@@ -89,6 +89,13 @@
|
||||
"validationCommand": "pnpm nx test epg-data-access",
|
||||
"e2eTags": ["@epg"]
|
||||
},
|
||||
{
|
||||
"name": "playback-data-access",
|
||||
"root": "libs/playback/data-access",
|
||||
"sourceRoot": "libs/playback/data-access/src",
|
||||
"validationCommand": "pnpm nx test playback-data-access",
|
||||
"e2eTags": ["@persistence"]
|
||||
},
|
||||
{
|
||||
"name": "m3u-state",
|
||||
"root": "libs/m3u-state",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"libs/portal/shared/util/src/index.ts"
|
||||
],
|
||||
"@iptvnator/epg/data-access": ["libs/epg/data-access/src/index.ts"],
|
||||
"@iptvnator/playback/data-access": [
|
||||
"libs/playback/data-access/src/index.ts"
|
||||
],
|
||||
"@iptvnator/playback/util": ["libs/playback/util/src/index.ts"],
|
||||
"@iptvnator/playlist/shared/ui": [
|
||||
"libs/playlist/shared/ui/src/index.ts"
|
||||
|
||||
Reference in new issue
Block a user