mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
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(),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
+8
-7
@@ -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.
|
||||
|
||||
+11
-6
@@ -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);
|
||||
|
||||
+25
-2
@@ -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,
|
||||
};
|
||||
}
|
||||
+4
-5
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user