mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
Add double-click stream open setting
This commit is contained in:
1 parent
f9843fcb0c
commit
626948f2f5
38 files changed
+382
-21
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
+1
@@ -44,6 +44,7 @@
|
||||
[showProgramInfoButton]="true"
|
||||
[showDetailsContextMenu]="hasChannelContextMenu(ch)"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(activated)="onChannelActivate(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
(contextMenuRequested)="onChannelContextMenu(ch, $event)"
|
||||
/>
|
||||
|
||||
+8
@@ -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();
|
||||
|
||||
|
||||
+12
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
[draggable]="mode() === 'favorites'"
|
||||
[sortMode]="sortMode()"
|
||||
(channelSelected)="onChannelSelected($event)"
|
||||
(playbackRequested)="onChannelPlaybackRequested($event)"
|
||||
(channelsReordered)="onReorder($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
|
||||
+29
-4
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -39,7 +39,9 @@ describe('StalkerCollectionChannelsListComponent', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerCollectionChannelsListComponent],
|
||||
providers: [{ provide: StalkerStore, useValue: stalkerStore }],
|
||||
providers: [
|
||||
{ provide: StalkerStore, useValue: stalkerStore },
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerCollectionChannelsListComponent, {
|
||||
remove: {
|
||||
|
||||
+1
@@ -73,6 +73,7 @@
|
||||
false
|
||||
"
|
||||
(clicked)="playChannel(item)"
|
||||
(activated)="playChannel(item, true)"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
/>
|
||||
}
|
||||
|
||||
+78
-1
@@ -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();
|
||||
|
||||
+56
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {
|
||||
|
||||
+34
@@ -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,
|
||||
|
||||
+10
-5
@@ -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[] {
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+7
@@ -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: {
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+1
@@ -89,6 +89,7 @@
|
||||
?.progressPercentage ?? 0
|
||||
"
|
||||
(clicked)="onChannelClick(channel)"
|
||||
(activated)="onChannelActivate(channel)"
|
||||
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
|
||||
[selected]="activeChannelUrl() === channel?.url"
|
||||
[showFavoriteButton]="true"
|
||||
|
||||
+8
@@ -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 });
|
||||
}
|
||||
|
||||
+10
@@ -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)"
|
||||
/>
|
||||
}
|
||||
|
||||
+7
-1
@@ -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: {
|
||||
|
||||
+14
-1
@@ -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
|
||||
*/
|
||||
|
||||
+2
-1
@@ -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"
|
||||
>
|
||||
|
||||
+23
@@ -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);
|
||||
|
||||
+13
@@ -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;
|
||||
|
||||
+1
@@ -31,6 +31,7 @@
|
||||
"
|
||||
[showDetailsContextMenu]="true"
|
||||
(clicked)="onChannelClick(channel)"
|
||||
(activated)="onChannelActivate(channel)"
|
||||
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
|
||||
[selected]="activeChannelUrl() === channel?.url"
|
||||
[showFavoriteButton]="true"
|
||||
|
||||
+8
@@ -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 });
|
||||
}
|
||||
|
||||
+1
@@ -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 });
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user