From 3e398f1d1a49540479e3be4e27e3506c59a7ab57 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 04:03:54 +0300 Subject: [PATCH] refactor(remote-control): gate live bridge by capability --- docs/architecture/remote-control.md | 7 ++++++ .../video-player.component.spec.ts | 18 +++++++++++++++ .../video-player/video-player.component.ts | 23 ++++++++++++------- ...alker-live-stream-layout.component.spec.ts | 17 ++++++++++++++ .../stalker-live-stream-layout.component.ts | 20 ++++++++++------ .../live-stream-layout.component.spec.ts | 16 +++++++++++++ .../live-stream-layout.component.ts | 20 ++++++++++------ 7 files changed, 99 insertions(+), 22 deletions(-) diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 72d5a4e1a..313985185 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -97,6 +97,13 @@ Type definitions: ## Renderer Integrations +All renderer integrations resolve the Electron remote-control bridge through +`RuntimeCapabilitiesService.supportsRemoteControl`. A partial Electron bridge +is treated as unsupported unless it exposes all remote-control methods: +`updateRemoteControlStatus`, `onChannelChange`, and +`onRemoteControlCommand`. This keeps PWA/self-hosted builds and partial test +bridges from accidentally activating desktop-only remote-control behavior. + ## Shared helpers - File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts` 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 ec1996b4b..f9bca304e 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 @@ -286,6 +286,9 @@ describe('VideoPlayerComponent', () => { beforeEach(async () => { window.electron = { platform: 'darwin', + updateRemoteControlStatus: jest.fn(), + onChannelChange: jest.fn(() => jest.fn()), + onRemoteControlCommand: jest.fn(() => jest.fn()), } as typeof window.electron; syncStoreState(null); @@ -464,6 +467,21 @@ describe('VideoPlayerComponent', () => { expect(headerContext.action()).toBeNull(); }); + it('does not publish remote-control status when the bridge is incomplete', () => { + fixture.destroy(); + const updateRemoteControlStatus = jest.fn(); + window.electron = { + updateRemoteControlStatus, + } as typeof window.electron; + + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + syncStoreState(sampleChannel); + fixture.detectChanges(); + + expect(updateRemoteControlStatus).not.toHaveBeenCalled(); + }); + 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 9e407777a..25279dc08 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 @@ -383,8 +383,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.registerHeaderShortcut(); // Setup remote control channel change listener (Electron only) - if (this.isDesktop && window.electron?.onChannelChange) { - const unsubscribe = window.electron.onChannelChange( + const remoteControl = this.remoteControlBridge; + if (remoteControl?.onChannelChange) { + const unsubscribe = remoteControl.onChannelChange( (data: { direction: 'up' | 'down' }) => { this.handleRemoteChannelChange(data.direction); } @@ -393,8 +394,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.unsubscribeRemoteChannelChange = unsubscribe; } } - if (this.isDesktop && window.electron?.onRemoteControlCommand) { - const unsubscribe = window.electron.onRemoteControlCommand( + if (remoteControl?.onRemoteControlCommand) { + const unsubscribe = remoteControl.onRemoteControlCommand( (command) => { this.handleRemoteControlCommand(command); } @@ -409,7 +410,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.store.select(selectActive), this.store.select(selectCurrentEpgProgram).pipe(startWith(null)), ]).subscribe(([channels, activeChannel, epgProgram]) => { - if (!window.electron?.updateRemoteControlStatus || !activeChannel) { + const remoteControl = this.remoteControlBridge; + if (!remoteControl?.updateRemoteControlStatus || !activeChannel) { return; } @@ -421,7 +423,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { (channel) => channel.url === activeChannel.url ); - window.electron.updateRemoteControlStatus({ + remoteControl.updateRemoteControlStatus({ portal: 'm3u', isLiveView: true, channelName: activeChannel.name ?? activeChannel.tvg?.name, @@ -805,8 +807,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } localStorage.setItem('volume', String(clamped)); - if (window.electron?.updateRemoteControlStatus) { - window.electron.updateRemoteControlStatus({ + const remoteControl = this.remoteControlBridge; + if (remoteControl?.updateRemoteControlStatus) { + remoteControl.updateRemoteControlStatus({ portal: 'm3u', isLiveView: true, supportsVolume: true, @@ -816,6 +819,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } } + private get remoteControlBridge(): Window['electron'] | undefined { + return this.runtime.supportsRemoteControl ? window.electron : undefined; + } + shouldShowInlinePlayer(channel: Channel | null | undefined): boolean { if (!channel) { return false; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index eea6577ed..8fdb120df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -243,6 +243,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { beforeEach(async () => { window.electron = { platform: 'darwin', + updateRemoteControlStatus: jest.fn(), + onChannelChange: jest.fn(() => jest.fn()), + onRemoteControlCommand: jest.fn(() => jest.fn()), } as typeof window.electron; fetchChannelEpg = stalkerStore.fetchChannelEpg; @@ -505,6 +508,20 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(finallySpy).not.toHaveBeenCalled(); }); + it('does not publish remote-control status when the bridge is incomplete', () => { + fixture.destroy(); + const updateRemoteControlStatus = jest.fn(); + window.electron = { + updateRemoteControlStatus, + } as typeof window.electron; + + fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(updateRemoteControlStatus).not.toHaveBeenCalled(); + }); + it('starts external playback from remote channel navigation when double-click opening is enabled', async () => { settingsStore.openStreamOnDoubleClick.set(true); portalPlayer.isEmbeddedPlayer.mockReturnValue(false); 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 ed454b032..713b8a6c2 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 @@ -335,7 +335,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }); effect(() => { - if (!window.electron?.updateRemoteControlStatus) { + const remoteControl = this.remoteControlBridge; + if (!remoteControl?.updateRemoteControlStatus) { return; } @@ -344,7 +345,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const channels = this.visibleChannels(); if (selectedType !== 'itv' || !selectedItem?.id) { - window.electron.updateRemoteControlStatus({ + remoteControl.updateRemoteControlStatus({ portal: 'stalker', isLiveView: false, supportsVolume: false, @@ -357,7 +358,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); const currentProgram = this.currentProgram(); - window.electron.updateRemoteControlStatus({ + remoteControl.updateRemoteControlStatus({ portal: 'stalker', isLiveView: true, channelName: selectedItem.o_name || selectedItem.name, @@ -369,8 +370,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }); }); - if (window.electron?.onChannelChange) { - const unsubscribe = window.electron.onChannelChange( + const remoteControl = this.remoteControlBridge; + if (remoteControl?.onChannelChange) { + const unsubscribe = remoteControl.onChannelChange( (data: { direction: 'up' | 'down' }) => { this.handleRemoteChannelChange(data.direction); } @@ -379,8 +381,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.unsubscribeRemoteChannelChange = unsubscribe; } } - if (window.electron?.onRemoteControlCommand) { - const unsubscribe = window.electron.onRemoteControlCommand( + if (remoteControl?.onRemoteControlCommand) { + const unsubscribe = remoteControl.onRemoteControlCommand( (command) => { this.handleRemoteControlCommand(command); } @@ -799,6 +801,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); } + private get remoteControlBridge(): Window['electron'] | undefined { + return this.runtime.supportsRemoteControl ? window.electron : undefined; + } + private handleRemoteChannelChange(direction: 'up' | 'down'): void { this.handleAdjacentChannelChange(direction); } 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 ded053d9f..ab16c4e13 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 @@ -182,6 +182,8 @@ describe('LiveStreamLayoutComponent', () => { window.electron = { updateRemoteControlStatus: jest.fn(), + onChannelChange: jest.fn(() => jest.fn()), + onRemoteControlCommand: jest.fn(() => jest.fn()), } as typeof window.electron; xtreamStore.constructStreamUrl.mockClear(); @@ -487,6 +489,20 @@ describe('LiveStreamLayoutComponent', () => { ); }); + it('does not publish remote-control status when the bridge is incomplete', () => { + fixture.destroy(); + const updateRemoteControlStatus = jest.fn(); + window.electron = { + updateRemoteControlStatus, + } as typeof window.electron; + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(updateRemoteControlStatus).not.toHaveBeenCalled(); + }); + 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 91ac35165..f4e0b6a6a 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 @@ -267,7 +267,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }); effect(() => { - if (!window.electron?.updateRemoteControlStatus) { + const remoteControl = this.remoteControlBridge; + if (!remoteControl?.updateRemoteControlStatus) { return; } @@ -277,7 +278,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const currentProgram = this.currentEpgItem(); if (selectedContentType !== 'live' || !selectedItem?.xtream_id) { - window.electron.updateRemoteControlStatus({ + remoteControl.updateRemoteControlStatus({ portal: 'xtream', isLiveView: false, supportsVolume: false, @@ -290,7 +291,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { Number(item.xtream_id) === Number(selectedItem.xtream_id) ); - window.electron.updateRemoteControlStatus({ + remoteControl.updateRemoteControlStatus({ portal: 'xtream', isLiveView: true, channelName: selectedItem.title ?? selectedItem.name, @@ -304,8 +305,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } ngOnInit() { - if (window.electron?.onChannelChange) { - const unsubscribe = window.electron.onChannelChange( + const remoteControl = this.remoteControlBridge; + if (remoteControl?.onChannelChange) { + const unsubscribe = remoteControl.onChannelChange( (data: { direction: 'up' | 'down' }) => { this.handleRemoteChannelChange(data.direction); } @@ -314,8 +316,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.unsubscribeRemoteChannelChange = unsubscribe; } } - if (window.electron?.onRemoteControlCommand) { - const unsubscribe = window.electron.onRemoteControlCommand( + if (remoteControl?.onRemoteControlCommand) { + const unsubscribe = remoteControl.onRemoteControlCommand( (command) => { this.handleRemoteControlCommand(command); } @@ -556,6 +558,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }; } + private get remoteControlBridge(): Window['electron'] | undefined { + return this.runtime.supportsRemoteControl ? window.electron : undefined; + } + private getProgramTimestampSeconds( dateValue: string, unixTimestampValue?: number | string | null