diff --git a/apps/electron-backend/src/app/events/remote-control-http.spec.ts b/apps/electron-backend/src/app/events/remote-control-http.spec.ts index 1c359715a..acfa42390 100644 --- a/apps/electron-backend/src/app/events/remote-control-http.spec.ts +++ b/apps/electron-backend/src/app/events/remote-control-http.spec.ts @@ -280,7 +280,7 @@ describe('RemoteControlEvents HTTP endpoints', () => { }); }); - it('keeps the last known portal when a non-live snapshot omits it', async () => { + it('keeps only the portal on a non-live snapshot, dropping stray fields', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z')); bootstrapRemoteControl(); @@ -290,7 +290,12 @@ describe('RemoteControlEvents HTTP endpoints', () => { {}, { portal: 'stalker', isLiveView: true, channelName: 'ITV One' } ); - updateStatus({}, { isLiveView: false }); + // Stray now-playing fields on a non-live update must be dropped too: + // the reset is authoritative, not a merge base. + updateStatus( + {}, + { isLiveView: false, channelName: 'Stray', supportsVolume: true } + ); const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { method: 'GET', diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index bbed86f41..0276bbd4c 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -88,26 +88,28 @@ export class RemoteControlEvents { ipcMain.on( 'REMOTE_CONTROL_STATUS_UPDATE', (_event, status: Partial) => { - // A non-live update is a snapshot, not a patch: merging it - // into the previous live state would keep stale now-playing - // fields (channel name, EPG, volume) on the remote forever - // after the player view is left or switched to VOD. - const base = + // A non-live update is an authoritative reset, not a patch: + // merging it into the previous live state would keep stale + // now-playing fields (channel name, EPG, volume) on the + // remote forever after the player view is left or switched + // to VOD. Only `portal` survives; every other field is + // dropped even if a caller accidentally includes one, and a + // non-live view never supports volume. + this.remoteControlStatus = status.isLiveView === false ? { portal: status.portal ?? this.remoteControlStatus.portal, - isLiveView: false as const, + isLiveView: false, supportsVolume: false, + updatedAt: new Date().toISOString(), } - : this.remoteControlStatus; - - this.remoteControlStatus = { - ...base, - ...status, - updatedAt: new Date().toISOString(), - }; + : { + ...this.remoteControlStatus, + ...status, + updatedAt: new Date().toISOString(), + }; } ); } diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index ef91bb0bd..41a2e05d0 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -80,11 +80,11 @@ Status ingestion from renderer: - Maintains in-memory `RemoteControlStatus` object returned by `/status` - Live updates (`isLiveView: true` or unspecified) MERGE into the previous status, so partial pushes (e.g. the M3U volume-only update) keep the - channel fields. A non-live update (`isLiveView: false`) is treated as a - SNAPSHOT: stale now-playing fields (channel name/number, EPG, volume) are - dropped rather than merged, keeping the remote from advertising a channel - that stopped playing. The snapshot keeps the last known `portal` unless the - update names one. + channel fields. A non-live update (`isLiveView: false`) is an + AUTHORITATIVE RESET: only `portal` survives (the update's value, else the + last known one), `supportsVolume` is forced to `false`, and every other + now-playing field is dropped — even if a caller accidentally includes one. + This keeps the remote from advertising a channel that stopped playing. ### Settings integration @@ -116,10 +116,15 @@ is treated as unsupported unless it exposes all remote-control methods: `onRemoteControlCommand`. This keeps PWA/self-hosted builds and partial test bridges from accidentally activating desktop-only remote-control behavior. -Every integration publishes a reset snapshot -(`{ portal: 'unknown', isLiveView: false, supportsVolume: false }`) in its +Every integration publishes the shared reset snapshot +(`REMOTE_CONTROL_RESET_STATUS` in +`libs/portal/shared/util/src/lib/remote-channel-navigation.ts`: +`{ portal: 'unknown', isLiveView: false, supportsVolume: false }`) in its `ngOnDestroy`/destroy hook, so leaving a live surface always clears the -remote UI instead of freezing the last channel on it. +remote UI instead of freezing the last channel on it. The M3U player also +publishes it when the active channel is cleared IN PLACE (e.g. quitting an +external MPV/VLC session dispatches `resetActiveChannel` while the route +stays mounted). ## Shared helpers @@ -164,7 +169,7 @@ Implemented behavior: - `isLiveView: true` - channel name/number - EPG now fields - - `supportsVolume` reflects the EFFECTIVE playback: `true` for built-in inline playback (radio audio, the DASH-forced web player, HTML5/Video.js/ArtPlayer), `false` while MPV/VLC or Embedded MPV owns the audio — the remote UI disables its volume buttons on `false` + - `supportsVolume` reflects the EFFECTIVE playback: `true` for built-in inline playback (radio audio, the DASH-forced web player, HTML5/Video.js/ArtPlayer), `false` while MPV/VLC or Embedded MPV owns the audio — including a diagnostic-recovery "Open in MPV/VLC" launch while a web player remains configured (`isRemoteVolumeSupported` also checks the live external session, and an effect republishes the capability when the session starts or ends). The remote UI disables its volume buttons on `false` - `volume`, `muted` - Cleans listeners/subscriptions and publishes the reset snapshot in `ngOnDestroy`. @@ -211,10 +216,13 @@ Implemented behavior: - `portal: 'stalker'` - `isLiveView` for selected content type `itv` OR `radio` with an active item — the radio route reuses this layout and its remote handlers, so - it reports live status too (channel numbering follows the radio list; - EPG fields stay empty). The index comparison uses - `normalizeStalkerEntityId`, because radio ids (`radio-1`) are not - numeric. + it reports live status too (channel numbering follows the radio list). + EPG fields are published for `itv` ONLY: `selectedItvEpgPrograms` is + fed by the ITV-keyed bulk cache, which survives itv→radio navigation, + and Ministra assigns small integer ids to itv and radio independently + — a radio id routinely collides with an unrelated TV channel. The + index comparison uses `normalizeStalkerEntityId`, because radio ids + (`radio-1`) are not numeric. - channel name/number + current EPG item - `supportsVolume: false` - Cleans listeners and publishes the reset snapshot in `ngOnDestroy`. 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 cc28e4d76..b36ce553a 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 @@ -640,6 +640,59 @@ describe('VideoPlayerComponent', () => { }); }); + it('publishes a remote status reset when the active channel clears in place', () => { + const updateRemoteControlStatus = window.electron + ?.updateRemoteControlStatus as jest.Mock; + fixture.detectChanges(); + syncStoreState(sampleChannel); + updateRemoteControlStatus.mockClear(); + + // E.g. quitting an external player dispatches resetActiveChannel + // while the route stays mounted. + syncStoreState(null); + + expect(updateRemoteControlStatus).toHaveBeenCalledWith({ + portal: 'unknown', + isLiveView: false, + supportsVolume: false, + }); + }); + + it('drops remote volume support while a live external session owns the audio', () => { + const updateRemoteControlStatus = window.electron + ?.updateRemoteControlStatus as jest.Mock; + player.set(VideoPlayer.Html5Player); + fixture.detectChanges(); + syncStoreState(sampleChannel); + updateRemoteControlStatus.mockClear(); + + // Diagnostic-recovery launch: web player configured, MPV audible. + externalSession.set({ + id: 'external-1', + player: 'mpv', + status: 'playing', + title: sampleChannel.name, + streamUrl: sampleChannel.url, + startedAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + }); + fixture.detectChanges(); + + expect(updateRemoteControlStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + isLiveView: true, + supportsVolume: false, + }) + ); + + externalSession.set(null); + fixture.detectChanges(); + + expect(updateRemoteControlStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ supportsVolume: true }) + ); + }); + it('opens MPV fallback with the active channel headers preserved', () => { 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 4f5e5f613..68e920022 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 @@ -12,6 +12,7 @@ import { effect, inject, signal, + untracked, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -81,6 +82,7 @@ import { persistLiveSidebarState, PORTAL_EXTERNAL_PLAYBACK, isLiveExternalPlayerSession, + REMOTE_CONTROL_RESET_STATUS, restoreLiveEpgPanelState, restoreLiveSidebarState, WorkspaceHeaderContextService, @@ -578,6 +580,31 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.store.dispatch(ChannelActions.resetActiveChannel()); }); + + // An external session starting or ending flips who owns the audio + // without any store emission (e.g. a diagnostic-recovery MPV launch + // while a web player is configured) — republish the volume + // capability so the remote's buttons stay honest. Everything except + // the session signal is read untracked: channel changes and volume + // changes already publish through their own paths. + effect(() => { + this.externalPlayback.activeSession(); + untracked(() => { + const remoteControl = this.remoteControlBridge; + const activeChannel = this.activeChannel(); + if (!remoteControl?.updateRemoteControlStatus || !activeChannel) { + return; + } + + remoteControl.updateRemoteControlStatus({ + portal: 'm3u', + isLiveView: true, + supportsVolume: this.isRemoteVolumeSupported(activeChannel), + volume: this.volume(), + muted: this.volume() === 0, + }); + }); + }); } /** @@ -617,7 +644,17 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.store.select(selectCurrentEpgProgram).pipe(startWith(null)), ]).subscribe(([channels, activeChannel, epgProgram]) => { const remoteControl = this.remoteControlBridge; - if (!remoteControl?.updateRemoteControlStatus || !activeChannel) { + if (!remoteControl?.updateRemoteControlStatus) { + return; + } + + // The active channel can be reset in place (e.g. the user quits + // an external MPV/VLC session) — without a reset the remote + // would keep advertising the last channel as live. + if (!activeChannel) { + remoteControl.updateRemoteControlStatus( + REMOTE_CONTROL_RESET_STATUS + ); return; } @@ -692,11 +729,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.statusSubscription?.unsubscribe(); // Leaving the player would otherwise keep the last channel advertised // as live on the remote forever. - this.remoteControlBridge?.updateRemoteControlStatus?.({ - portal: 'unknown', - isLiveView: false, - supportsVolume: false, - }); + this.remoteControlBridge?.updateRemoteControlStatus?.( + REMOTE_CONTROL_RESET_STATUS + ); } onSidebarWidthChange(width: number): void { @@ -1091,6 +1126,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return true; } + // A diagnostic-recovery "Open in MPV/VLC" launch owns the audio even + // while a web player remains configured — the setting alone cannot + // answer who is audible. + if (isLiveExternalPlayerSession(this.externalPlayback.activeSession())) { + return false; + } + const player = this.playerSettings.player; return ( !this.isExternalPlayer(player) && diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts index 5548be3d9..9c59d8f90 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts @@ -1,6 +1,7 @@ import { DestroyRef, Signal, effect, inject } from '@angular/core'; import { CollectionSourceType, + REMOTE_CONTROL_RESET_STATUS, UnifiedFavoriteChannel, getAdjacentChannelItem, getChannelItemByNumber, @@ -20,18 +21,18 @@ export interface UnifiedLiveRemoteControlHost { activeUid: Signal; activeSourceType: Signal; activeChannelName: Signal; - /** True once a selection has resolved playback (inline or external). */ + /** + * True once a selection has resolved its playback detail. This is + * selection-based, matching the routed live layouts: with an external + * player in double-click-to-play mode, a single click selects without + * starting playback yet — the status still reports the selection so the + * remote can navigate from it. + */ isPlaybackActive: Signal; epgSummary: Signal; playChannel: (channel: UnifiedFavoriteChannel) => void; } -const REMOTE_CONTROL_RESET_STATUS = { - portal: 'unknown', - isLiveView: false, - supportsVolume: false, -} as const; - /** * Must run in an injection context (the hosting component's constructor). * Subscribes to remote channel commands, publishes status snapshots, and diff --git a/libs/portal/shared/util/src/lib/remote-channel-navigation.ts b/libs/portal/shared/util/src/lib/remote-channel-navigation.ts index 04d75048d..9c4d8e5e0 100644 --- a/libs/portal/shared/util/src/lib/remote-channel-navigation.ts +++ b/libs/portal/shared/util/src/lib/remote-channel-navigation.ts @@ -1,5 +1,22 @@ +import { ElectronBridgeRemoteControlStatus } from '@iptvnator/shared/interfaces'; + export type RemoteChannelDirection = 'up' | 'down'; +/** + * The status snapshot every live surface publishes when it stops owning + * playback (destroy, or nothing selected). The main process treats a + * non-live update as authoritative, so this exact shape clears stale + * now-playing data on the remote. One shared constant — the docs specify + * this shape as "the reset snapshot", and hand-copied literals would fork + * that contract silently. + */ +export const REMOTE_CONTROL_RESET_STATUS: ElectronBridgeRemoteControlStatus = + Object.freeze({ + portal: 'unknown', + isLiveView: false, + supportsVolume: false, + }); + /** * Returns the adjacent item in the list for remote channel navigation. * Uses wraparound when reaching the start or end of the list. 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 bd92224a3..e86e71a0a 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 @@ -66,6 +66,7 @@ import { isTypingInInput, LiveEpgPanelState, persistLiveEpgPanelState, + REMOTE_CONTROL_RESET_STATUS, restoreLiveEpgPanelState, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; @@ -591,7 +592,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const currentIndex = channels.findIndex( (item) => normalizeStalkerEntityId(item.id) === selectedId ); - const currentProgram = this.currentProgram(); + // ITV only: selectedItvEpgPrograms is fed by the ITV-keyed bulk + // EPG cache, which survives itv→radio navigation. Ministra + // assigns small integer ids to itv and radio independently, so a + // radio station's id routinely collides with an unrelated TV + // channel's programmes. + const currentProgram = + selectedType === 'itv' ? this.currentProgram() : null; remoteControl.updateRemoteControlStatus({ portal: 'stalker', @@ -633,11 +640,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.unsubscribeRemoteCommand?.(); // Leaving the live view would otherwise keep the last channel // advertised as live on the remote forever. - this.remoteControlBridge?.updateRemoteControlStatus?.({ - portal: 'unknown', - isLiveView: false, - supportsVolume: false, - }); + this.remoteControlBridge?.updateRemoteControlStatus?.( + REMOTE_CONTROL_RESET_STATUS + ); this.removeScrollListener(); if (this.epgPreviewRefreshTimer !== null) { clearTimeout(this.epgPreviewRefreshTimer); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts index ddf050057..1a24f0a70 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts @@ -106,6 +106,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { selectedContentType.set('itv'); selectedItem.set(itvChannels()[0]); selectedItvId.set('10001'); + stalkerStore.selectedItvEpgPrograms.set([]); await TestBed.configureTestingModule({ imports: [StalkerLiveStreamLayoutComponent, NoopAnimationsModule], @@ -195,7 +196,9 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { window.electron = originalElectron; }); - it('publishes live status for the selected ITV channel', () => { + it('publishes live status with EPG for the selected ITV channel', () => { + stalkerStore.selectedItvEpgPrograms.set([nowProgram('Evening News')]); + fixture.detectChanges(); expect(updateRemoteControlStatus).toHaveBeenLastCalledWith( @@ -204,15 +207,20 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { isLiveView: true, channelName: 'Alpha TV', channelNumber: 1, + epgTitle: 'Evening News', supportsVolume: false, }) ); }); - it('publishes live status in radio mode with radio-list numbering', () => { + it('publishes radio live status without leaking ITV EPG from the bulk cache', () => { selectedContentType.set('radio'); selectedItem.set(radioChannels()[1]); selectedItvId.set('radio-2'); + // The ITV-keyed bulk cache survives itv→radio navigation, and small + // integer ids collide across the two lists — radio status must not + // read it. + stalkerStore.selectedItvEpgPrograms.set([nowProgram('TV Show')]); fixture.detectChanges(); @@ -222,6 +230,9 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { isLiveView: true, channelName: 'News Radio', channelNumber: 2, + epgTitle: undefined, + epgStart: undefined, + epgEnd: undefined, supportsVolume: false, }) ); @@ -252,3 +263,15 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { }); }); }); + +function nowProgram(title: string): EpgProgram { + const now = Date.now(); + return { + start: new Date(now - 10 * 60 * 1000).toISOString(), + stop: new Date(now + 10 * 60 * 1000).toISOString(), + channel: '10001', + title, + desc: null, + category: null, + }; +} 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 43bb9aec2..ca971872c 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 @@ -38,6 +38,7 @@ import { persistLiveEpgPanelState, persistPortalChannelSortMode, queryParamSignal, + REMOTE_CONTROL_RESET_STATUS, restoreLiveEpgPanelState, restorePortalChannelSortMode, } from '@iptvnator/portal/shared/util'; @@ -522,11 +523,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.unsubscribeRemoteCommand?.(); // Leaving the live view would otherwise keep the last channel // advertised as live on the remote forever. - this.remoteControlBridge?.updateRemoteControlStatus?.({ - portal: 'unknown', - isLiveView: false, - supportsVolume: false, - }); + this.remoteControlBridge?.updateRemoteControlStatus?.( + REMOTE_CONTROL_RESET_STATUS + ); } private handleRemoteChannelChange(direction: 'up' | 'down'): void {