Add double-click stream open setting

This commit is contained in:
Tedd Johnson committed 2026-05-07 20:03:33 -07:00
1 parent f9843fcb0c
commit 626948f2f5
38 files changed
+382 -21

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
epgUrl: [],
streamFormat: StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
@@ -69,6 +69,21 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' | translate }}</h4>
<p>
{{
'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="openStreamOnDoubleClick"></mat-checkbox>
</div>
</div>
@if (isDesktop()) {
<div class="setting-item">
<div class="setting-item__meta">
@@ -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,
@@ -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,
+2
View File
@@ -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",
+4 -1
View File
@@ -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<{
+6
View File
@@ -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'
)
@@ -44,6 +44,7 @@
[showProgramInfoButton]="true"
[showDetailsContextMenu]="hasChannelContextMenu(ch)"
(clicked)="onChannelClick(ch)"
(activated)="onChannelActivate(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
(contextMenuRequested)="onChannelContextMenu(ch, $event)"
/>
@@ -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();
@@ -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<MatMenuTrigger>('contextMenuTrigger');
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<GlobalFavoritesListMode>('favorites');
@@ -68,6 +73,7 @@ export class GlobalFavoritesListComponent {
);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly playbackRequested = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
@@ -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);
}
@@ -18,6 +18,7 @@
[draggable]="mode() === 'favorites'"
[sortMode]="sortMode()"
(channelSelected)="onChannelSelected($event)"
(playbackRequested)="onChannelPlaybackRequested($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(removeRequested)="onRemoveRequested($event)"
@@ -269,6 +269,17 @@ export class UnifiedLiveTabComponent {
}
}
async onChannelPlaybackRequested(
channel: UnifiedFavoriteChannel
): Promise<void> {
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<void> {
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
);
}
@@ -39,7 +39,9 @@ describe('StalkerCollectionChannelsListComponent', () => {
await TestBed.configureTestingModule({
imports: [StalkerCollectionChannelsListComponent],
providers: [{ provide: StalkerStore, useValue: stalkerStore }],
providers: [
{ provide: StalkerStore, useValue: stalkerStore },
],
})
.overrideComponent(StalkerCollectionChannelsListComponent, {
remove: {
@@ -73,6 +73,7 @@
false
"
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)"
/>
}
@@ -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<void>();
readonly activated = output<void>();
readonly favoriteToggled = output<void>();
}
@@ -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();
@@ -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<ResolvedPortalPlayback>;
}
| 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<ResolvedPortalPlayback> {
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);
}
}
@@ -74,6 +74,7 @@
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
/>
</div>
}
@@ -98,6 +99,7 @@
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
/>
</div>
} @else {
@@ -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<unknown>();
readonly playbackRequested = output<unknown>();
}
@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,
@@ -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[] {
@@ -39,6 +39,7 @@
[showProgramInfoButton]="false"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
(activated)="playbackRequested.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
/>
</cdk-virtual-scroll-viewport>
@@ -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: {
@@ -70,6 +70,7 @@ interface XtreamCategoryLike {
})
export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
readonly playClicked = output<XtreamChannelListItem>();
readonly playbackRequested = output<XtreamChannelListItem>();
readonly sortMode = input<PortalChannelSortMode>('server');
readonly channelsOverride = input<XtreamChannelListItem[] | null>(null);
readonly searchTermInput = input('');
@@ -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(),
@@ -28,6 +28,7 @@ export interface Settings {
player: VideoPlayer;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
language: Language;
showCaptions: boolean;
showDashboard: boolean;
@@ -89,6 +89,7 @@
?.progressPercentage ?? 0
"
(clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
@@ -75,6 +75,7 @@ export class AllChannelsViewComponent {
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
readonly openOnDoubleClick = input(false);
/** Item size for virtual scroll */
readonly itemSize = input.required<number>();
@@ -87,6 +88,7 @@ export class AllChannelsViewComponent {
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
readonly channelPlaybackRequested = output<Channel>();
/** 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 });
}
@@ -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)"
/>
}
@@ -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: {
@@ -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<string, EpgProgram | null>());
@@ -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
*/
@@ -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"
>
@@ -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);
@@ -54,6 +54,7 @@ export class ChannelListItemComponent {
readonly auxActionTooltip = input('');
readonly clicked = output<void>();
readonly activated = output<void>();
readonly favoriteToggled = output<MouseEvent>();
readonly auxActionClicked = output<MouseEvent>();
readonly contextMenuRequested = output<MouseEvent>();
@@ -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;
@@ -31,6 +31,7 @@
"
[showDetailsContextMenu]="true"
(clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
@@ -56,12 +56,14 @@ export class FavoritesViewComponent {
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
readonly openOnDoubleClick = input(false);
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
readonly channelPlaybackRequested = output<Channel>();
/** 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 });
}
@@ -279,6 +279,7 @@
: false
"
(clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)"
(contextMenuRequested)="
onChannelContextMenu(channel, $event)
"
@@ -92,6 +92,7 @@ export class GroupsViewComponent {
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
readonly openOnDoubleClick = input(false);
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
@@ -105,6 +106,7 @@ export class GroupsViewComponent {
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
readonly channelPlaybackRequested = output<Channel>();
/** 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 });
}
@@ -25,6 +25,7 @@
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(clicked)="onChannelClick(item.channel)"
(activated)="onChannelActivate(item.channel)"
(contextMenuRequested)="
onChannelContextMenu(item.channel, $event)
"
@@ -47,9 +47,11 @@ export class RecentViewComponent {
readonly channelIconMap = input.required<Map<string, string>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
readonly openOnDoubleClick = input(false);
readonly activeChannelUrl = input<string | undefined>();
readonly channelSelected = output<Channel>();
readonly channelPlaybackRequested = output<Channel>();
readonly removeRecent = output<string>();
readonly contextMenuChannel = signal<Channel | null>(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);