Files
KiraTV---App-Linux/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts
T
2026-05-03 23:43:09 +02:00

503 lines
16 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
PORTAL_PLAYER,
PortalChannelSortMode,
getPortalChannelSortModeLabel,
getAdjacentChannelItem,
getChannelItemByNumber,
isWorkspaceLayoutRoute,
persistPortalChannelSortMode,
queryParamSignal,
restorePortalChannelSortMode,
} from '@iptvnator/portal/shared/util';
import {
FavoriteItem,
FavoritesService,
XtreamUrlService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute } from '@angular/router';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
interface XtreamLiveChannelItem {
readonly name?: string;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
readonly xtream_id: number;
}
@Component({
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss'],
imports: [
EpgListComponent,
EpgViewComponent,
MatIcon,
MatIconButton,
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
PortalChannelsListComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly favoritesService = inject(FavoritesService);
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamUrlService = inject(XtreamUrlService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly epgItems = this.xtreamStore.epgItems;
readonly currentEpgItem = this.xtreamStore.currentEpgItem;
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
readonly isElectron = Boolean(window.electron);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
readonly selectedLiveItem = computed<XtreamLiveChannelItem | null>(() => {
if (this.xtreamStore.selectedContentType() !== 'live') {
return null;
}
const selectedItem = this.xtreamStore.selectedItem();
if (!selectedItem || typeof selectedItem !== 'object') {
return null;
}
const item = selectedItem as XtreamLiveChannelItem;
return item.xtream_id ? item : null;
});
readonly controlledEpgPrograms = computed<EpgProgram[]>(() =>
this.epgItems().map((program) => this.toControlledEpgProgram(program))
);
private readonly currentTimeMs = signal(Date.now());
readonly controlledArchiveDays = computed(() =>
Math.max(
0,
Number(this.selectedLiveItem()?.tv_archive_duration ?? 0) || 0
)
);
readonly archivePlaybackAvailable = computed(() => {
const selectedItem = this.selectedLiveItem();
return (
Number(selectedItem?.tv_archive ?? 0) === 1 &&
this.controlledArchiveDays() > 0
);
});
readonly hasPastPrograms = computed(() => {
const now = this.currentTimeMs();
return this.controlledEpgPrograms().some((program) => {
const stop = this.getProgramTimestampMilliseconds(
program.stop,
program.stopTimestamp
);
return stop !== null && stop < now;
});
});
readonly showArchiveUnavailableNotice = computed(
() =>
this.controlledEpgPrograms().length > 0 &&
this.hasPastPrograms() &&
!this.archivePlaybackAvailable()
);
readonly liveChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.liveChannelSortMode())
);
readonly selectedCategoryInfo = computed(() => {
const categoryId = this.selectedCategoryId();
if (!categoryId) return null;
const categories = this.categories();
const category = categories?.find(
(c) => (c.category_id ?? c.id) === categoryId
);
const count = this.categoryItemCounts()?.get(categoryId) ?? 0;
return {
name: category?.category_name ?? category?.name ?? 'Channels',
count,
};
});
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly activeStreamUrl = signal('');
favorites = new Map<number, boolean>();
constructor() {
effect((onCleanup) => {
const intervalId = window.setInterval(() => {
this.currentTimeMs.set(Date.now());
}, 30_000);
onCleanup(() => clearInterval(intervalId));
});
const requestedItemId = Number(
(window.history.state as Record<string, unknown> | null)?.[
'openXtreamLiveItemId'
]
);
if (Number.isFinite(requestedItemId) && requestedItemId > 0) {
this.pendingAutoOpenLiveItemId.set(requestedItemId);
}
effect(() => {
const pendingId = this.pendingAutoOpenLiveItemId();
if (!pendingId) {
return;
}
const channels = this.getVisibleChannels();
if (!Array.isArray(channels) || channels.length === 0) {
return;
}
const item = channels.find(
(channel) => Number(channel?.xtream_id) === pendingId
);
if (!item) {
this.pendingAutoOpenLiveItemId.set(null);
return;
}
this.playLive(item);
this.pendingAutoOpenLiveItemId.set(null);
this.clearAutoOpenHistoryState();
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
return;
}
const selectedContentType = this.xtreamStore.selectedContentType();
const selectedItem = this.xtreamStore.selectedItem();
const channels = this.getVisibleChannels();
const currentProgram = this.currentEpgItem();
if (selectedContentType !== 'live' || !selectedItem?.xtream_id) {
window.electron.updateRemoteControlStatus({
portal: 'xtream',
isLiveView: false,
supportsVolume: false,
});
return;
}
const currentIndex = channels.findIndex(
(item) =>
Number(item.xtream_id) === Number(selectedItem.xtream_id)
);
window.electron.updateRemoteControlStatus({
portal: 'xtream',
isLiveView: true,
channelName: selectedItem.title ?? selectedItem.name,
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: currentProgram?.title,
epgStart: currentProgram?.start,
epgEnd: currentProgram?.stop ?? currentProgram?.end,
supportsVolume: false,
});
});
}
ngOnInit() {
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteCommand = unsubscribe;
}
}
this.liveChannelSortMode.set(
restorePortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY)
);
const playlist = this.xtreamStore.currentPlaylist();
if (playlist) {
this.favoritesService
.getFavorites(playlist.id)
.subscribe((favorites) => {
// Map using content.id instead of xtream_id
favorites.forEach((fav: FavoriteItem) => {
this.favorites.set(fav.xtream_id, true);
});
});
}
}
playLive(item: XtreamLiveChannelItem) {
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.activeStreamUrl.set(streamUrl);
if (this.usesEmbeddedPlayer()) {
return;
}
this.xtreamStore.openPlayer(
streamUrl,
item.title ?? item.name ?? '',
item.poster_url ?? item.stream_icon ?? null
);
}
async onProgramActivated(event: EpgProgramActivationEvent): Promise<void> {
const selectedItem = this.selectedLiveItem();
if (!selectedItem?.xtream_id) {
return;
}
if (event.type === 'live') {
this.playLive(selectedItem);
return;
}
await this.playCatchup(event.program, selectedItem);
}
setLiveChannelSortMode(mode: PortalChannelSortMode): void {
this.liveChannelSortMode.set(mode);
persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode);
}
ngOnDestroy(): void {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.xtreamStore.selectedItem();
if (!activeItem?.xtream_id) {
return;
}
const channels = this.getVisibleChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.xtream_id,
direction,
(item) => item.xtream_id
);
if (!nextItem) {
return;
}
this.playLive(nextItem);
}
private handleRemoteControlCommand(command: {
type:
| 'channel-select-number'
| 'volume-up'
| 'volume-down'
| 'volume-toggle-mute';
number?: number;
}): void {
if (command.type !== 'channel-select-number' || !command.number) {
return;
}
const channels = this.getVisibleChannels();
const channel = getChannelItemByNumber(channels, command.number);
if (!channel) {
return;
}
this.playLive(channel);
}
private getVisibleChannels(): XtreamLiveChannelItem[] {
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
}
private async playCatchup(
program: EpgProgram,
item: XtreamLiveChannelItem
): Promise<void> {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const startTimestamp = this.getProgramTimestampSeconds(
program.start,
program.startTimestamp
);
const stopTimestamp = this.getProgramTimestampSeconds(
program.stop,
program.stopTimestamp
);
if (!startTimestamp || !stopTimestamp) {
return;
}
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
item.xtream_id,
startTimestamp,
stopTimestamp
);
this.activeStreamUrl.set(catchupUrl);
if (this.usesEmbeddedPlayer()) {
return;
}
this.xtreamStore.openPlayer(
catchupUrl,
this.getCatchupPlaybackTitle(item, program),
item.poster_url ?? item.stream_icon ?? null
);
}
private toControlledEpgProgram(program: EpgItem): EpgProgram {
return {
start: program.start,
stop: program.stop ?? program.end,
channel: program.channel_id ?? program.id,
title: program.title,
desc: program.description ?? null,
category: null,
startTimestamp: this.getProgramTimestampSeconds(
program.start,
program.start_timestamp
),
stopTimestamp: this.getProgramTimestampSeconds(
program.stop ?? program.end,
program.stop_timestamp
),
};
}
private getProgramTimestampSeconds(
dateValue: string,
unixTimestampValue?: number | string | null
): number | null {
const unixTimestamp = Number.parseInt(
String(unixTimestampValue ?? ''),
10
);
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
return unixTimestamp;
}
const parsedDate = Date.parse(dateValue);
return Number.isFinite(parsedDate)
? Math.floor(parsedDate / 1000)
: null;
}
private getProgramTimestampMilliseconds(
dateValue: string,
unixTimestampValue?: number | string | null
): number | null {
const unixTimestamp = this.getProgramTimestampSeconds(
dateValue,
unixTimestampValue
);
return unixTimestamp !== null ? unixTimestamp * 1000 : null;
}
private getCatchupPlaybackTitle(
item: XtreamLiveChannelItem,
program: EpgProgram
): string {
const channelTitle = item.title ?? item.name ?? '';
if (!program.title) {
return channelTitle;
}
return channelTitle
? `${channelTitle} - ${program.title}`
: program.title;
}
private clearAutoOpenHistoryState(): void {
try {
const state = (window.history.state ?? {}) as Record<
string,
unknown
>;
if (!('openXtreamLiveItemId' in state)) {
return;
}
const nextState = { ...state };
delete nextState['openXtreamLiveItemId'];
delete nextState['openXtreamLiveTitle'];
delete nextState['openXtreamLivePoster'];
window.history.replaceState(nextState, document.title);
} catch {
// no-op
}
}
}