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

@@ -0,0 +1,6 @@
---
type: feature
area: remote-control
---
The mobile remote now works in live TV favorites and recently viewed — per-portal and global — for M3U, Xtream and Stalker: channel switching, number select, and now-playing status follow the on-screen list. The remote no longer shows a channel as playing after leaving the player, Stalker radio reports its status, and volume buttons grey out when an external player owns the audio.
@@ -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(),
};
}
);
}
+96 -16
View File
@@ -78,6 +78,13 @@ Status ingestion from renderer:
- Listens on `REMOTE_CONTROL_STATUS_UPDATE`
- 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 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
@@ -109,6 +116,16 @@ 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 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. 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
- File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts`
@@ -118,7 +135,16 @@ Functions:
- `getAdjacentChannelItem(...)`: wraps around on boundaries for up/down
- `getChannelItemByNumber(...)`: 1-based number to list item mapping
Used by M3U, Xtream, and Stalker live integrations.
Used by the M3U, Xtream, and Stalker live integrations and the unified live
tab (collections).
- File: `libs/portal/shared/util/src/lib/favorites-channel-sort.ts`
`deriveVisibleFavoriteChannels(...)` computes the search-filtered,
mode-conditionally sorted list a collection surface renders. The global
favorites list and the unified live tab's remote navigation both call it, so
the remote's channel order and numbering can never diverge from the rendered
rows.
## M3U integration
@@ -137,14 +163,15 @@ Implemented behavior:
- toggle mute with last non-zero volume restore
- persists to `localStorage`
- propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent`
- does not control external MPV/VLC sessions or the experimental Embedded MPV player
- does not control external MPV/VLC sessions or the experimental Embedded MPV player; while one of those is the effective player, volume commands are a deliberate no-op (`isRemoteVolumeSupported`) instead of silently mutating the stored web-player volume
- Publishes status snapshots via `updateRemoteControlStatus(...)`:
- `portal: 'm3u'`
- `isLiveView: true`
- channel name/number
- EPG now fields
- `supportsVolume: true`, `volume`, `muted`
- Cleans listeners/subscriptions in `ngOnDestroy`.
- `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. `isRemoteVolumeSupported` checks in order: radio first (its inline audio element is always mounted, so it stays controllable even past a lingering external session), then a live external session (covers both a diagnostic-recovery "Open in MPV/VLC" launch while a web player remains configured AND the managed clear-DASH fallback after Shaka's browser-support preflight fails — the session check must precede the DASH shortcut), then the DASH-forced inline player, then the configured player setting. 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`.
## Xtream integration (live view)
@@ -167,7 +194,7 @@ Implemented behavior:
- `isLiveView` only when selected content type is `live` and item is selected
- channel name/number + current EPG item
- `supportsVolume: false`
- Cleans listeners in `ngOnDestroy`.
- Cleans listeners and publishes the reset snapshot in `ngOnDestroy`.
## Stalker integration (ITV live view)
@@ -187,10 +214,55 @@ Implemented behavior:
- Calls `playChannel(channel, true)` so remote actions explicitly start playback
- Publishes status via effect:
- `portal: 'stalker'`
- `isLiveView` only for selected content type `itv` with active item
- `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 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 in `ngOnDestroy`.
- Cleans listeners and publishes the reset snapshot in `ngOnDestroy`.
## Unified live tab integration (favorites / recent / global collections)
- Files:
- `libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts`
(`setupUnifiedLiveTabRemoteControl`, called from the tab's constructor)
- `libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts`
One integration covers every collection surface that plays live TV inline
without routing to a portal live layout:
- M3U favorites/recent (`/workspace/playlists/:id/favorites|recent`)
- Xtream favorites/recent (`/workspace/xtreams/:id/favorites|recent`)
- Stalker favorites/recent (`/workspace/stalker/:id/favorites|recent`)
- Global favorites/recent (`/workspace/global-favorites`, `/workspace/global-recent`)
- Dashboard live clicks, which land on those collection routes with an
auto-open item
Implemented behavior:
- Channel up/down and number select navigate `visibleChannels()` — the
search-filtered, mode-conditionally sorted list derived by the same
`deriveVisibleFavoriteChannels` helper the rendered sidebar uses
- Remote actions play via the same path as an explicit double-click
(`activateItem(item, false, true)`), so external MPV/VLC starts
immediately
- Publishes status via effect:
- `portal` is the ACTIVE item's `sourceType` (`m3u` / `xtream` /
`stalker`) — a mixed global collection reports whichever source is
playing
- `isLiveView: true` once a selection has resolved playback
- channel name, visible-list channel number, and the current EPG summary
(timeshift-aware)
- `supportsVolume: false` (the collections player does not expose a
remote-controllable volume yet)
- Publishes the reset snapshot when nothing is playing and on destroy;
unsubscribes both command listeners on destroy.
## Remote Web App
@@ -234,19 +306,27 @@ Implemented UI behavior:
## Feature Matrix (Current)
| Capability | M3U | Xtream Live | Stalker ITV |
| -------------------------- | ------------------------------------ | ----------- | ----------- |
| Channel up/down | Yes | Yes | Yes |
| Number select | Yes | Yes | Yes |
| Status publish | Yes | Yes | Yes |
| Volume command handling | Yes, for built-in inline M3U players | No | No |
| `supportsVolume` in status | true | false | false |
| Capability | M3U player | Xtream Live | Stalker ITV/Radio | Collections (favorites/recent/global) |
| -------------------------- | ------------------------------------- | ----------- | ----------------- | ------------------------------------- |
| Channel up/down | Yes | Yes | Yes | Yes |
| Number select | Yes | Yes | Yes | Yes |
| Status publish | Yes | Yes | Yes | Yes |
| Status reset on leave | Yes | Yes | Yes | Yes |
| Volume command handling | Yes, for built-in inline playback | No | No | No |
| `supportsVolume` in status | true only for built-in inline players | false | false | false |
Navigation scope differs by surface: the M3U player navigates the FULL
playlist (its sidebar always renders the full list), Xtream/Stalker navigate
the currently filtered category list, and the collections tab navigates the
search-filtered, sorted collection exactly as rendered.
## Known limitations
- Volume commands are currently no-op in Xtream and Stalker integrations.
- Volume commands are currently no-op in Xtream, Stalker, and collection
integrations; in the M3U player they are no-op while MPV/VLC or Embedded
MPV owns the audio.
- Remote status uses polling from web UI (2s), not push/WebSocket.
- Number-based selection is list-position based (1-based index in active list scope), not global EPG number mapping.
- Number-based selection is list-position based (1-based index in active list scope), not global EPG number mapping (`tvg-chno` is not consulted).
- Remote API currently has no auth/TLS; intended for trusted local networks.
## Operational notes
@@ -378,6 +378,21 @@ describe('VideoPlayerComponent', () => {
get isElectron() {
return Boolean(window.electron);
},
// Mirrors the real capability check: every
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
| Record<string, unknown>
| undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
].every(
(method) =>
typeof bridge?.[method] === 'function'
);
},
},
},
{
@@ -565,6 +580,129 @@ describe('VideoPlayerComponent', () => {
expect(updateRemoteControlStatus).not.toHaveBeenCalled();
});
it('reports no remote volume support and ignores volume commands on external players', () => {
const updateRemoteControlStatus = window.electron
?.updateRemoteControlStatus as jest.Mock;
player.set(VideoPlayer.MPV);
fixture.detectChanges();
syncStoreState(sampleChannel);
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({
portal: 'm3u',
isLiveView: true,
supportsVolume: false,
})
);
localStorage.removeItem('volume');
(
component as unknown as {
handleRemoteControlCommand(command: {
type: 'volume-down';
}): void;
}
).handleRemoteControlCommand({ type: 'volume-down' });
// The command must not touch the stored web-player volume either.
expect(localStorage.getItem('volume')).toBeNull();
});
it('reports remote volume support for built-in inline playback', () => {
const updateRemoteControlStatus = window.electron
?.updateRemoteControlStatus as jest.Mock;
player.set(VideoPlayer.VideoJs);
fixture.detectChanges();
syncStoreState(sampleChannel);
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({
supportsVolume: true,
volume: 1,
muted: false,
})
);
});
it('publishes a remote status reset when the player view is destroyed', () => {
const updateRemoteControlStatus = window.electron
?.updateRemoteControlStatus as jest.Mock;
fixture.detectChanges();
syncStoreState(sampleChannel);
updateRemoteControlStatus.mockClear();
fixture.destroy();
expect(updateRemoteControlStatus).toHaveBeenCalledWith({
portal: 'unknown',
isLiveView: false,
supportsVolume: false,
});
});
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,
})
);
// The DASH-forced inline player is not audible either while the
// managed clear-DASH fallback session is live.
syncStoreState({
...sampleChannel,
url: 'http://localhost/live.mpd',
} as Channel);
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ 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';
@@ -82,6 +83,7 @@ import {
persistLiveSidebarState,
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
REMOTE_CONTROL_RESET_STATUS,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
WorkspaceHeaderContextService,
@@ -585,6 +587,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,
});
});
});
}
/**
@@ -624,7 +651,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;
}
@@ -642,7 +679,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
epgTitle: currentEpgProgram?.title,
epgStart: currentEpgProgram?.start,
epgEnd: currentEpgProgram?.stop,
supportsVolume: true,
supportsVolume: this.isRemoteVolumeSupported(activeChannel),
volume: this.volume(),
muted: this.volume() === 0,
});
@@ -697,6 +734,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.statusSubscription?.unsubscribe();
// Leaving the player would otherwise keep the last channel advertised
// as live on the remote forever.
this.remoteControlBridge?.updateRemoteControlStatus?.(
REMOTE_CONTROL_RESET_STATUS
);
}
onSidebarWidthChange(width: number): void {
@@ -1020,6 +1062,15 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
| 'volume-toggle-mute';
number?: number;
}): void {
if (
command.type !== 'channel-select-number' &&
!this.isRemoteVolumeSupported(this.activeChannel())
) {
// The active playback runs in MPV/VLC or Embedded MPV — adjusting
// the stored web-player volume would silently do nothing audible.
return;
}
if (command.type === 'channel-select-number' && command.number) {
this.switchToChannelByNumber(command.number);
return;
@@ -1052,7 +1103,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
remoteControl.updateRemoteControlStatus({
portal: 'm3u',
isLiveView: true,
supportsVolume: true,
supportsVolume: this.isRemoteVolumeSupported(
this.activeChannel()
),
volume: this.volume(),
muted: this.volume() === 0,
});
@@ -1063,6 +1116,47 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.setVolume(volume);
}
/**
* Remote volume commands act on the built-in inline players only:
* radio's audio element, the DASH-forced web player, and the HTML5/
* Video.js/ArtPlayer engines. External MPV/VLC and Embedded MPV own
* their audio, so advertising volume support there would enable remote
* buttons that do nothing audible.
*/
private isRemoteVolumeSupported(
channel: Channel | null | undefined
): boolean {
if (!channel) {
return false;
}
// Radio's audio element is always mounted inline, so it stays
// audible and controllable even if an older external session
// lingers.
if (channel.radio === 'true') {
return true;
}
// A live external session owns the audio regardless of how it
// started: a diagnostic-recovery "Open in MPV/VLC" launch while a
// web player remains configured, or the managed clear-DASH fallback
// after Shaka's browser-support preflight fails — the DASH-forced
// inline player is not audible then, so this check must precede the
// DASH shortcut.
if (isLiveExternalPlayerSession(this.externalPlayback.activeSession())) {
return false;
}
if (this.activeChannelIsDash()) {
return true;
}
const player = this.playerSettings.player;
return (
!this.isExternalPlayer(player) &&
player !== VideoPlayer.EmbeddedMpv
);
}
private get remoteControlBridge(): Window['electron'] | undefined {
return this.runtime.supportsRemoteControl ? window.electron : undefined;
}
@@ -34,10 +34,10 @@ import { EpgMappingDialogComponent } from '@iptvnator/ui/components';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
deriveVisibleFavoriteChannels,
FavoritesChannelSortMode,
getXtreamCatchupDays,
isXtreamCatchupAvailable,
sortFavoriteChannelItems,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
import { TranslateModule } from '@ngx-translate/core';
@@ -116,22 +116,15 @@ export class GlobalFavoritesListComponent {
);
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
const channels = this.channels();
const epgMap = this.epgMap();
const term = this.searchTermInput().trim().toLowerCase();
this.progressTick();
const filtered = term
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
const sorted =
this.mode() === 'favorites'
? sortFavoriteChannelItems(filtered, this.sortMode(), {
getName: (ch) => ch.name,
getAddedAt: (ch) => ch.addedAt,
})
: filtered;
const sorted = deriveVisibleFavoriteChannels(this.channels(), {
searchTerm: this.searchTermInput(),
sortMode: this.mode() === 'favorites' ? this.sortMode() : null,
getName: (ch) => ch.name,
getAddedAt: (ch) => ch.addedAt,
});
return sorted.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
@@ -0,0 +1,140 @@
import { DestroyRef, Signal, effect, inject } from '@angular/core';
import {
CollectionSourceType,
REMOTE_CONTROL_RESET_STATUS,
UnifiedFavoriteChannel,
getAdjacentChannelItem,
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
/**
* Remote-control integration for the unified live tab (portal favorites,
* recently viewed, and the global collections). Unlike the three routed live
* layouts, this tab hosts channels from any source type, so the published
* portal is the ACTIVE item's source rather than a fixed value.
*/
export interface UnifiedLiveRemoteControlHost {
/** The list exactly as rendered: search-filtered and sorted. */
visibleChannels: Signal<readonly UnifiedFavoriteChannel[]>;
activeUid: Signal<string | null>;
activeSourceType: Signal<CollectionSourceType | null>;
activeChannelName: Signal<string | null>;
/**
* 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;
}
/**
* Must run in an injection context (the hosting component's constructor).
* Subscribes to remote channel commands, publishes status snapshots, and
* resets the remote status when the tab is destroyed so the remote never
* keeps advertising a live view that no longer exists.
*/
export function setupUnifiedLiveTabRemoteControl(
host: UnifiedLiveRemoteControlHost
): void {
const runtime = inject(RuntimeCapabilitiesService);
const destroyRef = inject(DestroyRef);
if (!runtime.supportsRemoteControl) {
return;
}
const bridge = window.electron;
if (
!bridge?.onChannelChange ||
!bridge.onRemoteControlCommand ||
!bridge.updateRemoteControlStatus
) {
return;
}
const unsubscribeChannelChange = bridge.onChannelChange((data) => {
const activeUid = host.activeUid();
if (!activeUid) {
return;
}
const nextChannel = getAdjacentChannelItem(
[...host.visibleChannels()],
activeUid,
data.direction,
(channel) => channel.uid
);
if (nextChannel) {
host.playChannel(nextChannel);
}
});
const unsubscribeCommand = bridge.onRemoteControlCommand((command) => {
if (command.type !== 'channel-select-number' || !command.number) {
return;
}
const channel = getChannelItemByNumber(
[...host.visibleChannels()],
command.number
);
if (channel) {
host.playChannel(channel);
}
});
effect(() => {
const sourceType = host.activeSourceType();
if (!host.isPlaybackActive() || !sourceType) {
bridge.updateRemoteControlStatus?.(REMOTE_CONTROL_RESET_STATUS);
return;
}
const activeUid = host.activeUid();
const currentIndex = host
.visibleChannels()
.findIndex((channel) => channel.uid === activeUid);
const summary = host.epgSummary();
bridge.updateRemoteControlStatus?.({
portal: sourceType,
isLiveView: true,
channelName: host.activeChannelName() ?? undefined,
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: summary?.title ?? undefined,
epgStart: toIsoTimeString(summary?.start),
epgEnd: toIsoTimeString(summary?.stop),
supportsVolume: false,
});
});
destroyRef.onDestroy(() => {
if (typeof unsubscribeChannelChange === 'function') {
unsubscribeChannelChange();
}
if (typeof unsubscribeCommand === 'function') {
unsubscribeCommand();
}
bridge.updateRemoteControlStatus?.(REMOTE_CONTROL_RESET_STATUS);
});
}
function toIsoTimeString(
value: string | number | Date | null | undefined
): string | undefined {
if (value == null) {
return undefined;
}
if (typeof value === 'string') {
return value;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
}
@@ -22,6 +22,7 @@ import {
} from '@iptvnator/shared/m3u-utils';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
deriveVisibleFavoriteChannels,
FavoritesChannelSortMode,
LiveEpgPanelState,
matchesOpenLiveCollectionItem,
@@ -32,6 +33,7 @@ import {
UnifiedCollectionItem,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
import { setupUnifiedLiveTabRemoteControl } from './unified-live-tab-remote-control';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
@@ -273,6 +275,20 @@ export class UnifiedLiveTabComponent {
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
);
/**
* The channel list in exactly the order the sidebar renders it
* (search-filtered; sorted in favorites mode) — remote-control
* navigation and channel numbers must follow what is on screen.
*/
readonly visibleChannels = computed(() =>
deriveVisibleFavoriteChannels(this.channelsForList(), {
searchTerm: this.searchTerm(),
sortMode: this.mode() === 'favorites' ? this.sortMode() : null,
getName: (channel) => channel.name,
getAddedAt: (channel) => channel.addedAt,
})
);
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
this.items().map((item) => ({
uid: item.uid,
@@ -342,6 +358,20 @@ export class UnifiedLiveTabComponent {
void this.activateItem(matchedItem, true);
});
setupUnifiedLiveTabRemoteControl({
visibleChannels: this.visibleChannels,
activeUid: this.activeUid,
activeSourceType: computed(
() => this.activeItem()?.sourceType ?? null
),
activeChannelName: computed(() => this.activeItem()?.name ?? null),
isPlaybackActive: computed(() => this.activeDetail() !== null),
epgSummary: this.liveEpgPanelSummary,
playChannel: (channel) => {
void this.onChannelPlaybackRequested(channel);
},
});
const tickInterval = setInterval(
() => this.progressTick.update((tick) => tick + 1),
30_000
@@ -0,0 +1,340 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
/**
* Focused spec for the remote-control integration of the unified live tab —
* the surface behind portal favorites/recent and the global collections.
* Kept separate from the main layout spec, which sits at the max-lines
* test budget.
*/
describe('UnifiedLiveTabComponent remote control', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
let component: UnifiedLiveTabComponent;
let channelChangeCallback:
| ((data: { direction: 'up' | 'down' }) => void)
| undefined;
let remoteCommandCallback:
| ((command: { type: string; number?: number }) => void)
| undefined;
let unsubscribeChannelChange: jest.Mock;
let unsubscribeCommand: jest.Mock;
let updateRemoteControlStatus: jest.Mock;
let streamResolver: {
resolveLiveDetail: jest.Mock;
resolveM3uPlaybackDetail: jest.Mock;
loadEpgForItems: jest.Mock;
};
let portalPlayer: {
isEmbeddedPlayer: jest.Mock;
openResolvedPlayback: jest.Mock;
openExternalPlayback: jest.Mock;
};
let openStreamOnDoubleClick: ReturnType<typeof signal<boolean>>;
let supportsRemoteControl: boolean;
const originalElectron = window.electron;
const configure = async () => {
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
providers: [
{ provide: StreamResolverService, useValue: streamResolver },
{
provide: UnifiedRecentDataService,
useValue: {
recordLivePlayback: jest
.fn()
.mockImplementation(async (item) => item),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
get supportsRemoteControl() {
return supportsRemoteControl;
},
},
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick,
player: signal(VideoPlayer.VideoJs),
stripCountryPrefix: signal(false),
resolvedEpgViewMode: signal('timeline'),
},
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
})
.overrideComponent(UnifiedLiveTabComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
component = fixture.componentInstance;
};
beforeEach(() => {
supportsRemoteControl = true;
channelChangeCallback = undefined;
remoteCommandCallback = undefined;
unsubscribeChannelChange = jest.fn();
unsubscribeCommand = jest.fn();
updateRemoteControlStatus = jest.fn();
window.electron = {
onChannelChange: jest.fn(
(callback: (data: { direction: 'up' | 'down' }) => void) => {
channelChangeCallback = callback;
return unsubscribeChannelChange;
}
),
onRemoteControlCommand: jest.fn(
(
callback: (command: {
type: string;
number?: number;
}) => void
) => {
remoteCommandCallback = callback;
return unsubscribeCommand;
}
),
updateRemoteControlStatus,
} as unknown as typeof window.electron;
openStreamOnDoubleClick = signal(false);
streamResolver = {
resolveLiveDetail: jest.fn().mockImplementation(async () => ({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
},
epgItems: [],
})),
resolveM3uPlaybackDetail: jest
.fn()
.mockImplementation(async () => ({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/m3u.m3u8',
title: 'M3U Live',
},
channel: null,
epgPrograms: [],
})),
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
};
portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
});
afterEach(() => {
fixture?.destroy();
window.electron = originalElectron;
});
const setItems = async (
items: UnifiedCollectionItem[],
mode: 'favorites' | 'recent' = 'favorites'
) => {
fixture.componentRef.setInput('items', items);
fixture.componentRef.setInput('mode', mode);
fixture.componentRef.setInput('sortMode', 'name-asc');
fixture.detectChanges();
await fixture.whenStable();
};
const activate = async (uid: string) => {
const channel = component
.channelsForList()
.find((candidate) => candidate.uid === uid);
if (!channel) {
throw new Error(`No channel with uid ${uid}`);
}
await component.onChannelSelected(channel);
fixture.detectChanges();
await fixture.whenStable();
};
it('does not subscribe when the runtime reports no remote-control support', async () => {
supportsRemoteControl = false;
await configure();
fixture.componentRef.setInput('items', []);
fixture.detectChanges();
expect(window.electron?.onChannelChange).not.toHaveBeenCalled();
expect(window.electron?.onRemoteControlCommand).not.toHaveBeenCalled();
});
it('navigates channel down through the sorted visible list and starts playback', async () => {
// Double-click-to-play would normally leave playback to a second
// click; a remote action must start it explicitly regardless.
openStreamOnDoubleClick.set(true);
await configure();
await setItems([
buildItem('xtream::pl-1::2', 'Bravo'),
buildItem('xtream::pl-1::1', 'Alpha'),
buildItem('xtream::pl-1::3', 'Charlie'),
]);
await activate('xtream::pl-1::1');
portalPlayer.openResolvedPlayback.mockClear();
channelChangeCallback?.({ direction: 'down' });
fixture.detectChanges();
await fixture.whenStable();
// name-asc order is Alpha, Bravo, Charlie — down from Alpha is Bravo.
expect(component.activeUid()).toBe('xtream::pl-1::2');
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('wraps upward from the first visible channel to the last', async () => {
await configure();
await setItems([
buildItem('xtream::pl-1::2', 'Bravo'),
buildItem('xtream::pl-1::1', 'Alpha'),
buildItem('xtream::pl-1::3', 'Charlie'),
]);
await activate('xtream::pl-1::1');
channelChangeCallback?.({ direction: 'up' });
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBe('xtream::pl-1::3');
});
it('ignores channel change while nothing is selected', async () => {
await configure();
await setItems([buildItem('xtream::pl-1::1', 'Alpha')]);
channelChangeCallback?.({ direction: 'down' });
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBeNull();
expect(streamResolver.resolveLiveDetail).not.toHaveBeenCalled();
});
it('selects a channel by its 1-based number in the visible list', async () => {
await configure();
await setItems([
buildItem('xtream::pl-1::2', 'Bravo'),
buildItem('xtream::pl-1::1', 'Alpha'),
buildItem('xtream::pl-1::3', 'Charlie'),
]);
remoteCommandCallback?.({ type: 'channel-select-number', number: 3 });
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBe('xtream::pl-1::3');
});
it('follows the search-filtered list for navigation and numbering', async () => {
await configure();
await setItems([
buildItem('xtream::pl-1::2', 'News Two'),
buildItem('xtream::pl-1::1', 'Music One'),
buildItem('xtream::pl-1::3', 'News One'),
]);
fixture.componentRef.setInput('searchTerm', 'news');
fixture.detectChanges();
// Visible list is News One, News Two — number 1 is News One.
remoteCommandCallback?.({ type: 'channel-select-number', number: 1 });
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBe('xtream::pl-1::3');
});
it('publishes the active source portal and visible channel number', async () => {
await configure();
await setItems([
buildItem('stalker::pl-9::2', 'Bravo', 'stalker'),
buildItem('stalker::pl-9::1', 'Alpha', 'stalker'),
]);
await activate('stalker::pl-9::2');
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith({
portal: 'stalker',
isLiveView: true,
channelName: 'Bravo',
channelNumber: 2,
epgTitle: undefined,
epgStart: undefined,
epgEnd: undefined,
supportsVolume: false,
});
});
it('publishes a reset snapshot while nothing is playing and on destroy', async () => {
await configure();
await setItems([buildItem('xtream::pl-1::1', 'Alpha')]);
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith({
portal: 'unknown',
isLiveView: false,
supportsVolume: false,
});
await activate('xtream::pl-1::1');
updateRemoteControlStatus.mockClear();
fixture.destroy();
expect(unsubscribeChannelChange).toHaveBeenCalledTimes(1);
expect(unsubscribeCommand).toHaveBeenCalledTimes(1);
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith({
portal: 'unknown',
isLiveView: false,
supportsVolume: false,
});
});
});
function buildItem(
uid: string,
name: string,
sourceType: 'm3u' | 'xtream' | 'stalker' = 'xtream'
): UnifiedCollectionItem {
const sourceItemId = uid.split('::')[2];
return {
uid,
name,
contentType: 'live',
sourceType,
playlistId: uid.split('::')[1],
playlistName: 'Playlist',
logo: null,
posterUrl: null,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
...(sourceType === 'xtream'
? { xtreamId: Number(sourceItemId) }
: sourceType === 'stalker'
? { stalkerId: sourceItemId, stalkerCmd: `ffmpeg http://s/${sourceItemId}` }
: { streamUrl: `https://example.com/${sourceItemId}.m3u8` }),
} as UnifiedCollectionItem;
}
@@ -56,6 +56,39 @@ export function getFavoritesChannelSortModeTranslationKey(
}
}
/**
* The list a collection surface actually renders: search-filtered, then
* sorted in favorites mode (`sortMode: null` keeps the input order, as the
* recent view does). Shared by the global favorites list and the unified
* live tab's remote-control navigation so the remote's channel order can
* never diverge from the rendered rows.
*/
export function deriveVisibleFavoriteChannels<T>(
channels: readonly T[],
options: {
searchTerm: string;
sortMode: FavoritesChannelSortMode | null;
getName: (item: T) => string | null | undefined;
getAddedAt?: (item: T) => string | null | undefined;
}
): readonly T[] {
const term = options.searchTerm.trim().toLowerCase();
const filtered = term
? channels.filter((channel) =>
(options.getName(channel) ?? '').toLowerCase().includes(term)
)
: channels;
if (options.sortMode === null) {
return filtered;
}
return sortFavoriteChannelItems(filtered, options.sortMode, {
getName: options.getName,
getAddedAt: options.getAddedAt,
});
}
export function sortFavoriteChannelItems<T>(
items: readonly T[],
mode: FavoritesChannelSortMode,
@@ -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';
@@ -570,7 +571,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.filteredChannels();
if (selectedType !== 'itv' || !selectedItem?.id) {
// Radio shares this layout and its remote channel handlers, so
// it must publish live status too — otherwise the remote shows
// "waiting for playback" while its commands keep working.
if (
(selectedType !== 'itv' && selectedType !== 'radio') ||
!selectedItem?.id
) {
remoteControl.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: false,
@@ -579,10 +586,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return;
}
// String-normalized comparison: radio ids ("radio-1") and other
// non-numeric portal ids would turn into NaN under Number().
const selectedId = normalizeStalkerEntityId(selectedItem.id);
const currentIndex = channels.findIndex(
(item) => Number(item.id) === Number(selectedItem.id)
(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',
@@ -622,6 +638,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
ngOnDestroy() {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
// Leaving the live view would otherwise keep the last channel
// advertised as live on the remote forever.
this.remoteControlBridge?.updateRemoteControlStatus?.(
REMOTE_CONTROL_RESET_STATUS
);
this.removeScrollListener();
if (this.epgPreviewRefreshTimer !== null) {
clearTimeout(this.epgPreviewRefreshTimer);
@@ -0,0 +1,277 @@
import { EventEmitter, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
/**
* Focused spec for the remote-control status lifecycle (ITV + radio modes
* and the destroy-time reset). Kept separate from the main layout spec,
* which sits at the max-lines test budget; the template is overridden to
* empty because these behaviors live entirely in constructor effects.
*/
describe('StalkerLiveStreamLayoutComponent remote status', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
const originalElectron = window.electron;
const itvChannels = signal([
{ id: '10001', cmd: 'ffrt4://itv/1', name: 'Alpha TV', o_name: 'Alpha TV', logo: 'a.png' },
{ id: '10002', cmd: 'ffrt4://itv/2', name: 'Beta TV', o_name: 'Beta TV', logo: 'b.png' },
]);
const radioChannels = signal([
{ id: 'radio-1', cmd: 'ifm https://s/jazz.mp3', name: 'Jazz FM', o_name: 'Jazz FM', logo: 'j.png' },
{ id: 'radio-2', cmd: 'ifm https://s/news.mp3', name: 'News Radio', o_name: 'News Radio', logo: 'n.png' },
]);
const selectedItem = signal<{
id: string;
cmd: string;
name: string;
o_name: string;
logo: string;
} | null>(null);
const selectedContentType = signal<'itv' | 'vod' | 'series' | 'radio'>(
'itv'
);
const selectedItvId = signal<string | undefined>(undefined);
const stalkerStore = {
getSelectedCategoryName: signal('News'),
itvChannels,
radioChannels,
searchPhrase: signal(''),
hasMoreChannels: signal(false),
itvFullListActive: signal(false),
itvFullListLoading: signal(false),
itvFullListProgress: signal(null),
itvFullChannelList: signal([]),
itvSelectedCategoryFromCache: signal(false),
isPaginatedContentLoading: signal(false),
preloadItvChannels: jest.fn(),
refreshItvChannels: jest.fn().mockResolvedValue(undefined),
selectedItvId,
currentPlaylist: signal({ _id: 'playlist-1', title: 'Demo Stalker' }),
selectedItvEpgPrograms: signal<EpgProgram[]>([]),
bulkItvEpgByChannel: signal<Record<string, EpgProgram[]>>({}),
bulkItvEpgLoaded: signal(false),
bulkItvEpgPlaylistId: signal<string | null>(null),
bulkItvEpgPeriodHours: signal<number | null>(null),
isLoadingBulkItvEpg: signal(false),
selectedCategoryId: signal<string | null>('1001'),
selectedItem,
selectedContentType,
page: signal(0),
setItvChannels: jest.fn(),
setRadioChannels: jest.fn(),
setPage: jest.fn(),
setSelectedItem: jest.fn(),
resolveItvPlayback: jest.fn().mockResolvedValue({
streamUrl: 'https://example.com/alpha.m3u8',
}),
resolveRadioPlayback: jest.fn().mockResolvedValue({
streamUrl: 'https://s/jazz.mp3',
}),
fetchChannelEpg: jest.fn().mockResolvedValue([]),
ensureBulkItvEpg: jest.fn().mockResolvedValue(undefined),
applyMappedItvEpg: jest.fn().mockResolvedValue(undefined),
hasItvEpgMappingOverride: jest.fn(() => false),
clearBulkItvEpgCache: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
};
const updateRemoteControlStatus = jest.fn();
beforeEach(async () => {
updateRemoteControlStatus.mockClear();
window.electron = {
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
updateRemoteControlStatus,
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
} as typeof window.electron;
selectedContentType.set('itv');
selectedItem.set(itvChannels()[0]);
selectedItvId.set('10001');
stalkerStore.selectedItvEpgPrograms.set([]);
await TestBed.configureTestingModule({
imports: [StalkerLiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{ provide: StalkerStore, useValue: stalkerStore },
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
get isElectron() {
return Boolean(window.electron);
},
get supportsEpgMapping() {
return Boolean(window.electron);
},
// Mirrors the real capability check: every
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
| Record<string, unknown>
| undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
].every(
(method) =>
typeof bridge?.[method] === 'function'
);
},
},
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: jest.fn(() => of([])) },
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
get: jest.fn((value: string) => of(value)),
stream: jest.fn((value: string) => of(value)),
onTranslationChange: new EventEmitter(),
onLangChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter(),
},
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: MatDialog, useValue: { open: jest.fn() } },
{
provide: EpgRuntimeBridgeService,
useValue: {
supportsEpgMapping: false,
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
},
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
});
afterEach(() => {
fixture?.destroy();
window.electron = originalElectron;
});
it('publishes live status with EPG for the selected ITV channel', () => {
stalkerStore.selectedItvEpgPrograms.set([nowProgram('Evening News')]);
fixture.detectChanges();
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({
portal: 'stalker',
isLiveView: true,
channelName: 'Alpha TV',
channelNumber: 1,
epgTitle: 'Evening News',
supportsVolume: false,
})
);
});
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();
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({
portal: 'stalker',
isLiveView: true,
channelName: 'News Radio',
channelNumber: 2,
epgTitle: undefined,
epgStart: undefined,
epgEnd: undefined,
supportsVolume: false,
})
);
});
it('publishes a non-live snapshot while VOD content is selected', () => {
selectedContentType.set('vod');
fixture.detectChanges();
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith({
portal: 'stalker',
isLiveView: false,
supportsVolume: false,
});
});
it('publishes a remote status reset when the live view is destroyed', () => {
fixture.detectChanges();
updateRemoteControlStatus.mockClear();
fixture.destroy();
expect(updateRemoteControlStatus).toHaveBeenCalledWith({
portal: 'unknown',
isLiveView: false,
supportsVolume: false,
});
});
});
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,
};
}
@@ -772,6 +772,21 @@ describe('LiveStreamLayoutComponent', () => {
expect(updateRemoteControlStatus).not.toHaveBeenCalled();
});
it('publishes a remote status reset when the live view is destroyed', () => {
const updateRemoteControlStatus = window.electron
?.updateRemoteControlStatus as jest.Mock;
fixture.detectChanges();
updateRemoteControlStatus.mockClear();
fixture.destroy();
expect(updateRemoteControlStatus).toHaveBeenCalledWith({
portal: 'unknown',
isLiveView: false,
supportsVolume: false,
});
});
it('resolves a catchup url for archived program activation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
@@ -38,6 +38,7 @@ import {
persistLiveEpgPanelState,
persistPortalChannelSortMode,
queryParamSignal,
REMOTE_CONTROL_RESET_STATUS,
restoreLiveEpgPanelState,
restorePortalChannelSortMode,
} from '@iptvnator/portal/shared/util';
@@ -520,6 +521,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
this.playbackRequestId += 1;
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
// Leaving the live view would otherwise keep the last channel
// advertised as live on the remote forever.
this.remoteControlBridge?.updateRemoteControlStatus?.(
REMOTE_CONTROL_RESET_STATUS
);
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {