From d73acd6bfcafed3b3296f46daede54b4e83dfb60 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:33:07 +0200 Subject: [PATCH] fix(playback): clarify external player launch feedback (#1388) --- .changes/playback-external-launch-feedback.md | 6 + AGENTS.md | 85 +- CLAUDE.md | 87 +- .../src/dash-clearkey.e2e.ts | 179 ++- .../events/external-player-process.spec.ts | 150 +++ .../src/app/events/external-player-process.ts | 189 +++ .../external-player-session-registry.spec.ts | 149 ++- .../external-player-session-registry.ts | 83 +- .../src/app/events/mpv-reusable-process.ts | 325 ++++++ .../app/events/mpv-session.service.spec.ts | 984 +++++++++++++++- .../src/app/events/mpv-session.service.ts | 270 +++-- .../electron-backend/src/app/events/vlc-rc.ts | 167 +++ .../src/app/events/vlc-reusable-process.ts | 257 +++++ .../vlc-session.service.lifecycle.spec.ts | 1012 ++++++++++++++++- .../src/app/events/vlc-session.service.ts | 460 ++++---- .../external-playback.service.spec.ts | 56 +- .../app/services/external-playback.service.ts | 27 +- apps/web/src/assets/i18n/ar.json | 16 + apps/web/src/assets/i18n/ary.json | 16 + apps/web/src/assets/i18n/by.json | 16 + apps/web/src/assets/i18n/de.json | 16 + apps/web/src/assets/i18n/el.json | 16 + apps/web/src/assets/i18n/en.json | 16 + apps/web/src/assets/i18n/es.json | 16 + apps/web/src/assets/i18n/fr.json | 16 + apps/web/src/assets/i18n/hu.json | 16 + apps/web/src/assets/i18n/it.json | 16 + apps/web/src/assets/i18n/ja.json | 16 + apps/web/src/assets/i18n/ko.json | 16 + apps/web/src/assets/i18n/nl.json | 16 + apps/web/src/assets/i18n/pl.json | 16 + apps/web/src/assets/i18n/pt.json | 16 + apps/web/src/assets/i18n/ru.json | 16 + apps/web/src/assets/i18n/tr.json | 16 + apps/web/src/assets/i18n/zh.json | 16 + apps/web/src/assets/i18n/zhtw.json | 16 + docs/architecture/embedded-inline-playback.md | 116 +- docs/architecture/vod-multi-source.md | 63 +- ...08-08-external-playback-launch-feedback.md | 492 ++++++++ ...xternal-playback-launch-feedback-design.md | 172 +++ .../diagnostics/playback-diagnostics.model.ts | 4 + .../video-player.component.spec.ts | 42 +- .../video-player/video-player.component.ts | 18 +- .../unified-live-tab.component.spec.ts | 1 + .../unified-live-tab.component.ts | 4 +- .../external-playback-button-state.spec.ts | 47 +- .../src/lib/external-playback-button-state.ts | 27 +- .../stalker-catalog-detail.component.ts | 4 +- ...alker-live-stream-layout.component.spec.ts | 5 +- .../stalker-live-stream-layout.component.ts | 4 +- .../stalker-series-view.component.ts | 7 +- .../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 | 13 +- .../serial-details.component.spec.ts | 5 + .../vod-details-external-launch-owner.ts | 88 ++ .../vod-details-external-launch.ts | 60 + .../vod-details-external-playback.spec.ts | 366 +++++- .../vod-details-external-session.ts | 31 +- .../vod-details-playback.service.spec.ts | 149 +++ .../vod-details-playback.service.ts | 188 ++- .../vod-details-route-caption.spec.ts | 19 +- .../vod-details-route-playback.spec.ts | 132 ++- .../vod-details-route.component.html | 2 + .../vod-details-route.component.ts | 66 +- .../vod-details/vod-details-route.harness.ts | 3 + .../vod-multi-source-host-pin-writes.spec.ts | 10 +- .../vod-multi-source-host-races.spec.ts | 32 +- .../vod-multi-source-host-session.spec.ts | 10 +- .../vod-multi-source-host.fixtures.ts | 11 +- .../vod-multi-source-host.service.spec.ts | 64 +- .../vod-multi-source-host.service.ts | 28 +- .../vod-details/vod-multi-source-session.ts | 18 +- .../lib/external-player-session.interface.ts | 8 +- .../external-playback-dock.component.html | 77 +- .../external-playback-dock.component.scss | 63 +- .../external-playback-dock.component.spec.ts | 89 +- .../external-playback-dock.component.ts | 26 +- .../playback-diagnostic-panel.component.html | 37 +- .../playback-diagnostic-panel.component.scss | 25 +- ...layback-diagnostic-panel.component.spec.ts | 90 +- .../playback-diagnostic-panel.component.ts | 47 +- .../playback-recommendation-view.util.spec.ts | 70 ++ .../playback-recommendation-view.util.ts | 72 +- .../lib/vod-details/vod-details.component.ts | 6 +- ...rnal-playback-recovery-coordinator.spec.ts | 205 ++++ .../external-playback-recovery-coordinator.ts | 119 ++ .../external-playback-recovery.spec.ts | 345 ++++++ .../external-playback-recovery.ts | 249 ++++ .../web-player-application-ownership.ts | 33 + .../web-player-recovery-policy.spec.ts | 169 +++ .../web-player-recovery-policy.ts | 132 ++- ...r-view.component.external-recovery.spec.ts | 415 +++++++ .../web-player-view.component.html | 1 + ...web-player-view.component.recovery.spec.ts | 143 +-- .../web-player-view.component.ts | 100 +- .../services/workspace-shell.facade.spec.ts | 21 + .../services/workspace-shell.facade.ts | 19 +- .../workspace-shell.component.html | 1 + .../workspace-shell.component.spec.ts | 7 + 101 files changed, 8819 insertions(+), 850 deletions(-) create mode 100644 .changes/playback-external-launch-feedback.md create mode 100644 apps/electron-backend/src/app/events/external-player-process.spec.ts create mode 100644 apps/electron-backend/src/app/events/external-player-process.ts create mode 100644 apps/electron-backend/src/app/events/mpv-reusable-process.ts create mode 100644 apps/electron-backend/src/app/events/vlc-rc.ts create mode 100644 apps/electron-backend/src/app/events/vlc-reusable-process.ts create mode 100644 docs/superpowers/plans/2026-08-08-external-playback-launch-feedback.md create mode 100644 docs/superpowers/specs/2026-08-08-external-playback-launch-feedback-design.md create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch-owner.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-application-ownership.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts diff --git a/.changes/playback-external-launch-feedback.md b/.changes/playback-external-launch-feedback.md new file mode 100644 index 000000000..fb8d5ec28 --- /dev/null +++ b/.changes/playback-external-launch-feedback.md @@ -0,0 +1,6 @@ +--- +type: fix +area: playback +--- + +MPV and VLC recovery actions now stay available, show precise opening, started, playing, and failure feedback, and prevent overlapping launches. The dock keeps Stop available when teardown is still needed and lets terminal errors be dismissed. diff --git a/AGENTS.md b/AGENTS.md index 9b0084ee3..e5d0fd220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,7 +288,90 @@ Key files: exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP DRM suppresses external targets because its payload is not transferable. Raw engine messages, arbitrary data, and credentials never enter recommendation - evidence or ownership state. + 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. The renderer claims the credential-free content identity + before awaiting Electron, so primary Play is disabled and a launching or + closable-error alternative remains owned before the controller commits it. + Every route action that can start the same external playback, including + Restart and the provider-source shortcut, observes that local pre-IPC guard. + The Xtream VOD diagnostic-fallback handler records the same route-scoped + destination and pending generation before invoking MPV/VLC, so route reuse + cannot orphan that process outside the next route's close-before-play path. + 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. Process teardown has bounded graceful and forced confirmation + windows, and reusable MPV bounds the IPC command that precedes them; if any + stage cannot reach a confirmed exit, the exact session stays live and the + replacement fails closed instead of overlapping it. A process-wide teardown + gate starts before any potentially slow teardown preparation, including VLC + position flush and a reused player's protocol quit, and rejects every + MPV/VLC spawn until that exact child reports exit. If bounded + teardown fails while a fresh launch is still pending, that launch IPC rejects + and the exact session remains a closable error instead of hanging forever. + If a pre-content reuse failure has no still-live displaced session to restore, + the replacement error keeps its attached closer so Stop can retry the orphaned + child teardown. A terminal error without a closer is never restorable. + A failed close is single-flight only while its promise is pending: Stop can + retry the same exact child after a bounded confirmation failure. Reuse maps + the child to its current content session, so a stale older closer becomes a + no-op instead of terminating a newer `loadfile`/VLC enqueue handoff. + A duplicate close for an already closed session returns its terminal snapshot + without re-entering the saved closer, and a late process error cannot revive + that terminal session. Reused MPV commands are bound to the socket captured + for that exact child, so a later process cannot inherit a stale protocol quit. + Stop observed before a pending MPV content command or VLC enqueue command + prevents that command from dispatching. A source handoff fails closed while + a live session has no closer (`canClose: false`); renderer Dismiss is not + teardown confirmation. That denied handoff advances neither the multi-source + switch token nor the playback generation, so it cannot cancel the sole launch + already in flight. + VLC rechecks the gate at each concrete spawn after port allocation or reuse + work; if a post-start fallback is blocked there, the opened session becomes + an error rather than retaining a false started status. A failed RC-port + allocation never claims reuse ownership, so the fallback VLC child retains + its exact one-shot closer. + Reuse failures before a content command restore the globally displaced + renderer session, not the reusable process's prior owner, and only while the + exact displaced-session ID is still active; after + `loadfile`/VLC `clear` is dispatched, + the replacement owns the process and remains a closable error instead of + restoring stale content metadata. Stop during an in-flight MPV or VLC reuse + command, including during failed-command teardown or the subsequent VLC + fallback port-allocation wait, settles that exact close without falling + through to a fresh spawn; + a stopped VLC spawn error that reports only `close` also settles its original + launch IPC with the exact closed session; + a fresh fallback retires the old child's exit under its prior session so it + cannot close the replacement. Source handoffs recheck ownership after launch + and accept only `opened`/`playing`; a stale returned session is closed exactly + and a Stop-returned `closed` session is never committed. If that exact stale + close fails, its credential-free destination owner is retained for the next + close attempt. Retained destination ownership is scoped to the initiating + playlist/VOD route key, so route reuse cannot expose Stop for the previous + movie's external session. Play/Resume capture that route key before awaiting + close and cancel if navigation changes it; a late diagnostic fallback closes + its exact returned session instead of adopting it on the new route. They + supersede an older source resolution before awaiting the shared + close-before-replacement path, and accepting a diagnostic fallback retires + the same older resolution before opening MPV/VLC. They publish the route-source + badge, caption evidence, and position only after start succeeds. + Closable errors still participate in every replacement close and keep Stop as + the global dock's only teardown affordance; Dismiss is reserved for terminal + errors that have no closer. The shared `isLiveExternalPlayerSession` predicate + keeps M3U and series ownership while + such an error can still be stopped; consumers must not treat every `error` + status as terminal. + 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 closable errors visible until Stop confirms teardown and + terminal errors visible until dismissal, and intentionally has no retry because + it does not own the original launch headers or credentials. - The built-in HTML5/hls.js player is the second guarded consumer. `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared diff --git a/CLAUDE.md b/CLAUDE.md index 62c2eb44f..1461d33e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -824,7 +824,90 @@ app as a real argument, so it is not an option. exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP DRM suppresses external targets because its payload is not transferable. Raw engine messages, arbitrary data, and credentials never enter recommendation - evidence or ownership state. + 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. The renderer claims the credential-free content identity + before awaiting Electron, so primary Play is disabled and a launching or + closable-error alternative remains owned before the controller commits it. + Every route action that can start the same external playback, including + Restart and the provider-source shortcut, observes that local pre-IPC guard. + The Xtream VOD diagnostic-fallback handler records the same route-scoped + destination and pending generation before invoking MPV/VLC, so route reuse + cannot orphan that process outside the next route's close-before-play path. + 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. Process teardown has bounded graceful and forced confirmation + windows, and reusable MPV bounds the IPC command that precedes them; if any + stage cannot reach a confirmed exit, the exact session stays live and the + replacement fails closed instead of overlapping it. A process-wide teardown + gate starts before any potentially slow teardown preparation, including VLC + position flush and a reused player's protocol quit, and rejects every + MPV/VLC spawn until that exact child reports exit. If bounded + teardown fails while a fresh launch is still pending, that launch IPC rejects + and the exact session remains a closable error instead of hanging forever. + If a pre-content reuse failure has no still-live displaced session to restore, + the replacement error keeps its attached closer so Stop can retry the orphaned + child teardown. A terminal error without a closer is never restorable. + A failed close is single-flight only while its promise is pending: Stop can + retry the same exact child after a bounded confirmation failure. Reuse maps + the child to its current content session, so a stale older closer becomes a + no-op instead of terminating a newer `loadfile`/VLC enqueue handoff. + A duplicate close for an already closed session returns its terminal snapshot + without re-entering the saved closer, and a late process error cannot revive + that terminal session. Reused MPV commands are bound to the socket captured + for that exact child, so a later process cannot inherit a stale protocol quit. + Stop observed before a pending MPV content command or VLC enqueue command + prevents that command from dispatching. A source handoff fails closed while + a live session has no closer (`canClose: false`); renderer Dismiss is not + teardown confirmation. That denied handoff advances neither the multi-source + switch token nor the playback generation, so it cannot cancel the sole launch + already in flight. + VLC rechecks the gate at each concrete spawn after port allocation or reuse + work; if a post-start fallback is blocked there, the opened session becomes + an error rather than retaining a false started status. A failed RC-port + allocation never claims reuse ownership, so the fallback VLC child retains + its exact one-shot closer. + Reuse failures before a content command restore the globally displaced + renderer session, not the reusable process's prior owner, and only while the + exact displaced-session ID is still active; after + `loadfile`/VLC `clear` is dispatched, + the replacement owns the process and remains a closable error instead of + restoring stale content metadata. Stop during an in-flight MPV or VLC reuse + command, including during failed-command teardown or the subsequent VLC + fallback port-allocation wait, settles that exact close without falling + through to a fresh spawn; + a stopped VLC spawn error that reports only `close` also settles its original + launch IPC with the exact closed session; + a fresh fallback retires the old child's exit under its prior session so it + cannot close the replacement. Source handoffs recheck ownership after launch + and accept only `opened`/`playing`; a stale returned session is closed exactly + and a Stop-returned `closed` session is never committed. If that exact stale + close fails, its credential-free destination owner is retained for the next + close attempt. Retained destination ownership is scoped to the initiating + playlist/VOD route key, so route reuse cannot expose Stop for the previous + movie's external session. Play/Resume capture that route key before awaiting + close and cancel if navigation changes it; a late diagnostic fallback closes + its exact returned session instead of adopting it on the new route. They + supersede an older source resolution before awaiting the shared + close-before-replacement path, and accepting a diagnostic fallback retires + the same older resolution before opening MPV/VLC. They publish the route-source + badge, caption evidence, and position only after start succeeds. + Closable errors still participate in every replacement close and keep Stop as + the global dock's only teardown affordance; Dismiss is reserved for terminal + errors that have no closer. The shared `isLiveExternalPlayerSession` predicate + keeps M3U and series ownership while + such an error can still be stopped; consumers must not treat every `error` + status as terminal. + 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 closable errors visible until Stop confirms teardown and + terminal errors visible until dismissal, and intentionally has no retry because + it does not own the original launch headers or credentials. - DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded Shaka Player source engine inside the HTML5 and ArtPlayer components (no new player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*` @@ -1179,7 +1262,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-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index c3ba12807..dae1e6a32 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'fs'; import { basename, join } from 'path'; import type { ExternalPlayerName, + ExternalPlayerSession, PlayerContentInfo, } from '@iptvnator/shared/interfaces'; import { @@ -189,6 +190,7 @@ type CapturedExternalPlayerLaunch = { }; type PlaybackRecommendationCaptureSnapshot = { + closed: string[]; completed: number; launches: CapturedExternalPlayerLaunch[]; released: boolean; @@ -197,6 +199,7 @@ type PlaybackRecommendationCaptureSnapshot = { type PlaybackRecommendationCaptureState = PlaybackRecommendationCaptureSnapshot & { releaseFirstResponse: (() => void) | undefined; + sessions: Record; waitForFirstResponse: Promise; }; @@ -209,10 +212,12 @@ async function installPlaybackRecommendationLaunchCapture( releaseFirstResponse = resolve; }); const state: PlaybackRecommendationCaptureState = { + closed: [], completed: 0, launches: [], released: false, releaseFirstResponse, + sessions: {}, waitForFirstResponse, }; const globalRef = globalThis as typeof globalThis & { @@ -223,7 +228,7 @@ async function installPlaybackRecommendationLaunchCapture( const captureLaunch = (player: ExternalPlayerName) => async ( - _event: unknown, + rawEvent: unknown, url: string, title: string, thumbnail?: string, @@ -248,30 +253,81 @@ async function installPlaybackRecommendationLaunchCapture( ], player, }); + const now = new Date().toISOString(); + const id = `e2e-recommended-${player}-${invocation}`; + const event = rawEvent as { + sender: { + send: ( + channel: string, + session: ExternalPlayerSession + ) => void; + }; + }; + const launching: ExternalPlayerSession = { + canClose: true, + id, + player, + startedAt: now, + status: 'launching', + streamUrl: url, + thumbnail: thumbnail ?? null, + title, + updatedAt: now, + }; + state.sessions[id] = launching; + event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', launching); if (invocation === 1 && !state.released) { await state.waitForFirstResponse; } state.completed += 1; - const now = new Date().toISOString(); - return { - canClose: false, - id: `e2e-recommended-${player}-${invocation}`, + const completed: ExternalPlayerSession = { + ...launching, + canClose: player === 'mpv', + error: + player === 'vlc' + ? 'E2E player launch failed' + : undefined, player, - startedAt: now, - status: 'opened', - streamUrl: url, - thumbnail: thumbnail ?? null, - title, - updatedAt: now, + status: player === 'mpv' ? 'opened' : 'error', + updatedAt: new Date().toISOString(), }; + state.sessions[id] = completed; + event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', completed); + return completed; }; ipcMain.removeHandler('OPEN_MPV_PLAYER'); ipcMain.removeHandler('OPEN_VLC_PLAYER'); ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv')); ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc')); + ipcMain.removeHandler('CLOSE_EXTERNAL_PLAYER_SESSION'); + ipcMain.handle( + 'CLOSE_EXTERNAL_PLAYER_SESSION', + (rawEvent: unknown, sessionId: string) => { + const session = state.sessions[sessionId]; + if (!session) return null; + const closed: ExternalPlayerSession = { + ...session, + canClose: false, + status: 'closed', + updatedAt: new Date().toISOString(), + }; + state.closed.push(sessionId); + state.sessions[sessionId] = closed; + const event = rawEvent as { + sender: { + send: ( + channel: string, + update: ExternalPlayerSession + ) => void; + }; + }; + event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', closed); + return closed; + } + ); }); } @@ -289,6 +345,7 @@ async function getPlaybackRecommendationCapture( ); } return { + closed: capture.closed, completed: capture.completed, launches: capture.launches, released: capture.released, @@ -313,7 +370,7 @@ async function releasePlaybackRecommendationCapture( }); } -test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens in MPV', async ({ +test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({ dataDir, }) => { const fixtureServer = await startDashFixtureServer(); @@ -439,6 +496,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens }, ] satisfies CapturedExternalPlayerLaunch[]; expect(await getPlaybackRecommendationCapture(app)).toEqual({ + closed: [], completed: 0, launches: [], released: false, @@ -450,18 +508,33 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens timeout: 10_000, }) .toEqual({ + closed: [], completed: 0, launches: expectedLaunches, released: false, }); - expect( - (await getPlaybackRecommendationCapture(app)).launches.filter( - ({ player }) => player === 'vlc' - ) - ).toHaveLength(0); - await expect(mpvFallback).toHaveCount(0); - await expect(vlcFallback).toHaveClass( - /web-player-diagnostic__player-card--primary/ + await expect(mpvFallback).toBeVisible(); + await expect(vlcFallback).toBeVisible(); + await expect(mpvFallback).toContainText('Opening MPV'); + await expect(mpvFallback).toContainText('Opening player'); + await expect(mpvFallback).toHaveAttribute('aria-busy', 'true'); + await expect(vlcFallback).toHaveAttribute('aria-disabled', 'true'); + await vlcFallback.evaluate((button: HTMLButtonElement) => + button.click() + ); + await expect + .poll(() => getPlaybackRecommendationCapture(app)) + .toEqual({ + closed: [], + completed: 0, + launches: expectedLaunches, + released: false, + }); + const dock = app.mainWindow.locator('app-external-playback-dock'); + await expect(dock).toContainText('Opening player'); + await expect(dock.locator('.external-playback-dock')).toHaveAttribute( + 'aria-busy', + 'true' ); await releasePlaybackRecommendationCapture(app); @@ -470,6 +543,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens timeout: 10_000, }) .toEqual({ + closed: [], completed: 1, launches: expectedLaunches, released: true, @@ -483,14 +557,77 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens }) ); expect(await getPlaybackRecommendationCapture(app)).toEqual({ + closed: [], completed: 1, launches: expectedLaunches, released: true, }); - await expect(mpvFallback).toHaveCount(0); + await expect(mpvFallback).toBeVisible(); + await expect(mpvFallback).toContainText('Open MPV again'); + await expect(mpvFallback).toContainText('Player started'); await expect(vlcFallback).toHaveClass( /web-player-diagnostic__player-card--primary/ ); + await expect(dock).toContainText('Player started'); + const dockThemeStyles = await dock + .locator('.external-playback-dock') + .evaluate((element) => { + const body = element.ownerDocument.body; + const wasDark = body.classList.contains('dark-theme'); + const read = () => { + const style = getComputedStyle(element); + return { + borderColor: style.borderColor, + color: style.color, + onSurface: style + .getPropertyValue('--app-heading-color') + .trim(), + surface: style + .getPropertyValue('--app-widget-bg') + .trim(), + }; + }; + body.classList.remove('dark-theme'); + const light = read(); + body.classList.add('dark-theme'); + const dark = read(); + body.classList.toggle('dark-theme', wasDark); + return { dark, light }; + }); + expect(dockThemeStyles.light.surface).not.toBe(''); + expect(dockThemeStyles.dark.surface).not.toBe(''); + expect(dockThemeStyles.light.onSurface).not.toBe(''); + expect(dockThemeStyles.dark.onSurface).not.toBe(''); + expect(dockThemeStyles.light).not.toEqual(dockThemeStyles.dark); + + await vlcFallback.click(); + const expectedBothLaunches = [ + ...expectedLaunches, + { ...expectedLaunches[0], player: 'vlc' as const }, + ]; + await expect + .poll(() => getPlaybackRecommendationCapture(app), { + timeout: 10_000, + }) + .toEqual({ + closed: ['e2e-recommended-mpv-1'], + completed: 2, + launches: expectedBothLaunches, + released: true, + }); + await expect(mpvFallback).toBeVisible(); + await expect(vlcFallback).toBeVisible(); + await expect(vlcFallback).toContainText('Try VLC again'); + await expect(vlcFallback).toContainText('External player error'); + await expect(mpvFallback).toHaveClass( + /web-player-diagnostic__player-card--primary/ + ); + await expect(dock).toContainText('E2E player launch failed'); + await expect( + dock.getByRole('button', { name: 'Dismiss' }) + ).toBeVisible(); + await dock.getByRole('button', { name: 'Dismiss' }).click(); + await expect(dock).toBeHidden(); } finally { await releasePlaybackRecommendationCapture(app).catch(() => undefined); await closeElectronApp(app); diff --git a/apps/electron-backend/src/app/events/external-player-process.spec.ts b/apps/electron-backend/src/app/events/external-player-process.spec.ts new file mode 100644 index 000000000..af7d35455 --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-process.spec.ts @@ -0,0 +1,150 @@ +import type { ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import { + ExternalPlayerProcessTeardownGate, + terminateExternalPlayerProcess, + waitForExternalPlayerProcessExit, +} from './external-player-process'; + +function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + exitCode: null, + killed: false, + kill: jest.fn(() => true), + signalCode: null, + stderr: null, + stdout: null, + unref: jest.fn(), + }) as unknown as ChildProcess; +} + +describe('external player process teardown', () => { + it('accepts close as confirmed termination after a spawn error', async () => { + const child = createMockChildProcess(); + child.on('error', () => undefined); + let settled = false; + const exit = waitForExternalPlayerProcessExit(child).then(() => { + settled = true; + }); + + child.emit( + 'error', + Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) + ); + await Promise.resolve(); + expect(settled).toBe(false); + + child.emit('close', -2, null); + + await expect(exit).resolves.toBeUndefined(); + }); + + it('does not treat a process error as confirmed exit', async () => { + const child = createMockChildProcess(); + child.on('error', () => undefined); + let settled = false; + const exit = waitForExternalPlayerProcessExit(child).then(() => { + settled = true; + }); + + child.emit('error', new Error('kill failed')); + await Promise.resolve(); + + expect(settled).toBe(false); + + Object.defineProperty(child, 'exitCode', { value: 0 }); + child.emit('exit', 0); + + await expect(exit).resolves.toBeUndefined(); + }); + + it('escalates teardown and resolves only after confirmed exit', async () => { + jest.useFakeTimers(); + try { + const child = createMockChildProcess(); + const teardown = terminateExternalPlayerProcess(child); + + expect(child.kill).toHaveBeenNthCalledWith(1); + + await jest.advanceTimersByTimeAsync(3_000); + + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); + + child.emit('close', null, 'SIGKILL'); + + await expect(teardown).resolves.toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + it('allows a protocol quit grace period before forced termination', async () => { + jest.useFakeTimers(); + try { + const child = createMockChildProcess(); + const teardown = terminateExternalPlayerProcess(child, { + sendTerminationSignal: false, + }); + + expect(child.kill).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(3_000); + + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + + child.emit('exit', null, 'SIGKILL'); + + await expect(teardown).resolves.toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + it('rejects after a bounded forced-teardown wait', async () => { + jest.useFakeTimers(); + try { + const child = createMockChildProcess(); + const teardown = terminateExternalPlayerProcess(child); + const result = expect(teardown).rejects.toThrow( + 'External player process did not exit' + ); + + await jest.advanceTimersByTimeAsync(5_000); + + await result; + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); + } finally { + jest.useRealTimers(); + } + }); + + it('blocks replacement launches until an unconfirmed child actually exits', async () => { + jest.useFakeTimers(); + try { + const child = createMockChildProcess(); + const gate = new ExternalPlayerProcessTeardownGate(); + const teardown = gate.terminate(child); + const rejection = expect(teardown).rejects.toThrow( + 'External player process did not exit' + ); + + expect(() => gate.assertLaunchAllowed()).toThrow( + 'previous external player is still shutting down' + ); + + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect(() => gate.assertLaunchAllowed()).toThrow( + 'previous external player is still shutting down' + ); + + Object.defineProperty(child, 'exitCode', { value: 0 }); + child.emit('exit', 0); + + expect(() => gate.assertLaunchAllowed()).not.toThrow(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/external-player-process.ts b/apps/electron-backend/src/app/events/external-player-process.ts new file mode 100644 index 000000000..e465994f5 --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-process.ts @@ -0,0 +1,189 @@ +import type { ChildProcess } from 'child_process'; + +const TERMINATION_GRACE_PERIOD_MS = 3_000; +const FORCED_TERMINATION_WAIT_MS = 2_000; + +interface TerminateExternalPlayerProcessOptions { + sendTerminationSignal?: boolean; +} + +const EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR = + 'Cannot launch player because the previous external player is still shutting down'; + +/** + * Serializes replacement launches against exact children whose exit has not + * been confirmed. `ChildProcess.killed` only means a signal was sent, so it is + * deliberately not used as a release condition. + */ +export class ExternalPlayerProcessTeardownGate { + private readonly pending = new Map void>(); + + assertLaunchAllowed(): void { + for (const child of this.pending.keys()) { + if (hasExited(child)) { + this.release(child); + } + } + + if (this.pending.size > 0) { + throw new Error(EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR); + } + } + + /** + * Guard replacement launches before a potentially slow protocol-level + * quit command is dispatched. `terminate()` keeps using the same exact + * child registration once that command completes or fails. + */ + beginTeardown(child: ChildProcess): void { + this.track(child); + } + + async terminate( + child: ChildProcess, + options: TerminateExternalPlayerProcessOptions = {} + ): Promise { + this.track(child); + try { + await terminateExternalPlayerProcess(child, options); + } finally { + if (hasExited(child)) { + this.release(child); + } + } + } + + terminateInBackground(child: ChildProcess): void { + void this.terminate(child).catch(() => { + // The child remains registered until an exact exit/close event. + // A later launch therefore still fails closed. + }); + } + + private track(child: ChildProcess): void { + if (hasExited(child) || this.pending.has(child)) { + return; + } + + const release = () => this.release(child); + this.pending.set(child, release); + child.once('exit', release); + child.once('close', release); + } + + private release(child: ChildProcess): void { + const release = this.pending.get(child); + if (!release) { + return; + } + + child.off('exit', release); + child.off('close', release); + this.pending.delete(child); + } +} + +export const externalPlayerProcessTeardownGate = + new ExternalPlayerProcessTeardownGate(); + +/** + * Resolves only after the exact child has stopped. A sent termination signal + * is not itself proof that a replacement can be started safely. + */ +export function waitForExternalPlayerProcessExit( + child: ChildProcess +): Promise { + if (hasExited(child)) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const complete = () => { + child.off('exit', complete); + child.off('close', complete); + resolve(); + }; + child.once('exit', complete); + child.once('close', complete); + }); +} + +export async function terminateExternalPlayerProcess( + child: ChildProcess, + options: TerminateExternalPlayerProcessOptions = {} +): Promise { + if (hasExited(child)) { + return; + } + + if ( + options.sendTerminationSignal !== false && + !hasExited(child) && + !child.killed + ) { + try { + child.kill(); + } catch { + // The forced termination attempt below still gets a chance to + // confirm that the exact child stopped. + } + } + + if ( + await waitForExternalPlayerProcessExitWithin( + child, + TERMINATION_GRACE_PERIOD_MS + ) + ) { + return; + } + + try { + child.kill('SIGKILL'); + } catch { + // Keep waiting for the bounded confirmation window. If the child does + // not report exit, reject so callers cannot launch a replacement. + } + + if ( + await waitForExternalPlayerProcessExitWithin( + child, + FORCED_TERMINATION_WAIT_MS + ) + ) { + return; + } + + throw new Error('External player process did not exit'); +} + +function waitForExternalPlayerProcessExitWithin( + child: ChildProcess, + timeoutMs: number +): Promise { + if (hasExited(child)) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let timer: ReturnType | null = null; + const complete = (exited: boolean) => { + child.off('exit', onExit); + child.off('close', onExit); + if (timer) { + clearTimeout(timer); + } + resolve(exited); + }; + const onExit = () => complete(true); + + child.once('exit', onExit); + child.once('close', onExit); + timer = setTimeout(() => complete(hasExited(child)), timeoutMs); + timer.unref(); + }); +} + +function hasExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null; +} diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts index f59337017..97a9e2ffa 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts @@ -47,7 +47,27 @@ describe('ExternalPlayerSessionRegistry', () => { expect(registry.getActiveSessionId()).toBeNull(); }); - it('marks the session closed even when the runtime close fails', async () => { + it('does not invoke a stale closer for an already closed session', async () => { + const close = jest.fn(); + const session = registry.beginSession({ + player: 'mpv', + title: 'Closed', + streamUrl: 'https://example.com/closed.m3u8', + }); + registry.attachCloser(session.id, close); + registry.markClosed(session.id); + + const closed = await registry.closeSession(session.id); + + expect(close).not.toHaveBeenCalled(); + expect(closed).toMatchObject({ + id: session.id, + status: 'closed', + canClose: false, + }); + }); + + it('keeps the session live when runtime close cannot be confirmed', async () => { const close = jest.fn().mockRejectedValue(new Error('close failed')); const session = registry.beginSession({ player: 'vlc', @@ -57,12 +77,14 @@ describe('ExternalPlayerSessionRegistry', () => { registry.attachCloser(session.id, close); - const closed = await registry.closeSession(session.id); + await expect(registry.closeSession(session.id)).rejects.toThrow( + 'close failed' + ); expect(close).toHaveBeenCalled(); - expect(closed?.status).toBe('closed'); - expect(closed?.canClose).toBe(false); - expect(registry.getActiveSessionId()).toBeNull(); + expect(registry.getSession(session.id)?.status).toBe('launching'); + expect(registry.getSession(session.id)?.canClose).toBe(true); + expect(registry.getActiveSessionId()).toBe(session.id); }); it('marks runtime failures as errors without clearing the active id', () => { @@ -78,4 +100,121 @@ describe('ExternalPlayerSessionRegistry', () => { expect(errored?.error).toBe('Failed to launch'); expect(registry.getActiveSessionId()).toBe(session.id); }); + + it('does not overwrite terminal status with late lifecycle updates', () => { + const failed = registry.beginSession({ + player: 'mpv', + title: 'Failed', + streamUrl: 'https://example.com/failed.m3u8', + }); + registry.markError(failed.id, 'Failed to launch'); + + expect(registry.markOpened(failed.id)?.status).toBe('error'); + + const closed = registry.beginSession({ + player: 'vlc', + title: 'Closed', + streamUrl: 'https://example.com/closed.m3u8', + }); + registry.markClosed(closed.id); + + expect(registry.markPlaying(closed.id)?.status).toBe('closed'); + expect( + registry.markError(closed.id, 'Late process exit')?.status + ).toBe('closed'); + expect(updates.at(-1)).toMatchObject({ + id: closed.id, + status: 'closed', + }); + }); + + it('keeps confirmed playback ahead of a late opened acknowledgement', () => { + const session = registry.beginSession({ + player: 'mpv', + title: 'Fast playback', + streamUrl: 'https://example.com/fast.m3u8', + }); + + expect(registry.markPlaying(session.id)?.status).toBe('playing'); + expect(registry.markOpened(session.id)?.status).toBe('playing'); + }); + + it('restores a previous live session after a replacement fails', () => { + const previous = registry.beginSession({ + player: 'mpv', + title: 'Previous', + streamUrl: 'https://example.com/previous.m3u8', + }); + registry.attachCloser(previous.id, jest.fn()); + registry.markOpened(previous.id); + const replacement = registry.beginSession({ + player: 'mpv', + title: 'Replacement', + streamUrl: 'https://example.com/replacement.m3u8', + }); + registry.markError(replacement.id, 'teardown unconfirmed'); + + const restored = registry.restoreActiveSession( + previous.id, + replacement.id + ); + + expect(restored).toMatchObject({ + id: previous.id, + status: 'opened', + canClose: true, + }); + expect(registry.getActiveSessionId()).toBe(previous.id); + expect(updates.at(-1)).toMatchObject({ + id: previous.id, + restoredFromSessionId: replacement.id, + }); + }); + + it('does not restore over a newer active session', () => { + const previous = registry.beginSession({ + player: 'mpv', + title: 'Previous', + streamUrl: 'https://example.com/previous.m3u8', + }); + registry.markOpened(previous.id); + const failedReplacement = registry.beginSession({ + player: 'mpv', + title: 'Failed replacement', + streamUrl: 'https://example.com/failed.m3u8', + }); + const newer = registry.beginSession({ + player: 'vlc', + title: 'Newer', + streamUrl: 'https://example.com/newer.m3u8', + }); + const updateCount = updates.length; + + expect( + registry.restoreActiveSession(previous.id, failedReplacement.id) + ).toBeNull(); + expect(registry.getActiveSessionId()).toBe(newer.id); + expect(updates).toHaveLength(updateCount); + }); + + it('does not restore an unclosable terminal error session', () => { + const terminal = registry.beginSession({ + player: 'mpv', + title: 'Terminal failure', + streamUrl: 'https://example.com/terminal.m3u8', + }); + registry.markError(terminal.id, 'No player process remains'); + const replacement = registry.beginSession({ + player: 'vlc', + title: 'Replacement', + streamUrl: 'https://example.com/replacement.m3u8', + }); + const updateCount = updates.length; + + expect( + registry.restoreActiveSession(terminal.id, replacement.id) + ).toBeNull(); + expect(registry.getActiveSessionId()).toBe(replacement.id); + expect(updates).toHaveLength(updateCount); + }); }); diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.ts b/apps/electron-backend/src/app/events/external-player-session-registry.ts index d3d981612..97ce71ce5 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.ts @@ -23,6 +23,19 @@ interface ExternalPlayerSessionRuntime { close?: () => Promise | void; } +interface MarkExternalPlayerSessionErrorOptions { + canClose?: boolean; +} + +function isRestorableSession(session: ExternalPlayerSession): boolean { + return ( + session.status === 'launching' || + session.status === 'opened' || + session.status === 'playing' || + (session.status === 'error' && session.canClose) + ); +} + export class ExternalPlayerSessionRegistry { private readonly sessions = new Map(); private activeSessionId: string | null = null; @@ -62,6 +75,32 @@ export class ExternalPlayerSessionRegistry { return this.sessions.get(id)?.snapshot ?? null; } + /** + * Re-publish the exact still-live session that a failed replacement had + * temporarily displaced. Its existing closer remains attached. + */ + restoreActiveSession( + id: string, + displacedSessionId: string + ): ExternalPlayerSession | null { + const runtime = this.sessions.get(id); + if ( + !runtime || + !isRestorableSession(runtime.snapshot) || + this.activeSessionId !== displacedSessionId + ) { + return null; + } + + this.activeSessionId = id; + this.onUpdate({ + ...runtime.snapshot, + updatedAt: new Date().toISOString(), + restoredFromSessionId: displacedSessionId, + }); + return runtime.snapshot; + } + attachCloser( id: string, close: () => Promise | void @@ -95,12 +134,21 @@ export class ExternalPlayerSessionRegistry { } markOpened(id: string): ExternalPlayerSession | null { + const current = this.getSession(id); + if (!current || current.status !== 'launching') { + return current; + } return this.updateSession(id, { status: 'opened' }); } markPlaying(id: string): ExternalPlayerSession | null { const current = this.getSession(id); - if (!current || current.status === 'playing') { + if ( + !current || + current.status === 'playing' || + current.status === 'error' || + current.status === 'closed' + ) { return current; } @@ -108,17 +156,30 @@ export class ExternalPlayerSessionRegistry { } markClosed(id: string): ExternalPlayerSession | null { + const current = this.getSession(id); + if (!current || current.status === 'closed') { + return current; + } if (this.activeSessionId === id) { this.activeSessionId = null; } return this.updateSession(id, { status: 'closed', canClose: false }); } - markError(id: string, error: string): ExternalPlayerSession | null { + markError( + id: string, + error: string, + options: MarkExternalPlayerSessionErrorOptions = {} + ): ExternalPlayerSession | null { + const current = this.getSession(id); + if (!current || current.status === 'closed') { + return current; + } + return this.updateSession(id, { status: 'error', error, - canClose: false, + canClose: options.canClose ?? false, }); } @@ -128,13 +189,19 @@ export class ExternalPlayerSessionRegistry { return null; } - try { - await runtime.close?.(); - } catch { - // Close failures must not keep the session in a live state; the - // registry still reports it as closed below. + // A renderer can deliver a delayed duplicate Stop after the exact + // child has already exited and a newer external player owns the + // process slot. Never re-enter the terminal session's saved closer: + // its protocol endpoint may since have been reused by another child. + if (runtime.snapshot.status === 'closed') { + return runtime.snapshot; } + // A failed closer cannot prove that the underlying process stopped. + // Preserve the live session and propagate the failure so callers do + // not start a replacement process alongside it. + await runtime.close?.(); + return this.markClosed(id); } } diff --git a/apps/electron-backend/src/app/events/mpv-reusable-process.ts b/apps/electron-backend/src/app/events/mpv-reusable-process.ts new file mode 100644 index 000000000..5642c799b --- /dev/null +++ b/apps/electron-backend/src/app/events/mpv-reusable-process.ts @@ -0,0 +1,325 @@ +import { ChildProcess } from 'child_process'; +import { createConnection } from 'net'; +import { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; +import { joinMpvHeaderFields } from '../util/mpv-string-list.util'; +import { externalPlayerProcessTeardownGate } from './external-player-process'; +import { + externalPlayerSessions, + traceExternalPlayer, +} from './external-player-runtime'; + +const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000; + +export interface MpvReuseAttemptState { + contentMutated: boolean; + teardownUnconfirmed: boolean; +} + +interface MpvReuseOptions { + session: ExternalPlayerSession; + previousProcessSessionId: string | null; + url: string; + title: string; + effectiveUserAgent?: string; + effectiveReferer?: string; + headerFields: string[]; + contentInfo?: PlayerContentInfo; + startTime?: number; + state: MpvReuseAttemptState; + startPositionPolling: ( + socketPath: string, + contentInfo: PlayerContentInfo, + sessionId: string + ) => void; + stopPositionPolling: () => void; +} + +function sendMpvCommand( + socketPath: string, + command: string, + args: Array, + shouldDispatch?: () => boolean +): Promise { + return new Promise((resolve, reject) => { + const client = createConnection(socketPath); + const request = JSON.stringify({ command: [command, ...args] }) + '\n'; + let settled = false; + let timeoutHandle: NodeJS.Timeout | null = null; + const complete = (error?: Error, dispatched = true) => { + if (settled) return; + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + if (!dispatched && !client.destroyed) client.destroy(); + if (error) { + reject(error); + } else { + resolve(dispatched); + } + }; + + client.on('connect', () => { + if (shouldDispatch && !shouldDispatch()) { + complete(undefined, false); + return; + } + traceExternalPlayer('mpv ipc command', { + command, + argsCount: args.length, + }); + try { + client.write(request); + client.end(); + complete(); + } catch (error) { + complete( + error instanceof Error ? error : new Error(String(error)) + ); + } + }); + client.on('error', (error) => complete(error)); + timeoutHandle = setTimeout(() => { + complete(new Error('MPV IPC command timed out')); + client.destroy(); + }, MPV_IPC_COMMAND_TIMEOUT_MS); + timeoutHandle.unref(); + }); +} + +/** Owns the one MPV child/socket retained when instance reuse is enabled. */ +export class MpvReusableProcess { + private process: ChildProcess | null = null; + private socketPath: string | null = null; + private processSessionId: string | null = null; + private readonly processSessionIds = new WeakMap(); + + currentSessionId(): string | null { + return this.processSessionId; + } + + sessionIdFor(process: ChildProcess, fallback: string): string { + return this.processSessionIds.get(process) ?? fallback; + } + + owns(process: ChildProcess, sessionId: string): boolean { + return this.processSessionIds.get(process) === sessionId; + } + + track(process: ChildProcess, socketPath: string, sessionId: string): void { + this.process = process; + this.socketPath = socketPath; + this.processSessionId = sessionId; + this.processSessionIds.set(process, sessionId); + } + + clear(process: ChildProcess): boolean { + if (this.process !== process) return false; + this.process = null; + this.socketPath = null; + this.processSessionId = null; + return true; + } + + stopStored( + reason: string, + stopPositionPolling: () => void, + guardFutureLaunches = false + ): void { + const process = this.process; + if (!process || process.killed) return; + traceExternalPlayer(reason); + if (guardFutureLaunches) { + externalPlayerProcessTeardownGate.terminateInBackground(process); + } else { + process.kill(); + } + this.clear(process); + stopPositionPolling(); + } + + async tryReuse( + options: MpvReuseOptions + ): Promise { + const reusedProcess = this.process; + const reusedSocketPath = this.socketPath; + if (!reusedProcess || reusedProcess.killed || !reusedSocketPath) { + return null; + } + + traceExternalPlayer('reuse existing mpv instance'); + const { session, state } = options; + const reusedProcessSessionId = + this.processSessionIds.get(reusedProcess) ?? + options.previousProcessSessionId; + let closeRequested = false; + let retryableClose: Promise | null = null; + let launchClose: Promise | null = null; + + const ownsReusedProcess = () => + this.processSessionIds.get(reusedProcess) === session.id || + externalPlayerSessions.getActiveSessionId() === session.id; + const closeReusedProcess = async () => { + externalPlayerProcessTeardownGate.beginTeardown(reusedProcess); + try { + await sendMpvCommand(reusedSocketPath, 'quit', []); + } catch { + await externalPlayerProcessTeardownGate.terminate( + reusedProcess + ); + return; + } + await externalPlayerProcessTeardownGate.terminate(reusedProcess, { + sendTerminationSignal: false, + }); + }; + const finishRequestedClose = async () => { + if (launchClose) { + try { + await launchClose; + } catch (error) { + state.teardownUnconfirmed = true; + throw error; + } + } + return externalPlayerSessions.markClosed(session.id) ?? session; + }; + + externalPlayerSessions.attachCloser(session.id, () => { + closeRequested = true; + if (!ownsReusedProcess()) return; + if (!retryableClose) { + const closeAttempt = closeReusedProcess(); + retryableClose = closeAttempt; + launchClose ??= closeAttempt; + void closeAttempt.catch((error) => { + if (retryableClose === closeAttempt) retryableClose = null; + state.teardownUnconfirmed = true; + externalPlayerSessions.markError( + session.id, + error instanceof Error ? error.message : String(error), + { canClose: true } + ); + }); + } + return retryableClose; + }); + + try { + await this.applyReuseCommands( + options, + reusedSocketPath, + () => !closeRequested + ); + if (closeRequested) return await finishRequestedClose(); + + state.contentMutated = true; + this.processSessionId = session.id; + this.processSessionIds.set(reusedProcess, session.id); + options.stopPositionPolling(); + + if (options.startTime) { + await sendMpvCommand( + reusedSocketPath, + 'seek', + [String(options.startTime), 'absolute'], + () => !closeRequested + ); + } + if (closeRequested) return await finishRequestedClose(); + + if (options.contentInfo) { + options.startPositionPolling( + reusedSocketPath, + options.contentInfo, + session.id + ); + } else { + options.stopPositionPolling(); + } + return externalPlayerSessions.markOpened(session.id) ?? session; + } catch (error) { + const current = externalPlayerSessions.getSession(session.id); + if (current?.status === 'closed') return current; + if (closeRequested) return await finishRequestedClose(); + console.error('Failed to send command to existing MPV:', error); + + if (state.contentMutated) { + if (reusedProcessSessionId) { + this.processSessionIds.set( + reusedProcess, + reusedProcessSessionId + ); + } else { + this.processSessionIds.delete(reusedProcess); + } + } + try { + await externalPlayerProcessTeardownGate.terminate( + reusedProcess + ); + } catch (teardownError) { + if (state.contentMutated) { + this.processSessionIds.set(reusedProcess, session.id); + } + state.teardownUnconfirmed = true; + throw teardownError; + } + this.clear(reusedProcess); + options.stopPositionPolling(); + if (closeRequested) return await finishRequestedClose(); + return null; + } + } + + private async applyReuseCommands( + options: MpvReuseOptions, + socketPath: string, + shouldDispatch: () => boolean + ): Promise { + if (options.effectiveUserAgent) { + const dispatched = await sendMpvCommand( + socketPath, + 'set_property', + ['user-agent', options.effectiveUserAgent], + shouldDispatch + ); + if (!dispatched) return; + } + if (options.effectiveReferer) { + const dispatched = await sendMpvCommand( + socketPath, + 'set_property', + ['referrer', options.effectiveReferer], + shouldDispatch + ); + if (!dispatched) return; + } + if (options.headerFields.length > 0) { + const dispatched = await sendMpvCommand( + socketPath, + 'set_property', + [ + 'http-header-fields', + joinMpvHeaderFields(options.headerFields), + ], + shouldDispatch + ); + if (!dispatched) return; + } + if (!shouldDispatch()) return; + const loadFileArgs: Array = [options.url, 'replace']; + if (options.title) { + loadFileArgs.push(-1, `force-media-title=${options.title}`); + } + const dispatched = await sendMpvCommand( + socketPath, + 'loadfile', + loadFileArgs, + shouldDispatch + ); + if (!dispatched) return; + traceExternalPlayer('loaded new url in existing mpv instance'); + } +} 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 3535289a4..dd11945a8 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 @@ -49,13 +49,25 @@ import { VLC_REUSE_INSTANCE, store, } from '../services/store.service'; -import { openMpvPlayer, shutdownMpvSession } from './mpv-session.service'; -import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service'; +import { externalPlayerSessions } from './external-player-runtime'; +import { externalPlayerProcessTeardownGate } from './external-player-process'; +import { + openMpvPlayer, + setMpvReuseInstance, + shutdownMpvSession, +} from './mpv-session.service'; +import { + openVlcPlayer, + setVlcReuseInstance, + shutdownVlcSession, +} from './vlc-session.service'; function createMockChildProcess(): ChildProcess { return Object.assign(new EventEmitter(), { + exitCode: null, killed: false, kill: jest.fn(() => true), + signalCode: null, stderr: null, stdout: null, unref: jest.fn(), @@ -113,6 +125,222 @@ describe('external player shutdown on app quit', () => { expect(proc.kill).toHaveBeenCalledTimes(1); }); + it('blocks a launch until MPV exits after reuse is disabled', async () => { + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'Reusable MPV stream', + url: 'https://example.com/live.m3u8', + }); + + setMpvReuseInstance(false); + + await expect( + openMpvPlayer({ + title: 'Replacement MPV stream', + url: 'https://example.com/replacement.m3u8', + }) + ).rejects.toThrow('previous external player is still shutting down'); + expect(spawn).toHaveBeenCalledTimes(1); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + }); + + it('settles a fresh MPV launch when Stop wins before startup confirmation', async () => { + jest.useFakeTimers(); + try { + const proc = createMockChildProcess(); + (proc.kill as jest.Mock).mockImplementation(() => { + Object.defineProperty(proc, 'killed', { value: true }); + setImmediate(() => { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + }); + return true; + }); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: false, + }); + + const opening = openMpvPlayer({ + title: 'Stopped stream', + url: 'https://example.com/stopped.m3u8', + }); + const sessionId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(sessionId); + + await jest.runAllTimersAsync(); + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + + let launchSettled = false; + void opening.then(() => { + launchSettled = true; + }); + await Promise.resolve(); + expect(launchSettled).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('settles a fresh MPV launch when bounded Stop cannot confirm exit', async () => { + jest.useFakeTimers(); + const proc = createMockChildProcess(); + try { + (proc.kill as jest.Mock).mockImplementation(() => { + Object.defineProperty(proc, 'killed', { + value: true, + configurable: true, + }); + return true; + }); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: false, + }); + + const opening = openMpvPlayer({ + title: 'Unresponsive stream', + url: 'https://example.com/unresponsive.m3u8', + }); + let launchSettled = false; + void opening.then( + () => { + launchSettled = true; + }, + () => { + launchSettled = true; + } + ); + const sessionId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(sessionId); + const closeRejection = expect(closing).rejects.toThrow( + 'External player process did not exit' + ); + + await jest.advanceTimersByTimeAsync(5_000); + await closeRejection; + await Promise.resolve(); + + expect(launchSettled).toBe(true); + expect(externalPlayerSessions.getSession(sessionId)).toMatchObject({ + status: 'error', + canClose: true, + }); + } finally { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + jest.useRealTimers(); + } + }); + + it('does not let a stale reusable MPV closer stop the remapped session', async () => { + const proc = createMockChildProcess(); + (proc.kill as jest.Mock).mockImplementation(() => { + Object.defineProperty(proc, 'exitCode', { + value: 0, + configurable: true, + }); + proc.emit('exit', 0); + return true; + }); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + const previous = 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('connect')); + return socket; + }); + const current = await openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + (proc.kill as jest.Mock).mockClear(); + + await expect( + externalPlayerSessions.closeSession(previous.id) + ).resolves.toMatchObject({ status: 'closed' }); + + expect(proc.kill).not.toHaveBeenCalled(); + expect(externalPlayerSessions.getSession(current.id)).toMatchObject({ + status: 'opened', + canClose: true, + }); + }); + + it('does not send a stale quit after a closed reused session is replaced', async () => { + shutdownMpvSession(); + const firstProc = createMockChildProcess(); + const nextProc = createMockChildProcess(); + (spawn as unknown as jest.Mock) + .mockReturnValueOnce(firstProc) + .mockReturnValueOnce(nextProc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + const written: string[] = []; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + write: jest.fn((chunk: string) => written.push(chunk)), + end: jest.fn(), + destroy: jest.fn(), + }); + setImmediate(() => socket.emit('connect')); + return socket; + }); + const reused = await openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + + Object.defineProperty(firstProc, 'exitCode', { value: 0 }); + firstProc.emit('exit', 0); + const current = await openMpvPlayer({ + title: 'Third stream', + url: 'https://example.com/three.m3u8', + }); + written.length = 0; + + await expect( + externalPlayerSessions.closeSession(reused.id) + ).resolves.toMatchObject({ status: 'closed' }); + + expect(written).toEqual([]); + expect(nextProc.kill).not.toHaveBeenCalled(); + expect(externalPlayerSessions.getSession(current.id)).toMatchObject({ + status: 'opened', + canClose: true, + }); + + shutdownMpvSession(); + }); + it('escapes commas in http header fields passed to mpv', async () => { const proc = createMockChildProcess(); (spawn as unknown as jest.Mock).mockReturnValue(proc); @@ -202,6 +430,696 @@ describe('external player shutdown on app quit', () => { shutdownMpvSession(); }); + it('bounds a reused MPV close when its IPC socket never connects', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + 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('connect')); + return socket; + }); + const reused = await openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + + (createConnection as unknown as jest.Mock).mockImplementation(() => + Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }) + ); + jest.useFakeTimers(); + try { + const closing = externalPlayerSessions.closeSession(reused.id); + + await jest.advanceTimersByTimeAsync(2_000); + expect(proc.kill).toHaveBeenCalledTimes(1); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + await expect(closing).resolves.toMatchObject({ + id: reused.id, + status: 'closed', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('allows Stop to retry a reused MPV teardown after confirmation times out', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + 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('connect')); + return socket; + }); + const reused = await openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.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('quit failed'))); + return socket; + }); + + jest.useFakeTimers(); + try { + const firstClose = externalPlayerSessions.closeSession(reused.id); + const firstRejection = expect(firstClose).rejects.toThrow( + 'External player process did not exit' + ); + await jest.advanceTimersByTimeAsync(5_000); + await firstRejection; + const killsAfterFirstAttempt = (proc.kill as jest.Mock).mock.calls + .length; + + const retry = externalPlayerSessions.closeSession(reused.id); + await jest.advanceTimersByTimeAsync(0); + expect((proc.kill as jest.Mock).mock.calls.length).toBeGreaterThan( + killsAfterFirstAttempt + ); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(retry).resolves.toMatchObject({ status: 'closed' }); + } finally { + jest.useRealTimers(); + } + }); + + it('guards reused MPV teardown while its protocol quit is pending', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + 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('connect')); + return socket; + }); + const session = await openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + + const sockets: EventEmitter[] = []; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }); + sockets.push(socket); + return socket; + }); + const closing = externalPlayerSessions.closeSession(session.id); + + try { + expect(() => + externalPlayerProcessTeardownGate.assertLaunchAllowed() + ).toThrow('previous external player is still shutting down'); + } finally { + while (sockets.length === 0) { + await Promise.resolve(); + } + Object.defineProperty(proc, 'exitCode', { value: 0 }); + sockets[0].emit('connect'); + proc.emit('exit', 0); + await closing; + } + }); + + it('does not dispatch reused MPV content after Stop interrupts header setup', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + + const sockets: Array< + EventEmitter & { write: jest.Mock; end: jest.Mock } + > = []; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }); + sockets.push(socket); + if (sockets.length > 2) { + setImmediate(() => socket.emit('connect')); + } + return socket; + }); + + const opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + userAgent: 'IPTVnator test agent', + }); + while (sockets.length < 1) await Promise.resolve(); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(replacementId); + while (sockets.length < 2) await Promise.resolve(); + + sockets[0].emit('connect'); + await new Promise((resolve) => setImmediate(resolve)); + + sockets[1].emit('connect'); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + await expect(opening).resolves.toMatchObject({ status: 'closed' }); + const commands = sockets.flatMap((socket) => + socket.write.mock.calls.map(([request]) => + JSON.parse(String(request)).command[0] + ) + ); + expect(commands).not.toContain('loadfile'); + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it('does not spawn a replacement when Stop interrupts reused MPV seek', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + + const sockets: EventEmitter[] = []; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }); + sockets.push(socket); + if (sockets.length === 1 || sockets.length === 3) { + setImmediate(() => socket.emit('connect')); + } + return socket; + }); + + const opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + startTime: 120, + }); + while (sockets.length < 2) { + await new Promise((resolve) => setImmediate(resolve)); + } + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(replacementId); + while (sockets.length < 3) { + await new Promise((resolve) => setImmediate(resolve)); + } + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + sockets[1].emit('error', new Error('connection closed')); + + await expect(closing).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + await expect(opening).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it('does not spawn fresh when Stop arrives during failed MPV reuse teardown', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + 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('reuse command failed')) + ); + return socket; + }); + + const opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + for ( + let attempt = 0; + attempt < 20 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + + const closing = externalPlayerSessions.closeSession(replacementId); + await new Promise((resolve) => setImmediate(resolve)); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + await expect(opening).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it('restores the globally displaced session when reusable MPV teardown is unconfirmed', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + const displaced = externalPlayerSessions.beginSession({ + player: 'vlc', + title: 'Current VLC stream', + streamUrl: 'https://example.com/current-vlc.m3u8', + }); + externalPlayerSessions.attachCloser(displaced.id, jest.fn()); + externalPlayerSessions.markOpened(displaced.id); + (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('reuse command failed')) + ); + return socket; + }); + + jest.useFakeTimers(); + try { + const opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + const rejection = expect(opening).rejects.toThrow( + 'External player process did not exit' + ); + + for ( + let attempt = 0; + attempt < 4 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await jest.runOnlyPendingTimersAsync(); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect(externalPlayerSessions.getActiveSessionId()).toBe( + displaced.id + ); + expect( + externalPlayerSessions.getSession(displaced.id) + ).toMatchObject({ + status: 'opened', + canClose: true, + }); + expect(spawn).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + } + }); + + it('keeps an orphaned reusable MPV teardown failure closable', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + const previous = await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + externalPlayerSessions.markClosed(previous.id); + (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('reuse command failed')) + ); + return socket; + }); + + jest.useFakeTimers(); + try { + const opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + const rejection = expect(opening).rejects.toThrow( + 'External player process did not exit' + ); + + for ( + let attempt = 0; + attempt < 4 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await jest.runOnlyPendingTimersAsync(); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect( + externalPlayerSessions.getSession(replacementId) + ).toMatchObject({ + title: 'Second stream', + status: 'error', + canClose: true, + }); + } finally { + jest.useRealTimers(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + } + }); + + it('keeps replacement ownership when reused MPV content changed before teardown failed', async () => { + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + const previous = await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + let connectionCount = 0; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + connectionCount += 1; + const socket = Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }); + setImmediate(() => { + if (connectionCount === 1) { + socket.emit('connect'); + } else { + socket.emit('error', new Error('seek failed')); + } + }); + return socket; + }); + + jest.useFakeTimers(); + try { + const opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + startTime: 120, + }); + const replacementId = externalPlayerSessions.getActiveSessionId(); + const rejection = expect(opening).rejects.toThrow( + 'External player process did not exit' + ); + + for ( + let attempt = 0; + attempt < 4 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await jest.runOnlyPendingTimersAsync(); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect(replacementId).not.toBe(previous.id); + expect(externalPlayerSessions.getActiveSessionId()).toBe( + replacementId + ); + expect( + externalPlayerSessions.getSession(replacementId as string) + ).toMatchObject({ + title: 'Second stream', + status: 'error', + canClose: true, + }); + + Object.defineProperty(proc, 'killed', { value: true }); + await expect( + openMpvPlayer({ + title: 'Third stream', + url: 'https://example.com/three.m3u8', + }) + ).rejects.toThrow( + 'previous external player is still shutting down' + ); + expect(spawn).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + } + }); + + 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('rechecks the process-wide teardown gate before fallback MPV spawn', async () => { + shutdownMpvSession(); + const staleProc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(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 opening = openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(staleProc.kill).toHaveBeenCalledTimes(1); + + const blocker = createMockChildProcess(); + const blockerTeardown = + externalPlayerProcessTeardownGate.terminate(blocker); + try { + Object.defineProperty(staleProc, 'exitCode', { value: 0 }); + staleProc.emit('exit', 0); + + await expect(opening).rejects.toThrow( + 'previous external player is still shutting down' + ); + expect(spawn).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(blocker, 'exitCode', { value: 0 }); + blocker.emit('exit', 0); + await blockerTeardown; + } + }); + + it('keeps the fallback MPV session live after partial reuse failure', async () => { + shutdownMpvSession(); + const reusedProc = createMockChildProcess(); + (reusedProc.kill as jest.Mock).mockImplementation(() => { + setImmediate(() => { + Object.defineProperty(reusedProc, 'exitCode', { value: 0 }); + reusedProc.emit('exit', 0); + }); + return true; + }); + const freshProc = createMockChildProcess(); + (spawn as unknown as jest.Mock) + .mockReturnValueOnce(reusedProc) + .mockReturnValueOnce(freshProc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'First stream', + url: 'https://example.com/one.m3u8', + }); + let connectionCount = 0; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + connectionCount += 1; + const socket = Object.assign(new EventEmitter(), { + write: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }); + setImmediate(() => { + if (connectionCount === 1) { + socket.emit('connect'); + } else { + socket.emit('error', new Error('seek failed')); + } + }); + return socket; + }); + + const session = await openMpvPlayer({ + title: 'Second stream', + url: 'https://example.com/two.m3u8', + startTime: 120, + }); + + expect(spawn).toHaveBeenCalledTimes(2); + expect(session.status).toBe('opened'); + expect(externalPlayerSessions.getSession(session.id)?.status).toBe( + 'opened' + ); + shutdownMpvSession(); + }); + it('does not track non-reusable MPV processes for shutdown', async () => { const proc = createMockChildProcess(); (spawn as unknown as jest.Mock).mockReturnValue(proc); @@ -222,6 +1140,39 @@ describe('external player shutdown on app quit', () => { expect(proc.kill).not.toHaveBeenCalled(); }); + it('waits for a detached MPV process to exit before closing its session', async () => { + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: false, + }); + const session = await openMpvPlayer({ + title: 'Detached MPV stream', + url: 'https://example.com/live.m3u8', + }); + + let closeSettled = false; + const closePromise = externalPlayerSessions + .closeSession(session.id) + .then((closed) => { + closeSettled = true; + return closed; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(closeSettled).toBe(false); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closePromise).resolves.toMatchObject({ + id: session.id, + status: 'closed', + }); + }); + it('kills the stored reusable VLC process on shutdown', async () => { const proc = createMockChildProcess(); (spawn as unknown as jest.Mock).mockReturnValue(proc); @@ -248,4 +1199,33 @@ describe('external player shutdown on app quit', () => { shutdownVlcSession(); expect(proc.kill).toHaveBeenCalledTimes(1); }); + + it('blocks a launch until VLC exits after reuse is disabled', async () => { + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + }); + const openPromise = openVlcPlayer({ + title: 'Reusable VLC stream', + url: 'https://example.com/live.m3u8', + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; + + setVlcReuseInstance(false); + + await expect( + openVlcPlayer({ + title: 'Replacement VLC stream', + url: 'https://example.com/replacement.m3u8', + }) + ).rejects.toThrow('previous external player is still shutting down'); + expect(spawn).toHaveBeenCalledTimes(1); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + }); }); 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 8492a94ac..b9809afcd 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -1,4 +1,4 @@ -import { ChildProcess, spawn } from 'child_process'; +import { spawn } from 'child_process'; import { createConnection } from 'net'; import { PlayerContentInfo } from '@iptvnator/shared/interfaces'; import { @@ -29,6 +29,11 @@ import { sendPlayerErrorNotification, traceExternalPlayer, } from './external-player-runtime'; +import { externalPlayerProcessTeardownGate } from './external-player-process'; +import { + MpvReusableProcess, + MpvReuseAttemptState, +} from './mpv-reusable-process'; export interface OpenExternalPlayerRequest { url: string; @@ -42,8 +47,7 @@ export interface OpenExternalPlayerRequest { headers?: Record; } -let mpvProcess: ChildProcess | null = null; -let mpvSocketPath: string | null = null; +const reusableMpvProcess = new MpvReusableProcess(); let positionPollingInterval: NodeJS.Timeout | null = null; function getMpvPath(options: PlayerPathOptions = {}): string { @@ -152,53 +156,16 @@ function startPositionPolling( }, 2000); } -function sendMpvCommand( - command: string, - args: Array -): Promise { - return new Promise((resolve, reject) => { - if (!mpvSocketPath) { - reject(new Error('No MPV socket path available')); - return; - } - - const client = createConnection(mpvSocketPath); - const request = JSON.stringify({ command: [command, ...args] }) + '\n'; - - client.on('connect', () => { - traceExternalPlayer('mpv ipc command', { - command, - argsCount: args.length, - }); - client.write(request); - client.end(); - resolve(); - }); - - client.on('error', (err) => { - console.error('MPV socket error:', err); - reject(err); - }); - }); -} - -function killStoredMpvProcess(reason: string): void { - if (!mpvProcess || mpvProcess.killed) { - return; - } - traceExternalPlayer(reason); - mpvProcess.kill(); - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); -} - export function setMpvReuseInstance(reuseInstance: boolean): void { traceExternalPlayer('set mpv reuse instance', { reuseInstance }); store.set(MPV_REUSE_INSTANCE, reuseInstance); if (!reuseInstance) { - killStoredMpvProcess('clean up mpv process after disabling reuse'); + reusableMpvProcess.stopStored( + 'clean up mpv process after disabling reuse', + stopPositionPolling, + true + ); } } @@ -208,7 +175,10 @@ export function setMpvReuseInstance(reuseInstance: boolean): void { * app and keeps playing after quit. */ export function shutdownMpvSession(): void { - killStoredMpvProcess('kill reused mpv process on app shutdown'); + reusableMpvProcess.stopStored( + 'kill reused mpv process on app shutdown', + stopPositionPolling + ); } export async function openMpvPlayer({ @@ -222,6 +192,9 @@ export async function openMpvPlayer({ startTime, headers, }: OpenExternalPlayerRequest) { + externalPlayerProcessTeardownGate.assertLaunchAllowed(); + const displacedSessionId = externalPlayerSessions.getActiveSessionId(); + const previousProcessSessionId = reusableMpvProcess.currentSessionId(); const session = externalPlayerSessions.beginSession({ player: 'mpv', title, @@ -229,6 +202,11 @@ export async function openMpvPlayer({ streamUrl: url, contentInfo, }); + const reuseState: MpvReuseAttemptState = { + teardownUnconfirmed: false, + contentMutated: false, + }; + let freshTeardownUnconfirmed = false; try { const isFlatpak = isRunningInFlatpak(); @@ -273,80 +251,22 @@ export async function openMpvPlayer({ parseExternalPlayerArguments(customMpvArguments).length, }); - if ( - reuseInstance && - mpvProcess && - !mpvProcess.killed && - mpvSocketPath - ) { - traceExternalPlayer('reuse existing mpv instance'); - try { - if (effectiveUserAgent) { - await sendMpvCommand('set_property', [ - 'user-agent', - effectiveUserAgent, - ]); - } - if (effectiveReferer) { - await sendMpvCommand('set_property', [ - 'referrer', - effectiveReferer, - ]); - } - if (headerFields.length > 0) { - await sendMpvCommand('set_property', [ - 'http-header-fields', - joinMpvHeaderFields(headerFields), - ]); - } - - const loadFileArgs: Array = [url, 'replace']; - const loadFileOptions: string[] = []; - - if (title) { - loadFileOptions.push(`force-media-title=${title}`); - } - if (loadFileOptions.length > 0) { - loadFileArgs.push(-1, loadFileOptions.join(',')); - } - - await sendMpvCommand('loadfile', loadFileArgs); - traceExternalPlayer('loaded new url in existing mpv instance'); - - externalPlayerSessions.attachCloser(session.id, async () => { - try { - await sendMpvCommand('quit', []); - } catch { - if (mpvProcess && !mpvProcess.killed) { - mpvProcess.kill(); - } - } - }); - - if (startTime) { - await sendMpvCommand('seek', [ - String(startTime), - 'absolute', - ]); - } - - if (contentInfo) { - startPositionPolling( - mpvSocketPath, - contentInfo, - session.id - ); - } else { - stopPositionPolling(); - } - - return externalPlayerSessions.markOpened(session.id) ?? session; - } catch (err) { - console.error('Failed to send command to existing MPV:', err); - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); - } + if (reuseInstance) { + const reused = await reusableMpvProcess.tryReuse({ + session, + previousProcessSessionId, + url, + title, + effectiveUserAgent, + effectiveReferer, + headerFields, + contentInfo, + startTime, + state: reuseState, + startPositionPolling, + stopPositionPolling, + }); + if (reused) return reused; } traceExternalPlayer('create new mpv instance'); @@ -373,7 +293,9 @@ export async function openMpvPlayer({ } if (headerFields.length > 0) { - args.push(`--http-header-fields=${joinMpvHeaderFields(headerFields)}`); + args.push( + `--http-header-fields=${joinMpvHeaderFields(headerFields)}` + ); } if (title) { @@ -387,10 +309,36 @@ export async function openMpvPlayer({ args.push(url); await new Promise((resolve, reject) => { + let settled = false; + let startConfirmationTimer: NodeJS.Timeout | null = null; + const resolveLaunch = () => { + if (settled) { + return; + } + settled = true; + if (startConfirmationTimer) { + clearTimeout(startConfirmationTimer); + } + resolve(); + }; + const rejectLaunch = (error: Error) => { + if (settled) { + return; + } + settled = true; + if (startConfirmationTimer) { + clearTimeout(startConfirmationTimer); + } + reject(error); + }; const spawnSpec = buildExternalPlayerSpawnSpec( mpvLaunchContext, buildPlayerArgsWithCustomArguments(customMpvArguments, args) ); + // Reuse teardown yields while waiting for the old child. Another + // external process may enter teardown during that window, so the + // process-wide invariant must be checked at the actual spawn too. + externalPlayerProcessTeardownGate.assertLaunchAllowed(); const proc = spawn(spawnSpec.command, spawnSpec.args, { shell: false, detached: !reuseInstance, @@ -455,20 +403,28 @@ export async function openMpvPlayer({ proc.on('error', (err) => { console.error('Failed to start MPV player:', err); - mpvProcess = null; - mpvSocketPath = null; + const processSessionId = reusableMpvProcess.sessionIdFor( + proc, + session.id + ); + reusableMpvProcess.clear(proc); stopPositionPolling(); externalPlayerSessions.markError( - session.id, + processSessionId, `Failed to start MPV player: ${err.message}` ); - reject(buildPlayerStartError('MPV', err, mpvLaunchContext)); + rejectLaunch( + buildPlayerStartError('MPV', err, mpvLaunchContext) + ); }); proc.on('exit', (code) => { traceExternalPlayer('mpv exited', { code }); - mpvProcess = null; - mpvSocketPath = null; + const processSessionId = reusableMpvProcess.sessionIdFor( + proc, + session.id + ); + reusableMpvProcess.clear(proc); stopPositionPolling(); if (code !== 0 && code !== null) { @@ -480,18 +436,19 @@ export async function openMpvPlayer({ `MPV player closed unexpectedly (exit code: ${code})` ); externalPlayerSessions.markError( - session.id, + processSessionId, `MPV player closed unexpectedly (exit code: ${code})` ); + resolveLaunch(); return; } - externalPlayerSessions.markClosed(session.id); + externalPlayerSessions.markClosed(processSessionId); + resolveLaunch(); }); if (reuseInstance && socketPath) { - mpvProcess = proc; - mpvSocketPath = socketPath; + reusableMpvProcess.track(proc, socketPath, session.id); traceExternalPlayer('stored mpv process for reuse', { socketPath, }); @@ -500,8 +457,27 @@ export async function openMpvPlayer({ } externalPlayerSessions.attachCloser(session.id, async () => { - if (!proc.killed) { - proc.kill(); + if ( + reuseInstance && + !reusableMpvProcess.owns(proc, session.id) + ) { + return; + } + try { + await externalPlayerProcessTeardownGate.terminate(proc); + } catch (error) { + const teardownError = + error instanceof Error + ? error + : new Error(String(error)); + freshTeardownUnconfirmed = true; + externalPlayerSessions.markError( + session.id, + teardownError.message, + { canClose: true } + ); + rejectLaunch(teardownError); + throw teardownError; } }); @@ -509,22 +485,38 @@ export async function openMpvPlayer({ startPositionPolling(socketPath, contentInfo, session.id); } - setTimeout(() => { + startConfirmationTimer = setTimeout(() => { if (!proc.killed) { - resolve(); + resolveLaunch(); } }, 100); + startConfirmationTimer.unref(); }); return externalPlayerSessions.markOpened(session.id) ?? session; } catch (error) { console.error('Error opening MPV player:', error); - mpvProcess = null; - mpvSocketPath = null; - stopPositionPolling(); + if (!reuseState.teardownUnconfirmed && !freshTeardownUnconfirmed) { + stopPositionPolling(); + } + const restoredSession = + reuseState.teardownUnconfirmed && + !reuseState.contentMutated && + displacedSessionId + ? externalPlayerSessions.restoreActiveSession( + displacedSessionId, + session.id + ) + : null; externalPlayerSessions.markError( session.id, - error instanceof Error ? error.message : String(error) + error instanceof Error ? error.message : String(error), + { + canClose: + freshTeardownUnconfirmed || + (reuseState.teardownUnconfirmed && + (reuseState.contentMutated || !restoredSession)), + } ); throw error; } diff --git a/apps/electron-backend/src/app/events/vlc-rc.ts b/apps/electron-backend/src/app/events/vlc-rc.ts new file mode 100644 index 000000000..5ab4340e1 --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-rc.ts @@ -0,0 +1,167 @@ +import { createConnection } from 'net'; +import { ExternalPlaybackSnapshot } from './external-player-runtime'; + +export function buildVlcEnqueueCommands(options: { + url: string; + title?: string; + userAgent?: string; + referer?: string; + origin?: string; + headers?: Record; + startTime?: number; +}): string[] { + const inputOptions: string[] = []; + + if (options.userAgent) { + inputOptions.push(`:http-user-agent=${options.userAgent}`); + } + if (options.referer) { + inputOptions.push(`:http-referrer=${options.referer}`); + } else if (options.origin) { + inputOptions.push(`:http-referrer=${options.origin}`); + } + Object.entries(options.headers ?? {}).forEach(([name, value]) => { + if (!name || value === undefined || value === null) return; + const trimmedValue = String(value).trim(); + if (!trimmedValue) return; + inputOptions.push(`:http-header=${name}: ${trimmedValue}`); + }); + if (options.title) { + inputOptions.push(`:meta-title=${options.title}`); + } + + const inputLine = + inputOptions.length > 0 + ? `${options.url} ${inputOptions.join(' ')}` + : options.url; + const commands = ['clear', `add ${inputLine}`]; + if (options.startTime && Number.isFinite(options.startTime)) { + commands.push(`seek ${Math.floor(options.startTime)}`); + } + return commands; +} + +export function sendVlcRcCommand( + port: number, + command: string, + onDispatched?: () => void, + shouldDispatch?: () => boolean +): Promise { + return new Promise((resolve, reject) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let settled = false; + const finish = (error?: Error, dispatched = true) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + if (!client.destroyed) client.destroy(); + if (error) reject(error); + else resolve(dispatched); + }; + const timeoutHandle = setTimeout( + () => finish(new Error('VLC RC command timed out')), + 2_000 + ); + + client.on('connect', () => { + if (shouldDispatch && !shouldDispatch()) { + finish(undefined, false); + return; + } + try { + client.write(`${command}\n`); + onDispatched?.(); + } catch (error) { + finish( + error instanceof Error ? error : new Error(String(error)) + ); + } + }); + client.on('data', (chunk) => { + if (chunk.toString().includes('>')) finish(); + }); + client.on('error', (error) => finish(error)); + }); +} + +export async function sendVlcRcCommands( + port: number, + commands: string[], + onCommandSent?: (command: string, index: number) => void, + shouldDispatch?: () => boolean +): Promise { + for (const [index, command] of commands.entries()) { + if (shouldDispatch && !shouldDispatch()) return; + const dispatched = await sendVlcRcCommand( + port, + command, + () => onCommandSent?.(command, index), + shouldDispatch + ); + if (!dispatched) return; + } +} + +export function parseVlcRcNumericResponse(data: string): string { + return data.match(/>\s*(-?\d+(?:\.\d+)?)/)?.[1] ?? ''; +} + +export function parseVlcRcPlaybackState(data: string): string | null { + return ( + data + .match(/\(\s*state\s+([^)]+)\s*\)/i)?.[1] + ?.trim() + .toLowerCase() ?? null + ); +} + +function getVlcCommandResponse(port: number, command: string): Promise { + return new Promise((resolve) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let data = ''; + let settled = false; + const finish = (result: string) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + if (!client.destroyed) client.destroy(); + resolve(result); + }; + const timeoutHandle = setTimeout(() => finish(''), 2_000); + + client.on('connect', () => client.write(`${command}\n`)); + client.on('data', (chunk) => { + data += chunk.toString(); + if (data.includes('>')) finish(data); + }); + client.on('error', () => finish('')); + }); +} + +export async function getVlcPlaybackState( + port: number +): Promise { + return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status')); +} + +export async function getVlcPlaybackSnapshot( + port: number +): Promise { + const time = parseInt( + parseVlcRcNumericResponse( + await getVlcCommandResponse(port, 'get_time') + ), + 10 + ); + const duration = parseInt( + parseVlcRcNumericResponse( + await getVlcCommandResponse(port, 'get_length') + ), + 10 + ); + if (Number.isNaN(time)) return null; + return { + positionSeconds: time, + durationSeconds: Number.isNaN(duration) ? null : duration, + }; +} diff --git a/apps/electron-backend/src/app/events/vlc-reusable-process.ts b/apps/electron-backend/src/app/events/vlc-reusable-process.ts new file mode 100644 index 000000000..fb32f6455 --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-reusable-process.ts @@ -0,0 +1,257 @@ +import { ChildProcess } from 'child_process'; +import { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; +import { externalPlayerProcessTeardownGate } from './external-player-process'; +import { + ExternalPlaybackSnapshot, + externalPlayerSessions, + sendPlaybackPositionUpdate, + traceExternalPlayer, +} from './external-player-runtime'; +import { + buildVlcEnqueueCommands, + sendVlcRcCommand, + sendVlcRcCommands, +} from './vlc-rc'; + +export interface VlcReuseAttemptState { + contentMutated: boolean; + teardownUnconfirmed: boolean; + closeRequested: boolean; + requestedClose: Promise | null; +} + +interface VlcReuseOptions { + session: ExternalPlayerSession; + previousProcessSessionId: string | null; + url: string; + title: string; + effectiveUserAgent?: string; + effectiveReferer?: string; + effectiveOrigin?: string; + mergedHeaders: Record; + contentInfo?: PlayerContentInfo; + startTime?: number; + state: VlcReuseAttemptState; + startPositionPolling: ( + port: number, + contentInfo: PlayerContentInfo, + sessionId: string, + onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void, + onStopped?: () => void + ) => void; + stopPositionPolling: () => void; +} + +/** Owns the one VLC child/RC port retained when instance reuse is enabled. */ +export class VlcReusableProcess { + private process: ChildProcess | null = null; + private rcPort: number | null = null; + private processSessionId: string | null = null; + private readonly processSessionIds = new WeakMap(); + + currentSessionId(): string | null { + return this.processSessionId; + } + + sessionIdFor(process: ChildProcess, fallback: string): string { + return this.processSessionIds.get(process) ?? fallback; + } + + owns(process: ChildProcess, sessionId: string): boolean { + return this.processSessionIds.get(process) === sessionId; + } + + track(process: ChildProcess, rcPort: number, sessionId: string): void { + this.process = process; + this.rcPort = rcPort; + this.processSessionId = sessionId; + this.processSessionIds.set(process, sessionId); + } + + clear(process: ChildProcess): boolean { + if (this.process !== process) return false; + this.process = null; + this.rcPort = null; + this.processSessionId = null; + return true; + } + + stopStored( + reason: string, + stopPositionPolling: () => void, + guardFutureLaunches = false + ): void { + const process = this.process; + if (!process || process.killed) return; + traceExternalPlayer(reason); + if (guardFutureLaunches) { + externalPlayerProcessTeardownGate.terminateInBackground(process); + } else { + process.kill(); + } + this.clear(process); + stopPositionPolling(); + } + + async tryReuse( + options: VlcReuseOptions + ): Promise { + const reusedProcess = this.process; + const reusedRcPort = this.rcPort; + if (!reusedProcess || reusedProcess.killed || !reusedRcPort) + return null; + + traceExternalPlayer('reuse existing vlc instance', { + rcPort: reusedRcPort, + }); + const { session, state } = options; + const reusedProcessSessionId = + this.processSessionIds.get(reusedProcess) ?? + options.previousProcessSessionId; + let closeRequested = false; + let retryableClose: Promise | null = null; + let launchClose: Promise | null = null; + + const ownsReusedProcess = () => + this.processSessionIds.get(reusedProcess) === session.id || + externalPlayerSessions.getActiveSessionId() === session.id; + const closeReusedProcess = async () => { + externalPlayerProcessTeardownGate.beginTeardown(reusedProcess); + try { + await sendVlcRcCommand(reusedRcPort, 'quit'); + } catch { + await externalPlayerProcessTeardownGate.terminate( + reusedProcess + ); + return; + } + await externalPlayerProcessTeardownGate.terminate(reusedProcess, { + sendTerminationSignal: false, + }); + }; + const finishRequestedClose = async () => { + if (launchClose) { + try { + await launchClose; + } catch (error) { + state.teardownUnconfirmed = true; + throw error; + } + } + return externalPlayerSessions.markClosed(session.id) ?? session; + }; + + externalPlayerSessions.attachCloser(session.id, () => { + closeRequested = true; + state.closeRequested = true; + if (!ownsReusedProcess()) return; + if (!retryableClose) { + const closeAttempt = closeReusedProcess(); + retryableClose = closeAttempt; + launchClose ??= closeAttempt; + void closeAttempt.catch((error) => { + if (retryableClose === closeAttempt) retryableClose = null; + state.teardownUnconfirmed = true; + externalPlayerSessions.markError( + session.id, + error instanceof Error ? error.message : String(error), + { canClose: true } + ); + }); + } + state.requestedClose = retryableClose; + return retryableClose; + }); + + try { + await sendVlcRcCommands( + reusedRcPort, + buildVlcEnqueueCommands({ + url: options.url, + title: options.title, + userAgent: options.effectiveUserAgent, + referer: options.effectiveReferer, + origin: options.effectiveOrigin, + headers: options.mergedHeaders, + startTime: options.startTime, + }), + (_command, index) => { + if (index !== 0) return; + state.contentMutated = true; + this.processSessionId = session.id; + this.processSessionIds.set(reusedProcess, session.id); + options.stopPositionPolling(); + }, + () => !closeRequested + ); + if (closeRequested) return await finishRequestedClose(); + traceExternalPlayer('loaded new url in existing vlc instance'); + + let lastSnapshot: ExternalPlaybackSnapshot | null = null; + if (options.contentInfo) { + options.startPositionPolling( + reusedRcPort, + options.contentInfo, + session.id, + (snapshot) => { + lastSnapshot = snapshot; + }, + () => { + if ( + lastSnapshot && + externalPlayerSessions.getSession(session.id) + ?.status !== 'closed' + ) { + sendPlaybackPositionUpdate( + session.id, + options.contentInfo as PlayerContentInfo, + lastSnapshot + ); + } + externalPlayerSessions.markClosed(session.id); + } + ); + } else { + options.stopPositionPolling(); + } + return externalPlayerSessions.markOpened(session.id) ?? session; + } catch (error) { + const current = externalPlayerSessions.getSession(session.id); + if (current?.status === 'closed') return current; + if (closeRequested) return await finishRequestedClose(); + console.error( + 'Failed to reuse existing VLC, spawning fresh:', + error + ); + + if (state.contentMutated) { + if (reusedProcessSessionId) { + this.processSessionIds.set( + reusedProcess, + reusedProcessSessionId + ); + } else { + this.processSessionIds.delete(reusedProcess); + } + } + try { + await externalPlayerProcessTeardownGate.terminate( + reusedProcess + ); + } catch (teardownError) { + if (state.contentMutated) { + this.processSessionIds.set(reusedProcess, session.id); + } + state.teardownUnconfirmed = true; + throw teardownError; + } + this.clear(reusedProcess); + options.stopPositionPolling(); + if (closeRequested) return await finishRequestedClose(); + return null; + } + } +} 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 b78363f61..f7f50de94 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 @@ -44,6 +44,7 @@ import { store, } from '../services/store.service'; import { externalPlayerSessions } from './external-player-runtime'; +import { externalPlayerProcessTeardownGate } from './external-player-process'; import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service'; const spawnMock = spawn as unknown as jest.Mock; @@ -52,8 +53,10 @@ const rcWrites: string[] = []; function createMockChildProcess(): ChildProcess { return Object.assign(new EventEmitter(), { + exitCode: null, killed: false, kill: jest.fn(() => true), + signalCode: null, stderr: null, stdout: null, unref: jest.fn(), @@ -163,6 +166,387 @@ describe('vlc-session.service process lifecycle', () => { expect(session.status).toBe('opened'); }); + it('quits a reused VLC process and waits for its exact exit on close', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + installRcSocketMock('ack'); + const session = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + rcWrites.length = 0; + + let closeSettled = false; + const closePromise = externalPlayerSessions + .closeSession(session.id) + .then((closed) => { + closeSettled = true; + return closed; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(rcWrites).toEqual(['quit\n']); + expect(closeSettled).toBe(false); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closePromise).resolves.toMatchObject({ + id: session.id, + status: 'closed', + }); + }); + + it('guards reused VLC teardown while its protocol quit is pending', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + const sockets: EventEmitter[] = []; + (createConnection as unknown as jest.Mock).mockImplementation( + () => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn((data: string) => { + if (data !== 'quit\n') { + setImmediate(() => + socket.emit('data', Buffer.from('> ')) + ); + } + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + sockets.push(socket); + setImmediate(() => socket.emit('connect')); + return socket; + } + ); + const session = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const closing = externalPlayerSessions.closeSession(session.id); + + try { + expect(() => + externalPlayerProcessTeardownGate.assertLaunchAllowed() + ).toThrow('previous external player is still shutting down'); + } finally { + while (sockets.length === 0) { + await Promise.resolve(); + } + Object.defineProperty(proc, 'exitCode', { value: 0 }); + sockets.at(-1)?.emit('data', Buffer.from('> ')); + proc.emit('exit', 0); + await closing; + } + }); + + it('guards a VLC content session while Stop flushes its position', async () => { + const proc = createMockChildProcess(); + (proc.kill as jest.Mock).mockImplementation(() => { + setImmediate(() => { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + }); + return true; + }); + spawnMock.mockReturnValueOnce(proc); + const opening = openVlcPlayer({ + title: 'Movie', + url: streamUrl, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 1, + contentType: 'vod', + }, + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await opening; + + const sockets: EventEmitter[] = []; + (createConnection as unknown as jest.Mock).mockImplementation( + () => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn((data: string) => { + rcWrites.push(data); + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + sockets.push(socket); + setImmediate(() => socket.emit('connect')); + return socket; + } + ); + + const closing = externalPlayerSessions.closeSession(session.id); + while (!rcWrites.includes('get_time\n')) { + await new Promise((resolve) => setImmediate(resolve)); + } + + try { + expect(() => + externalPlayerProcessTeardownGate.assertLaunchAllowed() + ).toThrow('previous external player is still shutting down'); + } finally { + sockets[0].emit('data', Buffer.from('> 12')); + while (sockets.length < 2) { + await new Promise((resolve) => + setImmediate(resolve) + ); + } + sockets[1].emit('data', Buffer.from('> 120')); + await closing; + } + }); + + it('does not spawn a replacement when Stop interrupts a reuse command', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + const sockets: EventEmitter[] = []; + (createConnection as unknown as jest.Mock).mockImplementation( + () => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn((data: string) => { + rcWrites.push(data); + if (data !== 'clear\n') { + setImmediate(() => + socket.emit('data', Buffer.from('> ')) + ); + } + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + sockets.push(socket); + setImmediate(() => socket.emit('connect')); + return socket; + } + ); + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + await new Promise((resolve) => setImmediate(resolve)); + expect(rcWrites).toEqual(['clear\n']); + + const closing = externalPlayerSessions.closeSession(replacementId); + await new Promise((resolve) => setImmediate(resolve)); + expect(rcWrites).toEqual(['clear\n', 'quit\n']); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + sockets[0].emit('error', new Error('connection closed')); + await new Promise((resolve) => setImmediate(resolve)); + + await expect(closing).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + await expect(opening).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('does not enqueue reused VLC content after Stop interrupts clear', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + const sockets: EventEmitter[] = []; + (createConnection as unknown as jest.Mock).mockImplementation( + () => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn((data: string) => { + rcWrites.push(data); + if (data === 'quit\n') { + setImmediate(() => + socket.emit('data', Buffer.from('> ')) + ); + } + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + sockets.push(socket); + setImmediate(() => socket.emit('connect')); + return socket; + } + ); + + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + await new Promise((resolve) => setImmediate(resolve)); + expect(rcWrites).toEqual(['clear\n']); + + const closing = externalPlayerSessions.closeSession(replacementId); + await new Promise((resolve) => setImmediate(resolve)); + expect(rcWrites).toEqual(['clear\n', 'quit\n']); + + sockets[0].emit('data', Buffer.from('> ')); + await new Promise((resolve) => setImmediate(resolve)); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + await expect(opening).resolves.toMatchObject({ status: 'closed' }); + expect(rcWrites).toEqual(['clear\n', 'quit\n']); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('does not spawn fresh when Stop arrives during failed VLC reuse teardown', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + installRcSocketMock('error'); + spawnMock.mockReturnValue(proc); + + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + for ( + let attempt = 0; + attempt < 20 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + + const closing = externalPlayerSessions.closeSession(replacementId); + await new Promise((resolve) => setImmediate(resolve)); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + setImmediate(() => proc.emit('spawn')); + + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + await expect(opening).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('does not spawn fresh when Stop arrives during fallback port allocation', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + (proc.kill as jest.Mock).mockImplementation(() => { + setImmediate(() => { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + }); + return true; + }); + let releaseClose: (() => void) | undefined; + (createConnection as unknown as jest.Mock) + .mockImplementationOnce(() => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn(), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + setImmediate(() => + socket.emit('error', new Error('rc connect failed')) + ); + return socket; + }) + .mockImplementationOnce(() => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn((data: string) => { + rcWrites.push(data); + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + releaseClose = () => socket.emit('data', Buffer.from('> ')); + setImmediate(() => socket.emit('connect')); + return socket; + }); + let releasePort: (() => void) | undefined; + (createServer as unknown as jest.Mock).mockImplementationOnce( + () => ({ + unref: jest.fn(), + on: jest.fn(), + listen: ( + _port: number, + _host: string, + cb: () => void + ) => { + releasePort = cb; + }, + address: () => ({ port: 43211 }), + close: (cb?: () => void) => cb?.(), + }) + ); + spawnMock.mockReturnValue(proc); + + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + for ( + let attempt = 0; + attempt < 20 && !releasePort; + attempt += 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(releasePort).toBeDefined(); + + const closing = externalPlayerSessions.closeSession(replacementId); + for ( + let attempt = 0; + attempt < 20 && !releaseClose; + attempt += 1 + ) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(releaseClose).toBeDefined(); + + // Resume the pending launch while Stop is still awaiting the old + // reuse closer. It must observe that cancellation instead of + // attaching a fresh child to the same session. + releasePort?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(spawnMock).toHaveBeenCalledTimes(1); + + releaseClose?.(); + + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + await expect(opening).resolves.toMatchObject({ + id: replacementId, + status: 'closed', + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + it('kills the stale instance and spawns fresh when RC reuse fails', async () => { const proc = createMockChildProcess(); await openTrackedVlcInstance(proc); @@ -174,16 +558,638 @@ 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'); }); + + it('keeps the fallback VLC session live after partial reuse failure', async () => { + const reusedProc = createMockChildProcess(); + await openTrackedVlcInstance(reusedProc); + (reusedProc.kill as jest.Mock).mockImplementation(() => { + setImmediate(() => { + Object.defineProperty(reusedProc, 'exitCode', { value: 0 }); + reusedProc.emit('exit', 0); + }); + return true; + }); + let connectionCount = 0; + (createConnection as unknown as jest.Mock).mockImplementation( + () => { + connectionCount += 1; + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn(() => { + setImmediate(() => + socket.emit('data', Buffer.from('> ')) + ); + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + setImmediate(() => { + if (connectionCount <= 2) { + socket.emit('connect'); + } else { + socket.emit('error', new Error('seek failed')); + } + }); + return socket; + } + ); + const freshProc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(freshProc); + + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + startTime: 120, + }); + await waitForSpawnCallCount(2); + freshProc.emit('spawn'); + const session = await opening; + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(session.status).toBe('opened'); + expect(externalPlayerSessions.getSession(session.id)?.status).toBe( + 'opened' + ); + }); + + it('restores the globally displaced session when reusable VLC teardown is unconfirmed', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + const displaced = externalPlayerSessions.beginSession({ + player: 'mpv', + title: 'Current MPV stream', + streamUrl: 'https://example.com/current-mpv.m3u8', + }); + externalPlayerSessions.attachCloser(displaced.id, jest.fn()); + externalPlayerSessions.markOpened(displaced.id); + installRcSocketMock('error'); + + jest.useFakeTimers(); + try { + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const rejection = expect(opening).rejects.toThrow( + 'External player process did not exit' + ); + + for ( + let attempt = 0; + attempt < 5 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await jest.runOnlyPendingTimersAsync(); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect(externalPlayerSessions.getActiveSessionId()).toBe( + displaced.id + ); + expect( + externalPlayerSessions.getSession(displaced.id) + ).toMatchObject({ status: 'opened', canClose: true }); + expect(spawnMock).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + } + }); + + it('keeps an orphaned reusable VLC teardown failure closable when the displaced session is terminal', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + const previousId = + externalPlayerSessions.getActiveSessionId() as string; + externalPlayerSessions.markError(previousId, 'Old terminal error'); + installRcSocketMock('error'); + + jest.useFakeTimers(); + try { + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + const replacementId = + externalPlayerSessions.getActiveSessionId() as string; + const rejection = expect(opening).rejects.toThrow( + 'External player process did not exit' + ); + + for ( + let attempt = 0; + attempt < 5 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await jest.runOnlyPendingTimersAsync(); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect( + externalPlayerSessions.getSession(replacementId) + ).toMatchObject({ + title: 'Second', + status: 'error', + canClose: true, + }); + } finally { + jest.useRealTimers(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + } + }); + + it('keeps replacement ownership when reused VLC content changed before teardown failed', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + const previousId = externalPlayerSessions.getActiveSessionId(); + let connectionCount = 0; + (createConnection as unknown as jest.Mock).mockImplementation( + () => { + connectionCount += 1; + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn(() => { + setImmediate(() => + socket.emit('data', Buffer.from('> ')) + ); + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + setImmediate(() => { + if (connectionCount <= 2) { + socket.emit('connect'); + } else { + socket.emit('error', new Error('seek failed')); + } + }); + return socket; + } + ); + + jest.useFakeTimers(); + try { + const opening = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + startTime: 120, + }); + const replacementId = + externalPlayerSessions.getActiveSessionId(); + const rejection = expect(opening).rejects.toThrow( + 'External player process did not exit' + ); + + for ( + let attempt = 0; + attempt < 5 && !(proc.kill as jest.Mock).mock.calls.length; + attempt += 1 + ) { + await jest.runOnlyPendingTimersAsync(); + } + expect(proc.kill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(5_000); + await rejection; + + expect(replacementId).not.toBe(previousId); + expect(externalPlayerSessions.getActiveSessionId()).toBe( + replacementId + ); + expect( + externalPlayerSessions.getSession(replacementId as string) + ).toMatchObject({ + title: 'Second', + status: 'error', + canClose: true, + }); + + Object.defineProperty(proc, 'killed', { value: true }); + await expect( + openVlcPlayer({ + title: 'Third', + url: 'https://example.com/three.m3u8', + }) + ).rejects.toThrow( + 'previous external player is still shutting down' + ); + expect(spawnMock).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + } + }); + + it('does not let a stale reusable VLC closer stop the remapped session', async () => { + const proc = createMockChildProcess(); + (proc.kill as jest.Mock).mockImplementation(() => { + Object.defineProperty(proc, 'exitCode', { + value: 0, + configurable: true, + }); + proc.emit('exit', 0); + return true; + }); + await openTrackedVlcInstance(proc); + const previousId = externalPlayerSessions.getActiveSessionId(); + installRcSocketMock('ack'); + const current = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + (proc.kill as jest.Mock).mockClear(); + + await expect( + externalPlayerSessions.closeSession(previousId as string) + ).resolves.toMatchObject({ status: 'closed' }); + + expect(proc.kill).not.toHaveBeenCalled(); + expect(externalPlayerSessions.getSession(current.id)).toMatchObject( + { status: 'opened', canClose: true } + ); + }); + + it('does not send a stale quit after a closed reused session is replaced', async () => { + const firstProc = createMockChildProcess(); + const nextProc = createMockChildProcess(); + await openTrackedVlcInstance(firstProc); + installRcSocketMock('ack'); + const reused = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + + spawnMock.mockReturnValueOnce(nextProc); + Object.defineProperty(firstProc, 'exitCode', { value: 0 }); + firstProc.emit('exit', 0); + const opening = openVlcPlayer({ + title: 'Third', + url: 'https://example.com/three.m3u8', + }); + await waitForSpawnCallCount(2); + nextProc.emit('spawn'); + const current = await opening; + rcWrites.length = 0; + + await expect( + externalPlayerSessions.closeSession(reused.id) + ).resolves.toMatchObject({ status: 'closed' }); + + expect(rcWrites).toEqual([]); + expect(nextProc.kill).not.toHaveBeenCalled(); + expect(externalPlayerSessions.getSession(current.id)).toMatchObject( + { status: 'opened', canClose: true } + ); + }); + + it('allows Stop to retry a reused VLC teardown after confirmation times out', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + installRcSocketMock('ack'); + const reused = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + installRcSocketMock('error'); + + jest.useFakeTimers(); + try { + const firstClose = externalPlayerSessions.closeSession( + reused.id + ); + const firstRejection = expect(firstClose).rejects.toThrow( + 'External player process did not exit' + ); + await jest.advanceTimersByTimeAsync(5_000); + await firstRejection; + const killsAfterFirstAttempt = (proc.kill as jest.Mock).mock + .calls.length; + + const retry = externalPlayerSessions.closeSession(reused.id); + await jest.advanceTimersByTimeAsync(0); + expect( + (proc.kill as jest.Mock).mock.calls.length + ).toBeGreaterThan(killsAfterFirstAttempt); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(retry).resolves.toMatchObject({ + status: 'closed', + }); + } finally { + jest.useRealTimers(); + } + }); }); describe('process exit handling', () => { + it('keeps a reused-mode VLC child closable when RC port allocation fails', async () => { + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + }); + let rejectPort: ((error: Error) => void) | undefined; + (createServer as unknown as jest.Mock).mockImplementation(() => ({ + unref: jest.fn(), + on: jest.fn((event: string, listener: (error: Error) => void) => { + if (event === 'error') rejectPort = listener; + }), + listen: () => + setImmediate(() => + rejectPort?.(new Error('port allocation failed')) + ), + address: () => null, + close: jest.fn(), + })); + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + + const opening = openVlcPlayer({ title: 'Fallback', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await opening; + + const closing = externalPlayerSessions.closeSession(session.id); + await new Promise((resolve) => setImmediate(resolve)); + + expect(proc.kill).toHaveBeenCalledTimes(1); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + }); + + it('rechecks teardown immediately before a delayed spawn', async () => { + let releasePort: (() => void) | undefined; + (createServer as unknown as jest.Mock).mockImplementation(() => ({ + unref: jest.fn(), + on: jest.fn(), + listen: (_port: number, _host: string, cb: () => void) => { + releasePort = cb; + }, + address: () => ({ port: 43210 }), + close: (cb?: () => void) => cb?.(), + })); + const opening = openVlcPlayer({ + title: 'Delayed', + url: streamUrl, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 1, + contentType: 'vod', + }, + }); + while (!releasePort) { + await Promise.resolve(); + } + + const blocker = createMockChildProcess(); + const unexpected = createMockChildProcess(); + spawnMock.mockReturnValueOnce(unexpected); + try { + externalPlayerProcessTeardownGate.terminateInBackground( + blocker + ); + releasePort(); + setImmediate(() => unexpected.emit('spawn')); + + await expect(opening).rejects.toThrow( + 'previous external player is still shutting down' + ); + expect(spawnMock).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(blocker, 'exitCode', { value: 0 }); + blocker.emit('exit', 0); + Object.defineProperty(unexpected, 'exitCode', { value: 0 }); + unexpected.emit('exit', 0); + // The fixed path never consumes the one-shot spawn result; + // do not let it leak into the next lifecycle case. + spawnMock.mockReset(); + } + }); + + it('settles a fresh VLC launch when Stop wins before spawn', async () => { + const proc = createMockChildProcess(); + (proc.kill as jest.Mock).mockImplementation(() => { + Object.defineProperty(proc, 'killed', { value: true }); + setImmediate(() => { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + }); + return true; + }); + spawnMock.mockReturnValueOnce(proc); + + const opening = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + const sessionId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(sessionId); + + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + + await expect(opening).resolves.toMatchObject({ + id: sessionId, + status: 'closed', + }); + }); + + it('settles a fresh VLC launch when bounded Stop cannot confirm exit', async () => { + const proc = createMockChildProcess(); + try { + (proc.kill as jest.Mock).mockImplementation(() => { + Object.defineProperty(proc, 'killed', { + value: true, + configurable: true, + }); + return true; + }); + spawnMock.mockReturnValueOnce(proc); + + const opening = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + jest.useFakeTimers(); + let launchSettled = false; + void opening.then( + () => { + launchSettled = true; + }, + () => { + launchSettled = true; + } + ); + const sessionId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(sessionId); + const closeRejection = expect(closing).rejects.toThrow( + 'External player process did not exit' + ); + + await jest.advanceTimersByTimeAsync(5_000); + await closeRejection; + await Promise.resolve(); + + expect(launchSettled).toBe(true); + expect( + externalPlayerSessions.getSession(sessionId) + ).toMatchObject({ status: 'error', canClose: true }); + } finally { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + jest.useRealTimers(); + } + }); + + it('allows Stop to retry a fresh VLC teardown after confirmation times out', async () => { + const proc = createMockChildProcess(); + (proc.kill as jest.Mock).mockImplementation(() => true); + spawnMock.mockReturnValueOnce(proc); + const opening = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await opening; + + jest.useFakeTimers(); + try { + const firstClose = externalPlayerSessions.closeSession( + session.id + ); + const firstRejection = expect(firstClose).rejects.toThrow( + 'External player process did not exit' + ); + await jest.advanceTimersByTimeAsync(5_000); + await firstRejection; + const killsAfterFirstAttempt = (proc.kill as jest.Mock).mock + .calls.length; + + const retry = externalPlayerSessions.closeSession(session.id); + await Promise.resolve(); + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(retry).resolves.toMatchObject({ + status: 'closed', + }); + expect( + (proc.kill as jest.Mock).mock.calls.length + ).toBeGreaterThan(killsAfterFirstAttempt); + } finally { + jest.useRealTimers(); + } + }); + + it('marks an opened VLC session failed when its fallback spawn is blocked', async () => { + const initial = createMockChildProcess(); + spawnMock.mockReturnValueOnce(initial); + const opening = openVlcPlayer({ + title: 'S', + url: streamUrl, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 1, + contentType: 'vod', + }, + }); + await waitForSpawnCallCount(1); + initial.emit('spawn'); + const session = await opening; + const blocker = createMockChildProcess(); + externalPlayerProcessTeardownGate.terminateInBackground(blocker); + + try { + Object.defineProperty(initial, 'exitCode', { value: 1 }); + initial.emit('exit', 1); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect( + externalPlayerSessions.getSession(session.id) + ).toMatchObject({ status: 'error', canClose: false }); + } finally { + Object.defineProperty(blocker, 'exitCode', { value: 0 }); + blocker.emit('exit', 0); + } + }); + + it('settles a stopped VLC launch when spawn errors then only closes', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + + const opening = openVlcPlayer({ title: 'S', url: streamUrl }); + const sessionId = + externalPlayerSessions.getActiveSessionId() as string; + const closing = externalPlayerSessions.closeSession(sessionId); + let launchResult: unknown; + void opening.then((result) => { + launchResult = result; + }); + + proc.emit( + 'error', + Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) + ); + Object.defineProperty(proc, 'exitCode', { value: -2 }); + proc.emit('close', -2, null); + + await expect(closing).resolves.toMatchObject({ status: 'closed' }); + await new Promise((resolve) => setImmediate(resolve)); + expect(launchResult).toMatchObject({ + id: sessionId, + status: 'closed', + }); + }); + + it('waits for a detached VLC process to exit before closing its session', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await openPromise; + + let closeSettled = false; + const closePromise = externalPlayerSessions + .closeSession(session.id) + .then((closed) => { + closeSettled = true; + return closed; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(closeSettled).toBe(false); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closePromise).resolves.toMatchObject({ + id: session.id, + status: 'closed', + }); + }); + it('marks the session closed on a clean exit', async () => { const proc = createMockChildProcess(); spawnMock.mockReturnValueOnce(proc); @@ -262,9 +1268,7 @@ describe('vlc-session.service process lifecycle', () => { }); const proc = createMockChildProcess(); const retryProc = createMockChildProcess(); - spawnMock - .mockReturnValueOnce(proc) - .mockReturnValueOnce(retryProc); + spawnMock.mockReturnValueOnce(proc).mockReturnValueOnce(retryProc); const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); await waitForSpawnCallCount(1); 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 a8ad22e6a..99cef348d 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -1,5 +1,5 @@ -import { ChildProcess, spawn } from 'child_process'; -import { AddressInfo, createConnection, createServer } from 'net'; +import { spawn } from 'child_process'; +import { AddressInfo, createServer } from 'net'; import { PlayerContentInfo } from '@iptvnator/shared/interfaces'; import { VLC_PLAYER_ARGUMENTS, @@ -28,6 +28,17 @@ import { sendPlayerErrorNotification, traceExternalPlayer, } from './external-player-runtime'; +import { externalPlayerProcessTeardownGate } from './external-player-process'; +import { getVlcPlaybackSnapshot, getVlcPlaybackState } from './vlc-rc'; +export { + buildVlcEnqueueCommands, + parseVlcRcNumericResponse, + parseVlcRcPlaybackState, +} from './vlc-rc'; +import { + VlcReusableProcess, + VlcReuseAttemptState, +} from './vlc-reusable-process'; export interface OpenVlcPlayerRequest { url: string; @@ -41,8 +52,7 @@ export interface OpenVlcPlayerRequest { headers?: Record; } -let vlcProcess: ChildProcess | null = null; -let vlcRcPort: number | null = null; +const reusableVlcProcess = new VlcReusableProcess(); let vlcPollingInterval: NodeJS.Timeout | null = null; function getVlcPath(options: PlayerPathOptions = {}): string { @@ -52,103 +62,6 @@ function getVlcPath(options: PlayerPathOptions = {}): string { ); } -export function buildVlcEnqueueCommands(options: { - url: string; - title?: string; - userAgent?: string; - referer?: string; - origin?: string; - headers?: Record; - startTime?: number; -}): string[] { - const inputOptions: string[] = []; - - if (options.userAgent) { - inputOptions.push(`:http-user-agent=${options.userAgent}`); - } - if (options.referer) { - inputOptions.push(`:http-referrer=${options.referer}`); - } else if (options.origin) { - inputOptions.push(`:http-referrer=${options.origin}`); - } - Object.entries(options.headers ?? {}).forEach(([name, value]) => { - if (!name || value === undefined || value === null) return; - const trimmedValue = String(value).trim(); - if (!trimmedValue) return; - inputOptions.push(`:http-header=${name}: ${trimmedValue}`); - }); - if (options.title) { - inputOptions.push(`:meta-title=${options.title}`); - } - - const inputLine = - inputOptions.length > 0 - ? `${options.url} ${inputOptions.join(' ')}` - : options.url; - - const commands = ['clear', `add ${inputLine}`]; - - if (options.startTime && Number.isFinite(options.startTime)) { - commands.push(`seek ${Math.floor(options.startTime)}`); - } - - return commands; -} - -function sendVlcRcCommand(port: number, command: string): Promise { - return new Promise((resolve, reject) => { - const client = createConnection({ port, host: '127.0.0.1' }); - let settled = false; - - const finish = (err?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeoutHandle); - if (!client.destroyed) client.destroy(); - if (err) { - reject(err); - return; - } - - resolve(); - }; - - const timeoutHandle = setTimeout( - () => finish(new Error('VLC RC command timed out')), - 2000 - ); - - client.on('connect', () => { - client.write(`${command}\n`); - }); - client.on('data', (chunk) => { - if (chunk.toString().includes('>')) { - finish(); - } - }); - client.on('error', (err) => finish(err)); - }); -} - -async function sendVlcRcCommands( - port: number, - commands: string[] -): Promise { - for (const command of commands) { - await sendVlcRcCommand(port, command); - } -} - -export function parseVlcRcNumericResponse(data: string): string { - const match = data.match(/>\s*(-?\d+(?:\.\d+)?)/); - return match ? match[1] : ''; -} - -export function parseVlcRcPlaybackState(data: string): string | null { - const match = data.match(/\(\s*state\s+([^)]+)\s*\)/i); - return match ? match[1].trim().toLowerCase() : null; -} - function stopVlcPositionPolling(): void { if (vlcPollingInterval) { clearInterval(vlcPollingInterval); @@ -156,69 +69,6 @@ function stopVlcPositionPolling(): void { } } -async function getVlcCommandResponse( - port: number, - command: string -): Promise { - return new Promise((resolve) => { - const client = createConnection({ port, host: '127.0.0.1' }); - let data = ''; - let resolved = false; - - const done = (result: string) => { - if (resolved) return; - resolved = true; - clearTimeout(timeoutHandle); - if (!client.destroyed) client.destroy(); - resolve(result); - }; - - const timeoutHandle = setTimeout(() => done(''), 2000); - - client.on('connect', () => { - client.write(command + '\n'); - }); - - client.on('data', (chunk) => { - data += chunk.toString(); - if (data.includes('>')) { - done(data); - } - }); - - client.on('error', () => done('')); - }); -} - -async function getVlcProperty(port: number, command: string): Promise { - return parseVlcRcNumericResponse( - await getVlcCommandResponse(port, command) - ); -} - -async function getVlcPlaybackState(port: number): Promise { - return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status')); -} - -async function getVlcPlaybackSnapshot( - port: number -): Promise { - const timeStr = await getVlcProperty(port, 'get_time'); - const lenStr = await getVlcProperty(port, 'get_length'); - - const position = parseInt(timeStr, 10); - const duration = parseInt(lenStr, 10); - - if (isNaN(position)) { - return null; - } - - return { - positionSeconds: position, - durationSeconds: !isNaN(duration) ? duration : null, - }; -} - function startVlcPositionPolling( port: number, contentInfo: PlayerContentInfo, @@ -270,23 +120,16 @@ function getFreePort(): Promise { }); } -function killStoredVlcProcess(reason: string): void { - if (!vlcProcess || vlcProcess.killed) { - return; - } - traceExternalPlayer(reason); - vlcProcess.kill(); - vlcProcess = null; - vlcRcPort = null; - stopVlcPositionPolling(); -} - export function setVlcReuseInstance(reuseInstance: boolean): void { traceExternalPlayer('set vlc reuse instance', { reuseInstance }); store.set(VLC_REUSE_INSTANCE, reuseInstance); if (!reuseInstance) { - killStoredVlcProcess('clean up vlc process after disabling reuse'); + reusableVlcProcess.stopStored( + 'clean up vlc process after disabling reuse', + stopVlcPositionPolling, + true + ); } } @@ -296,7 +139,10 @@ export function setVlcReuseInstance(reuseInstance: boolean): void { * playing after quit. */ export function shutdownVlcSession(): void { - killStoredVlcProcess('kill reused vlc process on app shutdown'); + reusableVlcProcess.stopStored( + 'kill reused vlc process on app shutdown', + stopVlcPositionPolling + ); } export async function openVlcPlayer({ @@ -310,6 +156,9 @@ export async function openVlcPlayer({ startTime, headers, }: OpenVlcPlayerRequest) { + externalPlayerProcessTeardownGate.assertLaunchAllowed(); + const displacedSessionId = externalPlayerSessions.getActiveSessionId(); + const previousProcessSessionId = reusableVlcProcess.currentSessionId(); const session = externalPlayerSessions.beginSession({ player: 'vlc', title, @@ -317,6 +166,13 @@ export async function openVlcPlayer({ streamUrl: url, contentInfo, }); + const reuseState: VlcReuseAttemptState = { + teardownUnconfirmed: false, + contentMutated: false, + closeRequested: false, + requestedClose: null, + }; + let freshTeardownUnconfirmed = false; try { const isFlatpak = isRunningInFlatpak(); @@ -358,79 +214,23 @@ export async function openVlcPlayer({ parseExternalPlayerArguments(customVlcArguments).length, }); - if (reuseInstance && vlcProcess && !vlcProcess.killed && vlcRcPort) { - traceExternalPlayer('reuse existing vlc instance', { - rcPort: vlcRcPort, + if (reuseInstance) { + const reused = await reusableVlcProcess.tryReuse({ + session, + previousProcessSessionId, + url, + title, + effectiveUserAgent, + effectiveReferer, + effectiveOrigin, + mergedHeaders, + contentInfo, + startTime, + state: reuseState, + startPositionPolling: startVlcPositionPolling, + stopPositionPolling: stopVlcPositionPolling, }); - try { - const enqueueCommands = buildVlcEnqueueCommands({ - url, - title, - userAgent: effectiveUserAgent, - referer: effectiveReferer, - origin: effectiveOrigin, - headers: mergedHeaders, - startTime, - }); - await sendVlcRcCommands(vlcRcPort, enqueueCommands); - traceExternalPlayer('loaded new url in existing vlc instance'); - - const reusedRcPort = vlcRcPort; - let lastReusedSnapshot: ExternalPlaybackSnapshot | null = null; - externalPlayerSessions.attachCloser(session.id, async () => { - try { - await sendVlcRcCommand(reusedRcPort, 'stop'); - } catch { - if (vlcProcess && !vlcProcess.killed) { - vlcProcess.kill(); - } - } - }); - - if (contentInfo) { - startVlcPositionPolling( - reusedRcPort, - contentInfo, - session.id, - (snapshot) => { - lastReusedSnapshot = snapshot; - }, - () => { - if ( - lastReusedSnapshot && - externalPlayerSessions.getSession(session.id) - ?.status !== 'closed' - ) { - sendPlaybackPositionUpdate( - session.id, - contentInfo, - lastReusedSnapshot - ); - } - externalPlayerSessions.markClosed(session.id); - } - ); - } else { - stopVlcPositionPolling(); - } - - return externalPlayerSessions.markOpened(session.id) ?? session; - } catch (err) { - console.error( - 'Failed to reuse existing VLC, spawning fresh:', - err - ); - if (vlcProcess && !vlcProcess.killed) { - try { - vlcProcess.kill(); - } catch { - // Ignore cleanup failures. - } - } - vlcProcess = null; - vlcRcPort = null; - stopVlcPositionPolling(); - } + if (reused) return reused; } let rcPort = 0; @@ -488,6 +288,8 @@ export async function openVlcPlayer({ await new Promise((resolve, reject) => { let settled = false; + let closeRequested = false; + let requestedClose: Promise | null = null; const resolveSpawn = () => { if (settled) { @@ -508,6 +310,50 @@ export async function openVlcPlayer({ }; const spawnVlc = (playerArgs: string[], isRetry = false) => { + if (reuseState.closeRequested) { + closeRequested = true; + const pendingReuseClose = + reuseState.requestedClose ?? Promise.resolve(); + void pendingReuseClose.then(() => { + externalPlayerSessions.markClosed(session.id); + resolveSpawn(); + }, rejectSpawn); + return; + } + if ( + externalPlayerSessions.getSession(session.id)?.status === + 'closed' + ) { + closeRequested = true; + resolveSpawn(); + return; + } + try { + // Port allocation and reuse fallback both yield. Another + // exact child can enter teardown during either wait, so + // the process-wide invariant must be checked at the + // actual spawn boundary as well as at request entry. + externalPlayerProcessTeardownGate.assertLaunchAllowed(); + } catch (error) { + const launchError = + error instanceof Error + ? error + : new Error(String(error)); + if (settled) { + const current = externalPlayerSessions.getSession( + session.id + ); + if (current?.status !== 'closed') { + externalPlayerSessions.markError( + session.id, + launchError.message + ); + } + } else { + rejectSpawn(launchError); + } + return; + } const spawnSpec = buildExternalPlayerSpawnSpec( vlcLaunchContext, buildPlayerArgsWithCustomArguments( @@ -515,18 +361,24 @@ export async function openVlcPlayer({ playerArgs ) ); - const trackProcess = reuseInstance && !isRetry; + // Reuse ownership exists only when an RC port was allocated. + // Without it this child is a normal one-shot process whose + // exact session closer must still terminate it. + const trackProcess = reuseInstance && !isRetry && rcPort > 0; const proc = spawn(spawnSpec.command, spawnSpec.args, { shell: false, detached: !trackProcess, stdio: trackProcess ? ['ignore', 'pipe', 'pipe'] : 'ignore', }); - proc.once('spawn', resolveSpawn); + proc.once('spawn', () => { + if (!closeRequested) { + resolveSpawn(); + } + }); if (trackProcess && rcPort > 0) { - vlcProcess = proc; - vlcRcPort = rcPort; + reusableVlcProcess.track(proc, rcPort, session.id); traceExternalPlayer('tracking vlc process for reuse', { rcPort, }); @@ -571,11 +423,45 @@ export async function openVlcPlayer({ ); }; - externalPlayerSessions.attachCloser(session.id, async () => { - await flushVlcPlaybackPosition(); - if (!proc.killed) { - proc.kill(); + externalPlayerSessions.attachCloser(session.id, () => { + closeRequested = true; + if ( + trackProcess && + !reusableVlcProcess.owns(proc, session.id) + ) { + return; } + if (!requestedClose) { + // Position flush uses two bounded RC requests. Guard + // the exact child before either request yields so no + // replacement can reuse or overlap it while Stop is + // still preparing the teardown. + externalPlayerProcessTeardownGate.beginTeardown(proc); + const closeAttempt = (async () => { + await flushVlcPlaybackPosition(); + await externalPlayerProcessTeardownGate.terminate( + proc + ); + })(); + requestedClose = closeAttempt; + void closeAttempt.catch((error) => { + if (requestedClose === closeAttempt) { + requestedClose = null; + } + const teardownError = + error instanceof Error + ? error + : new Error(String(error)); + freshTeardownUnconfirmed = true; + externalPlayerSessions.markError( + session.id, + teardownError.message, + { canClose: true } + ); + rejectSpawn(teardownError); + }); + } + return requestedClose; }); if (!isRetry && rcPort > 0 && contentInfo) { @@ -612,9 +498,21 @@ export async function openVlcPlayer({ proc.on('error', (err) => { console.error('Failed to start VLC player:', err); - if (vlcProcess === proc) { - vlcProcess = null; - vlcRcPort = null; + const processSessionId = reusableVlcProcess.sessionIdFor( + proc, + session.id + ); + reusableVlcProcess.clear(proc); + if (closeRequested) { + void requestedClose?.then(() => { + // A spawn failure reports `close` without an + // `exit` event. Close the exact session before + // settling OPEN_VLC_PLAYER so the renderer + // cannot receive a stale `opened` result. + externalPlayerSessions.markClosed(session.id); + resolveSpawn(); + }, rejectSpawn); + return; } if (!isRetry && rcPort > 0) { traceExternalPlayer( @@ -629,7 +527,7 @@ export async function openVlcPlayer({ spawnVlc(retryArgs, true); } else { externalPlayerSessions.markError( - session.id, + processSessionId, `Failed to start VLC player: ${err.message}` ); rejectSpawn( @@ -640,13 +538,15 @@ export async function openVlcPlayer({ proc.on('exit', (code) => { traceExternalPlayer('vlc exited', { code }); - if (vlcProcess === proc) { - vlcProcess = null; - vlcRcPort = null; - } + const processSessionId = reusableVlcProcess.sessionIdFor( + proc, + session.id + ); + reusableVlcProcess.clear(proc); stopVlcPositionPolling(); if ( + !closeRequested && lastVlcSnapshot && contentInfo && externalPlayerSessions.getSession(session.id) @@ -659,7 +559,12 @@ export async function openVlcPlayer({ ); } - if (code === 1 && !isRetry && rcPort > 0) { + if ( + code === 1 && + !closeRequested && + !isRetry && + rcPort > 0 + ) { traceExternalPlayer( 'retry vlc without rc interface after exit' ); @@ -683,13 +588,15 @@ export async function openVlcPlayer({ `VLC player closed unexpectedly (exit code: ${code})` ); externalPlayerSessions.markError( - session.id, + processSessionId, `VLC player closed unexpectedly (exit code: ${code})` ); + resolveSpawn(); return; } - externalPlayerSessions.markClosed(session.id); + externalPlayerSessions.markClosed(processSessionId); + resolveSpawn(); }); if (!trackProcess) { @@ -703,9 +610,24 @@ export async function openVlcPlayer({ return externalPlayerSessions.markOpened(session.id) ?? session; } catch (error) { console.error('Error opening VLC player:', error); + const restoredSession = + reuseState.teardownUnconfirmed && + !reuseState.contentMutated && + displacedSessionId + ? externalPlayerSessions.restoreActiveSession( + displacedSessionId, + session.id + ) + : null; externalPlayerSessions.markError( session.id, - error instanceof Error ? error.message : String(error) + error instanceof Error ? error.message : String(error), + { + canClose: + freshTeardownUnconfirmed || + (reuseState.teardownUnconfirmed && + (reuseState.contentMutated || !restoredSession)), + } ); throw error; } 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 f434c79bc..ab7fee381 100644 --- a/apps/web/src/app/services/external-playback.service.spec.ts +++ b/apps/web/src/app/services/external-playback.service.spec.ts @@ -2,9 +2,7 @@ import { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; import { ExternalPlaybackService } from './external-playback.service'; describe('ExternalPlaybackService', () => { - let listener: - | ((session: ExternalPlayerSession) => void) - | undefined; + let listener: ((session: ExternalPlayerSession) => void) | undefined; let closeExternalPlayerSession: jest.Mock; let service: ExternalPlaybackService; @@ -94,7 +92,9 @@ describe('ExternalPlaybackService', () => { contentType: 'vod', }) ).toBeNull(); - expect(service.visibleSession()).toBeNull(); + expect(service.visibleSession()).toEqual( + expect.objectContaining({ status: 'error' }) + ); }); it('delegates close requests for closable sessions', async () => { @@ -133,18 +133,48 @@ describe('ExternalPlaybackService', () => { expect(closeExternalPlayerSession).toHaveBeenCalledWith('session-2'); }); - it('hides terminal sessions from the dock', () => { + it('hides closed sessions but keeps errors visible until dismissed', () => { listener?.(createSession({ status: 'closed', canClose: false })); expect(service.visibleSession()).toBeNull(); - listener?.( - createSession({ - id: 'session-3', - status: 'error', - error: 'Launch failed', - canClose: false, - }) - ); + const failed = createSession({ + id: 'session-3', + status: 'error', + error: 'Launch failed', + canClose: false, + }); + listener?.(createSession({ id: failed.id })); + listener?.(failed); + + expect(service.visibleSession()).toEqual(failed); + + service.dismissActiveSession(); expect(service.visibleSession()).toBeNull(); }); + + it('adopts an exactly correlated restored session after replacement teardown fails', () => { + const previous = createSession({ + id: 'session-previous', + status: 'opened', + title: 'Previous stream', + }); + const replacement = createSession({ + id: 'session-replacement', + title: 'Replacement stream', + }); + listener?.(previous); + listener?.(replacement); + + listener?.({ + ...previous, + restoredFromSessionId: replacement.id, + updatedAt: '2026-03-07T10:00:20.000Z', + }); + + expect(service.activeSession()).toMatchObject({ + id: previous.id, + status: 'opened', + }); + expect(service.visibleSession()?.id).toBe(previous.id); + }); }); diff --git a/apps/web/src/app/services/external-playback.service.ts b/apps/web/src/app/services/external-playback.service.ts index 100476c7d..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,8 @@ import { computed, Injectable, signal } from '@angular/core'; -import { ExternalPlayerSession, PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; @Injectable({ providedIn: 'root', @@ -10,11 +13,7 @@ export class ExternalPlaybackService { readonly visibleSession = computed(() => { const session = this.activeSession(); - if ( - !session || - session.status === 'closed' || - session.status === 'error' - ) { + if (!session || session.status === 'closed') { return null; } @@ -60,9 +59,8 @@ export class ExternalPlaybackService { this.dismissedSessionId.set(session.id); try { - const updatedSession = await window.electron.closeExternalPlayerSession( - session.id - ); + const updatedSession = + await window.electron.closeExternalPlayerSession(session.id); if (updatedSession) { this.handleSessionUpdate(updatedSession); return; @@ -105,12 +103,19 @@ export class ExternalPlaybackService { private handleSessionUpdate(session: ExternalPlayerSession): void { const current = this.activeSession(); + const restoresCurrentReplacement = + current?.id === session.restoredFromSessionId; - if (!current || current.id === session.id || session.status === 'launching') { + if ( + !current || + current.id === session.id || + session.status === 'launching' || + restoresCurrentReplacement + ) { this.activeSession.set(session); } - if (session.status === 'launching') { + if (session.status === 'launching' || restoresCurrentReplacement) { this.dismissedSessionId.set(null); } } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 6985c5e0b..2c534b2eb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "هذا الهدف متوافق مع بيانات التشفير المتاحة", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "المفضلة", "RAIL_DOWNLOADS": "التنزيلات", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "جميع القنوات", "RAIL_GROUPS": "المجموعات", "RAIL_CONTEXT_ACTIONS": "إجراءات القسم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index a49b3ec9d..f3c638c5c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "هاد الهدف متوافق مع بيانات التشفير المتوفرة", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "المفضلة", "RAIL_DOWNLOADS": "التحميلات", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "جميع القنوات", "RAIL_GROUPS": "المجموعات", "RAIL_CONTEXT_ACTIONS": "إجراءات القسم", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e1b95deaa..96f173d40 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Гэтая мэта сумяшчальная з даступнымі данымі шыфравання", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Выбранае", "RAIL_DOWNLOADS": "Спампаванні", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Усе каналы", "RAIL_GROUPS": "Групы", "RAIL_CONTEXT_ACTIONS": "Дзеянні раздзела", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index fedf12e47..5a54a010d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Dieses Ziel ist mit den verfügbaren Verschlüsselungsdaten kompatibel", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "MPV wird geöffnet…", + "ACTION_OPENING_VLC": "VLC wird geöffnet…", + "ACTION_REOPEN_MPV": "MPV erneut öffnen", + "ACTION_REOPEN_VLC": "VLC erneut öffnen", + "ACTION_RETRY_MPV": "MPV erneut versuchen", + "ACTION_RETRY_VLC": "VLC erneut versuchen", + "EXTERNAL_OPENING": "Player wird geöffnet…", + "EXTERNAL_STARTED": "Player gestartet", + "EXTERNAL_PLAYING": "Wiedergabe läuft", + "EXTERNAL_FAILED": "Fehler im externen Player", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favoriten", "RAIL_DOWNLOADS": "Downloads", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Ausblenden", + "EXTERNAL_PLAYBACK_OPENING": "Player wird geöffnet…", + "EXTERNAL_PLAYBACK_STARTED": "Player gestartet", + "EXTERNAL_PLAYBACK_PLAYING": "Wiedergabe läuft", + "EXTERNAL_PLAYBACK_FAILED": "Fehler im externen Player", + "EXTERNAL_PLAYBACK_CLOSED": "Geschlossen", "RAIL_ALL_CHANNELS": "Alle Sender", "RAIL_GROUPS": "Gruppen", "RAIL_CONTEXT_ACTIONS": "Bereichsaktionen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 04f06a43d..2cefa7898 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Αυτός ο στόχος είναι συμβατός με τα διαθέσιμα δεδομένα κρυπτογράφησης", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Αγαπημένα", "RAIL_DOWNLOADS": "Λήψεις", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Όλα τα κανάλια", "RAIL_GROUPS": "Ομάδες", "RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c8f540f98..f35662d4f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy stream URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favorites", "RAIL_DOWNLOADS": "Downloads", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "All channels", "RAIL_GROUPS": "Groups", "RAIL_CONTEXT_ACTIONS": "Section actions", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4ab2372df..691d75580 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Este destino es compatible con los datos de cifrado disponibles", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favoritos", "RAIL_DOWNLOADS": "Descargas", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Todos los canales", "RAIL_GROUPS": "Grupos", "RAIL_CONTEXT_ACTIONS": "Acciones de la sección", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 0625168e5..cae45cc6e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Cette cible est compatible avec les données de chiffrement disponibles", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favoris", "RAIL_DOWNLOADS": "Téléchargements", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Toutes les chaînes", "RAIL_GROUPS": "Groupes", "RAIL_CONTEXT_ACTIONS": "Actions de section", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index ba496ef2a..15602430b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Ez a cél kompatibilis az elérhető titkosítási adatokkal", "ACTION_OPEN_MPV": "Megnyitás MPV-ben", "ACTION_OPEN_VLC": "Megnyitás VLC-ben", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "beállított lejátszó", "ACTION_OPEN_VLC_HINT": "beállított lejátszó", "ACTION_COPY_URL": "Adatfolyam URL-címének másolása", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Kedvencek", "RAIL_DOWNLOADS": "Letöltések", "EXTERNAL_PLAYBACK_CLOSE": "Lejátszó bezárása", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Összes csatorna", "RAIL_GROUPS": "Csoportok", "RAIL_CONTEXT_ACTIONS": "A szakasz műveletei", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f9b5739f4..d1c5ae2b7 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Questa destinazione è compatibile con i dati di crittografia disponibili", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Preferiti", "RAIL_DOWNLOADS": "Download", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Tutti i canali", "RAIL_GROUPS": "Gruppi", "RAIL_CONTEXT_ACTIONS": "Azioni di sezione", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 5538244c3..6f687b8c2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "この対象は利用可能な暗号化データに対応しています", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "お気に入り", "RAIL_DOWNLOADS": "ダウンロード", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "すべてのチャンネル", "RAIL_GROUPS": "グループ", "RAIL_CONTEXT_ACTIONS": "セクションのアクション", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 747f036cd..23d63b858 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "이 대상은 사용 가능한 암호화 데이터와 호환됩니다", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "즐겨찾기", "RAIL_DOWNLOADS": "다운로드", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "모든 채널", "RAIL_GROUPS": "그룹", "RAIL_CONTEXT_ACTIONS": "섹션 작업", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index e1c7b1287..2b80527b1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Dit doel is compatibel met de beschikbare versleutelingsgegevens", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favorieten", "RAIL_DOWNLOADS": "Downloads", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Alle kanalen", "RAIL_GROUPS": "Groepen", "RAIL_CONTEXT_ACTIONS": "Sectieacties", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index dcbf370a8..36d1b3dfd 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Ten cel jest zgodny z dostępnymi danymi szyfrowania", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Ulubione", "RAIL_DOWNLOADS": "Pobrane", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Wszystkie kanały", "RAIL_GROUPS": "Grupy", "RAIL_CONTEXT_ACTIONS": "Akcje sekcji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e2ca58bfd..79d89b736 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Este destino é compatível com os dados de criptografia disponíveis", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favoritos", "RAIL_DOWNLOADS": "Downloads", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Todos os canais", "RAIL_GROUPS": "Grupos", "RAIL_CONTEXT_ACTIONS": "Ações da seção", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 569d5657c..9cf9ba21b 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Эта цель совместима с доступными данными шифрования", "ACTION_OPEN_MPV": "Открыть в MPV", "ACTION_OPEN_VLC": "Открыть в VLC", + "ACTION_OPENING_MPV": "MPV открывается…", + "ACTION_OPENING_VLC": "VLC открывается…", + "ACTION_REOPEN_MPV": "Открыть MPV снова", + "ACTION_REOPEN_VLC": "Открыть VLC снова", + "ACTION_RETRY_MPV": "Повторить запуск MPV", + "ACTION_RETRY_VLC": "Повторить запуск VLC", + "EXTERNAL_OPENING": "Плеер открывается…", + "EXTERNAL_STARTED": "Плеер запущен", + "EXTERNAL_PLAYING": "Воспроизведение", + "EXTERNAL_FAILED": "Ошибка внешнего плеера", "ACTION_OPEN_MPV_HINT": "настроенный плеер", "ACTION_OPEN_VLC_HINT": "настроенный плеер", "ACTION_COPY_URL": "Скопировать URL потока", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Избранное", "RAIL_DOWNLOADS": "Загрузки", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Скрыть", + "EXTERNAL_PLAYBACK_OPENING": "Плеер открывается…", + "EXTERNAL_PLAYBACK_STARTED": "Плеер запущен", + "EXTERNAL_PLAYBACK_PLAYING": "Воспроизведение", + "EXTERNAL_PLAYBACK_FAILED": "Ошибка внешнего плеера", + "EXTERNAL_PLAYBACK_CLOSED": "Закрыт", "RAIL_ALL_CHANNELS": "Все каналы", "RAIL_GROUPS": "Группы", "RAIL_CONTEXT_ACTIONS": "Действия раздела", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index bee89036e..d37b02cf6 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "Bu hedef kullanılabilir şifreleme verileriyle uyumludur", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "Favoriler", "RAIL_DOWNLOADS": "İndirilenler", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Tüm kanallar", "RAIL_GROUPS": "Gruplar", "RAIL_CONTEXT_ACTIONS": "Bölüm işlemleri", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0829df4ac..855bda9e6 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "此目标与可用的加密数据兼容", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "收藏", "RAIL_DOWNLOADS": "下载", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "所有频道", "RAIL_GROUPS": "分组", "RAIL_CONTEXT_ACTIONS": "章节操作", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 551895e4b..aae2dd65a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -583,6 +583,16 @@ "REASON_COMPATIBLE_DRM_PATH": "此目標與可用的加密資料相容", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", + "ACTION_OPENING_MPV": "Opening MPV…", + "ACTION_OPENING_VLC": "Opening VLC…", + "ACTION_REOPEN_MPV": "Open MPV again", + "ACTION_REOPEN_VLC": "Open VLC again", + "ACTION_RETRY_MPV": "Try MPV again", + "ACTION_RETRY_VLC": "Try VLC again", + "EXTERNAL_OPENING": "Opening player…", + "EXTERNAL_STARTED": "Player started", + "EXTERNAL_PLAYING": "Playing", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1298,6 +1308,12 @@ "RAIL_FAVORITES": "我的最愛", "RAIL_DOWNLOADS": "下載", "EXTERNAL_PLAYBACK_CLOSE": "Close player", + "EXTERNAL_PLAYBACK_DISMISS": "Dismiss", + "EXTERNAL_PLAYBACK_OPENING": "Opening player…", + "EXTERNAL_PLAYBACK_STARTED": "Player started", + "EXTERNAL_PLAYBACK_PLAYING": "Playing", + "EXTERNAL_PLAYBACK_FAILED": "External player error", + "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "所有頻道", "RAIL_GROUPS": "群組", "RAIL_CONTEXT_ACTIONS": "區段操作", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 44bd446dd..421bab0aa 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -582,12 +582,13 @@ offer a same-family built-in alternative. Unknown source or engine-family facts also suppress built-in recommendations. The pure policy builds the following order, filters the current, unavailable, -incompatible, and already attempted targets, and then returns at most three -actions. It also projects attempted inline target IDs through the validated +incompatible, and already attempted inline targets, and then returns at most +three actions. It projects attempted inline target IDs through the validated canonical source/target capability matrix and filters every engine family that has already been attempted. HTML5 and ArtPlayer therefore cannot be offered as -separate hls.js recovery attempts. The first surviving action is primary and -later actions are secondary. +separate hls.js recovery attempts. External MPV/VLC attempts remain eligible; +the view reranks them by per-target attempt count instead of removing them. The +first surviving action is primary and later actions are secondary. | Sanitized evidence | Candidate order | | ----------------------------------------- | ------------------------------------------------------------ | @@ -654,8 +655,93 @@ override and saved player setting. The new engine receives the latest finite VOD position as a best-effort resume point; live playback starts at the live edge. Retry reloads the active target without clearing attempts. Selecting MPV or VLC records the external target before emitting the existing fallback -request. The system does not infer whether the external process ultimately -played the stream. +request. Both external actions remain mounted while their per-target state +moves through `launching`, `started`, `playing`, or `error`; an attempted idle +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. Play, Restart, and secondary +provider launch actions all observe the same local guard before Electron has +returned a session. Xtream VOD also records the diagnostic fallback's +route-scoped destination and pending generation before invoking MPV/VLC, so a +subsequent route cannot start a second detached player while the first launch +is being correlated. 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. Teardown waits through +bounded graceful and forced-exit windows, and reusable MPV also bounds its IPC +quit command before entering those windows. If any stage cannot confirm exit, +close rejects and keeps the exact session live so a replacement cannot overlap +it. A process-wide teardown gate starts before any potentially slow teardown +preparation, including VLC position flush and a reused player's MPV IPC or VLC +RC quit command, and rejects both player launches until that exact child +reports exit; `ChildProcess.killed` is never treated as proof. If +bounded teardown fails while a fresh launch IPC is pending, the IPC rejects and +the exact session becomes a closable error instead of remaining in Opening. +If a pre-content reuse failure has no still-live displaced session to restore, +the replacement error keeps its attached closer so Stop can retry teardown of +the orphaned reusable child. A terminal error without a closer is never +restorable. +A rejected close promise is cached only while that attempt is pending, so the +dock's Stop action can retry teardown of the same exact child after an +unconfirmed bounded attempt. Reusable children are mapped to the current +content session; a stale older closer becomes a no-op after MPV `loadfile` or +the VLC enqueue handoff remaps the process. Closing an already terminal session +is also idempotent: it returns the closed snapshot without invoking the saved +closer, and a later process error cannot revive it as a visible failure. Reused +MPV commands use the socket captured for their exact child, so a delayed close +cannot send `quit` to a replacement process through a newer global socket. +If Stop is observed before a pending MPV content command or VLC enqueue command +is dispatched, that command is skipped. A source handoff also fails closed +while a live session has no closer (`canClose: false`); renderer Dismiss is not +accepted as process-teardown confirmation. That denial advances neither the +multi-source switch token nor the playback generation, so it cannot cancel the +sole launch already in flight. +VLC rechecks that gate immediately around every concrete spawn after +asynchronous port allocation or reuse fallback work. If a post-start VLC +fallback is blocked by the gate, the already-opened session transitions to +error instead of continuing to claim that the player started. If RC-port +allocation fails, reuse ownership is never claimed: the spawned VLC child +keeps its exact one-shot session closer so Stop still confirms its teardown. +During reusable-player handoff, a failure before the content command restores +the globally displaced renderer session—not the reusable process's prior +owner—through an exact `restoredFromSessionId` transition, but only while that +displaced replacement is still the active session. +After MPV `loadfile` or VLC `clear` has been dispatched, the attempted session +owns the possibly changed process and stays a closable error instead of +restoring stale content metadata. Such an error still participates in every +replacement close. Stop during an in-flight MPV or VLC reuse command, including +the teardown wait after a failed command and VLC's subsequent fallback +port-allocation wait, settles that exact close and cancels the fallback spawn. +If a partially applied reuse +command is instead recovered by a fresh spawn, the old child's exit is retired +under its previous session so it cannot close the replacement session. A +during-start Stop also settles VLC's launch IPC when a spawn error reports +`close` without `exit`. The dock keeps Stop as the only global teardown action +while that exact closer remains live; safe Dismiss is available only after an +error becomes terminal and has no closer. If diagnostic ownership changes during close, the +unlaunched intent is cancelled without a false launch error; an exact stale +launch whose close fails keeps its credential-free owner for the next close +attempt. Route Play/Resume cancels older source resolution immediately but +captures the initiating playlist/VOD route before awaiting teardown; navigation +cancels that start. Accepting a diagnostic fallback cancels the same older +source resolution before opening MPV/VLC, and a fallback resolving on the new +route closes its exact returned session. The route commits its source badge and playback +evidence only after start succeeds. 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`, +and Playing only for `playing`. An error with a live closer remains visible with +Stop until teardown is confirmed; an unclosable terminal error remains visible +until dismissed. The dock deliberately has no retry because it does not own the +original headers or credentials required to reconstruct a safe launch request. No recommendation mutates `Settings.player` or another persisted setting. Recovery recommendations never auto-switch a player or source and do not @@ -709,14 +795,18 @@ External MPV/VLC integration is split across focused main-process modules: - `apps/electron-backend/src/app/events/external-player-runtime.ts` owns shared session tracking, trace logging, renderer notifications, playback-position forwarding, and user-facing start errors. -- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns MPV process, - socket, reuse, cleanup, and progress polling lifecycle. -- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns VLC process, - RC interface, reuse, cleanup, command parsing, and progress polling lifecycle. +- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns fresh MPV + launches and progress polling; `mpv-reusable-process.ts` owns the tracked + child, captured socket, remapping, retryable close, and reuse handoff. +- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns fresh VLC + launches and progress polling; `vlc-reusable-process.ts` owns the tracked + child, RC-port remapping, retryable close, and reuse handoff, while + `vlc-rc.ts` owns bounded RC commands, parsing, and playback snapshots. -Keep player-specific process state in the MPV/VLC session modules. Shared spawn, -request-header, session-registry, and notification helpers belong in the -`external-player-*` modules so IPC registration stays small and reviewable. +Keep player-specific reusable-process state in the focused MPV/VLC managers. +Shared spawn, request-header, session-registry, and notification helpers belong +in the `external-player-*` modules so IPC registration stays small and +reviewable. ## Flatpak External Players diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index a99657739..834b70262 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -496,8 +496,9 @@ cut. The toast offers Undo, and adds a dub warning when audio track as fact. Two guesses, or a guess against a fact, stay silent. Web engines only (HTML5/hls.js, Video.js, ArtPlayer). Embedded MPV suppresses -shared diagnostics and owns its own error block; external MPV/VLC are -fire-and-forget with no error channel back. +shared diagnostics and owns its own error block. External MPV/VLC use managed +Electron sessions: recovery actions and the global dock report their exact +launch state without retaining playback headers or credentials. ## External players and an alternative source @@ -507,7 +508,43 @@ that session carries the OTHER playlist's ids. `matchedExternalPlayback` would disown it: the primary button never became Stop, stopping found no session, and another click opened a second player. The page therefore claims a session that matches either the route's own stream or the alternative multi-source says is -active (`VodDetailsPlaybackBindings.activeSource`). +active (`VodDetailsPlaybackBindings.activeSource`). It also claims the +credential-free destination identity before awaiting the Electron launch: the +primary action becomes pending immediately, and a launching or closable-error +session remains matched before the controller can truthfully mark it active. +Restart and the provider-source shortcut read the same local pending state, so +a second activation cannot enter the Electron launch before the first IPC +response exists; their DOM controls stay mounted and disabled to preserve focus. +The diagnostic-fallback handler makes that same route-scoped ownership claim +before invoking MPV/VLC, rather than relying on the later controller commit. It +also supersedes an older source resolution at that acceptance boundary, so the +late switch cannot close and replace the newer diagnostic fallback. +That retained destination is scoped to the initiating playlist/VOD route key, +so Angular route reuse for a different movie cannot turn its Play action into +Stop for the previous movie's still-running external session. That new route's +Play and Resume actions still pass through the shared close-before-replacement +path, and they recheck their captured route key after teardown, so starting it +cannot leave the prior detached player running or launch stale content after +navigation. A diagnostic fallback that resolves after route reuse closes its +exact returned session instead of adopting it on the new route. + +Before an external alternative replaces another external session, teardown of +the exact old process must be confirmed. If close fails or times out, the +replacement is cancelled instead of allowing two external players to overlap. +If the old launch is still opening and has no exact closer yet, a source-row +replacement is denied before either the multi-source switch token or playback +generation advances; the only in-flight launch therefore remains owned and is +not closed as superseded. +Once that old session is closed, later duplicate Stop/Close delivery is a +terminal no-op and cannot invoke its saved closer against a newly started MPV +or VLC process. +The controller commits the destination row, previous-source pointer, and switch +notice only after the playback seam accepts that handoff and an external +Electron launch resolves, so a rejected close or launch leaves the old source +as the truthful selection. The host's exact switch-owner probe crosses that +seam too: after teardown but before applying playback, the route rechecks it so +a newer unresolvable selection cannot leave the older player launched but +disowned by the controller. `ownsContent()` answers that question once, for both consumers: the session matcher AND the playback-position bridge. They cannot be allowed to disagree — @@ -609,10 +646,22 @@ honest state, and the same one the rest of this feature takes when it does not know. Switching sources through `startResolvedPlayback` closes the external session -it LAUNCHED first — tracked separately from the controller's active source, -which a switch has already moved to the destination by then. It REPLACES what is playing — with MPV or VLC and instance -reuse off, the backend would otherwise spawn a second detached player, leaving -both sources running and Stop owning only the newer one. +it LAUNCHED first — tracked separately so refreshes and overlapping handoffs +cannot disown it within the same route session. The retained identity is ignored +after the playlist/VOD route key changes. Only after that exact teardown, the +host ownership checks on both sides of the launch await, and an `opened` or +`playing` launch result does the controller commit the destination. A launch +that loses ownership while IPC is pending is closed by its exact returned +session; a Stop that wins the race returns `closed` and is never committed. A +partial reusable-player handoff that leaves a closable error retains its +destination identity so the next source switch closes that exact process before +trying again. A stale launch whose exact close fails retains the same +credential-free identity for another close attempt. Direct route Play/Resume +supersede an older source resolution before waiting for teardown, but update the +active-source badge, playback evidence, and controller position only after the +new start succeeds. With MPV or VLC and instance reuse off, applying playback +earlier would spawn a second detached player, leaving both sources running and +Stop owning only the newer one. ## Short titles and Unicode 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 new file mode 100644 index 000000000..658cf5b01 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-external-playback-launch-feedback.md @@ -0,0 +1,492 @@ +# External Playback Launch Feedback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**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`. 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. + +--- + +### Task 1: External recovery state machine + +**Files:** + +- Create: `libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts` +- Create: `libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts` + +- [ ] **Step 1: Write the failing state-machine tests** + +Cover the exact public contract: + +```typescript +const state = new ExternalPlaybackRecovery(); +state.syncSession('content-a'); +const intent = state.begin('mpv'); + +expect(intent).not.toBeNull(); +expect(state.pending()).toBe(true); +expect(state.target('mpv')).toMatchObject({ + status: 'launching', + attempts: 1, + sessionId: null, +}); +expect(state.begin('vlc')).toBeNull(); + +expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe( + false +); +expect(state.observe(session({ id: 'new-session', player: 'vlc' }))).toBe( + false +); +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'); +``` + +Add separate tests for `playing`, `error`, `closed → idle`, stale exact-ID +updates, timeout-to-error, stale timeout after session reset, retry attempt +count, and `destroy()` timer cleanup. + +- [ ] **Step 2: Run RED** + +Run: + +```bash +pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts --skip-nx-cache +``` + +Expected: FAIL because `external-playback-recovery.ts` does not exist. + +- [ ] **Step 3: Implement the minimal signal state machine** + +Use these exported shapes: + +```typescript +export type ExternalRecoveryStatus = + 'idle' | 'launching' | 'started' | 'playing' | 'error'; + +export interface ExternalRecoveryTargetState { + readonly status: ExternalRecoveryStatus; + readonly attempts: number; + readonly sessionId: string | null; +} + +export interface ExternalRecoveryIntent { + readonly token: symbol; + readonly target: ExternalPlayerName; +} + +export class ExternalPlaybackRecovery { + readonly states: Signal< + Readonly> + >; + readonly pending: Signal; + syncSession(key: string): boolean; + 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; + destroy(): void; +} +``` + +The internal launch timer is 10 seconds, and all ownership tokens are fieldless +`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** + +Run the Task 1 command again. Expected: all state-machine tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts +git commit -m "feat(playback): track external recovery launches" +``` + +### Task 2: Preserve and rerank external recommendations + +**Files:** + +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts` +- Test: `libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts` + +- [ ] **Step 1: Write failing ranking tests** + +Add a focused spec for a helper with this contract: + +```typescript +const result = createWebPlayerRecommendations({ + ...baseOptions, + attemptedTargets: new Set(['videojs', 'mpv']), + externalStates: { + mpv: { status: 'error', attempts: 1, sessionId: 'mpv-1' }, + vlc: { status: 'idle', attempts: 0, sessionId: null }, + }, +}); + +expect( + result.map((item) => (item.action === 'player' ? item.target : item.action)) +).toEqual(['vlc', 'mpv', 'alternative-source']); +expect(result.map((item) => item.priority)).toEqual([ + 'primary', + 'secondary', + 'secondary', +]); +``` + +Also prove that an attempted inline family remains excluded, ties preserve MPV +before VLC, input arrays/sets/states are not mutated, and output stays capped at +three. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts --skip-nx-cache +``` + +Expected: FAIL because external state is not accepted and attempted MPV is +removed. + +- [ ] **Step 3: Implement filtered policy attempts and stable reranking** + +Pass only inline attempted targets into `recommendPlaybackRecovery()`. Stable +sort adjacent external recommendations by `attempts`, using their original +policy index as tie-breaker, then regenerate primary/secondary priority without +mutating inputs. + +- [ ] **Step 4: Update old integration expectations** + +Change old assertions that expected MPV/VLC to disappear. They must now assert +that both targets remain mounted and the less-attempted sibling is promoted. + +- [ ] **Step 5: Run GREEN and commit** + +```bash +pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --skip-nx-cache +git add libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts +git commit -m "fix(playback): keep external recovery actions available" +``` + +### Task 3: Wire launch ownership and close-before-switch + +**Files:** + +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.html` +- Modify: `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts` +- Modify: `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts` + +- [ ] **Step 1: Add failing integration tests** + +Provide a fake `PORTAL_EXTERNAL_PLAYBACK` with `activeSession: signal(null)`, +`visibleSession`, and `closeSession`. Prove: + +```typescript +mpvButton.click(); +mpvButton.click(); +expect(fallbackRequests).toHaveLength(1); +expect(component.externalRecoveryPending()).toBe(true); + +activeSession.set( + externalSession({ id: 'mpv-1', player: 'mpv', status: 'opened' }) +); +fixture.detectChanges(); +expect(component.externalRecoveryState().mpv.status).toBe('started'); +expect(playerActionIds()).toContain('playback-fallback-mpv'); +``` + +Add independent tests for close-before-VLC, refusing an unclosable active +session, stale session after `playbackSessionKey` change, no duplicate output, +and no URL/header fields in serialized coordinator state. + +- [ ] **Step 2: Run RED** + +Run the recovery integration spec. Expected: new assertions FAIL because the +component does not observe external sessions. + +- [ ] **Step 3: Wire the coordinator** + +Inject `PORTAL_EXTERNAL_PLAYBACK` optionally, observe its active signal in an +`effect`, reset external state from `syncRecoverySession()`, and destroy it from +the existing component cleanup. For external targets: + +1. validate the recommendation and current diagnostic; +2. begin one owned external intent; +3. if a nonterminal active external session exists, require `canClose`, await + `closeSession`, and confirm it is no longer live; +4. recheck intent and diagnostic ownership; +5. record the external attempt and emit the existing exact + `PlaybackFallbackRequest` once. + +- [ ] **Step 4: Run GREEN and commit** + +```bash +pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --skip-nx-cache +git add libs/ui/playback/src/lib/web-player-view +git commit -m "feat(playback): synchronize external launch feedback" +``` + +### Task 4: Render accessible per-target feedback + +**Files:** + +- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts` +- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html` +- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss` +- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts` +- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts` +- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts` + +- [ ] **Step 1: Write failing view/component tests** + +Prove state-aware keys and labels: + +```typescript +expect(getRecommendationLabelKey(mpvRecommendation, launchingState)).toBe( + 'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV' +); +expect(getRecommendationLabelKey(mpvRecommendation, errorState)).toBe( + 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_MPV' +); +``` + +The component test must assert that the same MPV `HTMLButtonElement` remains in +the DOM through state changes, keeps focus, contains a 16px spinner during +launch, exposes `aria-busy="true"` and `aria-disabled="true"`, and does not emit +when aria-disabled. Assert a visible polite status for started/playing/error. + +- [ ] **Step 2: Run RED** + +Run both panel specs. Expected: FAIL because the panel has no external-state +input or spinner. + +- [ ] **Step 3: Implement the view** + +Import the existing Angular Material progress spinner. Keep external buttons +mounted and use `aria-disabled` plus handler guards instead of native +`disabled` for external handshake feedback, preserving keyboard focus. Keep +the existing native `disabled` behaviour for inline switch/retry pending. + +Add only transform/opacity/color transitions under 180ms and a +`prefers-reduced-motion` rule that stops spinner animation when appropriate. + +- [ ] **Step 4: Run GREEN and commit** + +```bash +pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts --skip-nx-cache +git add libs/ui/playback/src/lib/playback-diagnostic-panel +git commit -m "feat(playback): show external launch action states" +``` + +### Task 5: Keep dock errors visible and use exact statuses + +**Files:** + +- Modify: `apps/web/src/app/services/external-playback.service.ts` +- Modify: `apps/web/src/app/services/external-playback.service.spec.ts` +- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts` +- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html` +- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss` +- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts` + +- [ ] **Step 1: Write failing service and dock tests** + +Change the error-session expectation from hidden to visible, then prove +`dismissActiveSession()` hides it. Add dock cases for: + +```typescript +expect(statusText('launching')).toContain('Opening player'); +expect(statusText('opened')).toContain('Player started'); +expect(statusText('playing')).toContain('Playing'); +expect(errorDock.getAttribute('aria-live')).toBe('polite'); +expect(dismissButton.textContent).toContain('Dismiss'); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test web --runTestsByPath apps/web/src/app/services/external-playback.service.spec.ts --skip-nx-cache +pnpm nx test components --runTestsByPath libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts --skip-nx-cache +``` + +Expected: service error visibility and dock status assertions FAIL. + +- [ ] **Step 3: Implement exact dock semantics** + +Hide only `closed` in `visibleSession`. Translate launching/opened/playing and +generic error states. Keep detailed existing `session.error` for the visible +error copy. Show “Close player” only for a closable live session and “Dismiss” +for error. Keep the existing output so workspace shell routing does not change. + +- [ ] **Step 4: Run GREEN and commit** + +Run both commands plus the workspace-shell focused spec, then commit: + +```bash +git add apps/web/src/app/services/external-playback.service.ts apps/web/src/app/services/external-playback.service.spec.ts libs/ui/components/src/lib/external-playback-dock libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +git commit -m "fix(playback): keep external launch errors visible" +``` + +### Task 6: Translation, documentation, and release note + +**Files:** + +- Modify: `apps/web/src/assets/i18n/*.json` +- Modify: `docs/architecture/embedded-inline-playback.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` +- Create: `.changes/playback-external-launch-feedback.md` + +- [ ] **Step 1: Add user-facing translation keys** + +Add the same complete key set to every locale, translated where practical and +English fallback otherwise: + +```json +"ACTION_OPENING_MPV": "Opening MPV…", +"ACTION_OPENING_VLC": "Opening VLC…", +"ACTION_REOPEN_MPV": "Open MPV again", +"ACTION_REOPEN_VLC": "Open VLC again", +"ACTION_RETRY_MPV": "Try MPV again", +"ACTION_RETRY_VLC": "Try VLC again", +"EXTERNAL_OPENING": "Opening player…", +"EXTERNAL_STARTED": "Player started", +"EXTERNAL_PLAYING": "Playing", +"EXTERNAL_FAILED": "External player error" +``` + +Add workspace dock keys for opening, started, playing, failed, and dismiss. + +- [ ] **Step 2: Update canonical docs** + +Replace the v1 “external target disappears after attempt” contract with the +new per-target state/reranking and exact external session correlation. Mirror +the shared-player summary byte-for-meaning between `AGENTS.md` and `CLAUDE.md`. + +- [ ] **Step 3: Add release note** + +```markdown +--- +type: fix +area: playback +--- + +MPV and VLC recovery buttons now stay available after an attempt and show launch progress, player-started, playback, and failure feedback. External-player errors remain in the bottom status bar until dismissed. +``` + +- [ ] **Step 4: Validate and commit** + +```bash +pnpm run i18n:validate +pnpm run release:notes:validate +git diff --check +git add apps/web/src/assets/i18n docs/architecture/embedded-inline-playback.md AGENTS.md CLAUDE.md .changes/playback-external-launch-feedback.md +git commit -m "docs(playback): document external launch feedback" +``` + +### Task 7: Electron regression flow + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/dash-clearkey.e2e.ts` + +- [ ] **Step 1: Change the existing E2E expectation before production code is considered complete** + +The fixture IPC handler must send repository-owned `launching`, `opened`, and +failure session updates through `EXTERNAL_PLAYER_SESSION_UPDATE`, and implement +the close handler without starting real players. + +Assert: + +- MPV remains visible during and after the held handshake; +- its spinner/status is visible while launching; +- VLC cannot launch during the handshake; +- after MPV starts, MPV remains available and VLC is primary; +- the dock reads “Player started”; +- a failed VLC session remains in the dock with a Dismiss action; +- dismiss hides the dock while both diagnostic action buttons remain. + +- [ ] **Step 2: Run the exact Electron target** + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts --skip-nx-cache +``` + +Expected: PASS with the repository-owned fixture and stubbed IPC only. + +- [ ] **Step 3: Commit** + +```bash +git add apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +git commit -m "test(playback): cover external launch feedback" +``` + +### Task 8: Full validation, review, and PR + +**Files:** + +- Review all changed files from `origin/master...HEAD`. + +- [ ] **Step 1: Run affected unit, lint, build, and policy validation** + +```bash +pnpm nx test ui-playback --skip-nx-cache +pnpm nx test components --skip-nx-cache +pnpm nx test web --skip-nx-cache +pnpm nx affected -t lint --base=origin/master --head=HEAD --skip-nx-cache +pnpm nx build web --configuration=production --skip-nx-cache +pnpm nx build electron-backend --configuration=production --skip-nx-cache +pnpm run i18n:validate +pnpm run release:notes:validate +pnpm run coverage:policy:check +git diff --check origin/master...HEAD +``` + +- [ ] **Step 2: Run the targeted Electron E2E again from the final tree** + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts --skip-nx-cache +``` + +- [ ] **Step 3: Perform local Codex P1/P2 review** + +Run the local Codex CLI against `origin/master`, inspect every finding, add a +failing regression test before each valid fix, and rerun the affected and full +validation commands. Do not create the PR while an actionable P1/P2 remains. + +- [ ] **Step 4: Push and create a ready PR** + +Use title: + +```text +fix(playback): clarify external player launch feedback +``` + +The PR body must summarize persistent recovery actions, exact session status, +accessibility/privacy ownership, dock dismissal, docs/release note, and every +validation command. Do not merge. + +- [ ] **Step 5: Run the review loop** + +Wait for all latest-head CI, Greptile 5/5, and Codex review. Fix actionable +feedback with regression coverage, push, retrigger both reviewers, and repeat +until the ready PR is merge-ready. 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 new file mode 100644 index 000000000..8b2782763 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-external-playback-launch-feedback-design.md @@ -0,0 +1,172 @@ +# External Playback Launch Feedback Design + +## Goal + +Make MPV and VLC recovery actions reversible and understandable. A target stays +available after an attempt, the diagnostic panel reports launch progress and +outcome, and the workspace dock remains the authoritative global view of the +external-player session. + +## Current Problem + +`WebPlayerViewComponent` records MPV or VLC as attempted before emitting the +fallback request. The recommendation policy then removes attempted targets, so +each button disappears even though IPTVnator only knows that the action was +requested. It does not yet know whether the external player started or played +the stream. + +The external-player session already reports `launching`, `opened`, `playing`, +`error`, and `closed`, but the dock hides `error`, renders `opened` and +`playing` as the same “Opened” status, and exposes no dismiss action for an +external-player error. + +## Considered Approaches + +### 1. Keep actions and show only a local timed spinner + +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. Use only callbacks on every `PlaybackFallbackRequest` host + +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 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. 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 + +Each external target has component-local state: + +- `idle`: no launch is in flight; a previous successful/closed attempt may + still be represented by an attempt count; +- `launching`: the request is being handed off or Electron is spawning the + player; +- `started`: Electron confirmed the process or existing player accepted the + stream; +- `playing`: MPV/VLC position polling confirmed playback; +- `error`: the launch/session failed. + +The state also contains an attempt count and, only after correlation, the exact +external session ID. It never contains the stream URL, headers, credentials, +engine messages, or error details. A fieldless `Symbol` intent plus the current +content-session generation rejects stale completions and timers. + +Changing `playbackSessionKey` resets both targets. Destroying the component +invalidates the intent and clears its bounded launch timer. A later session +update is accepted only if it belongs to the correlated session ID. + +## Recommendation Behaviour + +External attempts no longer remove MPV or VLC from the ranked action list. +Inline engine attempts retain the current family-exclusion rules. + +Within an otherwise unchanged policy result: + +- an untried external target ranks ahead of an attempted sibling; +- fewer attempts rank ahead of more attempts; +- policy order remains the tie-breaker; +- the first surviving action remains primary and the total remains capped at + three. + +Labels describe the action, while adjacent status copy describes the outcome: + +| State | Action label | Status | +| ----------------------- | ------------------ | --------------------- | +| `idle`, never attempted | Open in MPV/VLC | none | +| `launching` | Opening MPV/VLC… | Opening player… | +| `started` | Open MPV/VLC again | Player started | +| `playing` | Open MPV/VLC again | Playing | +| `error` | Try MPV/VLC again | External player error | + +Buttons remain mounted with stable recommendation keys, preserving layout and +focus. During a launch handshake, recovery actions expose `aria-busy` and +`aria-disabled`; activation handlers enforce the same single-flight guard, so +keyboard or programmatic activation cannot duplicate the request. A small +spinner replaces the external-action icon. State changes are announced through +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. 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 +stream playback. + +## Dock Behaviour + +`ExternalPlaybackService.visibleSession` keeps error sessions visible until +the user dismisses them or another launch begins. The dock maps session states +without inference: + +- `launching` → “Opening player…” with a spinner; +- `opened` → “Player started”; +- `playing` → “Playing”; +- `error` → the existing session error or a localized generic external-player + failure; +- `closed` remains hidden by the global service. + +The dock uses an `aria-live="polite"` status region and `aria-busy` while +launching. A live closable session shows “Close player”; an error shows +“Dismiss”. Dismiss only hides the terminal notification and never stores a +retry payload, so headers and credentials are not retained for a dock retry. + +## Error And Privacy Boundaries + +The diagnostic action shows only localized, app-owned launch-state copy. It +does not render rejected promise messages or external-player stderr. The dock +may continue to show the existing session error supplied by the Electron +boundary; it does not add new logging or persistence. + +Recovery recommendations remain user-selected. There is no automatic retry, +automatic player switch, persistent history, telemetry, or learning. + +## Testing + +Unit and component coverage will prove: + +- external attempts stay visible and rerank without weakening inline + engine-family exclusion; +- one handshake at a time, duplicate activation rejection, stable DOM keys, + spinner/labels, and `aria-busy`/`aria-disabled` feedback; +- exact session-ID correlation, stale update/timeout rejection, session-key + reset, close-before-switch, and unclosable-session failure; +- dock `launching`, `opened`, `playing`, `error`, close, and dismiss states; +- `ExternalPlaybackService` keeps errors visible until dismissal. + +Electron E2E will extend the deterministic recovery fixture to assert that the +buttons remain after an MPV attempt, VLC is promoted, the lower dock shows +launch feedback, and an error remains dismissible. Existing playback fixtures +and player stubs must be used; no real playlist, account, or external process is +required. + +## Documentation And Release Note + +Update the canonical playback recovery architecture in +`docs/architecture/embedded-inline-playback.md`, the shared-player summary in +`AGENTS.md` and `CLAUDE.md`, and add a user-facing `fix(playback)` note under +`.changes/`. + +## Out Of Scope + +- proving playback for sessions that do not expose position polling; +- retry from the dock; +- retaining playback headers or credentials after the original request; +- automatic failover between MPV and VLC; +- redesigning the diagnostic overlay or dock. 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..7fd017704 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 @@ -41,6 +41,7 @@ import { import { Channel, EpgProgram, + ExternalPlayerSession, ResolvedPortalPlayback, Settings, VideoPlayer, @@ -197,6 +198,7 @@ describe('VideoPlayerComponent', () => { const stripCountryPrefix = signal(false); const epgViewMode = signal<'timeline' | 'list'>('timeline'); const epgUrlSetting = signal([]); + const externalSession = signal(null); const originalElectron = window.electron; const overlayRef = { @@ -325,6 +327,7 @@ describe('VideoPlayerComponent', () => { channelsLoading.set(false); currentEpgProgram.set(null); activeEpgProgram.set(null); + externalSession.set(null); currentEpgProgram$.next(null); epgPrograms$.next([]); overlayMock.create.mockClear(); @@ -415,7 +418,7 @@ describe('VideoPlayerComponent', () => { { provide: PORTAL_EXTERNAL_PLAYBACK, useValue: { - activeSession: signal(null), + activeSession: externalSession, }, }, ], @@ -582,9 +585,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 +612,7 @@ describe('VideoPlayerComponent', () => { origin: 'https://origin.example.com', } ); + expect(trackLaunch).toHaveBeenCalledWith(launch); }); it('renders the embedded mpv inline player with the EPG panel', () => { @@ -641,6 +649,38 @@ describe('VideoPlayerComponent', () => { ).toBe(false); }); + it('clears the channel when a closable error becomes terminal', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.MPV); + const closableError: ExternalPlayerSession = { + id: 'external-1', + player: 'mpv', + status: 'error', + title: sampleChannel.name, + streamUrl: sampleChannel.url, + startedAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + error: 'Process exit was not confirmed', + canClose: true, + }; + externalSession.set(closableError); + fixture.detectChanges(); + expect(storeMock.dispatch).not.toHaveBeenCalledWith( + ChannelActions.resetActiveChannel() + ); + + externalSession.set({ + ...closableError, + canClose: false, + updatedAt: '2026-08-08T00:00:01.000Z', + }); + fixture.detectChanges(); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + ChannelActions.resetActiveChannel() + ); + }); + it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => { syncStoreState({ ...sampleChannel, 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..fcc6a0d16 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 @@ -80,6 +80,7 @@ import { persistLiveEpgPanelState, persistLiveSidebarState, PORTAL_EXTERNAL_PLAYBACK, + isLiveExternalPlayerSession, restoreLiveEpgPanelState, restoreLiveSidebarState, WorkspaceHeaderContextService, @@ -1083,10 +1084,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( @@ -1110,7 +1115,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return null; } - return `${session.id}:${session.status}`; + const lifecycle = isLiveExternalPlayerSession(session) + ? 'live' + : 'terminal'; + return `${session.id}:${session.status}:${lifecycle}`; } private isExternalPlayer( @@ -1122,7 +1130,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private isTerminalExternalSession( session: ExternalPlayerSession | null | undefined ): boolean { - return session?.status === 'closed' || session?.status === 'error'; + return !!session && !isLiveExternalPlayerSession(session); } private registerHeaderShortcut(): void { 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/shared/util/src/lib/external-playback-button-state.spec.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts index 62cc47b40..7483e417a 100644 --- a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts @@ -3,7 +3,10 @@ import type { ExternalPlayerSession, PlayerContentInfo, } from '@iptvnator/shared/interfaces'; -import { createExternalPlaybackButtonState } from './external-playback-button-state'; +import { + createExternalPlaybackButtonState, + isLiveExternalPlayerSession, +} from './external-playback-button-state'; function session( overrides: Partial = {} @@ -105,6 +108,21 @@ describe('createExternalPlaybackButtonState', () => { } ); + it('offers Stop for an errored session whose process may still be alive', () => { + const { api } = setup( + session({ + status: 'error', + error: 'Process exit was not confirmed', + canClose: true, + }) + ); + + expect(api.matchedSession()).not.toBeNull(); + expect(api.buttonState()).toBe('stop'); + expect(api.primaryLabel()).toBe('Stop MPV'); + expect(api.isStopAction()).toBe(true); + }); + it('reports a launching session', () => { const { api } = setup(session({ status: 'launching' })); @@ -208,3 +226,30 @@ describe('createExternalPlaybackButtonState', () => { expect(api.matchedSession()).toBeNull(); }); }); + +describe('isLiveExternalPlayerSession', () => { + it('keeps ownership while a teardown error can still be stopped', () => { + expect( + isLiveExternalPlayerSession( + session({ + status: 'error', + error: 'Process exit was not confirmed', + canClose: true, + }) + ) + ).toBe(true); + }); + + it('treats an unclosable error and a confirmed close as terminal', () => { + expect( + isLiveExternalPlayerSession( + session({ status: 'error', canClose: false }) + ) + ).toBe(false); + expect( + isLiveExternalPlayerSession( + session({ status: 'closed', canClose: false }) + ) + ).toBe(false); + }); +}); diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.ts index 916c2c784..c28d45a4a 100644 --- a/libs/portal/shared/util/src/lib/external-playback-button-state.ts +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.ts @@ -48,6 +48,21 @@ export interface ExternalPlaybackButtonStateApi { buttonState: Signal; } +/** + * An error can still own a real process when bounded teardown could not + * confirm its exit. Keep that session live until Stop succeeds; only a + * confirmed close or an unclosable failure is terminal. + */ +export function isLiveExternalPlayerSession( + session: ExternalPlayerSession | null | undefined +): session is ExternalPlayerSession { + return ( + !!session && + session.status !== 'closed' && + (session.status !== 'error' || session.canClose) + ); +} + export function createExternalPlaybackButtonState( config: ExternalPlaybackButtonStateConfig ): ExternalPlaybackButtonStateApi { @@ -56,11 +71,7 @@ export function createExternalPlaybackButtonState( const matchedSession = computed(() => { const session = config.session(); // A closed or errored session says nothing about what is playing now. - if ( - !session?.contentInfo || - session.status === 'closed' || - session.status === 'error' - ) { + if (!session?.contentInfo || !isLiveExternalPlayerSession(session)) { return null; } @@ -92,6 +103,7 @@ export function createExternalPlaybackButtonState( return `Opening in ${player}...`; case 'opened': case 'playing': + case 'error': return `Stop ${player}`; default: return null; @@ -104,6 +116,7 @@ export function createExternalPlaybackButtonState( return 'hourglass_top'; case 'opened': case 'playing': + case 'error': return 'stop_circle'; default: return 'play_arrow'; @@ -116,7 +129,9 @@ export function createExternalPlaybackButtonState( const isStopAction = computed(() => { const status = matchedSession()?.status; - return status === 'opened' || status === 'playing'; + return ( + status === 'opened' || status === 'playing' || status === 'error' + ); }); const buttonState = computed(() => { 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..92ace94cc 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', @@ -560,8 +561,8 @@ describe('StalkerLiveStreamLayoutComponent', () => { playback, 'vlc' ); - expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe( - playback + expect(request.trackLaunch).toHaveBeenCalledWith( + portalPlayer.openExternalPlayback.mock.results[0].value ); }); 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..1a57e70c9 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 @@ -33,6 +33,7 @@ import { } from '@iptvnator/shared/interfaces'; import { SafePipe } from '@iptvnator/pipes'; import { + isLiveExternalPlayerSession, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -438,7 +439,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { return; } - if (session.status === 'opened' || session.status === 'playing') { + if (isLiveExternalPlayerSession(session)) { this.openingEpisodeId.set(null); this.activeEpisodeId.set(session.contentInfo.contentXtreamId); return; @@ -900,10 +901,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..7537a272c 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 @@ -10,6 +10,7 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { + isLiveExternalPlayerSession, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -125,7 +126,7 @@ export class SerialDetailsPlaybackService { return; } - if (session.status === 'opened' || session.status === 'playing') { + if (isLiveExternalPlayerSession(session)) { this.openingEpisodeId.set(null); this.activeEpisodeId.set(session.contentInfo.contentXtreamId); return; @@ -295,12 +296,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-external-launch-owner.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch-owner.ts new file mode 100644 index 000000000..c5ea78736 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch-owner.ts @@ -0,0 +1,88 @@ +import { Signal, computed, signal } from '@angular/core'; +import type { PlayerContentInfo } from '@iptvnator/shared/interfaces'; + +interface RouteScopedExternalLaunch { + readonly routePlaylistId: string; + readonly routeContentId: number; + readonly content: PlayerContentInfo; +} + +export interface ExternalLaunchRouteIdentity { + readonly playlistId: string; + readonly contentId: number; +} + +export interface ExternalLaunchOwner { + readonly current: Signal; + /** Exact last destination retained for close-before-replacement. */ + readonly retained: Signal; + captureRoute(): ExternalLaunchRouteIdentity | null; + ownsRoute(identity: ExternalLaunchRouteIdentity | null): boolean; + set(content: PlayerContentInfo | null | undefined): void; + clear(): void; +} + +/** Retains an external destination only while its initiating VOD route owns it. */ +export function createExternalLaunchOwner( + routePlaylistId: () => string | undefined, + routeContentId: () => number | undefined +): ExternalLaunchOwner { + const launch = signal(null); + const captureRoute = (): ExternalLaunchRouteIdentity | null => { + const playlistId = routePlaylistId(); + const contentId = routeContentId(); + return playlistId && + contentId !== undefined && + Number.isSafeInteger(contentId) && + contentId > 0 + ? { playlistId, contentId } + : null; + }; + return { + current: computed(() => { + const value = launch(); + return value && + value.routePlaylistId === routePlaylistId() && + value.routeContentId === routeContentId() + ? value.content + : null; + }), + retained: computed(() => launch()?.content ?? null), + captureRoute, + ownsRoute: (identity) => { + const current = captureRoute(); + return ( + !!identity && + current?.playlistId === identity.playlistId && + current.contentId === identity.contentId + ); + }, + set: (content) => { + const route = captureRoute(); + launch.set( + content && route + ? { + routePlaylistId: route.playlistId, + routeContentId: route.contentId, + content, + } + : null + ); + }, + clear: () => launch.set(null), + }; +} + +/** Runs a Play/Resume only while its initiating VOD route still owns it. */ +export async function startRouteOwnedPlayback( + owner: ExternalLaunchOwner, + start: (isCurrent: () => boolean) => Promise +): Promise { + const route = owner.captureRoute(); + if (!route) return false; + try { + return await start(() => owner.ownsRoute(route)); + } catch { + return false; + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts new file mode 100644 index 000000000..0940db2aa --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts @@ -0,0 +1,60 @@ +import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; + +interface OwnedExternalLaunchOptions { + launch: Promise; + owns: () => boolean; + close: (session: ExternalPlayerSession) => Promise; + warnCloseFailure: (error: unknown) => void; + clearPending: () => void; + clearOwnership: () => void; +} + +/** Settles an exact MPV/VLC launch without letting stale results take ownership. */ +export async function settleOwnedExternalLaunch( + options: OwnedExternalLaunchOptions +): Promise { + try { + const launched = await options.launch; + const accepted = + launched?.status === 'opened' || launched?.status === 'playing'; + + if (accepted && options.owns()) { + options.clearPending(); + return true; + } + + if (isClosableExternalLaunch(launched)) { + try { + await options.close(launched); + } catch (error) { + options.warnCloseFailure(error); + // The exact child is still potentially live. Keep the + // credential-free destination owner so the next source start + // can retry its close instead of depending on the global dock. + options.clearPending(); + return false; + } + } + options.clearPending(); + options.clearOwnership(); + return false; + } catch { + // A partial reuse can reject while Electron retains a matching + // closable error session. Keep its credential-free identity so the + // next source start can still find and close that exact process. + options.clearPending(); + return false; + } +} + +function isClosableExternalLaunch( + session: ExternalPlayerSession | void +): session is ExternalPlayerSession { + return ( + !!session && + (session.status === 'launching' || + session.status === 'opened' || + session.status === 'playing' || + (session.status === 'error' && session.canClose)) + ); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts index 515caa99b..51416363a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts @@ -7,10 +7,7 @@ import { } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; -import type { - PlaybackPositionData, - PlayerContentInfo, -} from '@iptvnator/shared/interfaces'; +import type { PlayerContentInfo } from '@iptvnator/shared/interfaces'; import { VodDetailsPlaybackService } from './vod-details-playback.service'; /** @@ -30,18 +27,24 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { const ROUTE_VOD_ID = 650020; let service: VodDetailsPlaybackService; - /** The bridge callback the service registers at construction. */ - let positionListener: ((data: PlaybackPositionData) => void) | undefined; const addRecentItem = jest.fn(); const activeSession = signal(null); const closeSession = jest.fn().mockResolvedValue(undefined); const openResolvedPlayback = jest.fn(); const activeSource = signal(null); + const currentPlaylist = signal({ id: ROUTE_PLAYLIST }); + const routeVodId = signal(ROUTE_VOD_ID); function sessionFor(playlistId: string, contentXtreamId: number) { return { + id: `${playlistId}:${contentXtreamId}`, player: 'mpv', status: 'playing', + title: 'Example Movie', + streamUrl: 'https://example.com/movie.mkv', + startedAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + canClose: true, contentInfo: { playlistId, contentXtreamId, @@ -53,8 +56,13 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { beforeEach(() => { activeSession.set(null); activeSource.set(null); - positionListener = undefined; + currentPlaylist.set({ id: ROUTE_PLAYLIST }); + routeVodId.set(ROUTE_VOD_ID); addRecentItem.mockClear(); + closeSession.mockReset().mockResolvedValue(undefined); + openResolvedPlayback + .mockReset() + .mockResolvedValue(sessionFor('playlist-2', 991)); TestBed.configureTestingModule({ providers: [ @@ -62,7 +70,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { { provide: XtreamStore, useValue: { - currentPlaylist: signal({ id: ROUTE_PLAYLIST }), + currentPlaylist, addRecentItem, constructVodStreamUrl: jest .fn() @@ -90,12 +98,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { { provide: PlaybackPositionRuntimeBridgeService, useValue: { - onPlaybackPositionUpdate: ( - listener: (data: PlaybackPositionData) => void - ) => { - positionListener = listener; - return () => undefined; - }, + onPlaybackPositionUpdate: () => () => undefined, }, }, ], @@ -103,17 +106,15 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { service = TestBed.inject(VodDetailsPlaybackService); service.bind({ - vodId: signal(ROUTE_VOD_ID), + vodId: routeVodId, vodInfo: signal(null), activeSource, }); }); it('closes the alternative it launched, once the badge has moved on', async () => { - // The controller marks the DESTINATION active before playback is - // handed over, so by the time the switch reaches the service the - // running process no longer looks like "ours" — and was left playing - // beside its replacement. + // Ownership survives controller refreshes or overlapping UI state: + // the exact process this page launched still has to close first. activeSession.set(sessionFor('playlist-2', 991)); activeSource.set({ playlistId: 'playlist-3', @@ -153,22 +154,79 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { ); }); - it('still starts the replacement when closing the old player fails', async () => { + it('cancels the replacement when closing the old player fails', async () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); closeSession.mockRejectedValue(new Error('close ipc failed')); openResolvedPlayback.mockClear(); + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }) + ).resolves.toBe(false); + + expect(openResolvedPlayback).not.toHaveBeenCalled(); + + closeSession.mockResolvedValue(undefined); + }); + + it('cancels the replacement while a live session cannot be closed', async () => { + activeSession.set({ + ...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID), + status: 'launching', + canClose: false, + }); + openResolvedPlayback.mockClear(); + + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }) + ).resolves.toBe(false); + + expect(closeSession).not.toHaveBeenCalled(); + expect(openResolvedPlayback).not.toHaveBeenCalled(); + }); + + it('rejects a handoff when the external player launch fails', async () => { + openResolvedPlayback.mockRejectedValueOnce( + new Error('previous player is still shutting down') + ); + + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }) + ).resolves.toBe(false); + + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + }); + + it('closes a closable error before starting a replacement source', async () => { + activeSession.set({ + ...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID), + status: 'error', + error: 'Process exit was not confirmed', + canClose: true, + }); + closeSession.mockClear(); + await service.startResolvedPlayback({ streamUrl: 'https://example.com/alt.mkv', title: 'Example Movie', }); - // The switch is already committed — the badge names the new source. - // Bailing out here left the page claiming a source with nothing - // started at all. - expect(openResolvedPlayback).toHaveBeenCalledTimes(1); - - closeSession.mockResolvedValue(undefined); + expect(closeSession).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', canClose: true }) + ); }); it('launches only the newest source when two switches overlap', async () => { @@ -209,6 +267,232 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { closeSession.mockResolvedValue(undefined); }); + it('checks host ownership after teardown before applying playback', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + let releaseClose: (() => void) | undefined; + const closing = new Promise((resolve) => { + releaseClose = resolve; + }); + closeSession.mockReturnValue(closing); + openResolvedPlayback.mockClear(); + let ownsSwitch = true; + + const switching = service.startResolvedPlayback( + { + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }, + () => ownsSwitch + ); + ownsSwitch = false; + releaseClose?.(); + + await expect(switching).resolves.toBe(false); + expect(openResolvedPlayback).not.toHaveBeenCalled(); + + closeSession.mockResolvedValue(undefined); + }); + + it('closes a launch that loses host ownership while IPC is pending', async () => { + let releaseLaunch: ((value: unknown) => void) | undefined; + const launched = sessionFor('playlist-2', 991); + openResolvedPlayback.mockReturnValueOnce( + new Promise((resolve) => { + releaseLaunch = resolve; + }) + ); + let ownsSwitch = true; + + const switching = service.startResolvedPlayback( + { + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: launched.contentInfo, + }, + () => ownsSwitch + ); + while (openResolvedPlayback.mock.calls.length === 0) { + await Promise.resolve(); + } + ownsSwitch = false; + releaseLaunch?.(launched); + + await expect(switching).resolves.toBe(false); + expect(closeSession).toHaveBeenCalledWith(launched); + }); + + it('retains a stale launch when its exact close fails', async () => { + let releaseLaunch: ((value: unknown) => void) | undefined; + const launched = sessionFor('playlist-2', 991); + const failed = { + ...launched, + status: 'error', + error: 'Process exit was not confirmed', + canClose: true, + }; + openResolvedPlayback.mockReturnValueOnce( + new Promise((resolve) => { + releaseLaunch = resolve; + }) + ); + closeSession.mockRejectedValueOnce(new Error('close ipc failed')); + let ownsSwitch = true; + + const switching = service.startResolvedPlayback( + { + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: launched.contentInfo, + }, + () => ownsSwitch + ); + while (openResolvedPlayback.mock.calls.length === 0) { + await Promise.resolve(); + } + ownsSwitch = false; + activeSession.set(failed); + releaseLaunch?.(launched); + + await expect(switching).resolves.toBe(false); + expect(service.matchedExternalPlayback()?.id).toBe(failed.id); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/third.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-3', + contentXtreamId: 992, + contentType: 'vod', + }, + }); + expect(closeSession).toHaveBeenLastCalledWith(failed); + }); + + it('does not commit a session stopped while launch IPC is pending', async () => { + openResolvedPlayback.mockResolvedValueOnce({ + ...sessionFor('playlist-2', 991), + status: 'closed', + canClose: false, + }); + + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }) + ).resolves.toBe(false); + }); + + it('owns an alternative immediately while its external launch is pending', async () => { + let releaseLaunch: ((value: unknown) => void) | undefined; + const launching = { + ...sessionFor('playlist-2', 991), + status: 'launching', + }; + openResolvedPlayback.mockReturnValueOnce( + new Promise((resolve) => { + releaseLaunch = resolve; + }) + ); + + const switching = service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: launching.contentInfo, + }); + while (openResolvedPlayback.mock.calls.length === 0) { + await Promise.resolve(); + } + + expect(service.isExternalLaunchPending()).toBe(true); + activeSession.set(launching); + expect(service.matchedExternalPlayback()?.id).toBe(launching.id); + + const opened = { ...launching, status: 'opened' }; + activeSession.set(opened); + releaseLaunch?.(opened); + await expect(switching).resolves.toBe(true); + expect(service.isExternalLaunchPending()).toBe(false); + }); + + it('keeps the pending launch when an unclosable source replacement is denied', async () => { + let releaseLaunch: ((value: unknown) => void) | undefined; + const launching = { + ...sessionFor('playlist-2', 991), + status: 'launching', + canClose: false, + }; + openResolvedPlayback.mockReturnValueOnce( + new Promise((resolve) => { + releaseLaunch = resolve; + }) + ); + + const first = service.startResolvedPlayback({ + streamUrl: 'https://example.com/first.mkv', + title: 'Example Movie', + contentInfo: launching.contentInfo, + }); + while (openResolvedPlayback.mock.calls.length === 0) { + await Promise.resolve(); + } + activeSession.set(launching); + + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/second.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-3', + contentXtreamId: 77, + contentType: 'vod', + }, + }) + ).resolves.toBe(false); + + const opened = { ...launching, status: 'opened' }; + activeSession.set(opened); + releaseLaunch?.(opened); + + await expect(first).resolves.toBe(true); + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + expect(closeSession).not.toHaveBeenCalled(); + }); + + it('retains a closable failed alternative for the next exact close', async () => { + const failed = { + ...sessionFor('playlist-2', 991), + status: 'error', + error: 'Process exit was not confirmed', + canClose: true, + }; + openResolvedPlayback.mockImplementationOnce(async () => { + activeSession.set(failed); + throw new Error('External player process did not exit'); + }); + + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: failed.contentInfo, + }) + ).resolves.toBe(false); + + expect(service.matchedExternalPlayback()?.id).toBe(failed.id); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/third.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-3', + contentXtreamId: 992, + contentType: 'vod', + }, + }); + expect(closeSession).toHaveBeenCalledWith(failed); + }); + it('drops a switch that a plain Play overtook', async () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); let releaseClose: (() => void) | undefined; @@ -246,6 +530,36 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { closeSession.mockResolvedValue(undefined); }); + it('drops a plain Play when its initiating route changes during close', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + let releaseClose: (() => void) | undefined; + closeSession.mockReturnValue( + new Promise((resolve) => { + releaseClose = resolve; + }) + ); + openResolvedPlayback.mockClear(); + addRecentItem.mockClear(); + + const playing = service.playVod({ + movie_data: { + stream_id: ROUTE_VOD_ID, + name: 'First movie', + container_extension: 'mkv', + }, + } as never); + while (closeSession.mock.calls.length === 0) { + await Promise.resolve(); + } + + routeVodId.set(ROUTE_VOD_ID + 1); + releaseClose?.(); + + await expect(playing).resolves.toBe(false); + expect(openResolvedPlayback).not.toHaveBeenCalled(); + expect(addRecentItem).not.toHaveBeenCalled(); + }); + it('stops the running external player before switching sources', async () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts index af5dfb0a4..b40c5d6f0 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts @@ -2,14 +2,15 @@ import type { ExternalPlayerSession, PlayerContentInfo, } from '@iptvnator/shared/interfaces'; +import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util'; /** * Which external player process, and which position rows, belong to this page. * * Multi-source makes both questions harder than they look: playback can be on * a copy of the film in ANOTHER playlist, whose ids the session and the - * position rows then carry, and during a switch the controller has already - * moved "active" to the destination before playback is handed over. + * position rows then carry. A source handoff also waits for exact process + * teardown before the destination can become active. */ /** Ids this page owns, beyond the route's own copy. */ @@ -53,10 +54,9 @@ export function ownsContent( /** * The external process this page started, if it is still up. * - * Matched on the ids we LAUNCHED with rather than on what is active now: a - * switch marks the destination active before handing playback over, so asking - * "is this session ours?" at that moment answers no and leaves the running - * process playing beside its replacement. + * Matched on the ids we LAUNCHED with rather than only on what is active now: + * refreshes and overlapping handoffs may update controller state while exact + * process teardown is still in flight. */ export function runningExternalSession( session: ExternalPlayerSession | null, @@ -66,7 +66,7 @@ export function runningExternalSession( if ( !session?.contentInfo || session.status === 'closed' || - session.status === 'error' + (session.status === 'error' && !session.canClose) ) { return null; } @@ -83,26 +83,29 @@ export function runningExternalSession( /** * Close the running external player before its replacement starts. * - * A failure is logged rather than propagated: the caller has already - * committed the switch, so refusing to launch would leave the page naming a - * source with nothing playing — worse than a possibly-lingering process, and - * a close that rejects usually means the session was gone already. + * A failed close leaves teardown unconfirmed. Report false so the caller can + * cancel the replacement instead of starting a second external process. */ export async function closeRunningExternalSession( session: ExternalPlayerSession | null, close: (session: ExternalPlayerSession) => Promise, warn: (message: string, error: unknown) => void -): Promise { +): Promise { if (!session) { - return; + return true; + } + if (isLiveExternalPlayerSession(session) && !session.canClose) { + return false; } try { await close(session); + return true; } catch (error) { warn( - 'Closing the previous external player failed; starting the replacement anyway.', + 'Closing the previous external player failed; cancelling the replacement.', error ); + return false; } } 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 f9f8c71b0..7974c3cc4 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 @@ -31,6 +31,8 @@ describe('VodDetailsPlaybackService — external session ownership', () => { const activeSession = signal(null); const closeSession = jest.fn().mockResolvedValue(undefined); const openResolvedPlayback = jest.fn(); + const openExternalPlayback = jest.fn(); + const supersedePendingSwitch = jest.fn(); const activeSource = signal(null); const currentPlaylist = signal({ id: ROUTE_PLAYLIST }); const routeVodId = signal(ROUTE_VOD_ID); @@ -63,6 +65,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { return { player: 'mpv', status: 'playing', + canClose: true, contentInfo: { playlistId, contentXtreamId, @@ -78,6 +81,14 @@ describe('VodDetailsPlaybackService — external session ownership', () => { routeVodId.set(ROUTE_VOD_ID); positionListener = undefined; addRecentItem.mockClear(); + closeSession.mockReset().mockResolvedValue(undefined); + openResolvedPlayback + .mockReset() + .mockResolvedValue(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + openExternalPlayback + .mockReset() + .mockResolvedValue(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + supersedePendingSwitch.mockClear(); getPlaybackPosition.mockReset(); getPlaybackPosition.mockResolvedValue(null); @@ -110,6 +121,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { useValue: { isEmbeddedPlayer: jest.fn().mockReturnValue(false), openResolvedPlayback, + openExternalPlayback, }, }, { @@ -131,6 +143,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { vodId: routeVodId, vodInfo: signal(null), activeSource, + supersedePendingSwitch, }); }); @@ -176,6 +189,142 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(service.matchedExternalPlayback()).toBeNull(); }); + it('disowns its previous external launch when the routed movie changes', async () => { + const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'First movie', + contentInfo: launched.contentInfo, + }); + activeSession.set(launched); + + routeVodId.set(ROUTE_VOD_ID + 1); + + expect(service.matchedExternalPlayback()).toBeNull(); + expect(service.isExternalStopAction()).toBe(false); + }); + + it('closes the previous route launch before playing the new movie', async () => { + const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/first.mkv', + title: 'First movie', + contentInfo: launched.contentInfo, + }); + activeSession.set(launched); + routeVodId.set(ROUTE_VOD_ID + 1); + closeSession.mockClear(); + openResolvedPlayback.mockClear(); + + service.playVod({ + movie_data: { + stream_id: ROUTE_VOD_ID + 1, + name: 'Second movie', + container_extension: 'mkv', + }, + } as never); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (openResolvedPlayback.mock.calls.length > 0) break; + await Promise.resolve(); + } + + expect(closeSession).toHaveBeenCalledWith(launched); + expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan( + openResolvedPlayback.mock.invocationCallOrder[0] + ); + }); + + it('closes a diagnostic fallback before playing the next routed movie', async () => { + const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID); + const launch = Promise.resolve(launched); + const trackLaunch = jest.fn(); + openExternalPlayback.mockImplementationOnce(() => { + activeSession.set(launched); + expect(service.isExternalLaunchPending()).toBe(true); + expect(service.matchedExternalPlayback()).toBe(launched); + return launch; + }); + + service.handleExternalFallbackRequest({ + player: 'mpv', + playback: { + streamUrl: 'https://example.com/first.mkv', + title: 'First movie', + contentInfo: launched.contentInfo, + }, + diagnostic: {}, + trackLaunch, + } as never); + expect(trackLaunch).toHaveBeenCalledWith(launch); + await launch; + await Promise.resolve(); + + routeVodId.set(ROUTE_VOD_ID + 1); + closeSession.mockClear(); + openResolvedPlayback.mockClear(); + service.playVod({ + movie_data: { + stream_id: ROUTE_VOD_ID + 1, + name: 'Second movie', + container_extension: 'mkv', + }, + } as never); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (openResolvedPlayback.mock.calls.length > 0) break; + await Promise.resolve(); + } + + expect(closeSession).toHaveBeenCalledWith(launched); + expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan( + openResolvedPlayback.mock.invocationCallOrder[0] + ); + }); + + it('supersedes a pending source switch before starting a diagnostic fallback', () => { + const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID); + + service.handleExternalFallbackRequest({ + player: 'mpv', + playback: { + streamUrl: 'https://example.com/first.mkv', + title: 'First movie', + contentInfo: launched.contentInfo, + }, + diagnostic: {}, + trackLaunch: jest.fn(), + } as never); + + expect(supersedePendingSwitch).toHaveBeenCalledTimes(1); + expect(supersedePendingSwitch.mock.invocationCallOrder[0]).toBeLessThan( + openExternalPlayback.mock.invocationCallOrder[0] + ); + }); + + it('closes a diagnostic fallback that resolves after its route changed', async () => { + const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID); + const pending = deferred(); + const trackLaunch = jest.fn(); + openExternalPlayback.mockReturnValueOnce(pending.promise); + + service.handleExternalFallbackRequest({ + player: 'mpv', + playback: { + streamUrl: 'https://example.com/first.mkv', + title: 'First movie', + contentInfo: launched.contentInfo, + }, + diagnostic: {}, + trackLaunch, + } as never); + routeVodId.set(ROUTE_VOD_ID + 1); + pending.resolve(launched); + await pending.promise; + await Promise.resolve(); + + expect(trackLaunch).toHaveBeenCalledWith(pending.promise); + expect(closeSession).toHaveBeenCalledWith(launched); + }); + it('records a source started through multi-source as recently viewed', async () => { // Playing an alternative from the picker, or letting a pin decide the // primary Play, is still watching the movie — it belongs in Recently 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..0f3241f47 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 @@ -21,6 +21,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { + ExternalPlayerSession, PlaybackPositionData, PlayerContentInfo, ResolvedPortalPlayback, @@ -33,6 +34,11 @@ import { ownsContent, runningExternalSession, } from './vod-details-external-session'; +import { + createExternalLaunchOwner, + startRouteOwnedPlayback, +} from './vod-details-external-launch-owner'; +import { settleOwnedExternalLaunch } from './vod-details-external-launch'; import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; import { formatPlaybackPosition } from './vod-primary-action-position'; @@ -47,6 +53,8 @@ export interface VodDetailsPlaybackBindings { * this the session belongs to no page and never offers Stop. */ activeSource?: Signal; + /** Retires a source resolution that the accepted fallback now supersedes. */ + supersedePendingSwitch: () => void; } /** @@ -69,6 +77,10 @@ export class VodDetailsPlaybackService { /** Signals bound from the host component via `bind()` */ private readonly bindings = signal(null); + private readonly externalLaunchOwner = createExternalLaunchOwner( + () => this.xtreamStore.currentPlaylist()?.id, + () => this.bindings()?.vodId() + ); readonly inlinePlayback = signal(null); /** @@ -95,13 +107,22 @@ export class VodDetailsPlaybackService { session: this.externalPlayback.activeSession, playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id), contentId: computed(() => this.bindings()?.vodId()), - alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null), + alsoOwns: computed( + () => + this.externalLaunchOwner.current() ?? + this.bindings()?.activeSource?.() ?? + null + ), }); readonly matchedExternalPlayback = this.externalButton.matchedSession; readonly externalPrimaryLabel = this.externalButton.primaryLabel; readonly externalPrimaryIcon = this.externalButton.primaryIcon; - readonly isExternalLaunchPending = this.externalButton.isLaunchPending; + readonly isExternalLaunchPending = computed( + () => + this.externalLaunchGeneration() !== null || + this.externalButton.isLaunchPending() + ); readonly isExternalStopAction = this.externalButton.isStopAction; readonly externalPrimaryButtonState = this.externalButton.buttonState; readonly vodPlaybackProgress = computed(() => @@ -177,23 +198,22 @@ export class VodDetailsPlaybackService { this.bindings.set(bindings); } - playVod(vodItem: XtreamVodDetails | null): void { + async playVod(vodItem: XtreamVodDetails | null): Promise { if (!vodItem) { - return; + return false; } const source = resolveXtreamVodPlaybackSource(vodItem); if (!source) { - return; + return false; } const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) { - return; + return false; } const presentation = resolveXtreamVodPlaybackPresentation(vodItem); - this.addToRecentlyViewed(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const routeVodId = this.bindings()?.vodId(); const id = @@ -217,26 +237,25 @@ export class VodDetailsPlaybackService { contentInfo, }; - this.startPlayback(playback); + return await this.startPlayback(playback); } - resumeVod(vodItem: XtreamVodDetails | null): void { + async resumeVod(vodItem: XtreamVodDetails | null): Promise { if (!vodItem) { - return; + return false; } const source = resolveXtreamVodPlaybackSource(vodItem); if (!source) { - return; + return false; } const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) { - return; + return false; } const presentation = resolveXtreamVodPlaybackPresentation(vodItem); - this.addToRecentlyViewed(); // Master's sparse-details fallback: a provider that omits the route // id still has the stream id on the resolved source. const routeVodId = this.bindings()?.vodId(); @@ -265,7 +284,7 @@ export class VodDetailsPlaybackService { contentInfo, }; - this.startPlayback(playback); + return await this.startPlayback(playback); } onPrimaryAction(vodItem: XtreamVodDetails | null): void { @@ -274,22 +293,20 @@ export class VodDetailsPlaybackService { } if (this.isExternalStopAction()) { - void this.stopExternalPlayback(); + void this.stopExternalPlayback().catch(() => undefined); return; } if (this.hasPlaybackPosition()) { - this.resumeVod(vodItem); + void this.resumeVod(vodItem); return; } - this.playVod(vodItem); + void this.playVod(vodItem); } - async stopExternalPlayback(): Promise { - await this.externalPlayback.closeSession( - this.matchedExternalPlayback() - ); + stopExternalPlayback(): Promise { + return this.externalPlayback.closeSession(this.matchedExternalPlayback()); } formatPosition(): string { @@ -324,10 +341,20 @@ export class VodDetailsPlaybackService { } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( + const routeIdentity = this.externalLaunchOwner.captureRoute(); + this.bindings()?.supersedePendingSwitch(); + const generation = ++this.startGeneration; + this.claimExternalLaunch(request.playback, generation); + const launch = this.portalPlayer.openExternalPlayback( request.playback, request.player ); + request.trackLaunch(launch); + void this.settleExternalLaunch( + generation, + () => this.externalLaunchOwner.ownsRoute(routeIdentity), + launch + ); } async loadPosition(playlistId: string, vodId: number): Promise { @@ -375,27 +402,40 @@ export class VodDetailsPlaybackService { * What we last launched externally, remembered independently of the * controller's active source. * - * `matchedExternalPlayback` cannot answer this during a switch: the - * controller marks the DESTINATION active before playback is handed over, - * so by the time we get here the running process no longer looks like - * ours and would be left playing beside its replacement. + * Kept independently of the controller so a refresh or an overlapping + * handoff cannot make the exact process this page launched look foreign + * before its teardown has been confirmed. Its route owner prevents a + * reused component from attributing that process to a different movie. */ - private launchedExternally: PlayerContentInfo | null = null; + private launchedExternallyGeneration = 0; + private readonly externalLaunchGeneration = signal(null); async startResolvedPlayback( - playback: ResolvedPortalPlayback - ): Promise { + playback: ResolvedPortalPlayback, + isCurrent: () => boolean = () => true + ): Promise { + if (this.externalLaunchGeneration() !== null) { + return false; + } + const runningSession = runningExternalSession( + this.externalPlayback.activeSession(), + this.externalLaunchOwner.retained(), + this.matchedExternalPlayback() + ); + // A still-opening process has no exact closer yet. Denying this + // replacement must not supersede that launch generation, or its late + // successful result would be closed even though nothing replaced it. + if (runningSession && !runningSession.canClose) { + return false; + } + const generation = ++this.startGeneration; // A switch REPLACES what is playing. With MPV or VLC and instance // reuse off, the backend spawns a second detached player otherwise — // both sources keep running and Stop owns only the newer one. - await closeRunningExternalSession( - runningExternalSession( - this.externalPlayback.activeSession(), - this.launchedExternally, - this.matchedExternalPlayback() - ), + const previousPlayerClosed = await closeRunningExternalSession( + runningSession, (session) => this.externalPlayback.closeSession(session), (message, error) => this.logger.warn(message, error) ); @@ -404,30 +444,90 @@ export class VodDetailsPlaybackService { // reach this line too: both would have seen the same session, closed // it once, and then launched independently — two detached players // again, the older one holding a source the user has moved on from. - if (generation !== this.startGeneration) { - return; + if ( + !previousPlayerClosed || + generation !== this.startGeneration || + !isCurrent() + ) { + return false; } // Same movie, different source: still a view. this.addToRecentlyViewed(); - this.startPlayback(playback); + return await this.applyPlayback(playback, isCurrent); } - private startPlayback(playback: ResolvedPortalPlayback): void { + private startPlayback(playback: ResolvedPortalPlayback): Promise { + return startRouteOwnedPlayback(this.externalLaunchOwner, (isCurrent) => + this.startResolvedPlayback(playback, isCurrent) + ); + } + + private async applyPlayback( + playback: ResolvedPortalPlayback, + isCurrent: () => boolean = () => true + ): Promise { // EVERY start claims the generation, not just the switch path. Play, // Resume and Restart reach here directly, and a switch still waiting // on its `closeSession` would otherwise pass the check afterwards and // launch on top of what the user just chose. - this.startGeneration++; + const generation = ++this.startGeneration; this.positionWriter.reset(); if (this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlayback.set(playback); - this.launchedExternally = null; - return; + this.externalLaunchOwner.clear(); + this.externalLaunchGeneration.set(null); + return true; } this.closeInlinePlayer(); - this.launchedExternally = playback.contentInfo ?? null; - void this.portalPlayer.openResolvedPlayback(playback, true); + this.claimExternalLaunch(playback, generation); + const launch = this.portalPlayer.openResolvedPlayback(playback, true); + return await this.settleExternalLaunch( + generation, + isCurrent, + launch + ); + } + + private claimExternalLaunch( + playback: ResolvedPortalPlayback, + generation: number + ): void { + this.externalLaunchOwner.set(playback.contentInfo); + this.launchedExternallyGeneration = generation; + this.externalLaunchGeneration.set(generation); + } + + private async settleExternalLaunch( + generation: number, + isCurrent: () => boolean, + launch: Promise + ): Promise { + return await settleOwnedExternalLaunch({ + launch, + owns: () => generation === this.startGeneration && isCurrent(), + close: (session) => this.externalPlayback.closeSession(session), + warnCloseFailure: (error) => + this.logger.warn( + 'Closing a superseded external player failed.', + error + ), + clearPending: () => this.clearExternalLaunchPending(generation), + clearOwnership: () => + this.clearExternalLaunchOwnership(generation), + }); + } + + private clearExternalLaunchOwnership(generation: number): void { + if (this.launchedExternallyGeneration === generation) { + this.externalLaunchOwner.clear(); + } + } + + private clearExternalLaunchPending(generation: number): void { + if (this.externalLaunchGeneration() === generation) { + this.externalLaunchGeneration.set(null); + } } } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts index 539614c0f..3e5aa12ef 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts @@ -112,7 +112,7 @@ describe('VodDetailsRouteComponent — source caption', () => { expect(component.activeSourceCaption()).toBeNull(); }); - it('waits for the next stream to start after Play is pressed again', () => { + it('waits for the next stream to start after Play is pressed again', async () => { currentPlaylist.set({ id: 'playlist-1' }); const component = fixture.componentInstance; const playback = fixture.debugElement.injector.get( @@ -131,8 +131,12 @@ describe('VodDetailsRouteComponent — source caption', () => { // Restart keeps the same host mounted, so without clearing the latch // the caption would carry the previous stream's claim into the new one. - component.playVod({ - movie_data: { stream_id: 650020, name: 'Example' }, + await component.playVod({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mkv', + }, } as never); expect(playback.inlinePlayback()).not.toBeNull(); expect(component.activeSourceCaption()).toBeNull(); @@ -159,10 +163,12 @@ describe('VodDetailsRouteComponent — source caption', () => { // The exact callback `switchToSource` invokes once it has resolved a // new source. Standing up the resolver here would test the host, not // the route's half of the seam. - ( + await ( component.multiSource as unknown as { bindings: { - startPlayback: (playback: ResolvedPortalPlayback) => void; + startPlayback: ( + playback: ResolvedPortalPlayback + ) => Promise; }; } ).bindings.startPlayback({ @@ -170,9 +176,6 @@ describe('VodDetailsRouteComponent — source caption', () => { title: 'Example', startTime: 3, }); - // Replacing a running external player is a round-trip, so the handoff - // yields once before the new playback is mounted. - await Promise.resolve(); expect(playback.inlinePlayback()?.streamUrl).toBe( 'http://example.com/alt.mkv' diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 11f98ac39..8d5cd1bb6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -100,11 +100,61 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(component.isExternalStopAction()).toBe(true); }); - it('drops the carried position when Restart starts from the beginning', () => { + it('wires diagnostic fallback acceptance to source-switch supersession', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const supersede = jest.spyOn( + component.multiSource, + 'supersedePendingSwitch' + ); + const launched = { + id: 'diagnostic-session', + player: 'mpv' as const, + status: 'opened' as const, + title: 'Example', + streamUrl: 'https://example.com/movie.mkv', + startedAt: '2026-08-09T00:00:00.000Z', + updatedAt: '2026-08-09T00:00:01.000Z', + canClose: true, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod' as const, + }, + }; + const launch = Promise.resolve(launched); + stubs.openExternalPlayback.mockReturnValueOnce(launch); + + component.handleExternalFallbackRequest({ + player: 'mpv', + playback: { + streamUrl: launched.streamUrl, + title: launched.title, + contentInfo: launched.contentInfo, + }, + diagnostic: {}, + trackLaunch: jest.fn(), + } as never); + + expect(supersede).toHaveBeenCalledTimes(1); + expect(supersede.mock.invocationCallOrder[0]).toBeLessThan( + stubs.openExternalPlayback.mock.invocationCallOrder[0] + ); + }); + + it('drops the carried position when Restart starts from the beginning', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + stubs.isEmbeddedPlayer.mockReturnValue(true); const component = fixture.componentInstance; const reported = jest.spyOn(component.multiSource, 'reportPosition'); - component.playVod({} as XtreamVodDetails); + await component.playVod({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mkv', + }, + } as never); // The controller still holds whatever this page was seeded with, and // a failure before the first timeupdate would resolve the next source @@ -112,6 +162,44 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(reported).toHaveBeenCalledWith(0); }); + it('keeps the previous source state when its external close fails', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + activeSession.set({ + id: 'previous-session', + player: 'mpv', + status: 'playing', + title: 'Previous source', + streamUrl: 'https://example.com/previous.mkv', + startedAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + canClose: true, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + }, + }); + closeSession.mockRejectedValueOnce(new Error('close ipc failed')); + const component = fixture.componentInstance; + const markRouteSourceActive = jest.spyOn( + component.multiSource, + 'markRouteSourceActive' + ); + const beginPlayback = jest.spyOn(component.msUi, 'beginPlayback'); + + await component.playVod({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mkv', + }, + } as never); + + expect(markRouteSourceActive).not.toHaveBeenCalled(); + expect(beginPlayback).not.toHaveBeenCalled(); + expect(stubs.openResolvedPlayback).not.toHaveBeenCalled(); + }); + it('stops the external player when the button says Stop', async () => { currentPlaylist.set({ id: 'playlist-1' }); downloadsAvailable.set(true); @@ -235,6 +323,34 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(stubs.openResolvedPlayback).not.toHaveBeenCalled(); }); + it('guards Restart and provider actions before external IPC settles', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + stubs.openResolvedPlayback.mockReturnValue(new Promise(() => undefined)); + const component = fixture.componentInstance; + const item = { + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, + } as never; + + void component.restartVod(item); + for ( + let attempt = 0; + attempt < 20 && stubs.openResolvedPlayback.mock.calls.length === 0; + attempt += 1 + ) { + await new Promise((resolve) => setTimeout(resolve)); + } + expect(component.isExternalLaunchPending()).toBe(true); + await component.restartVod(item); + await component.playFromProviderSource(item); + + expect(component.isExternalLaunchPending()).toBe(true); + expect(stubs.openResolvedPlayback).toHaveBeenCalledTimes(1); + }); + it('does not let the provider secondary bypass a pending external launch', async () => { currentPlaylist.set({ id: 'playlist-1' }); activeSession.set({ @@ -492,6 +608,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { it('replaces an alternative’s timecode when Resume starts the route copy', async () => { currentPlaylist.set({ id: 'playlist-1' }); + stubs.isEmbeddedPlayer.mockReturnValue(true); const component = fixture.componentInstance; const playback = fixture.debugElement.injector.get( VodDetailsPlaybackService @@ -510,7 +627,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { // ROUTE copy has to overwrite it, or a failure before the first // timeupdate resolves the next source at a position from another copy. component.multiSource.reportPosition(4200); - component.resumeVod({ + await component.resumeVod({ movie_data: { stream_id: 650020, name: 'Example', @@ -544,8 +661,9 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(reported).not.toHaveBeenCalled(); }); - it('takes the route wrappers when the primary button falls through', () => { + it('takes the route wrappers when the primary button falls through', async () => { currentPlaylist.set({ id: 'playlist-1' }); + stubs.isEmbeddedPlayer.mockReturnValue(true); const component = fixture.componentInstance; withActiveSource('playlist-1', 650020); jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue( @@ -555,7 +673,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { // Reaching the service directly would skip the bookkeeping a route // start needs — the controller would keep an alternative's timecode. - void component.onPrimaryAction({ + await component.onPrimaryAction({ movie_data: { stream_id: 650020, name: 'Example', @@ -563,9 +681,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { }, } as never); - return Promise.resolve().then(() => { - expect(reported).toHaveBeenCalledWith(0); - }); + expect(reported).toHaveBeenCalledWith(0); }); it('offers no resume point for a pinned copy watched through', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index c1b46def5..9a79806d7 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -37,6 +37,7 @@ } + @if (showDismissAction()) { + @if (compact()) { + + } @else { + + } + } diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss index 29158fd71..2a121a3d1 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss @@ -9,27 +9,27 @@ gap: 14px; min-height: 72px; padding: 2px 14px; - border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent); + border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08)); border-radius: 15px 15px 0 0; - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%), - color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent) - ); - box-shadow: - 0 18px 42px rgba(15, 23, 42, 0.12), - inset 0 1px 0 rgba(255, 255, 255, 0.28); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--app-widget-bg, #fff) 88%, white 12%), + var(--app-widget-header-bg, #fafbfc) + ); + box-shadow: var(--app-widget-shadow, 0 18px 42px rgba(15, 23, 42, 0.12)); } .external-playback-dock--error { - border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent); - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent), - color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent) - ); + border-color: color-mix( + in srgb, + var(--app-expiry-expired-text, #b3261e) 50%, + transparent + ); + background: linear-gradient( + 135deg, + var(--app-expiry-expired-surface, rgba(244, 67, 54, 0.14)), + var(--app-widget-bg, #fff) + ); } .external-playback-dock--compact { @@ -37,9 +37,7 @@ padding: 8px 10px; gap: 10px; border-radius: 16px; - box-shadow: - 0 12px 28px rgba(15, 23, 42, 0.16), - inset 0 1px 0 rgba(255, 255, 255, 0.18); + box-shadow: var(--app-widget-shadow, 0 12px 28px rgba(15, 23, 42, 0.16)); .external-playback-dock__artwork { width: 38px; @@ -63,7 +61,7 @@ border: 0; border-radius: 14px; overflow: hidden; - background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent); + background: var(--app-widget-header-bg, #fafbfc); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); color: inherit; cursor: default; @@ -93,7 +91,7 @@ } &:focus-visible { - outline: 2px solid var(--mat-sys-primary); + outline: 2px solid var(--app-selection-color, #2f7bff); outline-offset: 2px; } } @@ -102,7 +100,7 @@ .external-playback-dock__placeholder { display: grid; place-items: center; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color, #4a5060); } .external-playback-dock__meta { @@ -114,14 +112,9 @@ align-items: center; height: 18px; border-radius: 999px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-highest) 80%, - transparent - ); - color: var(--mat-sys-on-surface-variant); - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent); + background: var(--app-card-hover-bg, #f0f2f5); + color: var(--app-body-color, #4a5060); + border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08)); font-size: 0.66rem; font-weight: 700; letter-spacing: 0.08em; @@ -133,7 +126,7 @@ white-space: nowrap; font-size: 0.95rem; font-weight: 700; - color: var(--mat-sys-on-surface); + color: var(--app-heading-color, #1a1d23); margin-bottom: 2px; } @@ -141,7 +134,7 @@ display: flex; align-items: center; gap: 6px; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color, #4a5060); font-size: 0.78rem; min-width: 0; @@ -179,6 +172,10 @@ white-space: nowrap; } +.external-playback-dock__dismiss { + flex-shrink: 0; +} + @media (max-width: 720px) { .external-playback-dock { grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts index 45a39d7a7..ab1c30176 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts @@ -17,6 +17,11 @@ class FakeTranslateLoader implements TranslateLoader { WORKSPACE: { SHELL: { EXTERNAL_PLAYBACK_CLOSE: 'Close player', + EXTERNAL_PLAYBACK_DISMISS: 'Dismiss', + EXTERNAL_PLAYBACK_OPENING: 'Opening player…', + EXTERNAL_PLAYBACK_STARTED: 'Player started', + EXTERNAL_PLAYBACK_PLAYING: 'Playing', + EXTERNAL_PLAYBACK_FAILED: 'External player error', }, }, }); @@ -65,8 +70,18 @@ describe('ExternalPlaybackDockComponent', () => { const text = fixture.nativeElement.textContent; expect(text).toContain('Example Video'); expect(text).toContain('MPV'); - expect(text).toContain('Launching'); + expect(text).toContain('Opening player'); expect(text).toContain('Close player'); + expect( + fixture.nativeElement + .querySelector('.external-playback-dock') + .getAttribute('aria-busy') + ).toBe('true'); + expect( + fixture.nativeElement + .querySelector('.external-playback-dock__status') + .getAttribute('aria-live') + ).toBe('polite'); }); it('emits a single close action when the close button is clicked', () => { @@ -96,6 +111,78 @@ describe('ExternalPlaybackDockComponent', () => { ).toBe(true); }); + it.each([ + ['opened', 'Player started'], + ['playing', 'Playing'], + ] as const)('renders exact %s status copy', (status, expected) => { + fixture.componentRef.setInput('session', { ...session, status }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain(expected); + expect( + fixture.nativeElement + .querySelector('.external-playback-dock') + .getAttribute('aria-busy') + ).toBeNull(); + }); + + it('keeps a failed session visible with a safe dismiss action and no retry', () => { + const dismissSpy = jest.fn(); + component.dismissClicked.subscribe(dismissSpy); + fixture.componentRef.setInput('session', { + ...session, + status: 'error', + error: '', + canClose: false, + }); + fixture.detectChanges(); + + const action = fixture.debugElement.query( + By.css('.external-playback-dock__button') + ); + expect(fixture.nativeElement.textContent).toContain( + 'External player error' + ); + expect(action.nativeElement.textContent).toContain('Dismiss'); + expect(action.nativeElement.textContent).not.toContain('Retry'); + expect(action.nativeElement.getAttribute('aria-label')).toBe('Dismiss'); + expect(action.nativeElement.classList).toContain('mat-mdc-button'); + expect(action.nativeElement.classList).not.toContain( + 'mat-mdc-icon-button' + ); + + action.nativeElement.click(); + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + + it('preserves the only stop affordance when an errored process may still be alive', () => { + const closeSpy = jest.fn(); + const dismissSpy = jest.fn(); + component.closeClicked.subscribe(closeSpy); + component.dismissClicked.subscribe(dismissSpy); + fixture.componentRef.setInput('session', { + ...session, + status: 'error', + error: 'Process exit was not confirmed', + canClose: true, + }); + fixture.detectChanges(); + + const stop = fixture.debugElement.query( + By.css('.external-playback-dock__button') + ); + const dismiss = fixture.debugElement.query( + By.css('.external-playback-dock__dismiss') + ); + + expect(stop.nativeElement.textContent).toContain('Close player'); + expect(dismiss).toBeNull(); + + stop.nativeElement.click(); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + }); + it('disables the artwork button when the session has no playlist target', () => { const artwork = fixture.debugElement.query( By.css('.external-playback-dock__artwork') diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts index 5e5713c83..8b3ed9795 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts @@ -35,6 +35,7 @@ export class ExternalPlaybackDockComponent { readonly compact = input(false); readonly closeClicked = output(); + readonly dismissClicked = output(); readonly artworkClicked = output(); private readonly artworkFailed = signal(false); @@ -55,25 +56,27 @@ export class ExternalPlaybackDockComponent { readonly artworkUrl = computed( () => this.session().thumbnail?.trim() ?? '' ); - readonly statusLabel = computed(() => { + readonly statusLabelKey = computed(() => { const session = this.session(); switch (session.status) { case 'launching': - return 'Launching…'; + return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_OPENING'; case 'opened': + return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_STARTED'; case 'playing': - return 'Opened'; + return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_PLAYING'; case 'error': - return session.error || 'Playback failed'; + return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED'; default: - return 'Closed'; + return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSED'; } }); readonly statusIcon = computed(() => { const status = this.session().status; if (status === 'error') return 'error_outline'; + if (status === 'playing') return 'play_circle'; return 'open_in_new'; }); @@ -101,8 +104,9 @@ export class ExternalPlaybackDockComponent { readonly artworkInteractive = computed( () => !!this.session().contentInfo?.playlistId ); - readonly showCloseButton = computed( - () => this.session().canClose && this.session().status !== 'error' + readonly showCloseAction = computed(() => this.session().canClose); + readonly showDismissAction = computed( + () => this.session().status === 'error' && !this.session().canClose ); constructor() { @@ -120,4 +124,12 @@ export class ExternalPlaybackDockComponent { if (!this.artworkInteractive()) return; this.artworkClicked.emit(); } + + onCloseClick(): void { + this.closeClicked.emit(); + } + + onDismissClick(): void { + this.dismissClicked.emit(); + } } diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html index a3930a3c8..012bae4e5 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html @@ -101,12 +101,30 @@ [attr.data-test-id]=" getRecommendationTestId(recommendation) " - [disabled]="pending()" + [class.web-player-diagnostic__player-card--disabled]=" + pending() + " + [disabled]=" + pending() && + !isExternalPlayerRecommendation(recommendation) + " + [attr.aria-disabled]="pending() ? 'true' : null" + [attr.aria-busy]=" + isExternalLaunching(recommendation) ? 'true' : null + " (click)="activate(recommendation)" > - + @if (isExternalLaunching(recommendation)) { +