From 5b211faf73ad96d714f8f13474f3cb35d70391a3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:18:20 +0200 Subject: [PATCH] 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 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 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 Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH --------- Co-authored-by: Claude --- .changes/remote-control-live-collections.md | 6 + .../app/events/remote-control-http.spec.ts | 73 ++++ .../src/app/events/remote-control.events.ts | 27 +- docs/architecture/remote-control.md | 112 +++++- .../video-player.component.spec.ts | 138 +++++++ .../video-player/video-player.component.ts | 100 +++++- .../global-favorites-list.component.ts | 21 +- .../unified-live-tab-remote-control.ts | 140 ++++++++ .../unified-live-tab.component.ts | 30 ++ .../unified-live-tab.remote-control.spec.ts | 340 ++++++++++++++++++ .../util/src/lib/favorites-channel-sort.ts | 33 ++ .../util/src/lib/remote-channel-navigation.ts | 17 + .../stalker-live-stream-layout.component.ts | 27 +- ...r-live-stream-layout.remote-status.spec.ts | 277 ++++++++++++++ .../live-stream-layout.component.spec.ts | 15 + .../live-stream-layout.component.ts | 6 + 16 files changed, 1321 insertions(+), 41 deletions(-) create mode 100644 .changes/remote-control-live-collections.md create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts diff --git a/.changes/remote-control-live-collections.md b/.changes/remote-control-live-collections.md new file mode 100644 index 000000000..4c41c1946 --- /dev/null +++ b/.changes/remote-control-live-collections.md @@ -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. diff --git a/apps/electron-backend/src/app/events/remote-control-http.spec.ts b/apps/electron-backend/src/app/events/remote-control-http.spec.ts index fc8c7617b..acfa42390 100644 --- a/apps/electron-backend/src/app/events/remote-control-http.spec.ts +++ b/apps/electron-backend/src/app/events/remote-control-http.spec.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index 9f20d8bec..0276bbd4c 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -88,11 +88,28 @@ export class RemoteControlEvents { ipcMain.on( 'REMOTE_CONTROL_STATUS_UPDATE', (_event, status: Partial) => { - 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(), + }; } ); } diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 5db0429c1..2b9ff3322 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -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 diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 7fd017704..eb75601d8 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -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 + | 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, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 3db5cf5f7..52f7fe8c3 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -12,6 +12,7 @@ import { effect, inject, signal, + untracked, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -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; } diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index e16821f47..98e34a91e 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -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(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts new file mode 100644 index 000000000..9c59d8f90 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts @@ -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; + activeUid: Signal; + activeSourceType: Signal; + activeChannelName: Signal; + /** + * True once a selection has resolved its playback detail. This is + * selection-based, matching the routed live layouts: with an external + * player in double-click-to-play mode, a single click selects without + * starting playback yet — the status still reports the selection so the + * remote can navigate from it. + */ + isPlaybackActive: Signal; + epgSummary: Signal; + playChannel: (channel: UnifiedFavoriteChannel) => void; +} + +/** + * 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; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 974d7c490..2c34a5ef1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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 diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts new file mode 100644 index 000000000..672e6d66b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts @@ -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; + 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>; + 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; +} diff --git a/libs/portal/shared/util/src/lib/favorites-channel-sort.ts b/libs/portal/shared/util/src/lib/favorites-channel-sort.ts index 2f946c7ed..39978c1da 100644 --- a/libs/portal/shared/util/src/lib/favorites-channel-sort.ts +++ b/libs/portal/shared/util/src/lib/favorites-channel-sort.ts @@ -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( + 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( items: readonly T[], mode: FavoritesChannelSortMode, diff --git a/libs/portal/shared/util/src/lib/remote-channel-navigation.ts b/libs/portal/shared/util/src/lib/remote-channel-navigation.ts index 04d75048d..9c4d8e5e0 100644 --- a/libs/portal/shared/util/src/lib/remote-channel-navigation.ts +++ b/libs/portal/shared/util/src/lib/remote-channel-navigation.ts @@ -1,5 +1,22 @@ +import { ElectronBridgeRemoteControlStatus } from '@iptvnator/shared/interfaces'; + export type RemoteChannelDirection = 'up' | 'down'; +/** + * The status snapshot every live surface publishes when it stops owning + * playback (destroy, or nothing selected). The main process treats a + * non-live update as authoritative, so this exact shape clears stale + * now-playing data on the remote. One shared constant — the docs specify + * this shape as "the reset snapshot", and hand-copied literals would fork + * that contract silently. + */ +export const REMOTE_CONTROL_RESET_STATUS: ElectronBridgeRemoteControlStatus = + Object.freeze({ + portal: 'unknown', + isLiveView: false, + supportsVolume: false, + }); + /** * Returns the adjacent item in the list for remote channel navigation. * Uses wraparound when reaching the start or end of the list. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index d008c1add..e86e71a0a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -66,6 +66,7 @@ import { isTypingInInput, LiveEpgPanelState, persistLiveEpgPanelState, + REMOTE_CONTROL_RESET_STATUS, restoreLiveEpgPanelState, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; @@ -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); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts new file mode 100644 index 000000000..1a24f0a70 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts @@ -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; + 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(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([]), + bulkItvEpgByChannel: signal>({}), + bulkItvEpgLoaded: signal(false), + bulkItvEpgPlaylistId: signal(null), + bulkItvEpgPeriodHours: signal(null), + isLoadingBulkItvEpg: signal(false), + selectedCategoryId: signal('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 + | 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, + }; +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 8f258efef..be66cfac8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -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); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 5c91afc36..ca971872c 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -38,6 +38,7 @@ import { persistLiveEpgPanelState, persistPortalChannelSortMode, queryParamSignal, + REMOTE_CONTROL_RESET_STATUS, restoreLiveEpgPanelState, restorePortalChannelSortMode, } from '@iptvnator/portal/shared/util'; @@ -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 {