From e350a2ea96a67800415c0e359f75da41c39fef41 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 8 May 2026 10:59:52 +0200 Subject: [PATCH] fix double-click external playback handling --- .../settings-playback-section.component.html | 38 ++++++---- ...ettings-playback-section.component.spec.ts | 73 +++++++++++++++++++ apps/web/src/assets/i18n/en.json | 4 +- docs/architecture/remote-control.md | 9 ++- .../m3u-channel-playback-actions.spec.ts | 20 +++++ .../m3u-channel-playback-actions.ts | 9 +++ .../video-player.component.spec.ts | 34 ++++++++- .../video-player/video-player.component.ts | 7 +- ...alker-live-stream-layout.component.spec.ts | 11 +++ .../stalker-live-stream-layout.component.ts | 6 +- 10 files changed, 184 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/app/settings/settings-playback-section.component.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.ts diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index f354c5da4..5fe7e5a46 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -69,20 +69,32 @@ -
-
-

{{ 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' | translate }}

-

- {{ - 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK_DESCRIPTION' - | translate - }} -

+ @if (isDesktop()) { +
+
+

+ {{ + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' + | translate + }} +

+

+ {{ + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
+ } @if (isDesktop()) {
diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts new file mode 100644 index 000000000..1350dea29 --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { StreamFormat, VideoPlayer } from 'shared-interfaces'; +import { SettingsPlaybackSectionComponent } from './settings-playback-section.component'; + +describe('SettingsPlaybackSectionComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + SettingsPlaybackSectionComponent, + NoopAnimationsModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); + fixture.componentRef.setInput('form', createForm()); + fixture.componentRef.setInput('activeSection', 'playback'); + fixture.componentRef.setInput('players', [ + { + id: VideoPlayer.VideoJs, + labelKey: 'SETTINGS.PLAYER_VIDEOJS', + }, + { + id: VideoPlayer.MPV, + labelKey: 'SETTINGS.PLAYER_MPV', + }, + ]); + fixture.componentRef.setInput('streamFormatEnum', StreamFormat); + }); + + it('hides the external-player double-click option outside desktop builds', () => { + fixture.componentRef.setInput('isDesktop', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain( + 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' + ); + }); + + it('labels the double-click option as external-player behavior on desktop', () => { + fixture.componentRef.setInput('isDesktop', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="external-player-double-click-setting"]' + ) + ).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' + ); + }); +}); + +function createForm(): FormGroup { + return new FormGroup({ + player: new FormControl(VideoPlayer.VideoJs), + streamFormat: new FormControl(StreamFormat.M3u8StreamFormat), + openStreamOnDoubleClick: new FormControl(false), + showExternalPlaybackBar: new FormControl(true), + }); +} diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 09d4e2665..c79653414 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -244,8 +244,8 @@ "NEW_VERSION_AVAILABLE": "There is a new version available", "LATEST_VERSION": "You are using the latest version", "STREAM_FORMAT": "Stream Format", - "OPEN_STREAM_ON_DOUBLE_CLICK": "Open streams with double-click", - "OPEN_STREAM_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before playback starts.", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Start MPV/VLC with double-click", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before MPV or VLC playback starts.", "LANGUAGE": "Language", "SETTINGS_SAVED": "Success! Configuration was saved.", "THEME": "Visual theme", diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 28f647c90..72d5a4e1a 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -119,6 +119,7 @@ Implemented behavior: - `onRemoteControlCommand` (number + volume) - Applies channel up/down by active channel URL over `channels$` - Applies number select through existing `switchToChannelByNumber(...)` +- Dispatches remote channel changes as explicit playback requests so MPV/VLC starts immediately even when mouse channel rows require double-click before external playback. - Applies volume commands: - up/down in 0.1 increments - toggle mute with last non-zero volume restore @@ -143,10 +144,10 @@ Implemented behavior: - Up/down: - Uses selected live item `selectedItem().xtream_id` - Navigates inside `selectItemsFromSelectedCategory()` - - Calls `playLive(nextItem)` + - Calls `playLive(nextItem, true)` so remote actions explicitly start playback - Number select: - Maps number to item in current category list - - Calls `playLive(channel)` + - Calls `playLive(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - `portal: 'xtream'` - `isLiveView` only when selected content type is `live` and item is selected @@ -166,10 +167,10 @@ Implemented behavior: - Up/down: - Uses `selectedItem().id` - Navigates inside `itvChannels()` - - Calls `playChannel(nextItem)` + - Calls `playChannel(nextItem, true)` so remote actions explicitly start playback - Number select: - Maps number into `itvChannels()` - - Calls `playChannel(channel)` + - 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 diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.spec.ts new file mode 100644 index 000000000..f58959dce --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.spec.ts @@ -0,0 +1,20 @@ +import { ChannelActions } from 'm3u-state'; +import { Channel } from 'shared-interfaces'; +import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions'; + +describe('createM3uChannelPlaybackRequest', () => { + it('marks channel activation as an explicit playback request', () => { + const channel = { + id: 'channel-1', + name: 'Sample TV', + url: 'http://localhost/live.m3u8', + } as Channel; + + expect(createM3uChannelPlaybackRequest(channel)).toEqual( + ChannelActions.setActiveChannel({ + channel, + startPlayback: true, + }) + ); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.ts new file mode 100644 index 000000000..c5850a057 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/m3u-channel-playback-actions.ts @@ -0,0 +1,9 @@ +import { ChannelActions } from 'm3u-state'; +import { Channel } from 'shared-interfaces'; + +export function createM3uChannelPlaybackRequest(channel: Channel) { + return ChannelActions.setActiveChannel({ + channel, + startPlayback: true, + }); +} 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 3d0774a93..1e988f549 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 @@ -495,14 +495,44 @@ describe('VideoPlayerComponent', () => { expect(overlayRef.attach).toHaveBeenCalledTimes(1); }); - it('switches channels by number through the store action', () => { + it('switches channels by number through a playback request', () => { syncStoreState(sampleChannel); fixture.detectChanges(); component.switchToChannelByNumber(1); expect(storeMock.dispatch).toHaveBeenCalledWith( - ChannelActions.setActiveChannel({ channel: sampleChannel }) + ChannelActions.setActiveChannel({ + channel: sampleChannel, + startPlayback: true, + }) + ); + }); + + it('changes channels from remote navigation through a playback request', () => { + const nextChannel = { + ...sampleChannel, + id: 'channel-2', + url: 'http://localhost/next.m3u8', + name: 'Next TV', + }; + activeChannel.set(sampleChannel); + activeChannel$.next(sampleChannel); + channels.set([sampleChannel, nextChannel]); + channels$.next([sampleChannel, nextChannel]); + fixture.detectChanges(); + + ( + component as unknown as { + handleRemoteChannelChange(direction: 'up' | 'down'): void; + } + ).handleRemoteChannelChange('down'); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + ChannelActions.setActiveChannel({ + channel: nextChannel, + startPlayback: true, + }) ); }); }); 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 3a1a725e9..a44ddb54a 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 @@ -88,6 +88,7 @@ import { Settings, VideoPlayer, } from 'shared-interfaces'; +import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions'; const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg'; const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width'; @@ -459,9 +460,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } this.store.dispatch( - ChannelActions.setActiveChannel({ - channel: nextChannel, - }) + createM3uChannelPlaybackRequest(nextChannel) ); }, error: (err) => { @@ -739,7 +738,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { .subscribe((channel) => { if (channel) { this.store.dispatch( - ChannelActions.setActiveChannel({ channel }) + createM3uChannelPlaybackRequest(channel) ); } }); 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 989d63af7..be6ace3f3 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 @@ -403,6 +403,17 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); }); + it('does not attach a dangling finally cleanup to failed playback resolution', async () => { + const playbackError = new Error('nothing_to_play'); + const playbackPromise = Promise.reject(playbackError); + const finallySpy = jest.spyOn(playbackPromise, 'finally'); + resolveItvPlayback.mockReturnValue(playbackPromise); + + await component.playChannel(itvChannels()[0]); + + expect(finallySpy).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 225132c9f..ba12d1d30 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 @@ -404,11 +404,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const promise = this.stalkerStore.resolveItvPlayback(item); this.playbackResolution = { channelId, promise }; - void promise.finally(() => { + const cleanup = () => { if (this.playbackResolution?.promise === promise) { this.playbackResolution = null; } - }); + }; + + void promise.then(cleanup, cleanup); return promise; }