From 626948f2f58a37ea298531aedf7e624ca8c8012f Mon Sep 17 00:00:00 2001 From: Tedd Johnson Date: Thu, 7 May 2026 18:08:27 -0700 Subject: [PATCH] Add double-click stream open setting --- apps/web/src/app/app.component.spec.ts | 1 + .../settings-playback-section.component.html | 15 ++++ .../app/settings/settings.component.spec.ts | 1 + .../src/app/settings/settings.component.ts | 1 + apps/web/src/assets/i18n/en.json | 2 + libs/m3u-state/src/lib/actions.ts | 5 +- libs/m3u-state/src/lib/effects.ts | 6 ++ .../global-favorites-list.component.html | 1 + .../global-favorites-list.component.spec.ts | 8 ++ .../global-favorites-list.component.ts | 12 +++ .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.ts | 33 +++++++- ...collection-channels-list.component.spec.ts | 4 +- .../stalker-live-stream-layout.component.html | 1 + ...alker-live-stream-layout.component.spec.ts | 79 ++++++++++++++++++- .../stalker-live-stream-layout.component.ts | 62 +++++++++++++-- .../live-stream-layout.component.html | 2 + .../live-stream-layout.component.spec.ts | 34 ++++++++ .../live-stream-layout.component.ts | 15 ++-- .../portal-channels-list.component.html | 1 + .../portal-channels-list.component.spec.ts | 7 ++ .../portal-channels-list.component.ts | 1 + .../src/lib/settings-store.service.ts | 2 + .../interfaces/src/lib/settings.interface.ts | 1 + .../all-channels-view.component.html | 1 + .../all-channels-view.component.ts | 8 ++ .../channel-list-container.component.html | 10 +++ .../channel-list-container.component.spec.ts | 8 +- .../channel-list-container.component.ts | 15 +++- .../channel-list-item.component.html | 3 +- .../channel-list-item.component.spec.ts | 23 ++++++ .../channel-list-item.component.ts | 13 +++ .../favorites-view.component.html | 1 + .../favorites-view.component.ts | 8 ++ .../groups-view/groups-view.component.html | 1 + .../groups-view/groups-view.component.ts | 8 ++ .../recent-view/recent-view.component.html | 1 + .../recent-view/recent-view.component.ts | 8 ++ 38 files changed, 382 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 37a8dc3d2..81150dc9e 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -36,6 +36,7 @@ const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, epgUrl: [], streamFormat: StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, showDashboard: true, 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 a29c1d4d2..f354c5da4 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -69,6 +69,21 @@ +
+
+

{{ 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' | translate }}

+

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

+
+
+ +
+
+ @if (isDesktop()) {
diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 7301de1e4..437dea3b0 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -68,6 +68,7 @@ export class MockRouter { const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, streamFormat: StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, showDashboard: true, diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 435032340..35dc758cf 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -179,6 +179,7 @@ export class SettingsComponent implements OnInit, OnDestroy { player: [VideoPlayer.VideoJs], ...(this.isDesktop ? { epgUrl: new FormArray([]) } : {}), streamFormat: StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, showDashboard: true, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3b0e3bc14..09d4e2665 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -244,6 +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.", "LANGUAGE": "Language", "SETTINGS_SAVED": "Success! Configuration was saved.", "THEME": "Visual theme", diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 3d10cd25f..6065795c8 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -35,7 +35,10 @@ export const ChannelActions = createActionGroup({ events: { 'Set Channels Loading': props<{ loading: boolean }>(), 'Set Channels': props<{ channels: Channel[] }>(), - 'Set Active Channel': props<{ channel: Channel }>(), + 'Set Active Channel': props<{ + channel: Channel; + startPlayback?: boolean; + }>(), 'Set Active Channel Success': props<{ channel: Channel }>(), 'Reset Active Channel': emptyProps(), 'Set Adjacent Channel As Active': props<{ diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index bef31d0ff..5fbbdee04 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -170,9 +170,14 @@ export class PlaylistEffects { firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( (settings: any) => { + const shouldOpenExternalPlayer = + !settings?.openStreamOnDoubleClick || + action.startPlayback === true; + if ( settings && Object.keys(settings).length > 0 && + shouldOpenExternalPlayer && settings.player === VideoPlayer.MPV && channel.radio !== 'true' ) { @@ -186,6 +191,7 @@ export class PlaylistEffects { } else if ( settings && Object.keys(settings).length > 0 && + shouldOpenExternalPlayer && settings.player === VideoPlayer.VLC && channel.radio !== 'true' ) diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index d45da9181..ce4bbe8bf 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -44,6 +44,7 @@ [showProgramInfoButton]="true" [showDetailsContextMenu]="hasChannelContextMenu(ch)" (clicked)="onChannelClick(ch)" + (activated)="onChannelActivate(ch)" (favoriteToggled)="onFavoriteToggled(ch)" (contextMenuRequested)="onChannelContextMenu(ch, $event)" /> diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index dcc976169..270cc1bdb 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -1,10 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; import { ChannelDetailsDialogComponent } from 'components'; import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util'; import { Channel } from 'shared-interfaces'; +import { SettingsStore } from 'services'; import { GlobalFavoritesListComponent } from './global-favorites-list.component'; describe('GlobalFavoritesListComponent', () => { @@ -27,6 +29,12 @@ describe('GlobalFavoritesListComponent', () => { provide: MatDialog, useValue: dialog, }, + { + provide: SettingsStore, + useValue: { + openStreamOnDoubleClick: signal(false), + }, + }, ], }).compileComponents(); diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index 30024df67..26d519f2d 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -20,6 +20,7 @@ import { ChannelDetailsDialogComponent, ChannelListItemComponent, } from 'components'; +import { SettingsStore } from 'services'; import { EpgProgram } from 'shared-interfaces'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, @@ -51,9 +52,13 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent'; }) export class GlobalFavoritesListComponent { private readonly dialog = inject(MatDialog); + private readonly settingsStore = inject(SettingsStore); readonly contextMenuTrigger = viewChild.required('contextMenuTrigger'); + readonly openStreamOnDoubleClick = computed(() => + this.settingsStore.openStreamOnDoubleClick() + ); readonly channels = input.required(); readonly mode = input('favorites'); @@ -68,6 +73,7 @@ export class GlobalFavoritesListComponent { ); readonly channelSelected = output(); + readonly playbackRequested = output(); readonly channelsReordered = output(); readonly favoriteToggled = output(); readonly removeRequested = output(); @@ -126,6 +132,12 @@ export class GlobalFavoritesListComponent { this.channelSelected.emit(channel); } + onChannelActivate(channel: UnifiedFavoriteChannel): void { + if (this.openStreamOnDoubleClick()) { + this.playbackRequested.emit(channel); + } + } + onFavoriteToggled(channel: UnifiedFavoriteChannel): void { this.favoriteToggled.emit(channel); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index a3fd8b613..3d8c42fe7 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -18,6 +18,7 @@ [draggable]="mode() === 'favorites'" [sortMode]="sortMode()" (channelSelected)="onChannelSelected($event)" + (playbackRequested)="onChannelPlaybackRequested($event)" (channelsReordered)="onReorder($event)" (favoriteToggled)="onFavoriteToggled($event)" (removeRequested)="onRemoveRequested($event)" diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index e760c52c8..e9c3843e1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -269,6 +269,17 @@ export class UnifiedLiveTabComponent { } } + async onChannelPlaybackRequested( + channel: UnifiedFavoriteChannel + ): Promise { + const item = this.items().find( + (candidate) => candidate.uid === channel.uid + ); + if (item) { + await this.activateItem(item, false, true); + } + } + onFavoriteToggled(channel: UnifiedFavoriteChannel): void { const item = this.items().find( (candidate) => candidate.uid === channel.uid @@ -330,9 +341,18 @@ export class UnifiedLiveTabComponent { private async activateItem( item: UnifiedCollectionItem, - isAutoOpen = false + isAutoOpen = false, + startPlayback = false ): Promise { if (this.activeUid() === item.uid && this.activeDetail()) { + if ( + startPlayback && + this.shouldOpenExternalPlayback(this.activeDetail()!, true) + ) { + void this.portalPlayer.openResolvedPlayback( + this.activeDetail()!.playback + ); + } if (isAutoOpen) { this.autoOpenHandled.emit(); } @@ -359,7 +379,7 @@ export class UnifiedLiveTabComponent { void this.hydrateSelectedM3uPrograms(item, detail, requestId); } - if (this.shouldOpenExternalPlayback(detail)) { + if (this.shouldOpenExternalPlayback(detail, startPlayback)) { void this.portalPlayer.openResolvedPlayback(detail.playback); } @@ -389,10 +409,15 @@ export class UnifiedLiveTabComponent { } private shouldOpenExternalPlayback( - detail: ResolvedLiveCollectionDetail + detail: ResolvedLiveCollectionDetail, + startPlayback = false ): boolean { + if (this.isRadioDetail(detail) || this.portalPlayer.isEmbeddedPlayer()) { + return false; + } + return ( - !this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer() + !this.settingsStore.openStreamOnDoubleClick() || startPlayback ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts index 96d56d510..aefcf0f9d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts @@ -39,7 +39,9 @@ describe('StalkerCollectionChannelsListComponent', () => { await TestBed.configureTestingModule({ imports: [StalkerCollectionChannelsListComponent], - providers: [{ provide: StalkerStore, useValue: stalkerStore }], + providers: [ + { provide: StalkerStore, useValue: stalkerStore }, + ], }) .overrideComponent(StalkerCollectionChannelsListComponent, { remove: { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 49297fd36..e9afb035a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -73,6 +73,7 @@ false " (clicked)="playChannel(item)" + (activated)="playChannel(item, true)" (favoriteToggled)="toggleFavorite(item)" /> } 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 7a8158922..989d63af7 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 @@ -14,7 +14,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ChannelListItemComponent } from 'components'; import { MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; -import { PlaylistsService } from 'services'; +import { PlaylistsService, SettingsStore } from 'services'; import { EpgProgram } from 'shared-interfaces'; import { LiveEpgPanelComponent, @@ -38,6 +38,7 @@ class StubChannelListItemComponent { readonly showProgramInfoButton = input(false); readonly isFavorite = input(false); readonly clicked = output(); + readonly activated = output(); readonly favoriteToggled = output(); } @@ -194,6 +195,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { isEmbeddedPlayer: jest.fn(() => true), openResolvedPlayback: jest.fn(), }; + const settingsStore = { + openStreamOnDoubleClick: signal(false), + }; beforeEach(async () => { fetchChannelEpg = stalkerStore.fetchChannelEpg; @@ -213,6 +217,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { hasMoreChannels.set(false); page.set(0); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); + settingsStore.openStreamOnDoubleClick.set(false); resolveItvPlayback.mockReset(); resolveItvPlayback.mockResolvedValue({ @@ -254,6 +259,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { providers: [ { provide: StalkerStore, useValue: stalkerStore }, { provide: PlaylistsService, useValue: playlistService }, + { provide: SettingsStore, useValue: settingsStore }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, { provide: TranslateService, @@ -368,6 +374,77 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBe(false); }); + it('reuses a pending playback resolution during double-click activation', async () => { + settingsStore.openStreamOnDoubleClick.set(true); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + let resolvePlayback!: (value: { streamUrl: string }) => void; + resolveItvPlayback.mockReturnValue( + new Promise((resolve) => { + resolvePlayback = resolve; + }) + ); + + const firstClick = component.playChannel(itvChannels()[0], false); + const secondClick = component.playChannel(itvChannels()[0], false); + const doubleClick = component.playChannel(itvChannels()[0], true); + + expect(resolveItvPlayback).toHaveBeenCalledTimes(1); + + resolvePlayback({ + streamUrl: 'https://example.com/alpha.m3u8', + }); + await Promise.all([firstClick, secondClick, doubleClick]); + + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledTimes(1); + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith( + { streamUrl: 'https://example.com/alpha.m3u8' }, + true + ); + }); + + it('starts external playback from remote channel navigation when double-click opening is enabled', async () => { + settingsStore.openStreamOnDoubleClick.set(true); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + selectedItem.set(itvChannels()[0]); + selectedItvId.set('10001'); + + ( + component as unknown as { + handleRemoteChannelChange(direction: 'up' | 'down'): void; + } + ).handleRemoteChannelChange('down'); + await fixture.whenStable(); + + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith( + { streamUrl: 'https://example.com/alpha.m3u8' }, + true + ); + }); + + it('starts external playback from remote number selection when double-click opening is enabled', async () => { + settingsStore.openStreamOnDoubleClick.set(true); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + ( + component as unknown as { + handleRemoteControlCommand(command: { + type: 'channel-select-number'; + number: number; + }): void; + } + ).handleRemoteControlCommand({ + type: 'channel-select-number', + number: 2, + }); + await fixture.whenStable(); + + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith( + { streamUrl: 'https://example.com/alpha.m3u8' }, + true + ); + }); + it('renders the current EPG program in the collapsible panel summary', async () => { fixture.detectChanges(); await fixture.whenStable(); 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 3380e62c2..225132c9f 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 @@ -25,7 +25,7 @@ import { ChannelListSkeletonComponent, ResizableDirective, } from 'components'; -import { PlaylistsService } from 'services'; +import { PlaylistsService, SettingsStore } from 'services'; import { Channel, EpgItem, @@ -86,6 +86,7 @@ import { export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); private readonly playlistService = inject(PlaylistsService); + private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); @@ -125,6 +126,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly selectedChannelId = this.stalkerStore.selectedItvId; protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; + readonly openStreamOnDoubleClick = computed(() => + this.settingsStore.openStreamOnDoubleClick() + ); /** Player */ readonly usesEmbeddedPlayer = computed(() => @@ -207,6 +211,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; private epgLoadRequestId = 0; + private playbackRequestId = 0; + private playbackResolution: + | { + channelId: string; + promise: Promise; + } + | null = null; private lastPlaylistId: string | null | undefined = undefined; constructor() { @@ -341,19 +352,38 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); } - async playChannel(item: StalkerItvChannel) { + async playChannel( + item: StalkerItvChannel, + startPlayback = !this.settingsStore.openStreamOnDoubleClick() + ) { + const requestId = ++this.playbackRequestId; + const channelId = normalizeStalkerEntityId(item.id); this.stalkerStore.setSelectedItem(item); try { - const playback = await this.stalkerStore.resolveItvPlayback(item); + const playback = await this.resolvePlaybackForChannel( + item, + channelId + ); + if ( + requestId !== this.playbackRequestId || + this.selectedChannelId() !== channelId + ) { + return; + } + void this.loadEpgForChannel(item); if (this.usesEmbeddedPlayer()) { this.activePlayback.set(playback); - } else { + } else if (startPlayback) { void this.portalPlayer.openResolvedPlayback(playback, true); } } catch (error) { + if (requestId !== this.playbackRequestId) { + return; + } + this.logger.error('Playback failed', error); const errorMessage = error?.message === 'nothing_to_play' @@ -363,6 +393,26 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } + private resolvePlaybackForChannel( + item: StalkerItvChannel, + channelId: string + ): Promise { + if (this.playbackResolution?.channelId === channelId) { + return this.playbackResolution.promise; + } + + const promise = this.stalkerStore.resolveItvPlayback(item); + this.playbackResolution = { channelId, promise }; + + void promise.finally(() => { + if (this.playbackResolution?.promise === promise) { + this.playbackResolution = null; + } + }); + + return promise; + } + toggleFavorite(item: StalkerItvChannel) { const itemId = normalizeStalkerEntityId(item.id); if (this.favorites.has(itemId)) { @@ -680,7 +730,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return; } - void this.playChannel(nextItem); + void this.playChannel(nextItem, true); } private handleRemoteControlCommand(command: { @@ -703,6 +753,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return; } - void this.playChannel(channel); + void this.playChannel(channel, true); } } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index fcdfe651f..023f0623f 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -74,6 +74,7 @@ [sortMode]="liveChannelSortMode()" [searchTermInput]="workspaceSearchTerm()" (playClicked)="playLive($event)" + (playbackRequested)="playLive($event, true)" />
} @@ -98,6 +99,7 @@ [sortMode]="liveChannelSortMode()" [searchTermInput]="workspaceSearchTerm()" (playClicked)="playLive($event)" + (playbackRequested)="playLive($event, true)" />
} @else { 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 dc1a5b2fc..2b0976c39 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 @@ -27,6 +27,7 @@ import { import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { LiveStreamLayoutComponent } from './live-stream-layout.component'; +import { SettingsStore } from 'services'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; @@ -39,6 +40,7 @@ class StubPortalChannelsListComponent { readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server'); readonly searchTermInput = input(''); readonly playClicked = output(); + readonly playbackRequested = output(); } @Component({ @@ -156,6 +158,9 @@ describe('LiveStreamLayoutComponent', () => { const portalPlayer = { isEmbeddedPlayer: jest.fn().mockReturnValue(true), }; + const settingsStore = { + openStreamOnDoubleClick: signal(false), + }; const originalElectron = window.electron; @@ -165,6 +170,7 @@ describe('LiveStreamLayoutComponent', () => { localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY); + settingsStore.openStreamOnDoubleClick.set(false); window.electron = { updateRemoteControlStatus: jest.fn(), @@ -212,6 +218,7 @@ describe('LiveStreamLayoutComponent', () => { { provide: XtreamStore, useValue: xtreamStore }, { provide: FavoritesService, useValue: favoritesService }, { provide: XtreamUrlService, useValue: xtreamUrlService }, + { provide: SettingsStore, useValue: settingsStore }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, ], }) @@ -424,6 +431,33 @@ describe('LiveStreamLayoutComponent', () => { ); }); + it('starts external playback from remote channel navigation when double-click opening is enabled', () => { + const nextChannel = { + ...sampleChannel, + xtream_id: 102, + name: 'Channel 102', + }; + settingsStore.openStreamOnDoubleClick.set(true); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + selectedItem.set(sampleChannel); + xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ + sampleChannel, + nextChannel, + ]); + + ( + component as unknown as { + handleRemoteChannelChange(direction: 'up' | 'down'): void; + } + ).handleRemoteChannelChange('down'); + + expect(xtreamStore.openPlayer).toHaveBeenCalledWith( + 'https://example.com/live.ts', + 'Channel 102', + 'channel-101.png' + ); + }); + it('shows an archive-unavailable notice when past programs exist but archive playback is unavailable', () => { const nonArchiveChannel = { ...sampleChannel, 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 df7bcb950..88e4e54c0 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 @@ -60,6 +60,7 @@ import { } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; +import { SettingsStore } from 'services'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; @@ -100,6 +101,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); private readonly xtreamUrlService = inject(XtreamUrlService); + private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly liveSidebarStateService = inject( LiveLayoutSidebarStateService @@ -332,14 +334,17 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } } - playLive(item: XtreamLiveChannelItem) { + playLive( + item: XtreamLiveChannelItem, + startPlayback = !this.settingsStore.openStreamOnDoubleClick() + ) { const streamUrl = this.xtreamStore.constructStreamUrl(item); this.activePlayback.set({ streamUrl, title: item.title ?? item.name ?? '', thumbnail: item.poster_url ?? item.stream_icon ?? null, }); - if (this.usesEmbeddedPlayer()) { + if (this.usesEmbeddedPlayer() || !startPlayback) { return; } this.xtreamStore.openPlayer( @@ -356,7 +361,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } if (event.type === 'live') { - this.playLive(selectedItem); + this.playLive(selectedItem, true); return; } @@ -423,7 +428,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } - this.playLive(nextItem); + this.playLive(nextItem, true); } private handleRemoteControlCommand(command: { @@ -444,7 +449,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } - this.playLive(channel); + this.playLive(channel, true); } private getVisibleChannels(): XtreamLiveChannelItem[] { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index ef08b5a5b..f25a16c8b 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -39,6 +39,7 @@ [showProgramInfoButton]="false" [isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false" (clicked)="playClicked.emit(item)" + (activated)="playbackRequested.emit(item)" (favoriteToggled)="toggleFavorite($event, item)" /> diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index dc3c35848..15c54a62d 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -10,6 +10,7 @@ import { FavoritesService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { SettingsStore } from 'services'; import { PortalChannelsListComponent } from './portal-channels-list.component'; function buildEpgItem(params: { @@ -117,6 +118,12 @@ describe('PortalChannelsListComponent', () => { enqueue: jest.fn(), }, }, + { + provide: SettingsStore, + useValue: { + openStreamOnDoubleClick: signal(false), + }, + }, { provide: ActivatedRoute, useValue: { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index fabface7c..9c49e3edb 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -70,6 +70,7 @@ interface XtreamCategoryLike { }) export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly playClicked = output(); + readonly playbackRequested = output(); readonly sortMode = input('server'); readonly channelsOverride = input(null); readonly searchTermInput = input(''); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 010551507..ab368bf3b 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -21,6 +21,7 @@ import { const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, streamFormat: StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, showDashboard: true, @@ -125,6 +126,7 @@ export const SettingsStore = signalStore( return { player: store.player(), streamFormat: store.streamFormat(), + openStreamOnDoubleClick: store.openStreamOnDoubleClick(), language: store.language(), showCaptions: store.showCaptions(), showDashboard: store.showDashboard(), diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 61c713a62..781808e39 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -28,6 +28,7 @@ export interface Settings { player: VideoPlayer; epgUrl: string[]; streamFormat: StreamFormat; + openStreamOnDoubleClick: boolean; language: Language; showCaptions: boolean; showDashboard: boolean; diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 062b507e4..490185458 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -89,6 +89,7 @@ ?.progressPercentage ?? 0 " (clicked)="onChannelClick(channel)" + (activated)="onChannelActivate(channel)" (contextMenuRequested)="onChannelContextMenu(channel, $event)" [selected]="activeChannelUrl() === channel?.url" [showFavoriteButton]="true" diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index d7b5f21f4..4a7031144 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -75,6 +75,7 @@ export class AllChannelsViewComponent { /** Whether to show EPG data */ readonly shouldShowEpg = input.required(); + readonly openOnDoubleClick = input(false); /** Item size for virtual scroll */ readonly itemSize = input.required(); @@ -87,6 +88,7 @@ export class AllChannelsViewComponent { /** Emits when a channel is selected */ readonly channelSelected = output(); + readonly channelPlaybackRequested = output(); /** Emits when favorite is toggled */ readonly favoriteToggled = output<{ @@ -203,6 +205,12 @@ export class AllChannelsViewComponent { this.channelSelected.emit(channel); } + onChannelActivate(channel: Channel): void { + if (this.openOnDoubleClick()) { + this.channelPlaybackRequested.emit(channel); + } + } + onFavoriteToggle(channel: Channel, event: MouseEvent): void { this.favoriteToggled.emit({ channel, event }); } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 4d0fb6267..437dd388d 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -29,10 +29,12 @@ [channelIconMap]="channelIconMap()" [progressTick]="progressTick()" [shouldShowEpg]="shouldShowEpg()" + [openOnDoubleClick]="openStreamOnDoubleClick()" [itemSize]="itemSize()" [activeChannelUrl]="activeChannelUrl()" [favoriteIds]="favoriteIds()" (channelSelected)="onChannelSelected($event)" + (channelPlaybackRequested)="onChannelPlaybackRequested($event)" (favoriteToggled)="onFavoriteToggled($event)" (sidebarToggleRequested)="sidebarToggleRequested.emit()" /> @@ -45,11 +47,13 @@ [channelIconMap]="channelIconMap()" [progressTick]="progressTick()" [shouldShowEpg]="shouldShowEpg()" + [openOnDoubleClick]="openStreamOnDoubleClick()" [activeChannelUrl]="activeChannelUrl()" [favoriteIds]="favoriteIds()" [hiddenGroupTitles]="hiddenGroupTitles()" [sidebarWidth]="sidebarWidth()" (channelSelected)="onChannelSelected($event)" + (channelPlaybackRequested)="onChannelPlaybackRequested($event)" (favoriteToggled)="onFavoriteToggled($event)" (hiddenGroupTitlesChanged)="onHiddenGroupTitlesChanged($event)" (sidebarWidthRequested)="onSidebarWidthRequested($event)" @@ -67,8 +71,12 @@ [channelIconMap]="channelIconMap()" [progressTick]="progressTick()" [shouldShowEpg]="shouldShowEpg()" + [openOnDoubleClick]="openStreamOnDoubleClick()" [activeChannelUrl]="activeChannelUrl()" (channelSelected)="onChannelSelected($event)" + (channelPlaybackRequested)=" + onChannelPlaybackRequested($event) + " (favoriteToggled)="onFavoriteToggled($event)" (favoritesReordered)="onFavoritesReordered($event)" /> @@ -82,8 +90,10 @@ [channelIconMap]="channelIconMap()" [progressTick]="progressTick()" [shouldShowEpg]="shouldShowEpg()" + [openOnDoubleClick]="openStreamOnDoubleClick()" [activeChannelUrl]="activeChannelUrl()" (channelSelected)="onChannelSelected($event)" + (channelPlaybackRequested)="onChannelPlaybackRequested($event)" (removeRecent)="removeRecentChannel($event)" /> } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index b6982a124..08189c6ce 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -12,7 +12,7 @@ import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { ChannelActions, PlaylistActions } from 'm3u-state'; -import { PlaylistsService } from 'services'; +import { PlaylistsService, SettingsStore } from 'services'; import { Channel, PlaylistMeta } from 'shared-interfaces'; import { ChannelListContainerComponent } from './channel-list-container.component'; @@ -115,6 +115,12 @@ describe('ChannelListContainerComponent', () => { provide: PlaylistsService, useValue: {}, }, + { + provide: SettingsStore, + useValue: { + openStreamOnDoubleClick: signal(false), + }, + }, { provide: StorageMap, useValue: { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 0447fb841..b6cedb397 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -40,7 +40,7 @@ import { firstValueFrom, map, } from 'rxjs'; -import { PlaylistsService } from 'services'; +import { PlaylistsService, SettingsStore } from 'services'; import { Channel, EpgProgram, @@ -109,6 +109,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly settingsStore = inject(SettingsStore); /** Map of channel ID to current EPG program */ readonly channelEpgMap = signal(new Map()); @@ -125,6 +126,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Whether to show EPG data in channel items */ readonly shouldShowEpg = signal(false); + readonly openStreamOnDoubleClick = computed(() => + this.settingsStore.openStreamOnDoubleClick() + ); /** Item size for virtual scroll - compact when no EPG */ readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); @@ -370,6 +374,15 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { this.store.dispatch(ChannelActions.setActiveChannel({ channel })); } + onChannelPlaybackRequested(channel: Channel): void { + this.store.dispatch( + ChannelActions.setActiveChannel({ + channel, + startPlayback: true, + }) + ); + } + /** * Handles favorite toggle from favorites tab */ diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 403df1bf0..c215f2cb8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -5,7 +5,8 @@ [cdkDragDisabled]="!isDraggable()" cdkDragPreviewContainer="parent" [class.active]="selected()" - (click)="clicked.emit()" + (click)="onClick($event)" + (dblclick)="onDoubleClick()" (contextmenu)="onContextMenu($event)" data-test-id="channel-item" > diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 7d23cccb6..247ee2813 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -89,6 +89,29 @@ describe('ChannelListItemComponent', () => { expect(fixture.nativeElement.querySelector('.channel-logo')).toBeNull(); }); + it('emits clicked on a single click by default', () => { + const clicked = jest.fn(); + fixture.componentInstance.clicked.subscribe(clicked); + + fixture.componentInstance.onClick({ detail: 1 } as MouseEvent); + + expect(clicked).toHaveBeenCalledTimes(1); + }); + + it('suppresses the second browser click and emits activation on double click', () => { + const clicked = jest.fn(); + const activated = jest.fn(); + fixture.componentInstance.clicked.subscribe(clicked); + fixture.componentInstance.activated.subscribe(activated); + + fixture.componentInstance.onClick({ detail: 1 } as MouseEvent); + fixture.componentInstance.onClick({ detail: 2 } as MouseEvent); + fixture.componentInstance.onDoubleClick(); + + expect(clicked).toHaveBeenCalledTimes(1); + expect(activated).toHaveBeenCalledTimes(1); + }); + it('emits a context menu request on right click when details are enabled', () => { fixture.componentRef.setInput('name', 'News One'); fixture.componentRef.setInput('showDetailsContextMenu', true); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 1c7b65f5d..a752e5202 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -54,6 +54,7 @@ export class ChannelListItemComponent { readonly auxActionTooltip = input(''); readonly clicked = output(); + readonly activated = output(); readonly favoriteToggled = output(); readonly auxActionClicked = output(); readonly contextMenuRequested = output(); @@ -87,6 +88,18 @@ export class ChannelListItemComponent { this.auxActionClicked.emit(event); } + onClick(event?: MouseEvent): void { + if ((event?.detail ?? 1) > 1) { + return; + } + + this.clicked.emit(); + } + + onDoubleClick(): void { + this.activated.emit(); + } + onContextMenu(event: MouseEvent): void { if (!this.showDetailsContextMenu()) { return; diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html index 5b99f2cd7..9960ee3db 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html @@ -31,6 +31,7 @@ " [showDetailsContextMenu]="true" (clicked)="onChannelClick(channel)" + (activated)="onChannelActivate(channel)" (contextMenuRequested)="onChannelContextMenu(channel, $event)" [selected]="activeChannelUrl() === channel?.url" [showFavoriteButton]="true" diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index f99e1db6c..7c52459c0 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -56,12 +56,14 @@ export class FavoritesViewComponent { /** Whether to show EPG data */ readonly shouldShowEpg = input.required(); + readonly openOnDoubleClick = input(false); /** Currently active channel URL */ readonly activeChannelUrl = input(); /** Emits when a channel is selected */ readonly channelSelected = output(); + readonly channelPlaybackRequested = output(); /** Emits when favorite is toggled (removed) */ readonly favoriteToggled = output<{ @@ -151,6 +153,12 @@ export class FavoritesViewComponent { this.channelSelected.emit(channel); } + onChannelActivate(channel: Channel): void { + if (this.openOnDoubleClick()) { + this.channelPlaybackRequested.emit(channel); + } + } + onFavoriteToggle(channel: Channel, event: MouseEvent): void { this.favoriteToggled.emit({ channel, event }); } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index fdfa6e283..7b57b770f 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -279,6 +279,7 @@ : false " (clicked)="onChannelClick(channel)" + (activated)="onChannelActivate(channel)" (contextMenuRequested)=" onChannelContextMenu(channel, $event) " diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 9ab618ade..f1d172605 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -92,6 +92,7 @@ export class GroupsViewComponent { /** Whether to show EPG data */ readonly shouldShowEpg = input.required(); + readonly openOnDoubleClick = input(false); /** Currently active channel URL */ readonly activeChannelUrl = input(); @@ -105,6 +106,7 @@ export class GroupsViewComponent { /** Emits when a channel is selected */ readonly channelSelected = output(); + readonly channelPlaybackRequested = output(); /** Emits when favorite is toggled */ readonly favoriteToggled = output<{ @@ -469,6 +471,12 @@ export class GroupsViewComponent { this.channelSelected.emit(channel); } + onChannelActivate(channel: Channel): void { + if (this.openOnDoubleClick()) { + this.channelPlaybackRequested.emit(channel); + } + } + onFavoriteToggle(channel: Channel, event: MouseEvent): void { this.favoriteToggled.emit({ channel, event }); } diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html index 8d4db33d9..73b68ecbd 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html @@ -25,6 +25,7 @@ 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate " (clicked)="onChannelClick(item.channel)" + (activated)="onChannelActivate(item.channel)" (contextMenuRequested)=" onChannelContextMenu(item.channel, $event) " diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index b24b1f4e1..19622820d 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -47,9 +47,11 @@ export class RecentViewComponent { readonly channelIconMap = input.required>(); readonly progressTick = input.required(); readonly shouldShowEpg = input.required(); + readonly openOnDoubleClick = input(false); readonly activeChannelUrl = input(); readonly channelSelected = output(); + readonly channelPlaybackRequested = output(); readonly removeRecent = output(); readonly contextMenuChannel = signal(null); @@ -117,6 +119,12 @@ export class RecentViewComponent { this.channelSelected.emit(channel); } + onChannelActivate(channel: Channel): void { + if (this.openOnDoubleClick()) { + this.channelPlaybackRequested.emit(channel); + } + } + onRemoveRecent(channel: Channel, event: MouseEvent): void { event.stopPropagation(); this.removeRecent.emit(channel.url);