fix(remote-control): review-loop hardening for status honesty

- Make the non-live status update an authoritative reset in the main
  process: only portal survives, supportsVolume is forced false, stray
  now-playing fields from callers are dropped (Copilot review)
- Stop Stalker radio status from leaking an unrelated TV channel's EPG:
  the ITV-keyed bulk cache survives itv->radio navigation and Ministra
  ids collide across the two lists, so EPG fields publish for itv only
- Publish the reset snapshot when the M3U active channel clears in
  place (e.g. quitting external MPV), not only on route destroy
- Consider a live external session in the M3U volume gate: a
  diagnostic-recovery MPV/VLC launch owns the audio even while a web
  player is configured; republish capability on session start/end
- Share one REMOTE_CONTROL_RESET_STATUS constant across all four
  integrations instead of four hand-copied literals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH
This commit is contained in:
Claude committed 2026-08-09 21:05:05 +00:00
1 parent 4d99326269
commit e4e3a55961
10 files changed
+209 -54

No files matched your search

@@ -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',
@@ -88,26 +88,28 @@ export class RemoteControlEvents {
ipcMain.on(
'REMOTE_CONTROL_STATUS_UPDATE',
(_event, status: Partial<RemoteControlStatus>) => {
// 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(),
};
}
);
}
+21 -13
View File
@@ -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`.
@@ -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,
@@ -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) &&
@@ -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<string | null>;
activeSourceType: Signal<CollectionSourceType | null>;
activeChannelName: Signal<string | null>;
/** 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<boolean>;
epgSummary: Signal<LiveEpgPanelSummary | null>;
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
@@ -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.
@@ -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);
@@ -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,
};
}
@@ -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 {