mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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:
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(),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+7
-14
@@ -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();
|
||||
|
||||
+140
@@ -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;
|
||||
}
|
||||
+30
@@ -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
|
||||
|
||||
+340
@@ -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.
|
||||
|
||||
+24
-3
@@ -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);
|
||||
|
||||
+277
@@ -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,
|
||||
};
|
||||
}
|
||||
+15
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user