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 499ab5a9b..ab7fee381 100644 --- a/apps/web/src/app/services/external-playback.service.spec.ts +++ b/apps/web/src/app/services/external-playback.service.spec.ts @@ -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', () => { diff --git a/apps/web/src/app/services/external-playback.service.ts b/apps/web/src/app/services/external-playback.service.ts index a99f1e0c8..161848f00 100644 --- a/apps/web/src/app/services/external-playback.service.ts +++ b/apps/web/src/app/services/external-playback.service.ts @@ -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(null); private readonly dismissedSessionId = signal(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; diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 6aac96903..e4dcded67 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 13bc2fe5a..836c3b680 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -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`: diff --git a/libs/playback/data-access/jest.config.ts b/libs/playback/data-access/jest.config.ts new file mode 100644 index 000000000..bb6df3689 --- /dev/null +++ b/libs/playback/data-access/jest.config.ts @@ -0,0 +1,22 @@ +export default { + displayName: 'playback-data-access', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/playback/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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'], +}; diff --git a/libs/playback/data-access/project.json b/libs/playback/data-access/project.json new file mode 100644 index 000000000..263f324b9 --- /dev/null +++ b/libs/playback/data-access/project.json @@ -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" + } + } +} diff --git a/libs/playback/data-access/src/index.ts b/libs/playback/data-access/src/index.ts new file mode 100644 index 000000000..d91e2e8dd --- /dev/null +++ b/libs/playback/data-access/src/index.ts @@ -0,0 +1 @@ +export * from './lib/playback-history-gate.service'; diff --git a/libs/services/src/lib/playback-history-gate.service.spec.ts b/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts similarity index 74% rename from libs/services/src/lib/playback-history-gate.service.spec.ts rename to libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts index 4bcb84281..230418cd0 100644 --- a/libs/services/src/lib/playback-history-gate.service.spec.ts +++ b/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts @@ -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) => 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') diff --git a/libs/services/src/lib/playback-history-gate.service.ts b/libs/playback/data-access/src/lib/playback-history-gate.service.ts similarity index 87% rename from libs/services/src/lib/playback-history-gate.service.ts rename to libs/playback/data-access/src/lib/playback-history-gate.service.ts index e053a3f27..a3ba9e75b 100644 --- a/libs/services/src/lib/playback-history-gate.service.ts +++ b/libs/playback/data-access/src/lib/playback-history-gate.service.ts @@ -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) { diff --git a/libs/playback/data-access/src/test-setup.ts b/libs/playback/data-access/src/test-setup.ts new file mode 100644 index 000000000..1ca0136b9 --- /dev/null +++ b/libs/playback/data-access/src/test-setup.ts @@ -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, +}); diff --git a/libs/playback/data-access/tsconfig.json b/libs/playback/data-access/tsconfig.json new file mode 100644 index 000000000..8fcb75804 --- /dev/null +++ b/libs/playback/data-access/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/playback/data-access/tsconfig.lib.json b/libs/playback/data-access/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/playback/data-access/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"] +} diff --git a/libs/playback/data-access/tsconfig.spec.json b/libs/playback/data-access/tsconfig.spec.json new file mode 100644 index 000000000..7f31bb031 --- /dev/null +++ b/libs/playback/data-access/tsconfig.spec.json @@ -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" + ] +} 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 02f2662c8..93643cc16 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 @@ -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, 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 5f43c5023..4e6e9a64e 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 @@ -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, 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 c403dff03..bd05802a3 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 @@ -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'; 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 b960be88d..37f192b30 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 @@ -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, 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 c5cd55394..be59ba6f8 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,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, 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 4dcc94a33..adec67f2d 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 @@ -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, 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 9409d33e5..6cff03cca 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 @@ -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, 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 bc1bc54bc..544c3a407 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, 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 () => { 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 37f70a2e2..f388baa3f 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,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 = 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 37454f74e..53ba217d1 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 @@ -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() 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 5b23f0f94..36cd4bc9a 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 @@ -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 }); 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 f5fe56e7f..433f38465 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -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, + }) ); }; } diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 987fc1118..cf052bf34 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; 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 89723e4fe..268dea187 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 @@ -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', () => { 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 8658fced8..34469d51a 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 @@ -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({ 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 3a0175146..be7916a2a 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,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', () => { 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 abfd0686e..ec8413ea0 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,7 +1,7 @@ import type { PlaybackHistoryGate, PlaybackHistoryTarget, -} from '@iptvnator/services'; +} from '@iptvnator/playback/data-access'; import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; export interface PlaybackHistoryConfirmationOptions { 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 9ac9cc4ac..2639def1a 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 @@ -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, 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 b9dc67db3..be175e237 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 @@ -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, diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 379cad26f..ef1d82de8 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -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", diff --git a/tsconfig.base.json b/tsconfig.base.json index 828f042d5..e22f2f8fa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"