feat(remote-control): cover live collections, honest volume, status resets (#1399)

* feat(remote-control): cover live collections, honest volume, status resets

Remote control previously worked only on the three routed live layouts
(M3U player, Xtream live, Stalker ITV); playing live TV from favorites,
recently viewed, or the global collections left the mobile remote inert.

- Wire channel up/down, number select, and status publishing into the
  unified live tab, covering per-portal and global favorites/recent for
  M3U, Xtream, and Stalker; navigation follows the search-filtered,
  sorted list exactly as rendered (shared deriveVisibleFavoriteChannels)
- Treat non-live status updates as snapshots in the main process so
  stale now-playing fields are cleared instead of merged forever
- Publish a reset snapshot from every integration on destroy, so
  leaving a live view clears the remote instead of freezing it
- Report M3U supportsVolume only for built-in inline playback and no-op
  volume commands while MPV/VLC/Embedded MPV owns the audio
- Publish live status for Stalker radio (same layout, same handlers)
  and fix its channel-number lookup for non-numeric radio ids

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

* 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

* fix(remote-control): external session outranks DASH in M3U volume gate

The managed clear-DASH MPV/VLC fallback (Shaka browser-support preflight
failure) leaves activeChannelIsDash() true while the external session
owns the audio, so the DASH shortcut bypassed the session check and kept
advertising remote volume support. The live-session check now precedes
the DASH branch; radio stays first because its inline audio element is
always mounted and remains audible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-10 08:18:20 +02:00
1 parent 9aeb83e515
commit 5b211faf73
16 files changed
+1321 -41

No files matched your search

@@ -236,6 +236,79 @@ describe('RemoteControlEvents HTTP endpoints', () => {
});
});
it('clears now-playing fields when a non-live snapshot arrives', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z'));
bootstrapRemoteControl();
const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE');
updateStatus(
{},
{
portal: 'xtream',
isLiveView: true,
channelName: 'News',
channelNumber: 4,
epgTitle: 'Evening News',
epgStart: '2026-07-25T09:30:00.000Z',
epgEnd: '2026-07-25T10:30:00.000Z',
supportsVolume: true,
volume: 0.5,
muted: false,
}
);
// Browsing away from live (e.g. VOD) publishes a non-live snapshot;
// it must replace the live state instead of merging into it.
updateStatus(
{},
{ portal: 'xtream', isLiveView: false, supportsVolume: false }
);
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
method: 'GET',
});
expect(result.response).toEqual({
statusCode: 200,
headers: JSON_HEADERS,
body: JSON.stringify({
portal: 'xtream',
isLiveView: false,
supportsVolume: false,
updatedAt: '2026-07-25T10:00:00.000Z',
}),
});
});
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();
const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE');
updateStatus(
{},
{ portal: 'stalker', isLiveView: true, channelName: 'ITV One' }
);
// 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',
});
expect(JSON.parse(result.response.body)).toEqual({
portal: 'stalker',
isLiveView: false,
supportsVolume: false,
updatedAt: '2026-07-25T10:00:00.000Z',
});
});
it.each([
{
path: REMOTE_CONTROL_PATHS.CHANNEL_UP,
@@ -88,11 +88,28 @@ export class RemoteControlEvents {
ipcMain.on(
'REMOTE_CONTROL_STATUS_UPDATE',
(_event, status: Partial<RemoteControlStatus>) => {
this.remoteControlStatus = {
...this.remoteControlStatus,
...status,
updatedAt: new Date().toISOString(),
};
// 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,
supportsVolume: false,
updatedAt: new Date().toISOString(),
}
: {
...this.remoteControlStatus,
...status,
updatedAt: new Date().toISOString(),
};
}
);
}