From 24482c429edadf466de9eceb6b282da1c755856c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Aug 2026 11:46:25 +0200 Subject: [PATCH] fix(playback): bind external launch ownership --- AGENTS.md | 8 +- CLAUDE.md | 10 +- .../app/events/mpv-session.service.spec.ts | 42 ++++++ .../src/app/events/mpv-session.service.ts | 7 +- .../vlc-session.service.lifecycle.spec.ts | 9 +- .../src/app/events/vlc-session.service.ts | 12 +- docs/architecture/embedded-inline-playback.md | 16 ++- ...08-08-external-playback-launch-feedback.md | 28 ++-- ...xternal-playback-launch-feedback-design.md | 30 +++-- .../diagnostics/playback-diagnostics.model.ts | 4 + .../video-player.component.spec.ts | 5 + .../video-player/video-player.component.ts | 10 +- .../unified-live-tab.component.spec.ts | 1 + .../unified-live-tab.component.ts | 4 +- .../stalker-catalog-detail.component.ts | 4 +- ...alker-live-stream-layout.component.spec.ts | 4 + .../stalker-live-stream-layout.component.ts | 4 +- .../stalker-series-view.component.ts | 4 +- .../lib/stalker-vod-playback-controller.ts | 4 +- .../live-stream-layout.component.spec.ts | 1 + .../live-stream-layout.component.ts | 4 +- .../serial-details-playback.service.ts | 10 +- .../serial-details.component.spec.ts | 5 + .../vod-details-playback.service.ts | 4 +- ...rnal-playback-recovery-coordinator.spec.ts | 121 +++++++++++++++++- .../external-playback-recovery-coordinator.ts | 39 +++++- .../external-playback-recovery.spec.ts | 121 +++++++++++++++--- .../external-playback-recovery.ts | 65 +++++++--- ...r-view.component.external-recovery.spec.ts | 64 +++++++-- ...web-player-view.component.recovery.spec.ts | 42 ++++-- .../web-player-view.component.ts | 13 +- 31 files changed, 566 insertions(+), 129 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ffecf202..212cee1f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,8 +291,12 @@ Key files: evidence or ownership state. MPV/VLC actions remain mounted after an attempt and expose credential-free per-target launching/started/playing/error state; only an exact Electron `playing` update is labelled Playing. One handshake is - allowed at a time, a replacement waits for confirmed teardown of the tracked - external process, and stale updates cannot take ownership. If the local + allowed at a time. Its fieldless intent is bound to the exact session returned + by the source owner's launch promise, so a late timed-out attempt cannot take + over a retry; later global updates must match that ID. A replacement waits for + confirmed teardown of the tracked external process, applies the old exact + close before launch, and cancels an unlaunched handoff if diagnostic ownership + changes. If the local handshake times out after an exact Electron session is known, that ID remains correlated so a later exact update can recover the UI. The global dock mirrors those statuses, keeps errors visible until dismissal, and intentionally has no diff --git a/CLAUDE.md b/CLAUDE.md index 6e1e470f5..ba0c68271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -827,8 +827,12 @@ app as a real argument, so it is not an option. evidence or ownership state. MPV/VLC actions remain mounted after an attempt and expose credential-free per-target launching/started/playing/error state; only an exact Electron `playing` update is labelled Playing. One handshake is - allowed at a time, a replacement waits for confirmed teardown of the tracked - external process, and stale updates cannot take ownership. If the local + allowed at a time. Its fieldless intent is bound to the exact session returned + by the source owner's launch promise, so a late timed-out attempt cannot take + over a retry; later global updates must match that ID. A replacement waits for + confirmed teardown of the tracked external process, applies the old exact + close before launch, and cancels an unlaunched handoff if diagnostic ownership + changes. If the local handshake times out after an exact Electron session is known, that ID remains correlated so a later exact update can recover the UI. The global dock mirrors those statuses, keeps errors visible until dismissal, and intentionally has no @@ -1187,7 +1191,7 @@ engine` (restart required) or - Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one. - Import probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists the proven endpoint and mode. -- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair *drives*, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. +- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. - Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them. - Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream. - Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding"). diff --git a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts index 4027d3b04..dbfbf5041 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts @@ -205,6 +205,48 @@ describe('external player shutdown on app quit', () => { shutdownMpvSession(); }); + it('waits for the stale reusable MPV process to exit before spawning fresh', async () => { + shutdownMpvSession(); + const staleProc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValueOnce(staleProc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }); + setImmediate(() => socket.emit('error', new Error('stale socket'))); + return socket; + }); + const freshProc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValueOnce(freshProc); + + const secondLaunch = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(staleProc.kill).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(1); + + Object.defineProperty(staleProc, 'exitCode', { value: 0 }); + staleProc.emit('exit', 0); + await waitForSpawnCallCount(2); + await secondLaunch; + + expect(spawn).toHaveBeenCalledTimes(2); + shutdownMpvSession(); + }); + it('does not track non-reusable MPV processes for shutdown', async () => { const proc = createMockChildProcess(); (spawn as unknown as jest.Mock).mockReturnValue(proc); diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index c1346e81f..b18aa3110 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -348,8 +348,11 @@ export async function openMpvPlayer({ return externalPlayerSessions.markOpened(session.id) ?? session; } catch (err) { console.error('Failed to send command to existing MPV:', err); - mpvProcess = null; - mpvSocketPath = null; + await terminateExternalPlayerProcess(reusedProcess); + if (mpvProcess === reusedProcess) { + mpvProcess = null; + mpvSocketPath = null; + } stopPositionPolling(); } } diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts index ccc6edb25..437eab00b 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -207,11 +207,18 @@ describe('vlc-session.service process lifecycle', () => { title: 'Second', url: 'https://example.com/two.m3u8', }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledTimes(1); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); await waitForSpawnCallCount(2); freshProc.emit('spawn'); const session = await openPromise; - expect(proc.kill).toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(2); expect(session.status).toBe('opened'); }); }); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index 9630b26f2..ade552628 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -425,15 +425,11 @@ export async function openVlcPlayer({ 'Failed to reuse existing VLC, spawning fresh:', err ); - if (vlcProcess && !vlcProcess.killed) { - try { - vlcProcess.kill(); - } catch { - // Ignore cleanup failures. - } + await terminateExternalPlayerProcess(reusedProcess); + if (vlcProcess === reusedProcess) { + vlcProcess = null; + vlcRcPort = null; } - vlcProcess = null; - vlcRcPort = null; stopVlcPositionPolling(); } } diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3def9343e..0a7991433 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -661,11 +661,17 @@ target becomes an explicit reopen action and a failed target becomes Try again. Only an exact correlated Electron `playing` session update earns the Playing label. A single external launch handshake owns the session: duplicate actions are ignored, other external actions wait, and an existing live external session -must close before a different player can start. A replacement does not launch -until teardown of the exact spawned process is confirmed. If the local -handshake timeout fires after Electron has supplied an exact session ID, the ID -stays correlated so a later `opened`, `playing`, or `error` update can reconcile -the action with the global dock. +must close before a different player can start. The source-owning host binds +its returned launch promise to the fieldless current intent, so only that exact +result supplies the initial session ID; a late result from a timed-out attempt +cannot take over a retry. Later global updates must match the exact ID. A +replacement does not launch until teardown of the exact spawned process is +confirmed, and the old target is settled synchronously before the new launch so +coalesced signal effects cannot preserve stale feedback. If diagnostic +ownership changes during close, the unlaunched intent is cancelled without a +false launch error. If the local handshake timeout fires after Electron has +supplied an exact session ID, the ID stays correlated so a later `opened`, +`playing`, or `error` update can reconcile the action with the global dock. The global external-playback dock uses the same Electron session status. It shows Opening player with progress during launch, Player started for `opened`, diff --git a/docs/superpowers/plans/2026-08-08-external-playback-launch-feedback.md b/docs/superpowers/plans/2026-08-08-external-playback-launch-feedback.md index 2e0aeb03c..658cf5b01 100644 --- a/docs/superpowers/plans/2026-08-08-external-playback-launch-feedback.md +++ b/docs/superpowers/plans/2026-08-08-external-playback-launch-feedback.md @@ -4,7 +4,7 @@ **Goal:** Keep MPV/VLC recovery actions visible while reporting exact launch/session state in the diagnostic panel and global dock. -**Architecture:** Add a component-local, credential-free external recovery state machine beside `PlaybackRecoverySession`. It correlates a launch intent to the next matching global `ExternalPlayerSession`, reranks but never removes attempted external targets, and rejects stale session/timer updates. The existing host output still owns source-specific launch payloads; `PORTAL_EXTERNAL_PLAYBACK` supplies session observation and close-before-switch. +**Architecture:** Add a component-local, credential-free external recovery state machine beside `PlaybackRecoverySession`. The existing host output still owns source-specific launch payloads and binds its returned launch promise to the fieldless current intent; only that exact result supplies the initial session ID. `PORTAL_EXTERNAL_PLAYBACK` then supplies exact-ID status observation and close-before-switch. Attempts rerank but never remove external targets, while stale promises, sessions, timers, and diagnostic owners are rejected. **Tech Stack:** Angular signals and control flow, Angular Material icons/spinner, Jest, Nx, Playwright Electron E2E, ngx-translate. @@ -24,7 +24,7 @@ Cover the exact public contract: ```typescript const state = new ExternalPlaybackRecovery(); state.syncSession('content-a'); -const intent = state.begin('mpv', 'old-session'); +const intent = state.begin('mpv'); expect(intent).not.toBeNull(); expect(state.pending()).toBe(true); @@ -33,7 +33,7 @@ expect(state.target('mpv')).toMatchObject({ attempts: 1, sessionId: null, }); -expect(state.begin('vlc', 'old-session')).toBeNull(); +expect(state.begin('vlc')).toBeNull(); expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe( false @@ -41,7 +41,12 @@ expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe( expect(state.observe(session({ id: 'new-session', player: 'vlc' }))).toBe( false ); -expect(state.observe(session({ id: 'new-session', player: 'mpv' }))).toBe(true); +expect(state.observe(session({ id: 'new-session', player: 'mpv' }))).toBe( + false +); +expect( + state.confirm(intent!, session({ id: 'new-session', player: 'mpv' })) +).toBe(true); expect(state.target('mpv').status).toBe('started'); ``` @@ -84,11 +89,14 @@ export class ExternalPlaybackRecovery { >; readonly pending: Signal; syncSession(key: string): boolean; - begin( - target: ExternalPlayerName, - previousSessionId: string | null - ): ExternalRecoveryIntent | null; + begin(target: ExternalPlayerName): ExternalRecoveryIntent | null; owns(intent: ExternalRecoveryIntent): boolean; + confirm( + intent: ExternalRecoveryIntent, + session: ExternalPlayerSession + ): boolean; + cancel(intent: ExternalRecoveryIntent): boolean; + close(target: ExternalPlayerName, sessionId: string): boolean; observe(session: ExternalPlayerSession | null): boolean; fail(intent: ExternalRecoveryIntent): boolean; target(target: ExternalPlayerName): ExternalRecoveryTargetState; @@ -97,8 +105,8 @@ export class ExternalPlaybackRecovery { ``` The internal launch timer is 10 seconds, and all ownership tokens are fieldless -`Symbol()` values. Store only target, attempt count, status, ignored prior ID, -and the correlated session ID. +`Symbol()` values. Store only target, attempt count, status, and the exact +session ID returned through the owned host launch promise. - [ ] **Step 4: Run GREEN and refactor** diff --git a/docs/superpowers/specs/2026-08-08-external-playback-launch-feedback-design.md b/docs/superpowers/specs/2026-08-08-external-playback-launch-feedback-design.md index 8d8e5b9b9..8b2782763 100644 --- a/docs/superpowers/specs/2026-08-08-external-playback-launch-feedback-design.md +++ b/docs/superpowers/specs/2026-08-08-external-playback-launch-feedback-design.md @@ -28,21 +28,21 @@ This is the smallest change, but the UI would guess when launching finished and could disagree with the Electron session. It is rejected because the app already has structured launch state. -### 2. Add callbacks to every `PlaybackFallbackRequest` host +### 2. Use only callbacks on every `PlaybackFallbackRequest` host -Every M3U, Xtream, Stalker, and shared host could return its launch promise to -the web-player component. This is explicit but spreads recovery bookkeeping -through many source-owning components and still misses later `playing` and -`error` session updates. +Every M3U, Xtream, Stalker, and shared host could make the launch promise the +only feedback source. This supplies an exact initial session ID, but by itself +still misses later `playing` and `error` session updates. -### 3. Correlate the recovery attempt with the shared external session +### 3. Correlate the owned launch result with the shared external session This is the selected approach. The web-player component keeps source launch -ownership in its existing host output, but observes the app-wide -`PORTAL_EXTERNAL_PLAYBACK.activeSession` signal. A local coordinator accepts -only the next session for the requested target and then only updates for that -exact session ID. No URL, headers, DRM data, credentials, or raw diagnostic -payload enters this ownership state. +ownership in its existing host output. Its credential-free tracker binds the +host's returned launch promise to the current fieldless intent token, and only +that exact result may supply the initial session ID. Later status changes come +from `PORTAL_EXTERNAL_PLAYBACK.activeSession` and must match that ID. This +prevents a delayed timed-out attempt from taking over a retry. No URL, headers, +DRM data, credentials, or raw diagnostic payload enters the ownership state. ## State Model @@ -99,9 +99,11 @@ concise `aria-live="polite"` status text. Before a recovery action opens another external player, it closes the currently tracked live external session through `PORTAL_EXTERNAL_PLAYBACK`. If the session is still live and cannot be closed, the new attempt fails locally -instead of starting a second process. Only after that close settles does the -existing host output emit the new fallback request. MPV and VLC are available -again after the handshake. +instead of starting a second process. The coordinator applies the exact +confirmed close synchronously before emitting the replacement, so coalesced +Angular effects cannot leave stale Playing feedback. If diagnostic ownership +changes while closing, the unlaunched intent is cancelled without reporting a +launch error. MPV and VLC are available again after the handshake. If no matching session arrives within a bounded timeout, the target transitions to `error`. The timeout is feedback for a missing handoff, not evidence about diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts index 413e89105..14341f405 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts @@ -1,5 +1,6 @@ import type { ExternalPlayerName, + ExternalPlayerSession, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import type { ErrorDetails, ErrorTypes } from 'hls.js'; @@ -278,4 +279,7 @@ export interface PlaybackFallbackRequest { readonly player: ExternalPlayerName; readonly playback: ResolvedPortalPlayback; readonly diagnostic: PlaybackDiagnostic; + readonly trackLaunch: ( + launch: Promise + ) => void; } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index cb3bdc3f4..d3baac354 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -582,9 +582,13 @@ describe('VideoPlayerComponent', () => { Origin: 'https://origin.example.com', }, }; + const launch = Promise.resolve(); + const trackLaunch = jest.fn(); + dataServiceMock.sendIpcEvent.mockReturnValueOnce(launch); component.handleExternalFallbackRequest({ player: 'mpv', playback, + trackLaunch, diagnostic: { code: 'unsupported-codec', source: 'hls', @@ -605,6 +609,7 @@ describe('VideoPlayerComponent', () => { origin: 'https://origin.example.com', } ); + expect(trackLaunch).toHaveBeenCalledWith(launch); }); it('renders the embedded mpv inline player with the EPG panel', () => { 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 b014929ae..25daa1a37 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 @@ -1083,10 +1083,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return; } - this.dataService.sendIpcEvent( - request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER, - payload + const launch = Promise.resolve( + this.dataService.sendIpcEvent( + request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER, + payload + ) ); + request.trackLaunch(launch); + void launch; } private toLiveEpgPanelSummary( 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 0eb895fb0..f43570e82 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 @@ -611,6 +611,7 @@ describe('UnifiedLiveTabComponent', () => { webPlayer.externalFallbackRequested.emit({ player: 'mpv', playback, + trackLaunch: jest.fn(), diagnostic: { code: 'network-error', player: 'videojs', diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 820757a50..974d7c490 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -537,10 +537,12 @@ export class UnifiedLiveTabComponent { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } onClose(): void { diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 804e3d111..1d34094a9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -267,10 +267,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } async onVodDownload(item: VodDetailsItem): Promise { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 8aee00df1..ebc891a79 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -543,6 +543,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { const request: PlaybackFallbackRequest = { player: 'vlc', playback, + trackLaunch: jest.fn(), diagnostic: { code: 'network-error', player: 'html5', @@ -563,6 +564,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe( playback ); + expect(request.trackLaunch).toHaveBeenCalledWith( + portalPlayer.openExternalPlayback.mock.results[0].value + ); }); it('renders radio channel rows with compact no-EPG density', () => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index ac570060d..d008c1add 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -1394,9 +1394,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index b85af5472..bd63f1b9e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -900,10 +900,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } playPreviousEpisode(): void { diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index 2d477d356..94c28bd8e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -153,9 +153,11 @@ export class StalkerVodPlaybackController { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.config.portalPlayer.openExternalPlayback( + const launch = this.config.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 36e6479f6..8f258efef 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -625,6 +625,7 @@ describe('LiveStreamLayoutComponent', () => { component.handleExternalFallbackRequest({ player: 'mpv', playback, + trackLaunch: jest.fn(), } as PlaybackFallbackRequest); const [forwardedPlayback, forwardedPlayer] = diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 4833d8835..5c91afc36 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -565,10 +565,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } private getAllLiveStreams(): XtreamLiveChannelItem[] { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 134a8378e..e115cda1c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -295,12 +295,14 @@ export class SerialDetailsPlaybackService { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + const launch = this.portalPlayer.openExternalPlayback( + request.playback, + request.player + ); + request.trackLaunch(launch); void this.playbackPositionState.recordExternalLaunch( request.playback, - this.portalPlayer.openExternalPlayback( - request.playback, - request.player - ), + launch, (playlistId, position) => this.playbackPositions.savePlaybackPosition( playlistId, 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 edaea4051..d04a17720 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 @@ -776,8 +776,10 @@ describe('SerialDetailsComponent', () => { const playbackService = fixture.debugElement.injector.get( SerialDetailsPlaybackService ); + const trackLaunch = jest.fn(); playbackService.handleExternalFallbackRequest({ player: 'mpv', + trackLaunch, playback: { streamUrl: 'http://xtream.example/series/2001.mp4', title: 'Season 2 Episode 1', @@ -795,6 +797,9 @@ describe('SerialDetailsComponent', () => { await fixture.whenStable(); expect(openExternalPlayback).toHaveBeenCalledTimes(1); + expect(trackLaunch).toHaveBeenCalledWith( + openExternalPlayback.mock.results[0].value + ); expect(savePlaybackPosition).toHaveBeenCalledWith( 'xtream-1', expect.objectContaining({ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index eef77a66c..7832d3af3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -324,10 +324,12 @@ export class VodDetailsPlaybackService { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void launch; } async loadPosition(playlistId: string, vodId: number): Promise { diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts index 253836275..81eb48528 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts @@ -4,6 +4,11 @@ import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator'; describe('ExternalPlaybackRecoveryCoordinator', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); it('does not launch beside a replacement live session that appears during close', async () => { const previous = session({ id: 'previous', player: 'mpv' }); const activeSession = signal(previous); @@ -24,7 +29,7 @@ describe('ExternalPlaybackRecoveryCoordinator', () => { externalPlayback ); coordinator.syncSession('content-a'); - const ready = jest.fn(); + const ready = jest.fn(() => true); coordinator.request('vlc', jest.fn(), ready); activeSession.set(session({ id: 'replacement', player: 'mpv' })); @@ -35,6 +40,120 @@ describe('ExternalPlaybackRecoveryCoordinator', () => { expect(coordinator.states().vlc.status).toBe('error'); coordinator.destroy(); }); + + it('applies the confirmed old-session close before launching a replacement', async () => { + const previous = session({ id: 'previous', player: 'mpv' }); + const activeSession = signal(null); + const closeSession = jest.fn(async () => { + activeSession.set( + session({ + id: 'previous', + player: 'mpv', + status: 'closed', + canClose: false, + }) + ); + }); + const coordinator = new ExternalPlaybackRecoveryCoordinator({ + activeSession, + visibleSession: activeSession, + closeSession, + dismissActiveSession: jest.fn(), + }); + coordinator.syncSession('content-a'); + let trackFirst: + | ((launch: Promise) => void) + | undefined; + coordinator.request('mpv', jest.fn(), (trackLaunch) => { + trackFirst = trackLaunch; + return true; + }); + trackFirst?.(Promise.resolve(previous)); + await Promise.resolve(); + activeSession.set(previous); + + let stateAtReady: string | undefined; + coordinator.request('vlc', jest.fn(), () => { + stateAtReady = coordinator.states().mpv.status; + return true; + }); + await Promise.resolve(); + + expect(stateAtReady).toBe('idle'); + coordinator.destroy(); + }); + + it('cancels an intent when launch ownership disappears during close', async () => { + const previous = session({ id: 'previous', player: 'mpv' }); + const activeSession = signal(previous); + let releaseClose: (() => void) | undefined; + const coordinator = new ExternalPlaybackRecoveryCoordinator({ + activeSession, + visibleSession: activeSession, + closeSession: jest.fn( + () => + new Promise((resolve) => { + releaseClose = () => { + activeSession.set( + session({ + ...previous, + status: 'closed', + canClose: false, + }) + ); + resolve(); + }; + }) + ), + dismissActiveSession: jest.fn(), + }); + coordinator.syncSession('content-a'); + + coordinator.request('vlc', jest.fn(), () => false); + releaseClose?.(); + await Promise.resolve(); + + expect(coordinator.pending()).toBe(false); + expect(coordinator.states().vlc.status).toBe('idle'); + coordinator.destroy(); + }); + + it('correlates only the exact promise bound to the current attempt', async () => { + const activeSession = signal(null); + const coordinator = new ExternalPlaybackRecoveryCoordinator({ + activeSession, + visibleSession: activeSession, + closeSession: jest.fn(), + dismissActiveSession: jest.fn(), + }); + coordinator.syncSession('content-a'); + let firstTracker: + | ((launch: Promise) => void) + | undefined; + coordinator.request('mpv', jest.fn(), (trackLaunch) => { + firstTracker = trackLaunch; + return true; + }); + jest.advanceTimersByTime(10_000); + let retryTracker: + | ((launch: Promise) => void) + | undefined; + coordinator.request('mpv', jest.fn(), (trackLaunch) => { + retryTracker = trackLaunch; + return true; + }); + + firstTracker?.( + Promise.resolve(session({ id: 'late-first', player: 'mpv' })) + ); + retryTracker?.( + Promise.resolve(session({ id: 'exact-retry', player: 'mpv' })) + ); + await Promise.resolve(); + + expect(coordinator.states().mpv.sessionId).toBe('exact-retry'); + coordinator.destroy(); + }); }); function session( diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts index 376f41dcd..a6499858b 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts @@ -6,6 +6,9 @@ import { } from './external-playback-recovery'; export type ExternalRecoveryTarget = 'mpv' | 'vlc'; +export type ExternalPlaybackLaunchTracker = ( + launch: Promise +) => void; /** * Serializes managed external-player recovery without retaining playback data. @@ -33,17 +36,17 @@ export class ExternalPlaybackRecoveryCoordinator { request( target: ExternalRecoveryTarget, onBegin: () => void, - onReady: () => void + onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean ): void { const activeSession = this.externalPlayback?.activeSession() ?? null; - const intent = this.recovery.begin(target, activeSession?.id ?? null); + const intent = this.recovery.begin(target); if (!intent) { return; } onBegin(); if (!isLiveExternalSession(activeSession)) { - onReady(); + this.runReady(intent, onReady); return; } if (!activeSession.canClose || !this.externalPlayback) { @@ -61,7 +64,7 @@ export class ExternalPlaybackRecoveryCoordinator { private async closeThenRun( session: ExternalPlayerSession, intent: ExternalRecoveryIntent, - onReady: () => void + onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean ): Promise { try { await this.externalPlayback?.closeSession(session); @@ -69,13 +72,37 @@ export class ExternalPlaybackRecoveryCoordinator { this.recovery.fail(intent); return; } + this.recovery.close(session.player, session.id); const current = this.externalPlayback?.activeSession() ?? null; if (isLiveExternalSession(current)) { this.recovery.fail(intent); return; } - if (this.recovery.owns(intent)) { - onReady(); + this.runReady(intent, onReady); + } + + private runReady( + intent: ExternalRecoveryIntent, + onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean + ): void { + if (!this.recovery.owns(intent)) { + return; + } + + const accepted = onReady((launch) => { + void launch.then( + (session) => { + if (session) { + this.recovery.confirm(intent, session); + } else { + this.recovery.fail(intent); + } + }, + () => this.recovery.fail(intent) + ); + }); + if (!accepted) { + this.recovery.cancel(intent); } } } diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts index 26e61df7f..b5de7bfd0 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts @@ -31,7 +31,7 @@ describe('ExternalPlaybackRecovery', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - const intent = recovery.begin('mpv', 'old-session'); + const intent = recovery.begin('mpv'); expect(intent).not.toBeNull(); expect(recovery.pending()).toBe(true); @@ -40,14 +40,14 @@ describe('ExternalPlaybackRecovery', () => { sessionId: null, status: 'launching', }); - expect(recovery.begin('vlc', 'old-session')).toBeNull(); + expect(recovery.begin('vlc')).toBeNull(); recovery.destroy(); }); - it('correlates only the next matching target and then requires its exact id', () => { + it('requires the exact owned launch result before accepting session updates', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', 'old-session'); + const intent = requireIntent(recovery.begin('mpv')); expect( recovery.observe(session({ id: 'old-session', status: 'playing' })) @@ -61,6 +61,12 @@ describe('ExternalPlaybackRecovery', () => { recovery.observe( session({ id: 'mpv-session', player: 'mpv', status: 'opened' }) ) + ).toBe(false); + expect( + recovery.confirm( + intent, + session({ id: 'mpv-session', player: 'mpv', status: 'opened' }) + ) ).toBe(true); expect(recovery.target('mpv')).toEqual({ attempts: 1, @@ -84,8 +90,11 @@ describe('ExternalPlaybackRecovery', () => { ] as const)('maps exact session %s to %s', (sessionStatus, stateStatus) => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); - recovery.observe(session({ id: 'mpv-session', status: 'opened' })); + const intent = requireIntent(recovery.begin('mpv')); + recovery.confirm( + intent, + session({ id: 'mpv-session', status: 'opened' }) + ); expect( recovery.observe( @@ -99,8 +108,11 @@ describe('ExternalPlaybackRecovery', () => { it('returns a closed exact session to idle while retaining attempt history', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); - recovery.observe(session({ id: 'mpv-session', status: 'opened' })); + const intent = requireIntent(recovery.begin('mpv')); + recovery.confirm( + intent, + session({ id: 'mpv-session', status: 'opened' }) + ); expect( recovery.observe( @@ -122,7 +134,7 @@ describe('ExternalPlaybackRecovery', () => { it('times out a missing launch handoff without accepting its later session', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); + recovery.begin('mpv'); jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS); @@ -137,8 +149,9 @@ describe('ExternalPlaybackRecovery', () => { it('keeps an exact launching session correlated after the local timeout', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); - recovery.observe( + const intent = requireIntent(recovery.begin('mpv')); + recovery.confirm( + intent, session({ id: 'slow-mpv', player: 'mpv', status: 'launching' }) ); @@ -161,7 +174,7 @@ describe('ExternalPlaybackRecovery', () => { it('invalidates an old intent and timer when the content session changes', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - const intent = recovery.begin('mpv', null); + const intent = recovery.begin('mpv'); expect(intent).not.toBeNull(); recovery.syncSession('content-b'); @@ -183,7 +196,7 @@ describe('ExternalPlaybackRecovery', () => { it('clears the launch timer and ownership when destroyed', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); + recovery.begin('mpv'); recovery.destroy(); jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS); @@ -196,11 +209,11 @@ describe('ExternalPlaybackRecovery', () => { it('fails only the current intent and counts a later retry', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - const first = requireIntent(recovery.begin('mpv', null)); + const first = requireIntent(recovery.begin('mpv')); expect(recovery.fail(first)).toBe(true); expect(recovery.fail(first)).toBe(false); - const second = requireIntent(recovery.begin('mpv', null)); + const second = requireIntent(recovery.begin('mpv')); expect(recovery.target('mpv')).toEqual({ attempts: 2, @@ -211,12 +224,60 @@ describe('ExternalPlaybackRecovery', () => { recovery.destroy(); }); + it('rejects a late result from a timed-out attempt while accepting the retry result', () => { + const recovery = new ExternalPlaybackRecovery(); + recovery.syncSession('content-a'); + const first = requireIntent(recovery.begin('mpv')); + jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS); + const retry = requireIntent(recovery.begin('mpv')); + + expect( + recovery.confirm( + first, + session({ id: 'late-first', status: 'opened' }) + ) + ).toBe(false); + expect( + recovery.observe(session({ id: 'late-first', status: 'playing' })) + ).toBe(false); + expect( + recovery.confirm( + retry, + session({ id: 'exact-retry', status: 'opened' }) + ) + ).toBe(true); + expect(recovery.target('mpv')).toEqual({ + attempts: 2, + sessionId: 'exact-retry', + status: 'started', + }); + recovery.destroy(); + }); + + it('cancels an unlaunched current intent without reporting an error', () => { + const recovery = new ExternalPlaybackRecovery(); + recovery.syncSession('content-a'); + const intent = requireIntent(recovery.begin('vlc')); + + expect(recovery.cancel(intent)).toBe(true); + expect(recovery.pending()).toBe(false); + expect(recovery.target('vlc')).toEqual({ + attempts: 1, + sessionId: null, + status: 'idle', + }); + recovery.destroy(); + }); + it('updates the previous exact session without cancelling a different target intent', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); - recovery.observe(session({ id: 'mpv-session', status: 'opened' })); - const vlcIntent = requireIntent(recovery.begin('vlc', 'mpv-session')); + const mpvIntent = requireIntent(recovery.begin('mpv')); + recovery.confirm( + mpvIntent, + session({ id: 'mpv-session', status: 'opened' }) + ); + const vlcIntent = requireIntent(recovery.begin('vlc')); expect( recovery.observe( @@ -233,11 +294,31 @@ describe('ExternalPlaybackRecovery', () => { recovery.destroy(); }); + it('applies a confirmed exact close even when a reactive observer coalesces it', () => { + const recovery = new ExternalPlaybackRecovery(); + recovery.syncSession('content-a'); + const mpvIntent = requireIntent(recovery.begin('mpv')); + recovery.confirm( + mpvIntent, + session({ id: 'mpv-session', status: 'opened' }) + ); + + expect(recovery.close('mpv', 'mpv-session')).toBe(true); + expect(recovery.target('mpv')).toEqual({ + attempts: 1, + sessionId: null, + status: 'idle', + }); + expect(recovery.close('mpv', 'other-session')).toBe(false); + recovery.destroy(); + }); + it('stores no stream URL, headers, credentials, title, or error text', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); - recovery.begin('mpv', null); - recovery.observe( + const intent = requireIntent(recovery.begin('mpv')); + recovery.confirm( + intent, session({ error: 'token=private at /Users/example/player', headers: undefined, diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts index 001c7363d..d62a64812 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts @@ -25,7 +25,6 @@ export type ExternalRecoveryStates = Readonly< >; interface ActiveIntent extends ExternalRecoveryIntent { - readonly ignoredSessionId: string | null; readonly sessionId: string | null; } @@ -59,17 +58,13 @@ export class ExternalPlaybackRecovery { return true; } - begin( - target: ExternalPlayerName, - previousSessionId: string | null - ): ExternalRecoveryIntent | null { + begin(target: ExternalPlayerName): ExternalRecoveryIntent | null { if (this.activeIntentState() !== null) { return null; } const token = Symbol(); const intent: ActiveIntent = Object.freeze({ - ignoredSessionId: previousSessionId, sessionId: null, target, token, @@ -108,15 +103,7 @@ export class ExternalPlaybackRecovery { const active = this.activeIntentState(); if (active) { if (active.sessionId === null) { - if ( - session.player !== active.target || - session.id === active.ignoredSessionId - ) { - return false; - } - this.activeIntentState.set( - Object.freeze({ ...active, sessionId: session.id }) - ); + return false; } else if (session.id !== active.sessionId) { return false; } @@ -130,6 +117,54 @@ export class ExternalPlaybackRecovery { : false; } + confirm( + intent: ExternalRecoveryIntent, + session: ExternalPlayerSession + ): boolean { + if (!this.owns(intent) || session.player !== intent.target) { + return false; + } + + const active = this.activeIntentState(); + if (!active) { + return false; + } + this.activeIntentState.set( + Object.freeze({ ...active, sessionId: session.id }) + ); + return this.applySession(intent.target, session); + } + + cancel(intent: ExternalRecoveryIntent): boolean { + if (!this.owns(intent)) { + return false; + } + + const current = this.target(intent.target); + if (current.status === 'launching') { + this.setTarget(intent.target, { + attempts: current.attempts, + sessionId: null, + status: 'idle', + }); + } + this.clearActiveIntent(); + return true; + } + + close(target: ExternalPlayerName, sessionId: string): boolean { + const current = this.target(target); + if (current.sessionId !== sessionId) { + return false; + } + this.setTarget(target, { + attempts: current.attempts, + sessionId: null, + status: 'idle', + }); + return true; + } + fail(intent: ExternalRecoveryIntent): boolean { if (!this.owns(intent)) { return false; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts index 9ca4ae272..5e0187b5a 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts @@ -16,6 +16,7 @@ import { PlaybackDiagnosticCode, PlaybackDiagnosticSource, type PlaybackDiagnostic, + type PlaybackFallbackRequest, } from '@iptvnator/playback/util'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { @@ -133,7 +134,7 @@ describe('WebPlayerViewComponent external recovery integration', () => { afterEach(() => fixture.destroy()); it('keeps both external actions mounted while MPV launches and after it starts', async () => { - const requests: unknown[] = []; + const requests: PlaybackFallbackRequest[] = []; component.externalFallbackRequested.subscribe((request) => requests.push(request) ); @@ -155,9 +156,10 @@ describe('WebPlayerViewComponent external recovery integration', () => { expect(requiredButton('playback-fallback-mpv')).toBe(mpvButton); expect(requiredButton('playback-fallback-vlc')).toBe(vlcButton); - activeSession.set( - externalSession({ id: 'mpv-session', status: 'opened' }) - ); + const opened = externalSession({ id: 'mpv-session', status: 'opened' }); + activeSession.set(opened); + requests[0].trackLaunch(Promise.resolve(opened)); + await Promise.resolve(); fixture.detectChanges(); expect(component.externalRecoveryPending()).toBe(false); @@ -174,15 +176,16 @@ describe('WebPlayerViewComponent external recovery integration', () => { }); it('closes an active external player before emitting a different target', async () => { - const requests: Array<{ player: string }> = []; + const requests: PlaybackFallbackRequest[] = []; component.externalFallbackRequested.subscribe((request) => requests.push(request) ); await showDiagnostic(); click('playback-fallback-mpv'); - activeSession.set( - externalSession({ id: 'mpv-session', status: 'opened' }) - ); + const opened = externalSession({ id: 'mpv-session', status: 'opened' }); + activeSession.set(opened); + requests[0].trackLaunch(Promise.resolve(opened)); + await Promise.resolve(); fixture.detectChanges(); click('playback-fallback-vlc'); @@ -194,6 +197,48 @@ describe('WebPlayerViewComponent external recovery integration', () => { expect(requests.map(({ player }) => player)).toEqual(['mpv', 'vlc']); }); + it('cancels the pending replacement when the diagnostic clears during close', async () => { + const requests: PlaybackFallbackRequest[] = []; + component.externalFallbackRequested.subscribe((request) => + requests.push(request) + ); + await showDiagnostic(); + click('playback-fallback-mpv'); + const opened = externalSession({ id: 'mpv-session', status: 'opened' }); + activeSession.set(opened); + requests[0].trackLaunch(Promise.resolve(opened)); + await Promise.resolve(); + fixture.detectChanges(); + let releaseClose: (() => void) | undefined; + closeSession.mockImplementationOnce( + (session) => + new Promise((resolve) => { + releaseClose = () => { + activeSession.set({ + ...session, + status: 'closed', + canClose: false, + }); + resolve(); + }; + }) + ); + + click('playback-fallback-vlc'); + vjs().playbackIssue.emit(null); + fixture.detectChanges(); + releaseClose?.(); + await fixture.whenStable(); + + expect(requests.map(({ player }) => player)).toEqual(['mpv']); + expect(component.externalRecoveryPending()).toBe(false); + expect(component.externalRecoveryState().vlc).toEqual({ + attempts: 1, + sessionId: null, + status: 'idle', + }); + }); + it('reports a local failure instead of launching beside an unclosable session', async () => { activeSession.set( externalSession({ @@ -242,7 +287,7 @@ describe('WebPlayerViewComponent external recovery integration', () => { ])( 'emits one exact $target request for a same-tick double activation', async ({ target, testId }) => { - const requests: unknown[] = []; + const requests: PlaybackFallbackRequest[] = []; component.externalFallbackRequested.subscribe((request) => requests.push(request) ); @@ -257,6 +302,7 @@ describe('WebPlayerViewComponent external recovery integration', () => { player: target, playback: component.resolvedPlayback(), diagnostic: issue, + trackLaunch: expect.any(Function), }, ]); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts index a1115ac8b..1679a54f9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts @@ -16,6 +16,7 @@ import { PlaybackDiagnosticCode, PlaybackDiagnosticSource, type PlaybackDiagnostic, + type PlaybackFallbackRequest, } from '@iptvnator/playback/util'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { @@ -200,7 +201,7 @@ describe('WebPlayerViewComponent recovery integration', () => { }); it('retries the active target with a reload while preserving attempts', async () => { - const fallbackRequests: unknown[] = []; + const fallbackRequests: PlaybackFallbackRequest[] = []; component.externalFallbackRequested.subscribe((request) => fallbackRequests.push(request) ); @@ -209,9 +210,13 @@ describe('WebPlayerViewComponent recovery integration', () => { fixture.detectChanges(); click('playback-fallback-mpv'); expect(fallbackRequests).toHaveLength(1); - activeExternalSession.set( - externalSession({ id: 'mpv-retry-session', status: 'opened' }) - ); + const opened = externalSession({ + id: 'mpv-retry-session', + status: 'opened', + }); + activeExternalSession.set(opened); + fallbackRequests[0].trackLaunch(Promise.resolve(opened)); + await Promise.resolve(); fixture.detectChanges(); vjs().playbackIssue.emit(networkIssue('videojs')); fixture.detectChanges(); @@ -234,24 +239,33 @@ describe('WebPlayerViewComponent recovery integration', () => { }); it('keeps desktop browser-access guidance and both actions after both attempts', async () => { + const fallbackRequests: PlaybackFallbackRequest[] = []; + component.externalFallbackRequested.subscribe((request) => + fallbackRequests.push(request) + ); await render(); vjs().playbackIssue.emit(browserAccessIssue('videojs')); fixture.detectChanges(); click('playback-fallback-mpv'); - activeExternalSession.set( - externalSession({ id: 'mpv-browser-session', status: 'opened' }) - ); + const openedMpv = externalSession({ + id: 'mpv-browser-session', + status: 'opened', + }); + activeExternalSession.set(openedMpv); + fallbackRequests[0].trackLaunch(Promise.resolve(openedMpv)); + await Promise.resolve(); fixture.detectChanges(); click('playback-fallback-vlc'); await fixture.whenStable(); - activeExternalSession.set( - externalSession({ - id: 'vlc-browser-session', - player: 'vlc', - status: 'opened', - }) - ); + const openedVlc = externalSession({ + id: 'vlc-browser-session', + player: 'vlc', + status: 'opened', + }); + activeExternalSession.set(openedVlc); + fallbackRequests[1].trackLaunch(Promise.resolve(openedVlc)); + await Promise.resolve(); fixture.detectChanges(); const banner = query('playback-diagnostic-banner'); 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 f0207a238..693ca8f83 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 @@ -66,9 +66,8 @@ import { function resolveWebPlayerSharedControls(): boolean { const storedValue = inject(SettingsStore).webPlayerSharedControls?.(); - return typeof storedValue === 'boolean' - ? storedValue - : WEB_PLAYER_SHARED_CONTROLS_ENABLED; + const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED; + return typeof storedValue === 'boolean' ? storedValue : fallback; } @Component({ @@ -383,13 +382,17 @@ export class WebPlayerViewComponent implements OnDestroy { this.externalRecovery.request( target, () => this.recoverySession.recordExternalAttempt(target), - () => { - if (this.visiblePlaybackDiagnostic() !== diagnostic) return; + (trackLaunch) => { + if (this.visiblePlaybackDiagnostic() !== diagnostic) { + return false; + } this.externalFallbackRequested.emit({ player: target, playback: this.resolvedPlayback(), diagnostic, + trackLaunch, }); + return true; } ); return;