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 @@
-
-
+ }
@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;
}