refactor(remote-control): gate live bridge by capability

This commit is contained in:
4gray committed 2026-05-22 13:35:03 +03:00
1 parent 8cfc40f59b
commit 3e398f1d1a
7 files changed
+99 -22

No files matched your search

+7
View File
@@ -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`
@@ -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,
@@ -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;
@@ -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);
@@ -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);
}
@@ -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);
@@ -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