feat(groups-view): add search functionality to filter grouped channels

feat(recent-view): implement search term filtering for recent items
fix(dashboard-data): update return type for navigation state methods
chore(jest): add jest configuration for workspace shell feature
refactor(global-favorites): migrate global favorites list to shared UI
refactor(global-favorites): remove global favorites page component and its styles
refactor(global-favorites): update unified favorite channel interface to use shared util
test(workspace-shell): setup test environment for workspace shell feature
Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
4gray committed 2026-03-25 19:16:03 +01:00
1 parent aa5e4097f1
commit 8874e4a17e
80 files changed
+2396 -4316

No files matched your search

@@ -52,6 +52,24 @@ nav.flex .portal-rail-link {
justify-content: center;
color: var(--rail-link-color);
app-region: no-drag;
animation: rail-link-enter 0.2s ease-out both;
}
@for $i from 1 through 8 {
nav.flex .portal-rail-link:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 30}ms;
}
}
@keyframes rail-link-enter {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
nav.flex .portal-rail-link.is-active,
@@ -0,0 +1,162 @@
// ─── Shared skeleton / shimmer primitives ────────────────────────────────────
// Used by grid-list (poster cards) and unified-collection (channel list rows).
// ─── Grid skeleton (poster cards) ────────────────────────────────────────────
.grid-skeleton-card {
width: 100%;
}
.grid-skeleton-thumb {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 10px;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 94%,
transparent
);
}
.grid-skeleton-badge {
position: absolute;
top: 8px;
right: 8px;
width: 34px;
height: 14px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 44%
);
}
.grid-skeleton-title {
margin-top: 10px;
padding: 0 3px;
display: grid;
gap: 6px;
}
.grid-skeleton-line {
height: 9px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&--primary {
width: 92%;
}
&--secondary {
width: 68%;
}
}
// ─── List skeleton (channel rows) ────────────────────────────────────────────
.list-skeleton-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid
color-mix(
in srgb,
var(--mat-sys-outline-variant) 40%,
transparent
);
}
.list-skeleton-handle {
width: 20px;
height: 20px;
border-radius: 4px;
flex-shrink: 0;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 60%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 40%
);
}
.list-skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
);
}
.list-skeleton-text {
flex: 1;
min-width: 0;
display: grid;
gap: 6px;
}
.list-skeleton-star {
width: 20px;
height: 20px;
border-radius: 4px;
flex-shrink: 0;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 60%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 40%
);
}
// ─── Shimmer animation (shared) ──────────────────────────────────────────────
.grid-skeleton-thumb,
.grid-skeleton-badge,
.grid-skeleton-line,
.list-skeleton-handle,
.list-skeleton-avatar,
.list-skeleton-star,
.list-skeleton-text .grid-skeleton-line {
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 10%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 20%,
transparent
)
46%,
transparent 76%
);
transform: translateX(-120%);
animation: skeleton-shimmer 1.2s ease-in-out infinite;
}
}
@keyframes skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
+3 -1
View File
@@ -1,4 +1,3 @@
export * from './lib/favorites-context.service';
export * from './lib/logger';
export * from './lib/portal-catalog-detail';
export * from './lib/portal-catalog-facade';
@@ -7,8 +6,11 @@ export * from './lib/portal-player';
export * from './lib/portal-playback-positions';
export * from './lib/portal-navigation-actions';
export * from './lib/portal-collection-context';
export * from './lib/portal-collection-context.service';
export * from './lib/portal-collection-items';
export * from './lib/remote-channel-navigation';
export * from './lib/workspace-header-context.service';
export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/portal-route.utils';
export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
@@ -0,0 +1,36 @@
import { StalkerPortalItem } from 'shared-interfaces';
import { CollectionContentType } from './unified-collection-item.interface';
export interface XtreamFavoriteRow {
readonly id: number;
readonly category_id: number;
readonly playlist_id: string;
readonly playlist_name: string;
readonly xtream_id: number;
readonly title: string;
readonly type: string;
readonly poster_url?: string | null;
readonly rating?: string | null;
readonly added_at?: string | null;
readonly position?: number | null;
}
export function isStalkerItem(
v: string | StalkerPortalItem
): v is StalkerPortalItem {
return typeof v !== 'string';
}
export function stalkerContentType(
fav: StalkerPortalItem
): CollectionContentType {
if (fav.movie_id) return 'movie';
if (fav.series_id) return 'series';
return 'live';
}
export function xtreamContentType(type: string): CollectionContentType {
if (type === 'movie' || type === 'movies') return 'movie';
if (type === 'series') return 'series';
return 'live';
}
@@ -0,0 +1,6 @@
export * from './unified-collection-item.interface';
export * from './unified-favorite-channel.interface';
export * from './scope-toggle.service';
export * from './unified-favorites-data.service';
export * from './unified-recent-data.service';
export * from './stream-resolver.service';
@@ -0,0 +1,38 @@
import { Injectable, signal } from '@angular/core';
export type CollectionScope = 'playlist' | 'all';
const STORAGE_PREFIX = 'collection-scope-';
@Injectable({ providedIn: 'root' })
export class ScopeToggleService {
private readonly scopes = new Map<string, ReturnType<typeof signal<CollectionScope>>>();
getScope(viewKey: string): ReturnType<typeof signal<CollectionScope>> {
if (!this.scopes.has(viewKey)) {
const persisted = this.readFromStorage(viewKey);
this.scopes.set(viewKey, signal<CollectionScope>(persisted));
}
return this.scopes.get(viewKey)!;
}
setScope(viewKey: string, value: CollectionScope): void {
const s = this.getScope(viewKey);
s.set(value);
try {
localStorage.setItem(STORAGE_PREFIX + viewKey, value);
} catch {
// storage full or unavailable
}
}
private readFromStorage(viewKey: string): CollectionScope {
try {
const stored = localStorage.getItem(STORAGE_PREFIX + viewKey);
if (stored === 'all' || stored === 'playlist') return stored;
} catch {
// ignore
}
return 'playlist';
}
}
@@ -0,0 +1,256 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
EpgItem,
EpgProgram,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
interface StalkerCreateLinkResponse {
readonly js?: { readonly cmd?: string };
}
@Injectable({ providedIn: 'root' })
export class StreamResolverService {
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamApi = inject(XtreamApiService);
private readonly xtreamUrl = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
async resolvePlayback(
item: UnifiedCollectionItem
): Promise<ResolvedPortalPlayback> {
switch (item.sourceType) {
case 'm3u':
return this.resolveM3u(item);
case 'xtream':
return this.resolveXtream(item);
case 'stalker':
return this.resolveStalker(item);
}
}
async loadEpgItems(item: UnifiedCollectionItem): Promise<EpgItem[]> {
try {
if (item.sourceType === 'm3u' && item.tvgId && window.electron?.getChannelPrograms) {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(item.tvgId);
return programs.map((p, i) => ({
id: String(i),
epg_id: p.channel,
title: p.title,
lang: 'en',
start: p.start,
end: p.stop,
stop: p.stop,
description: p.desc ?? '',
channel_id: p.channel,
start_timestamp: String(Math.floor(new Date(p.start).getTime() / 1000)),
stop_timestamp: String(Math.floor(new Date(p.stop).getTime() / 1000)),
}));
}
if (item.sourceType === 'xtream' && item.xtreamId) {
const creds = await this.getXtreamCredentials(item.playlistId);
if (!creds) return [];
return await this.xtreamApi.getShortEpg(creds, item.xtreamId, 10);
}
} catch {
// ignore
}
return [];
}
async loadEpgForItems(
items: UnifiedCollectionItem[]
): Promise<Map<string, EpgProgram | null>> {
const epgMap = new Map<string, EpgProgram | null>();
const now = Date.now();
const xtreamByPlaylist = new Map<string, UnifiedCollectionItem[]>();
for (const ch of items) {
if (ch.sourceType === 'xtream' && ch.contentType === 'live') {
const list = xtreamByPlaylist.get(ch.playlistId) ?? [];
list.push(ch);
xtreamByPlaylist.set(ch.playlistId, list);
}
}
const tasks: Promise<void>[] = [];
for (const ch of items) {
if (ch.sourceType === 'm3u' && ch.tvgId && ch.contentType === 'live') {
tasks.push(this.loadM3uEpg(ch, epgMap, now));
}
}
for (const [playlistId, chList] of xtreamByPlaylist.entries()) {
tasks.push(this.loadXtreamEpgBatch(playlistId, chList, epgMap, now));
}
await Promise.all(tasks.map((t) => t.catch(() => null)));
return epgMap;
}
// ─── Private ──────────────────────────────────────
private resolveM3u(item: UnifiedCollectionItem): ResolvedPortalPlayback {
return {
streamUrl: item.streamUrl ?? '',
title: item.name,
thumbnail: item.logo ?? null,
};
}
private async resolveXtream(
item: UnifiedCollectionItem
): Promise<ResolvedPortalPlayback> {
const creds = await this.getXtreamCredentials(item.playlistId);
if (!creds || item.xtreamId == null) {
throw new Error('Missing Xtream credentials');
}
const streamUrl = this.xtreamUrl.constructLiveUrl(creds, item.xtreamId);
return {
streamUrl,
title: item.name,
thumbnail: item.logo ?? null,
};
}
private async resolveStalker(
item: UnifiedCollectionItem
): Promise<ResolvedPortalPlayback> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
)) as Playlist | undefined;
const portalUrl = item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
const params = {
action: StalkerPortalActions.CreateLink,
cmd: item.stalkerCmd ?? '',
type: 'itv' as const,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
};
let response: StalkerCreateLinkResponse | undefined;
if (playlist?.isFullStalkerPortal && playlist) {
response = await this.stalkerSession.makeAuthenticatedRequest(playlist, params);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
}
const rawCmd = response?.js?.cmd ?? '';
const streamUrl = this.normalizeStalkerCmd(rawCmd);
return {
streamUrl,
title: item.name,
thumbnail: item.logo ?? null,
};
}
private async getXtreamCredentials(playlistId: string) {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
if (!playlist?.serverUrl || !playlist.username || !playlist.password) return null;
return {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
}
private async loadM3uEpg(
ch: UnifiedCollectionItem,
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
if (!window.electron?.getChannelPrograms || !ch.tvgId) return;
try {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
const current =
programs.find(
(p) =>
new Date(p.start).getTime() <= now &&
now < new Date(p.stop).getTime()
) ?? null;
epgMap.set(ch.tvgId.trim(), current);
} catch {
epgMap.set(ch.tvgId!.trim(), null);
}
}
private async loadXtreamEpgBatch(
playlistId: string,
channels: UnifiedCollectionItem[],
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
const creds = await this.getXtreamCredentials(playlistId);
if (!creds) return;
await Promise.all(
channels.map(async (ch) => {
if (!ch.xtreamId) return;
try {
const items = await this.xtreamApi.getShortEpg(creds, ch.xtreamId, 2);
const nowSec = Math.floor(now / 1000);
const current =
items.find(
(p) =>
Number(p.start_timestamp) <= nowSec &&
nowSec < Number(p.stop_timestamp)
) ?? null;
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
if (epgKey) {
epgMap.set(
epgKey,
current
? {
start: new Date(Number(current.start_timestamp) * 1000).toISOString(),
stop: new Date(Number(current.stop_timestamp) * 1000).toISOString(),
channel: String(ch.xtreamId),
title: current.title,
desc: current.description ?? null,
category: null,
}
: null
);
}
} catch {
// skip
}
})
);
}
private normalizeStalkerCmd(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (candidate.startsWith('http://') || candidate.startsWith('https://')) {
return candidate;
}
}
return trimmed;
}
}
@@ -0,0 +1,81 @@
/**
* Unified interface for displaying favorites or recently-viewed items
* from any source (M3U, Xtream, Stalker) in one list.
*
* Extends the previous live-only UnifiedFavoriteChannel to cover
* all content types: live TV, movies, and series.
*/
export type CollectionSourceType = 'm3u' | 'xtream' | 'stalker';
export type CollectionContentType = 'live' | 'movie' | 'series';
export interface UnifiedCollectionItem {
/** Unique identifier: `{sourceType}::{playlistId}::{sourceItemId}` */
uid: string;
/** Display name */
name: string;
/** Content type — determines which tab / layout to use */
contentType: CollectionContentType;
/** Source type — determines how to resolve playback */
sourceType: CollectionSourceType;
/** Parent playlist ID */
playlistId: string;
/** Human-readable playlist name */
playlistName: string;
/** Logo / thumbnail URL (live channels) */
logo?: string | null;
/** Poster image URL (movies / series) */
posterUrl?: string | null;
/** Direct stream URL (M3U channels only) */
streamUrl?: string;
/** TVG id for EPG lookup (M3U / Xtream live) */
tvgId?: string;
/** Xtream numeric stream ID */
xtreamId?: number;
/** Xtream DB content id — used for reorder / remove IPC */
contentId?: number;
/** Stalker cmd for stream resolution */
stalkerCmd?: string;
stalkerPortalUrl?: string;
stalkerMacAddress?: string;
/** Category ID for VOD/Series navigation */
categoryId?: string | number;
/** Content rating (VOD) */
rating?: string;
/** ISO timestamp when added to favorites */
addedAt?: string;
/** Display order (favorites) */
position?: number;
/** ISO timestamp when viewed (recently-viewed) */
viewedAt?: string;
/** Original stalker item for detail navigation */
stalkerItem?: unknown;
}
/**
* Build a stable UID string for a unified collection item.
*/
export function buildCollectionUid(
sourceType: CollectionSourceType,
playlistId: string,
sourceItemId: string | number
): string {
return `${sourceType}::${playlistId}::${sourceItemId}`;
}
@@ -0,0 +1,73 @@
/**
* Unified interface for displaying live TV favorites from any source
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
*/
export type FavoriteSourceType = 'm3u' | 'xtream' | 'stalker';
export interface UnifiedFavoriteChannel {
/**
* Unique identifier within the global-favorites list.
* Format: `{sourceType}::{playlistId}::{sourceItemId}`
*/
uid: string;
/** Display name of the channel */
name: string;
/** Logo / thumbnail URL */
logo: string | null;
/** Source type – determines how to resolve the stream URL */
sourceType: FavoriteSourceType;
/** ID of the parent playlist */
playlistId: string;
/** Human-readable playlist name */
playlistName: string;
/** Direct stream URL (M3U channels only) */
streamUrl?: string;
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
/**
* TVG id for M3U EPG lookup.
* For Xtream channels, also used for short-EPG lookup.
*/
tvgId?: string;
/**
* The stalker cmd value needed to resolve the stream URL.
* Stored for Stalker favorites that are live channels.
*/
stalkerCmd?: string;
/** ISO timestamp when added to favorites */
addedAt: string;
/**
* Display order within the unified list.
* Lower values appear first. 0 = unset (sort by addedAt desc).
*/
position: number;
/** Xtream DB content id — used for REORDER IPC call */
contentId?: number;
/** Stalker portal credentials needed to resolve the live stream */
stalkerPortalUrl?: string;
stalkerMacAddress?: string;
}
/**
* Build a stable UID string for a unified favorite channel.
*/
export function buildFavoriteUid(
sourceType: FavoriteSourceType,
playlistId: string,
sourceItemId: string | number
): string {
return `${sourceType}::${playlistId}::${sourceItemId}`;
}
@@ -0,0 +1,276 @@
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Channel, Playlist, PlaylistMeta } from 'shared-interfaces';
import {
buildCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import {
isStalkerItem,
stalkerContentType,
xtreamContentType,
XtreamFavoriteRow,
} from './collection-helpers';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
};
@Injectable({ providedIn: 'root' })
export class UnifiedFavoritesDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly translate = inject(TranslateService);
async getFavorites(
scope: CollectionScope,
playlistId?: string,
portalType?: string
): Promise<UnifiedCollectionItem[]> {
if (scope === 'playlist' && playlistId) {
return this.getPlaylistFavorites(playlistId, portalType);
}
return this.getAllFavorites();
}
async removeFavorite(item: UnifiedCollectionItem): Promise<void> {
switch (item.sourceType) {
case 'm3u': {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
);
const filtered = ((playlist.favorites as string[]) ?? [])
.filter((f) => f !== item.streamUrl);
await firstValueFrom(
this.playlistsService.setFavorites(item.playlistId, filtered)
);
break;
}
case 'xtream':
if (window.electron && item.contentId != null) {
await window.electron.dbRemoveFavorite(item.contentId, item.playlistId);
}
break;
case 'stalker': {
const sourceItemId = item.uid.split('::')[2];
await firstValueFrom(
this.playlistsService.removeFromPortalFavorites(item.playlistId, sourceItemId)
);
break;
}
}
}
async reorder(items: UnifiedCollectionItem[]): Promise<void> {
const uidOrder = items.map((i) => i.uid);
const xtreamUpdates = items
.filter((i) => i.sourceType === 'xtream' && i.contentId != null)
.map((i, idx) => ({ content_id: i.contentId!, position: idx }));
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(uidOrder),
]);
}
private async getAllFavorites(): Promise<UnifiedCollectionItem[]> {
const [m3u, xtream, stalker, order] = await Promise.all([
this.getM3uFavorites(),
this.getXtreamAllFavorites(),
this.getStalkerAllFavorites(),
this.getSavedOrder(),
]);
return this.applyOrder([...m3u, ...xtream, ...stalker], order);
}
private async getPlaylistFavorites(
playlistId: string,
portalType?: string
): Promise<UnifiedCollectionItem[]> {
if (portalType === 'xtream') return this.getXtreamPlaylistFavorites(playlistId);
if (portalType === 'stalker') return this.getStalkerPlaylistFavorites(playlistId);
return this.getM3uPlaylistFavorites(playlistId);
}
private async getM3uFavorites(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((p) => p._id && !p.serverUrl && !p.macAddress)) {
results.push(...(await this.extractM3uFavorites(meta)));
}
return results;
}
private async getM3uPlaylistFavorites(id: string): Promise<UnifiedCollectionItem[]> {
const meta = await this.getPlaylistMeta(id);
return meta ? this.extractM3uFavorites(meta) : [];
}
private async extractM3uFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
if (!meta.favorites?.length) return [];
const favIds = new Set((meta.favorites as string[]).map(String));
let playlist: PlaylistWithChannels | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as PlaylistWithChannels | undefined;
} catch { return []; }
return (playlist?.playlist?.items ?? [])
.filter((ch) => favIds.has(ch.id) || favIds.has(ch.url))
.map((ch) => ({
uid: buildCollectionUid('m3u', meta._id, ch.url || ch.id),
name: ch.name,
contentType: 'live' as const,
sourceType: 'm3u' as const,
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'M3U',
logo: ch.tvg?.logo ?? null,
streamUrl: ch.url,
tvgId: ch.tvg?.id || ch.tvg?.name || ch.name,
addedAt: new Date(0).toISOString(),
position: 0,
}));
}
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
if (!window.electron?.dbGetAllGlobalFavorites) return [];
try {
const rows = (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
return rows.map((r) => this.mapXtreamRow(r));
} catch { return []; }
}
private async getXtreamPlaylistFavorites(playlistId: string): Promise<UnifiedCollectionItem[]> {
if (!window.electron) return [];
try {
const rows = await this.dbService.getFavorites(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({
...this.mapXtreamRow(r),
playlistId,
playlistName: meta?.title || 'Xtream',
}));
} catch { return []; }
}
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
const ct = xtreamContentType(row.type);
return {
uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id),
name: row.title,
contentType: ct,
sourceType: 'xtream',
playlistId: row.playlist_id,
playlistName: row.playlist_name ?? 'Xtream',
logo: ct === 'live' ? (row.poster_url ?? null) : null,
posterUrl: ct !== 'live' ? (row.poster_url ?? null) : null,
xtreamId: row.xtream_id,
categoryId: row.category_id,
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
rating: row.rating ?? undefined,
addedAt: row.added_at ?? new Date(0).toISOString(),
position: row.position ?? 0,
contentId: row.id,
};
}
private async getStalkerAllFavorites(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((p) => p._id && p.macAddress)) {
results.push(...(await this.extractStalkerFavorites(meta)));
}
return results;
}
private async getStalkerPlaylistFavorites(id: string): Promise<UnifiedCollectionItem[]> {
const meta = await this.getPlaylistMeta(id);
return meta ? this.extractStalkerFavorites(meta) : [];
}
private async extractStalkerFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
if (!meta.favorites?.length) return [];
let playlist: Playlist | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as Playlist | undefined;
} catch { return []; }
const favs = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(isStalkerItem) : [];
return favs.map((fav) => {
const ct = stalkerContentType(fav);
return {
uid: buildCollectionUid('stalker', meta._id, fav.stream_id ?? fav.id),
name: fav.o_name || fav.name || this.translate.instant('WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL'),
contentType: ct,
sourceType: 'stalker' as const,
playlistId: meta._id,
playlistName: meta.title || meta.filename || this.translate.instant('WORKSPACE.DASHBOARD.STALKER_PORTAL'),
logo: ct === 'live' ? (fav.logo ?? fav.cover ?? null) : null,
posterUrl: ct !== 'live' ? (fav.logo ?? fav.cover ?? null) : null,
stalkerCmd: fav.cmd,
stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url,
stalkerMacAddress: playlist?.macAddress,
categoryId: fav.category_id,
stalkerItem: fav,
addedAt: fav.added_at ? new Date(fav.added_at).toISOString() : new Date(0).toISOString(),
position: 0,
};
});
}
private async getAllMeta(): Promise<PlaylistMeta[]> {
return firstValueFrom(
this.store.select(selectAllPlaylistsMeta).pipe(map((m) => m as PlaylistMeta[]))
);
}
private async getPlaylistMeta(id: string): Promise<PlaylistMeta | undefined> {
return (await this.getAllMeta()).find((p) => p._id === id);
}
private async getSavedOrder(): Promise<string[]> {
if (!window.electron?.dbGetAppState) return [];
try {
const raw = await window.electron.dbGetAppState(GLOBAL_FAVORITES_ORDER_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch { return []; }
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!window.electron?.dbSetAppState) return;
try {
await window.electron.dbSetAppState(GLOBAL_FAVORITES_ORDER_KEY, JSON.stringify(uidOrder));
} catch { /* ignore */ }
}
private applyOrder(items: UnifiedCollectionItem[], savedOrder: string[]): UnifiedCollectionItem[] {
if (!savedOrder.length) {
return items.slice().sort((a, b) => {
const pa = a.position ?? 0, pb = b.position ?? 0;
if (pa !== pb) return pa - pb;
return new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime();
});
}
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
const ordered: UnifiedCollectionItem[] = [];
const unordered: UnifiedCollectionItem[] = [];
for (const item of items) {
const pos = orderMap.get(item.uid);
pos != null ? ordered.push({ ...item, position: pos }) : unordered.push(item);
}
ordered.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
unordered.sort((a, b) => new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime());
return [...ordered, ...unordered];
}
}
@@ -0,0 +1,207 @@
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
buildPlaylistRecentItems,
Playlist,
PlaylistMeta,
} from 'shared-interfaces';
import {
buildCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import { xtreamContentType } from './collection-helpers';
@Injectable({ providedIn: 'root' })
export class UnifiedRecentDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
async getRecentItems(
scope: CollectionScope,
playlistId?: string,
portalType?: string
): Promise<UnifiedCollectionItem[]> {
if (scope === 'playlist' && playlistId) {
return this.getPlaylistRecentItems(playlistId, portalType);
}
return this.getAllRecentItems();
}
async removeRecentItem(item: UnifiedCollectionItem): Promise<void> {
if (item.sourceType === 'xtream' && item.contentId != null) {
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
);
} else if (item.sourceType === 'stalker' || item.sourceType === 'm3u') {
const sourceItemId = item.uid.split('::')[2];
await firstValueFrom(
this.playlistsService.removeFromPortalRecentlyViewed(
item.playlistId,
sourceItemId
)
);
}
}
async clearRecentItems(
scope: CollectionScope,
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
await this.dbService.clearPlaylistRecentItems(playlistId);
await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
);
} else {
await this.dbService.clearGlobalRecentlyViewed();
const playlists = (await firstValueFrom(
this.playlistsService.getAllPlaylists()
)) as Playlist[];
await Promise.all(
playlists
.filter((p) => Boolean(p.macAddress) || !p.serverUrl)
.map((p) =>
firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(p._id)
)
)
);
}
}
// ─── Private ──────────────────────────────────────
private async getAllRecentItems(): Promise<UnifiedCollectionItem[]> {
const [xtream, playlistBased] = await Promise.all([
this.getXtreamGlobalRecent(),
this.getPlaylistBasedRecent(),
]);
return [...xtream, ...playlistBased].sort(
(a, b) =>
new Date(b.viewedAt ?? 0).getTime() -
new Date(a.viewedAt ?? 0).getTime()
);
}
private async getPlaylistRecentItems(
playlistId: string,
portalType?: string
): Promise<UnifiedCollectionItem[]> {
if (portalType === 'xtream') {
return this.getXtreamPlaylistRecent(playlistId);
}
if (portalType === 'stalker') {
return this.getStalkerPlaylistRecent(playlistId);
}
return this.getM3uPlaylistRecent(playlistId);
}
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.dbService.getGlobalRecentlyViewed();
return (rows || []).map((row) => ({
uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id),
name: row.title,
contentType: xtreamContentType(row.type),
sourceType: 'xtream' as const,
playlistId: row.playlist_id,
playlistName: row.playlist_name ?? 'Xtream',
logo: row.type === 'live' ? (row.poster_url ?? null) : null,
posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null,
xtreamId: row.xtream_id,
categoryId: row.category_id,
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
contentId: row.id,
viewedAt: row.viewed_at,
}));
} catch {
return [];
}
}
private async getXtreamPlaylistRecent(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.dbService.getRecentItems(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
return (rows || []).map((row) => ({
uid: buildCollectionUid('xtream', playlistId, row.xtream_id),
name: row.title,
contentType: xtreamContentType(row.type),
sourceType: 'xtream' as const,
playlistId,
playlistName: meta?.title || 'Xtream',
logo: row.type === 'live' ? (row.poster_url ?? null) : null,
posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null,
xtreamId: row.xtream_id,
categoryId: row.category_id,
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
contentId: row.id,
viewedAt: row.viewed_at ?? '',
}));
} catch {
return [];
}
}
private async getPlaylistBasedRecent(): Promise<UnifiedCollectionItem[]> {
const playlists = (await firstValueFrom(
this.playlistsService.getAllPlaylists()
)) as Playlist[];
const items = buildPlaylistRecentItems(playlists, {
stalker: 'Stalker Portal',
m3u: 'M3U',
});
return items.map((item) => ({
uid: buildCollectionUid(
(item.source as 'm3u' | 'stalker') ?? 'm3u',
item.playlist_id,
item.id
),
name: item.title,
contentType: xtreamContentType(item.type),
sourceType: (item.source as 'm3u' | 'stalker') ?? 'm3u',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? '',
logo: item.type === 'live' ? (item.poster_url ?? null) : null,
posterUrl: item.type !== 'live' ? (item.poster_url ?? null) : null,
stalkerItem: item.stalker_item,
viewedAt: item.viewed_at,
}));
}
private async getStalkerPlaylistRecent(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
const all = await this.getPlaylistBasedRecent();
return all.filter(
(i) => i.sourceType === 'stalker' && i.playlistId === playlistId
);
}
private async getM3uPlaylistRecent(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
const all = await this.getPlaylistBasedRecent();
return all.filter(
(i) => i.sourceType === 'm3u' && i.playlistId === playlistId
);
}
private async getPlaylistMeta(
id: string
): Promise<PlaylistMeta | undefined> {
const all = await firstValueFrom(
this.store.select(selectAllPlaylistsMeta)
.pipe(map((m) => m as PlaylistMeta[]))
);
return all.find((p) => p._id === id);
}
}
@@ -195,7 +195,7 @@ export function buildPortalRailLinks(
section: 'recent',
},
{
icon: 'star',
icon: 'favorite',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
exact: true,
@@ -7,9 +7,11 @@ import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import {
extractPortalPlaylistId,
extractPortalSection,
isWorkspaceLayoutRoute,
queryParamSignal,
resolveCurrentPortalPlaylistId,
resolveCurrentPortalSection,
} from './portal-route.utils';
@@ -26,6 +28,36 @@ describe('portal-route.utils', () => {
expect(isWorkspaceLayoutRoute(route)).toBe(true);
});
it('detects workspace layout from ancestor route data', () => {
const parentRoute = {
snapshot: {
data: {
layout: 'workspace',
},
},
} as ActivatedRoute;
const childRoute = {
snapshot: {
data: {},
},
pathFromRoot: [
{
snapshot: {
data: {},
},
},
parentRoute,
{
snapshot: {
data: {},
},
},
],
} as unknown as ActivatedRoute;
expect(isWorkspaceLayoutRoute(childRoute)).toBe(true);
});
it('extracts Xtream sections from workspace and local URLs', () => {
expect(extractPortalSection('/xtreams/123/search', 'xtreams')).toBe(
'search'
@@ -44,21 +76,47 @@ describe('portal-route.utils', () => {
).toBe('favorites');
});
it('prefers the child snapshot section and falls back to URL parsing', () => {
const routeWithChild = {
it('extracts playlist ids from workspace and local URLs', () => {
expect(
extractPortalPlaylistId('/workspace/xtreams/123/search', 'xtreams')
).toBe('123');
expect(extractPortalPlaylistId('/stalker/321/series', 'stalker')).toBe(
'321'
);
});
it('resolves portal sections from the active descendant chain and falls back to URL parsing', () => {
const routeWithDescendants = {
snapshot: {
url: [],
},
firstChild: {
snapshot: {
url: [{ path: 'vod' }],
url: [{ path: 'workspace' }],
},
firstChild: {
snapshot: {
url: [{ path: 'stalker' }],
},
firstChild: {
snapshot: {
url: [{ path: 'vod' }],
},
firstChild: null,
},
},
},
} as unknown as ActivatedRoute;
const routeWithoutChild = {
snapshot: {
url: [],
},
firstChild: null,
} as unknown as ActivatedRoute;
expect(
resolveCurrentPortalSection(
routeWithChild,
routeWithDescendants,
'/workspace/stalker/123/search',
'stalker'
)
@@ -72,6 +130,56 @@ describe('portal-route.utils', () => {
).toBe('search');
});
it('resolves playlist ids from ancestors and falls back to URL parsing', () => {
const routeWithParent = {
pathFromRoot: [
{
snapshot: {
params: {},
paramMap: convertToParamMap({}),
},
},
{
snapshot: {
params: { id: 'playlist-1' },
paramMap: convertToParamMap({ id: 'playlist-1' }),
},
},
{
snapshot: {
params: {},
paramMap: convertToParamMap({}),
},
},
],
} as unknown as ActivatedRoute;
const routeWithoutParent = {
pathFromRoot: [
{
snapshot: {
params: {},
paramMap: convertToParamMap({}),
},
},
],
} as unknown as ActivatedRoute;
expect(
resolveCurrentPortalPlaylistId(
routeWithParent,
'/workspace/xtreams/ignored/vod',
'xtreams'
)
).toBe('playlist-1');
expect(
resolveCurrentPortalPlaylistId(
routeWithoutParent,
'/workspace/stalker/playlist-2/vod',
'stalker'
)
).toBe('playlist-2');
});
it('creates normalized query-param signals', () => {
TestBed.configureTestingModule({});
@@ -9,8 +9,30 @@ import {
type QueryParamNormalizer<T> = (value: string | null) => T;
const PORTAL_SECTIONS = new Set<PortalRailSection>([
'all',
'downloads',
'favorites',
'groups',
'itv',
'library',
'live',
'recent',
'recently-added',
'search',
'series',
'vod',
]);
export function isWorkspaceLayoutRoute(route: ActivatedRoute): boolean {
return route.snapshot.data['layout'] === 'workspace';
const routeChain =
Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0
? route.pathFromRoot
: [route];
return routeChain.some(
(currentRoute) => currentRoute.snapshot.data['layout'] === 'workspace'
);
}
export function queryParamSignal<T = string>(
@@ -42,16 +64,54 @@ export function extractPortalSection(
return (match?.[1] as PortalRailSection | undefined) ?? null;
}
export function extractPortalPlaylistId(
url: string,
provider: PortalProvider
): string | null {
const match = url.match(new RegExp(`^/(?:workspace/)?${provider}/([^/?]+)`));
return match?.[1] ?? null;
}
export function resolveCurrentPortalPlaylistId(
route: ActivatedRoute,
routerUrl: string,
provider: PortalProvider
): string | null {
const routeChain =
Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0
? route.pathFromRoot
: [route];
for (let index = routeChain.length - 1; index >= 0; index -= 1) {
const currentRoute = routeChain[index];
const playlistId =
currentRoute.snapshot.paramMap.get('id') ??
currentRoute.snapshot.params['id'];
if (playlistId) {
return playlistId;
}
}
return extractPortalPlaylistId(routerUrl, provider);
}
export function resolveCurrentPortalSection(
route: ActivatedRoute,
routerUrl: string,
provider: PortalProvider
): PortalRailSection | null {
const sectionFromSnapshot =
route.firstChild?.snapshot?.url?.[0]?.path ?? null;
let currentRoute: ActivatedRoute | null = route;
while (currentRoute) {
const currentSegment = currentRoute.snapshot.url?.[0]?.path;
if (
currentSegment &&
PORTAL_SECTIONS.has(currentSegment as PortalRailSection)
) {
return currentSegment as PortalRailSection;
}
return (
(sectionFromSnapshot as PortalRailSection | null) ??
extractPortalSection(routerUrl, provider)
);
currentRoute = currentRoute.firstChild;
}
return extractPortalSection(routerUrl, provider);
}
@@ -0,0 +1,97 @@
import {
buildXtreamItemLink,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
getUnifiedCollectionNavigation,
} from './workspace-portal-navigation';
describe('workspace-portal-navigation', () => {
it('builds canonical Xtream live links without category segments', () => {
expect(
buildXtreamItemLink({
playlistId: 'xtream-1',
type: 'live',
categoryId: 42,
itemId: 99,
})
).toEqual(['/workspace', 'xtreams', 'xtream-1', 'live']);
});
it('builds Xtream detail links with category and item ids', () => {
expect(
buildXtreamItemLink({
playlistId: 'xtream-1',
type: 'movie',
categoryId: 42,
itemId: 99,
})
).toEqual([
'/workspace',
'xtreams',
'xtream-1',
'vod',
'42',
'99',
]);
});
it('keeps M3U favorites on the playlist favorites route', () => {
expect(
getGlobalFavoriteNavigation({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
category_id: 'live',
xtream_id: 'channel-1',
source: 'm3u',
poster_url: 'https://example.com/logo.png',
added_at: '2026-03-01T00:00:00.000Z',
})
).toEqual({
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
});
});
it('builds recent-item state for Stalker recent entries', () => {
expect(
getRecentItemNavigation({
id: 'stalker-1',
title: 'Movie One',
type: 'movie',
playlist_id: 'stalker-1',
category_id: 'vod',
xtream_id: 'stalker-1',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
viewed_at: '2026-03-01T00:00:00.000Z',
})
).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'recent'],
state: {
openRecentItem: expect.objectContaining({
category_id: 'vod',
id: 'stalker-1',
title: 'Movie One',
}),
},
});
});
it('builds unified Xtream grid navigation from category-aware items', () => {
expect(
getUnifiedCollectionNavigation({
uid: 'xtream::xtream-1::99',
name: 'Movie One',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Playlist',
xtreamId: 99,
categoryId: 42,
})
).toEqual({
link: ['/workspace', 'xtreams', 'xtream-1', 'vod', '42', '99'],
});
});
});
@@ -4,18 +4,16 @@ import {
PortalRecentItem,
StalkerPortalItem,
} from 'shared-interfaces';
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
/** Internal target produced by navigation helpers. */
export interface DashboardNavigationTarget {
export interface WorkspaceNavigationTarget {
link: string[];
state?: Record<string, unknown>;
}
// ────── Recent-item navigation ──────
export function getRecentItemNavigation(
item: PortalRecentItem
): DashboardNavigationTarget {
): WorkspaceNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
@@ -44,11 +42,9 @@ export function getRecentItemNavigation(
});
}
// ────── Favorite-item navigation ──────
export function getGlobalFavoriteNavigation(
item: PortalFavoriteItem
): DashboardNavigationTarget {
): WorkspaceNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
@@ -77,18 +73,17 @@ export function getGlobalFavoriteNavigation(
});
}
// ────── Xtream link builders ──────
export function buildXtreamNavigationTarget(params: {
playlistId: string;
type: PortalActivityType;
categoryId: string | number;
itemId: string | number;
categoryId?: string | number | null;
itemId?: string | number | null;
title?: string;
imageUrl?: string;
}): DashboardNavigationTarget {
imageUrl?: string | null;
}): WorkspaceNavigationTarget {
const link = buildXtreamItemLink(params);
const routeType = toXtreamRouteType(params.type);
if (routeType !== 'live') {
return { link };
}
@@ -111,17 +106,15 @@ export function buildXtreamNavigationTarget(params: {
export function buildXtreamItemLink(params: {
playlistId: string;
type: PortalActivityType;
categoryId: string | number;
itemId: string | number;
categoryId?: string | number | null;
itemId?: string | number | null;
}): string[] {
const routeType = toXtreamRouteType(params.type);
const categoryId = toPathSegment(params.categoryId);
const itemId = toPathSegment(params.itemId);
if (routeType === 'live') {
return categoryId
? ['/workspace', 'xtreams', params.playlistId, 'live', categoryId]
: ['/workspace', 'xtreams', params.playlistId, 'live'];
return ['/workspace', 'xtreams', params.playlistId, 'live'];
}
if (categoryId && itemId) {
@@ -148,7 +141,32 @@ export function buildXtreamItemLink(params: {
return ['/workspace', 'xtreams', params.playlistId, routeType];
}
// ────── Stalker state helpers ──────
export function getUnifiedCollectionNavigation(
item: UnifiedCollectionItem
): WorkspaceNavigationTarget | null {
if (item.sourceType === 'xtream') {
return buildXtreamNavigationTarget({
playlistId: item.playlistId,
type: item.contentType,
categoryId: item.categoryId,
itemId: item.xtreamId,
title: item.name,
imageUrl: item.posterUrl ?? item.logo ?? null,
});
}
if (item.sourceType === 'stalker') {
const section = item.contentType === 'movie' ? 'vod' : item.contentType;
const categoryId = toPathSegment(item.categoryId);
const link = categoryId
? ['/workspace', 'stalker', item.playlistId, section, categoryId]
: ['/workspace', 'stalker', item.playlistId, section];
return { link };
}
return null;
}
export function buildStalkerStateItem(
rawItem: StalkerPortalItem | undefined,
@@ -181,8 +199,6 @@ export function buildStalkerStateItem(
};
}
// ────── Small helpers ──────
export function toXtreamRouteType(
type: PortalActivityType
): 'live' | 'vod' | 'series' {
@@ -196,6 +212,6 @@ export function toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
return 'vod';
}
function toPathSegment(value: string | number): string {
function toPathSegment(value: unknown): string {
return String(value ?? '').trim();
}
@@ -2,15 +2,15 @@ import { Injectable, signal } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
/**
* Lightweight service that bridges the favorites/recently-viewed routed
* Lightweight service that bridges collection-backed routed
* components (inside the router-outlet) with the WorkspaceShellComponent
* (the outer shell that renders the context panel aside).
*
* The routed component pushes its category list here via an effect;
* The active routed component pushes its category list here via an effect;
* the workspace shell reads it to populate the context panel.
*/
@Injectable({ providedIn: 'root' })
export class FavoritesContextService {
export class PortalCollectionContextService {
/** Category list managed by the active routed component (Favorites / RecentlyViewed). */
readonly categories = signal<XtreamCategory[]>([]);
@@ -5,18 +5,18 @@ import {
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FavoritesContextService } from './favorites-context.service';
import { createPortalCollectionContext } from './portal-collection-context';
import { PortalCollectionContextService } from './portal-collection-context.service';
describe('portal-collection-context', () => {
it('syncs categories into the favorites context', () => {
it('syncs categories into the shared collection context', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
providers: [PortalCollectionContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const ctx = TestBed.inject(PortalCollectionContextService);
const categories = signal([
{
id: 1,
@@ -45,12 +45,12 @@ describe('portal-collection-context', () => {
it('does not mutate the shared context when disabled', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
providers: [PortalCollectionContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const ctx = TestBed.inject(PortalCollectionContextService);
const categories = signal([
{
id: 1,
@@ -94,12 +94,12 @@ describe('portal-collection-context', () => {
it('resets managed context state on destroy', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
providers: [PortalCollectionContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const ctx = TestBed.inject(PortalCollectionContextService);
const categories = signal([
{
id: 1,
@@ -1,6 +1,6 @@
import { DestroyRef, Signal, effect, inject } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesContextService } from './favorites-context.service';
import { PortalCollectionContextService } from './portal-collection-context.service';
export interface PortalCollectionContextBridge {
readonly selectedCategoryId: Signal<string>;
@@ -8,7 +8,7 @@ export interface PortalCollectionContextBridge {
}
interface CreatePortalCollectionContextOptions {
ctx: FavoritesContextService;
ctx: PortalCollectionContextService;
categories: () => XtreamCategory[];
enabled?: () => boolean;
}
+1 -4
View File
@@ -1,13 +1,10 @@
export * from './lib/stalker-main-container.component';
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites/stalker-favorites.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
export * from './lib/stalker-series-view/stalker-series-view.component';
export * from './lib/stalker-shell/stalker-shell.component';
export * from './lib/recently-viewed/recently-viewed.component';
export * from './lib/stalker-workspace-route-session.service';
@@ -1,49 +0,0 @@
@if (isLiveCategory()) {
<app-portal-collection-live-shell
[title]="'PORTALS.SIDEBAR.RECENT' | translate"
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[count]="liveItemsToShow().length"
[streamUrl]="liveStreamUrl()"
[isEmbeddedPlayer]="isEmbeddedPlayer()"
[hasSelection]="!!selectedLiveItem()"
[epgItems]="epgItems()"
[isLoadingEpg]="isLoadingEpg()"
[hasMoreEpg]="hasMoreEpg()"
(loadMoreEpgClicked)="loadMoreEpg()"
>
<app-stalker-collection-channels-list
liveChannels
[items]="liveItemsToShow()"
[selectedItemId]="selectedLiveItem()?.id ?? null"
[favoriteIds]="liveFavoriteIds()"
(playClicked)="selectLiveItem($event)"
(favoriteToggled)="toggleLiveFavorite($event)"
/>
</app-portal-collection-live-shell>
} @else {
<app-portal-collection-shell
[categories]="categories() ?? []"
[items]="itemsToShow() ?? []"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[mode]="mode"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromRecentlyViewed($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
(openItem)="openItem($event)"
>
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
</app-portal-collection-shell>
}
@@ -1,592 +0,0 @@
import {
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
FavoriteLayoutItem,
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import {
createLogger,
FavoritesContextService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem, StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalCollectionResource,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
normalizeStalkerVodDetailsItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
const STALKER_RECENT_LAYOUT: Omit<
PortalCollectionShellLayout,
'showHeaderAction'
> = {
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
removeTooltip: 'Remove from recent',
emptyIcon: 'history',
headerActionIcon: 'delete_sweep',
headerActionTooltip: 'Clear recently viewed',
};
@Component({
selector: 'app-recently-viewed',
templateUrl: './recently-viewed.component.html',
imports: [
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
StalkerCollectionChannelsListComponent,
StalkerInlineDetailComponent,
TranslatePipe,
],
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
position: relative;
}
.back-button {
margin: 10px 0 0 16px;
}
`,
],
})
export class RecentlyViewedComponent {
private static isCategoryType(
value: string
): value is 'vod' | 'series' | 'itv' {
return value === 'vod' || value === 'series' || value === 'itv';
}
private readonly route = inject(ActivatedRoute);
private readonly playlistService = inject(PlaylistsService);
private readonly collectionRefresh = createRefreshTrigger();
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
private previousSelectedCategoryId: string | null = null;
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly selectedLiveItem = signal<StalkerVodSource | null>(null);
readonly liveStreamUrl = signal('');
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
readonly hasMoreEpg = signal(false);
readonly isResolvingPlayback = signal(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly playlistSubtitle = 'Stalker Portal';
readonly playlistTitle = computed(
() => this.currentPlaylist()?.title || 'Portal'
);
readonly allFavorites = createPortalCollectionResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.collectionRefresh.refreshVersion(),
(playlistService, portalId) =>
playlistService.getPortalRecentlyViewed(portalId)
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: {
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
},
counts: {
all: this.allFavorites.value()?.length ?? 0,
movie: this.movies()?.length ?? 0,
live: this.live()?.length ?? 0,
series: this.series()?.length ?? 0,
},
includeLive: true,
liveCategoryId: 'itv',
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly itemsToShow = computed(() => {
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allFavorites.value(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
liveCategoryId: 'itv',
textOf: (item: StalkerVodSource) =>
`${item.name ?? ''} ${item.o_name ?? ''}`,
});
});
/** Synced with workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly layout: PortalCollectionShellLayout = {
...STALKER_RECENT_LAYOUT,
showHeaderAction: !this.isWorkspaceLayout,
};
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly refreshToken = queryParamSignal(
this.route,
'refresh',
(value) => value ?? ''
);
readonly liveItemsToShow = computed<
(StalkerVodSource & { category_id: string })[]
>(() =>
((this.allFavorites.value() ?? []) as (StalkerVodSource & {
category_id: string;
})[]).filter((item) => {
if (item.category_id !== 'itv') {
return false;
}
const term = this.searchTerm();
if (!term) {
return true;
}
return `${item?.name ?? ''} ${item?.o_name ?? ''}`
.toLowerCase()
.includes(term);
})
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv');
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
readonly series = computed(() =>
this.allFavorites
.value()
?.filter((item) => item.category_id === 'series')
);
readonly movies = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'vod')
);
readonly live = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
get mode(): PortalCollectionMode {
if (this.isLiveCategory()) {
return 'live';
}
return this.showDetails() ? 'detail' : 'grid';
}
constructor() {
effect(() => {
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
effect(() => {
const selectedCategoryId = this.selectedCategoryId();
const hasInlineDetail = this.inlineDetail().categoryId !== null;
const previousCategoryId = this.previousSelectedCategoryId;
this.previousSelectedCategoryId = selectedCategoryId;
if (
hasInlineDetail &&
previousCategoryId !== null &&
previousCategoryId !== selectedCategoryId
) {
this.clearDetailsView();
}
});
effect(() => {
const refresh = this.refreshToken();
if (!refresh) {
return;
}
this.collectionRefresh.refresh();
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) return;
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const item = state?.openRecentItem;
if (
!item ||
!RecentlyViewedComponent.isCategoryType(item.category_id)
) {
return;
}
this.openItem(item);
// Clear consumed history state to avoid reopening item on reload/back.
try {
window.history.replaceState({}, document.title);
} catch {
// no-op
}
});
effect(() => {
const selectedItem = this.selectedLiveItem();
if (!selectedItem) {
return;
}
const stillExists = this.liveItemsToShow().some(
(item) => String(item.id ?? '') === String(selectedItem.id ?? '')
);
if (!stillExists) {
this.clearLiveSelection();
}
});
effect(() => {
if (this.isLiveCategory()) {
return;
}
this.clearLiveSelection();
});
}
removeFromRecentlyViewed(item: Pick<StalkerVodSource, 'id'>) {
const id = String(item.id ?? '').trim();
if (!id) return;
this.stalkerStore.removeFromRecentlyViewed(id, () => {
this.collectionRefresh.refresh();
});
}
clearAllRecentlyViewed() {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) return;
this.playlistService.clearPortalRecentlyViewed(playlistId).subscribe({
next: () => this.collectionRefresh.refresh(),
error: (error) =>
this.logger.error(
'Failed to clear recently viewed items',
error
),
});
}
setCategoryId(categoryId: string) {
this.collectionContext.setCategoryId(categoryId);
}
openItem(item: FavoriteLayoutItem) {
const categoryId = String(item.category_id ?? '');
if (!RecentlyViewedComponent.isCategoryType(categoryId)) {
return;
}
this.stalkerStore.setSelectedContentType(categoryId);
switch (categoryId) {
case 'itv': {
this.setCategoryId('itv');
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
void this.selectLiveItem(
item as StalkerVodSource & { category_id: string }
);
break;
}
case 'vod': {
// Normalize the item to ensure is_series flag is properly set
const normalizedItem = this.normalizeRecentItem(
item as StalkerVodSource & { category_id: string }
);
const detailViewState = createStalkerDetailViewState(
normalizedItem,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = {
...(detailViewState.itemDetails ?? normalizedItem),
category_id: 'vod',
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
this.syncSelectedVodFavorite();
break;
}
case 'series':
this.itemDetails = this.normalizeRecentItem(
item as StalkerVodSource & { category_id: string }
);
this.stalkerStore.setSelectedItem(this.itemDetails);
break;
default:
break;
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.clearDetailsView();
}
async selectLiveItem(item: StalkerVodSource) {
this.selectedLiveItem.set(item);
this.liveStreamUrl.set('');
this.stalkerStore.setSelectedContentType('itv');
this.stalkerStore.setSelectedItem(item);
this.isResolvingPlayback.set(true);
try {
const playback = await this.stalkerStore.resolveItvPlayback(item);
await this.loadEpgForChannel(item.id);
this.liveStreamUrl.set(playback.streamUrl);
if (!this.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Failed to resolve ITV playback', error);
this.showPlaybackError(error);
} finally {
this.isResolvingPlayback.set(false);
}
}
async loadMoreEpg() {
if (!this.epgChannelId || this.isLoadingEpg()) {
return;
}
this.epgPageSize += 10;
this.isLoadingEpg.set(true);
try {
const items = await this.stalkerStore.fetchChannelEpg(
this.epgChannelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.hasMoreEpg.set(false);
} finally {
this.isLoadingEpg.set(false);
}
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
thumbnail?: string
) {
this.logger.debug('Create link to play VOD/ITV', {
cmd,
title,
thumbnail,
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, onDone);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
toggleLiveFavorite(item: StalkerVodSource): void {
const itemId = String(item.id ?? '');
if (this.liveFavoriteIds().has(itemId)) {
this.removeFromFavorites(itemId);
return;
}
this.addToFavorites({
...item,
category_id: 'itv',
title: item.o_name || item.name,
cover: item.logo || item.cover,
added_at: new Date().toISOString(),
});
}
readonly liveFavoriteIds = computed(() => {
const ids = new Map<string | number, boolean>();
for (const item of (this.portalFavorites.value() ?? []) as StalkerVodSource[]) {
if (item.category_id === 'itv') {
ids.set(String(item.id ?? ''), true);
}
}
return ids;
});
private clearLiveSelection() {
this.selectedLiveItem.set(null);
this.liveStreamUrl.set('');
this.isResolvingPlayback.set(false);
this.epgItems.set([]);
this.isLoadingEpg.set(false);
this.hasMoreEpg.set(false);
this.epgChannelId = null;
this.stalkerStore.setSelectedItem(null);
}
/**
* Normalize recent item to ensure is_series flag is properly set
* and all required fields are present for the series view
*/
private normalizeRecentItem(
item: StalkerVodSource
): StalkerSelectedVodItem {
return normalizeStalkerVodDetailsItem(item);
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.portalFavorites.value() ?? []
)
);
}
private clearDetailsView(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem
);
}
showDetails() {
return this.inlineDetail().categoryId !== null;
}
private async loadEpgForChannel(channelId: number | string | undefined) {
if (channelId === undefined || channelId === null) {
this.epgItems.set([]);
this.hasMoreEpg.set(false);
return;
}
this.epgChannelId = channelId;
this.epgPageSize = 10;
this.isLoadingEpg.set(true);
this.epgItems.set([]);
this.hasMoreEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.epgItems.set([]);
} finally {
this.isLoadingEpg.set(false);
}
}
private showPlaybackError(error: unknown): void {
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, undefined, { duration: 3000 });
}
}
@@ -0,0 +1,22 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
@Component({
selector: 'app-stalker-collection-route',
imports: [UnifiedCollectionPageComponent],
template: `
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCollectionRouteComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('stalker');
}
@@ -1,48 +0,0 @@
@if (isLiveCategory()) {
<app-portal-collection-live-shell
[title]="'CHANNELS.FAVORITES' | translate"
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[count]="liveItemsToShow().length"
[streamUrl]="liveStreamUrl()"
[isEmbeddedPlayer]="isEmbeddedPlayer()"
[hasSelection]="!!selectedLiveItem()"
[epgItems]="epgItems()"
[isLoadingEpg]="isLoadingEpg()"
[hasMoreEpg]="hasMoreEpg()"
(loadMoreEpgClicked)="loadMoreEpg()"
>
<app-stalker-collection-channels-list
liveChannels
[items]="liveItemsToShow()"
[selectedItemId]="selectedLiveItem()?.id ?? null"
[favoriteIds]="liveFavoriteIds()"
(playClicked)="selectLiveItem($event)"
(favoriteToggled)="toggleLiveFavorite($event)"
/>
</app-portal-collection-live-shell>
} @else {
<app-portal-collection-shell
[categories]="categories() ?? []"
[items]="favoritesToShow() ?? []"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[mode]="mode"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromFavorites($event)"
(openItem)="openItem($event)"
>
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onSeriesBack()"
/>
</app-portal-collection-shell>
}
@@ -1,543 +0,0 @@
import {
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
FavoriteLayoutItem,
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import {
createLogger,
FavoritesContextService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerFavoriteItem } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
NormalizedStalkerFavoriteItem,
normalizeStalkerEntityId,
normalizeStalkerFavoriteItem,
StalkerVodSource,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
@Component({
selector: 'app-stalker-favorites',
templateUrl: './stalker-favorites.component.html',
imports: [
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
StalkerCollectionChannelsListComponent,
StalkerInlineDetailComponent,
TranslatePipe,
],
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
position: relative;
}
.back-button {
margin: 10px 0 0 16px;
}
`,
],
})
export class StalkerFavoritesComponent {
private static isCategoryType(
value: unknown
): value is 'vod' | 'series' | 'itv' {
const normalized = String(value ?? '').toLowerCase();
return (
normalized === 'vod' ||
normalized === 'series' ||
normalized === 'itv'
);
}
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly playlistService = inject(PlaylistsService);
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerFavorites');
private readonly favoritesCtx = inject(FavoritesContextService);
private previousSelectedCategoryId: string | null = null;
itemDetails: NormalizedStalkerFavoriteItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly selectedLiveItem = signal<StalkerVodSource | null>(null);
readonly liveStreamUrl = signal('');
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
readonly hasMoreEpg = signal(false);
readonly isResolvingPlayback = signal(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly layout = STALKER_FAVORITES_LAYOUT;
readonly playlistSubtitle = 'Stalker Portal';
readonly playlistTitle = computed(
() => this.currentPlaylist()?.title || 'Portal'
);
readonly allFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: {
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
},
counts: {
all: this.allFavorites.value()?.length ?? 0,
movie: this.movies()?.length ?? 0,
live: this.live()?.length ?? 0,
series: this.series()?.length ?? 0,
},
includeLive: true,
liveCategoryId: 'itv',
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly liveItemsToShow = computed<StalkerFavoriteItem[]>(() =>
((this.allFavorites.value() ?? []) as StalkerFavoriteItem[]).filter((item) => {
if (item.category_id !== 'itv') {
return false;
}
const term = this.searchTerm();
if (!term) {
return true;
}
return `${item?.name ?? ''} ${item?.o_name ?? ''}`
.toLowerCase()
.includes(term);
})
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv');
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
readonly favoritesToShow = computed(() => {
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allFavorites.value(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
liveCategoryId: 'itv',
textOf: (item: StalkerFavoriteItem) =>
`${item.name ?? ''} ${item.o_name ?? ''}`,
});
});
/** Synced with workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly series = computed(() =>
this.allFavorites
.value()
?.filter((item) => item.category_id === 'series')
);
readonly movies = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'vod')
);
readonly live = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
get mode(): PortalCollectionMode {
if (this.isLiveCategory()) {
return 'live';
}
return this.showDetails() ? 'detail' : 'grid';
}
constructor() {
effect(() => {
this.allFavorites.value();
this.syncSelectedVodFavorite();
});
effect(() => {
const selectedCategoryId = this.selectedCategoryId();
const hasInlineDetail = this.inlineDetail().categoryId !== null;
const previousCategoryId = this.previousSelectedCategoryId;
this.previousSelectedCategoryId = selectedCategoryId;
if (
hasInlineDetail &&
previousCategoryId !== null &&
previousCategoryId !== selectedCategoryId
) {
this.resetDetailsView(false);
}
});
effect(() => {
const selectedItem = this.selectedLiveItem();
if (!selectedItem) {
return;
}
const stillExists = this.liveItemsToShow().some(
(item) => normalizeStalkerEntityId(item.id) === normalizeStalkerEntityId(selectedItem.id)
);
if (!stillExists) {
this.clearLiveSelection();
}
});
effect(() => {
if (this.isLiveCategory()) {
return;
}
this.clearLiveSelection();
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) {
return;
}
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const item = state?.openFavoriteItem;
if (
!item ||
!StalkerFavoritesComponent.isCategoryType(item.category_id)
) {
return;
}
this.openItem(item);
try {
window.history.replaceState({}, document.title);
} catch {
// no-op
}
});
}
removeFromFavorites(
item: Pick<StalkerFavoriteItem, 'id'>,
onDone?: () => void
) {
this.stalkerStore.removeFromFavorites(
normalizeStalkerEntityId(item.id),
() => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
}
);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
});
}
setCategoryId(categoryId: string) {
this.collectionContext.setCategoryId(categoryId);
}
openItem(item: FavoriteLayoutItem) {
this.logger.debug('Open item', item);
const normalizedCategory =
item.category_id === 'movie' ? 'vod' : item.category_id;
const itemToOpen = {
...item,
category_id: normalizedCategory,
} as StalkerFavoriteItem;
const normalizedItem = normalizeStalkerFavoriteItem(itemToOpen);
switch (itemToOpen.category_id) {
case 'itv':
this.setCategoryId('itv');
this.resetDetailsView(false);
void this.selectLiveItem(itemToOpen);
break;
case 'vod': {
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('vod');
const detailViewState = createStalkerDetailViewState(
normalizedItem.details,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = {
...normalizedItem,
details: detailViewState.itemDetails,
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
break;
}
case 'series':
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('series');
break;
default:
break;
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites({ id: favoriteId }, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.resetDetailsView(true);
}
onSeriesBack(): void {
this.resetDetailsView(true);
}
private resetDetailsView(refreshList: boolean): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = null;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
if (refreshList) {
this.favoritesRefresh.refresh();
}
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.allFavorites.value() ?? []
)
);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem
);
}
showDetails() {
return this.inlineDetail().categoryId !== null;
}
async selectLiveItem(item: StalkerVodSource) {
this.selectedLiveItem.set(item);
this.liveStreamUrl.set('');
this.stalkerStore.setSelectedContentType('itv');
this.stalkerStore.setSelectedItem(item);
this.isResolvingPlayback.set(true);
try {
const playback = await this.stalkerStore.resolveItvPlayback(item);
await this.loadEpgForChannel(item.id);
this.liveStreamUrl.set(playback.streamUrl);
if (!this.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Failed to resolve ITV playback', error);
this.showPlaybackError(error);
} finally {
this.isResolvingPlayback.set(false);
}
}
async loadMoreEpg() {
if (!this.epgChannelId || this.isLoadingEpg()) {
return;
}
this.epgPageSize += 10;
this.isLoadingEpg.set(true);
try {
const items = await this.stalkerStore.fetchChannelEpg(
this.epgChannelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.hasMoreEpg.set(false);
} finally {
this.isLoadingEpg.set(false);
}
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
thumbnail?: string
) {
this.logger.debug('Create link to play VOD/ITV', {
cmd,
title,
thumbnail,
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
toggleLiveFavorite(item: StalkerVodSource): void {
const itemId = normalizeStalkerEntityId(item.id);
if (this.liveFavoriteIds().has(itemId)) {
this.removeFromFavorites({ id: item.id });
return;
}
this.addToFavorites({
...item,
category_id: 'itv',
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
}
readonly liveFavoriteIds = computed(() => {
const ids = new Map<string | number, boolean>();
for (const item of this.liveItemsToShow()) {
ids.set(normalizeStalkerEntityId(item.id), true);
}
return ids;
});
private clearLiveSelection() {
this.selectedLiveItem.set(null);
this.liveStreamUrl.set('');
this.isResolvingPlayback.set(false);
this.epgItems.set([]);
this.isLoadingEpg.set(false);
this.hasMoreEpg.set(false);
this.epgChannelId = null;
this.stalkerStore.setSelectedItem(null);
}
private async loadEpgForChannel(channelId: number | string | undefined) {
if (channelId === undefined || channelId === null) {
this.epgItems.set([]);
this.hasMoreEpg.set(false);
return;
}
this.epgChannelId = channelId;
this.epgPageSize = 10;
this.isLoadingEpg.set(true);
this.epgItems.set([]);
this.hasMoreEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.epgItems.set([]);
} finally {
this.isLoadingEpg.set(false);
}
}
private showPlaybackError(error: unknown): void {
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, undefined, { duration: 3000 });
}
}
@@ -2,6 +2,7 @@ import { Route } from '@angular/router';
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
import { provideStalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
type ComponentLoader = NonNullable<Route['loadComponent']>;
@@ -10,16 +11,6 @@ const loadDownloadsComponent: ComponentLoader = () =>
(c) => c.DownloadsComponent
);
const loadStalkerShellComponent: ComponentLoader = () =>
import('./stalker-shell/stalker-shell.component').then(
(c) => c.StalkerShellComponent
);
const loadStalkerMainContainerComponent: ComponentLoader = () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
);
const loadStalkerLiveStreamLayoutComponent: ComponentLoader = () =>
import('./stalker-live-stream-layout/stalker-live-stream-layout.component').then(
(c) => c.StalkerLiveStreamLayoutComponent
@@ -30,14 +21,9 @@ const loadCategoryContentViewComponent: ComponentLoader = () =>
(c) => c.CategoryContentViewComponent
);
const loadStalkerFavoritesComponent: ComponentLoader = () =>
import('./stalker-favorites/stalker-favorites.component').then(
(c) => c.StalkerFavoritesComponent
);
const loadStalkerRecentlyViewedComponent: ComponentLoader = () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
const loadStalkerCollectionRouteComponent: ComponentLoader = () =>
import('./stalker-collection-route.component').then(
(c) => c.StalkerCollectionRouteComponent
);
const loadStalkerSearchComponent: ComponentLoader = () =>
@@ -49,7 +35,7 @@ export function createStalkerRoutes(): Route[] {
return [
{
path: 'stalker/:id',
loadComponent: loadStalkerShellComponent,
providers: provideStalkerWorkspaceRouteSession(),
children: [
{
path: '',
@@ -65,7 +51,6 @@ export function createStalkerRoutes(): Route[] {
useValue: StalkerCatalogDetailComponent,
},
],
loadComponent: loadStalkerMainContainerComponent,
children: [
{
path: '',
@@ -85,11 +70,6 @@ export function createStalkerRoutes(): Route[] {
},
],
},
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'itv',
loadComponent: loadStalkerLiveStreamLayoutComponent,
@@ -103,7 +83,6 @@ export function createStalkerRoutes(): Route[] {
useValue: StalkerCatalogDetailComponent,
},
],
loadComponent: loadStalkerMainContainerComponent,
children: [
{
path: '',
@@ -125,11 +104,13 @@ export function createStalkerRoutes(): Route[] {
},
{
path: 'favorites',
loadComponent: loadStalkerFavoritesComponent,
loadComponent: loadStalkerCollectionRouteComponent,
data: { mode: 'favorites', portalType: 'stalker' },
},
{
path: 'recent',
loadComponent: loadStalkerRecentlyViewedComponent,
loadComponent: loadStalkerCollectionRouteComponent,
data: { mode: 'recent', portalType: 'stalker' },
},
{
path: 'search',
@@ -1,6 +1,6 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
@if (stalkerStore.selectedCategoryId()) {
<div
class="sidebar"
appResizable
@@ -9,42 +9,20 @@
[defaultWidth]="400"
storageKey="sidebar-width"
>
@if (stalkerStore.selectedCategoryId() || !isWorkspaceLayout) {
<div class="sidebar-header">
@if (stalkerStore.selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ selectedCategoryTitle() }}</h2>
} @else {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
}
</div>
}
<div class="sidebar-header">
<h2>{{ selectedCategoryTitle() }}</h2>
</div>
@if (stalkerStore.selectedCategoryId()) {
<!-- <div class="search-panel">
<mat-form-field class="search-bar" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
[ngModel]="searchString()"
(ngModelChange)="searchString.set($event)"
<div #scrollContainer class="channels-list">
@if (searchTerm() && visibleChannels().length === 0) {
<div class="empty-search-state">
<app-portal-empty-state
icon="search_off"
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
/>
@if (searchString()) {
<button matSuffix mat-icon-button (click)="searchString.set('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div> -->
<div #scrollContainer class="channels-list">
@for (item of itvChannels(); track item.id ?? $index) {
</div>
} @else {
@for (item of visibleChannels(); track item.id ?? $index) {
<app-channel-list-item
[name]="item.o_name || item.name"
[logo]="item.logo"
@@ -69,53 +47,29 @@
(favoriteToggled)="toggleFavorite(item)"
/>
}
@if (isLoadingMore()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
</div>
} @else {
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate
"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION'
| translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="
$any(stalkerStore.selectedCategoryId())
"
(categoryClicked)="selectCategory($event)"
/>
}
}
@if (isLoadingMore()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
</div>
</div>
}
<div class="content-container">
@if (streamUrl && isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
@if (stalkerStore.selectedItem()) {
@if (!stalkerStore.selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
} @else if (stalkerStore.selectedItem()) {
@if (streamUrl && isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems()" />
@@ -12,21 +12,14 @@ import {
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
ResizableDirective,
} from 'components';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistsService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
@@ -35,11 +28,8 @@ import {
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
isWorkspaceLayoutRoute,
} from '@iptvnator/portal/shared/util';
import {
CategoryViewComponent,
PlaylistErrorViewComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
@@ -54,19 +44,10 @@ import {
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [
CategoryViewComponent,
ChannelListItemComponent,
EpgViewComponent,
FormsModule,
MatButton,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
MatProgressSpinnerModule,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
@@ -75,29 +56,36 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
readonly stalkerStore = inject(StalkerStore);
private readonly playlistService = inject(PlaylistsService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerLiveStream');
/** Categories */
readonly categories = this.stalkerStore.getCategoryResource;
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
/** Channels */
readonly itvChannels = this.stalkerStore.itvChannels;
readonly searchTerm = computed(() =>
this.stalkerStore.searchPhrase().trim().toLowerCase()
);
readonly visibleChannels = computed(() => {
const channels = this.itvChannels();
const term = this.searchTerm();
if (!term) {
return channels;
}
return channels.filter((item) =>
`${item.o_name ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
readonly isLoadingMore = signal(false);
/** Search */
readonly searchString = signal('');
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
@@ -127,7 +115,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
/** Scroll */
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
private scrollListener: (() => void) | null = null;
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
@@ -157,10 +144,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// Reset loading state when channels load + check viewport fill + load EPG previews
effect(() => {
const channels = this.itvChannels();
const channels = this.visibleChannels();
if (channels.length > 0) {
this.isLoadingMore.set(false);
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
if (!this.searchTerm()) {
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
}
this.loadEpgPreviewsForChannels(channels);
}
});
@@ -173,17 +162,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
});
// Debounced server-side search
effect(() => {
const search = this.searchString();
if (this.searchDebounceTimer)
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = setTimeout(() => {
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setSearchPhrase(search);
}, 500);
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
return;
@@ -191,7 +169,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const selectedItem = this.stalkerStore.selectedItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.itvChannels();
const channels = this.visibleChannels();
const epgItems = this.epgItems();
if (selectedType !== 'itv' || !selectedItem?.id) {
@@ -246,20 +224,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.removeScrollListener();
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
selectCategory(item: { category_id?: string | number }) {
this.stalkerStore.setSelectedCategory(String(item.category_id ?? '*'));
this.stalkerStore.setPage(0);
}
backToCategories() {
this.searchString.set('');
this.stalkerStore.setSearchPhrase('');
this.stalkerStore.setSelectedCategory(null);
}
isSelectedChannel(item: StalkerItvChannel): boolean {
@@ -475,7 +439,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return;
}
const channels = this.itvChannels();
const channels = this.visibleChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.id,
@@ -503,7 +467,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
const channel = getChannelItemByNumber(
this.itvChannels(),
this.visibleChannels(),
command.number
);
if (!channel) {
@@ -1,78 +0,0 @@
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
@if (!isWorkspaceLayout) {
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
</div>
}
@if (isCategoryLoading()) {
<div class="category-skeleton" aria-hidden="true">
@for (row of skeletonRows; track row) {
<div class="category-skeleton__row">
<span class="category-skeleton__dot"></span>
<span
class="category-skeleton__label"
[style.width.%]="skeletonLabelWidths[row]"
></span>
<span class="category-skeleton__count"></span>
</div>
}
</div>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
(categoryClicked)="categoryClicked($event)"
/>
}
</div>
}
<div class="main-content">
@if (!isWorkspaceLayout && !stalkerStore.selectedItem()) {
<div class="content-header">
<h2>
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ getContentLabel() }}
@if (
!contentItems() &&
!isContentLoading() &&
!stalkerStore.selectedCategoryId()
) {
<h2>
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</h2>
}
</h2>
</div>
}
<div
class="scrollable-content"
[class.no-header]="isWorkspaceLayout || stalkerStore.selectedItem()"
>
<router-outlet />
</div>
</div>
@@ -1,129 +0,0 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
justify-content: flex-start;
}
.loading-wrapper {
display: block;
padding: 20px;
}
.loading-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.category-content-view {
gap: 30px;
}
.category-view {
gap: 10px;
}
.sidebar-header {
display: flex;
flex-direction: row;
align-items: center;
app-region: drag;
button {
@include mat.form-field-density(-5);
app-region: no-drag;
}
}
.content-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.category-skeleton {
display: grid;
gap: 10px;
padding: 12px 8px 10px;
}
.category-skeleton__row {
display: grid;
grid-template-columns: 14px minmax(0, 1fr) 34px;
align-items: center;
gap: 12px;
min-height: 40px;
padding: 0 12px;
border-radius: 14px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 92%,
transparent
);
}
.category-skeleton__dot,
.category-skeleton__label,
.category-skeleton__count {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 55%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 45%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 22%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: category-skeleton-shimmer 1.25s ease-in-out infinite;
}
}
.category-skeleton__dot {
width: 14px;
height: 14px;
border-radius: 999px;
}
.category-skeleton__label {
height: 10px;
border-radius: 999px;
}
.category-skeleton__count {
justify-self: end;
width: 26px;
height: 10px;
border-radius: 999px;
}
@keyframes category-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -1,130 +0,0 @@
import { Component, effect, inject } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule } from '@angular/material/paginator';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import {
CategoryViewComponent,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-main-container',
templateUrl: './stalker-main-container.component.html',
styleUrls: [
'./stalker-main-container.component.scss',
'../../../../shared/ui/src/lib/styles/portal-main-container.scss',
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
MatIcon,
MatIconButton,
MatPaginatorModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
ResizableDirective,
RouterOutlet,
TranslatePipe,
],
})
export class StalkerMainContainerComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly translateService = inject(TranslateService);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
currentLayout:
| 'category_content'
| 'serial-details'
| 'vod-details'
| 'not-available' = 'category_content';
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly categories = this.stalkerStore.getCategoryResource;
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
readonly contentItems = this.stalkerStore.getPaginatedContent;
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
readonly skeletonRows = Array.from({ length: 12 }, (_, index) => index);
readonly skeletonLabelWidths = [
82, 70, 77, 66, 86, 73, 79, 62, 84, 68, 76, 71,
];
constructor() {
effect(() => {
this.stalkerStore.selectedContentType();
});
}
categoryClicked(item: {
category_name?: string;
category_id?: string | number;
}) {
const categoryId = String(item.category_id ?? '*');
this.stalkerStore.setSelectedCategory(categoryId);
this.currentLayout = 'category_content';
this.stalkerStore.setPage(0);
this.stalkerStore.setSelectedItem(undefined);
this.router.navigate(['.', categoryId], {
relativeTo: this.route,
});
}
historyBack() {
this.currentLayout = 'category_content';
}
backToCategories() {
this.stalkerStore.setSelectedCategory(null);
}
goBackToList() {
this.stalkerStore.clearSelectedItem();
}
getContentLabel(): string {
const selectedContentType = this.stalkerStore.selectedContentType();
const selectedCategoryId = this.stalkerStore.selectedCategoryId();
const typeLabelByContentType: Record<string, string> = {
vod: this.translateService.instant('PORTALS.SIDEBAR.MOVIES'),
series: this.translateService.instant('PORTALS.SIDEBAR.SERIES'),
itv: this.translateService.instant('PORTALS.SIDEBAR.LIVE_TV'),
};
const selectedTypeLabel =
typeLabelByContentType[selectedContentType] ?? '';
const selectedCategoryName =
this.stalkerStore.getSelectedCategoryName();
let categoryName = selectedCategoryName;
if (selectedCategoryId === '*') {
categoryName = `${selectedTypeLabel} - ${this.translateService.instant(
'PORTALS.ALL_CATEGORIES'
)}`;
} else if (!categoryName) {
categoryName = selectedTypeLabel;
}
if (
!this.stalkerStore.selectedItem() &&
!this.isContentLoading() &&
this.stalkerStore.getTotalPages() > 1
) {
const currentPage = this.stalkerStore.page() + 1;
const totalPages = this.stalkerStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
}
return categoryName;
}
}
@@ -1,9 +0,0 @@
<div class="stalker-container">
@if (!isWorkspaceLayout) {
<app-navigation
class="navigation-sidebar"
[selectedContentType]="selectedSection()"
/>
}
<router-outlet />
</div>
@@ -1,21 +0,0 @@
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.stalker-container {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
> router-outlet {
min-width: 0;
}
}
.navigation-sidebar {
border-right: 1px solid var(--mat-sys-outline-variant);
}
@@ -1,107 +0,0 @@
import { Component, computed, effect, inject, OnDestroy, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
NavigationEnd,
Router,
RouterOutlet,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
import { map, switchMap } from 'rxjs';
import {
isWorkspaceLayoutRoute,
PortalRailSection,
resolveCurrentPortalSection,
} from '@iptvnator/portal/shared/util';
import { NavigationComponent } from '@iptvnator/portal/shared/ui';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
type StalkerContentType = 'vod' | 'series' | 'itv';
@Component({
selector: 'app-stalker-shell',
templateUrl: './stalker-shell.component.html',
styleUrls: ['./stalker-shell.component.scss'],
imports: [NavigationComponent, RouterOutlet],
})
export class StalkerShellComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly store = inject(Store);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly currentPlaylist = toSignal(
this.route.params.pipe(
map((params) => params['id']),
switchMap((id) => this.store.select(selectPlaylistById(id)))
)
);
readonly selectedSection = computed<PortalRailSection | undefined>(() => {
const section = this.currentSection();
return section ?? undefined;
});
private readonly currentSection = signal<PortalRailSection | null>(null);
constructor() {
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
const playlistId = params['id'];
this.store.dispatch(
PlaylistActions.setActivePlaylist({ playlistId })
);
this.store.dispatch(
PlaylistActions.setCurrentPlaylistId({ playlistId })
);
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
const childRoute = this.route.snapshot.firstChild;
const path = childRoute?.url?.[0]?.path;
const initialType: StalkerContentType =
path === 'itv' || path === 'series' || path === 'vod'
? path
: 'vod';
this.stalkerStore.setSelectedContentType(initialType);
});
this.router.events
.pipe(takeUntilDestroyed())
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.syncSectionFromRoute();
}
});
this.syncSectionFromRoute();
effect(() => {
this.stalkerStore.setCurrentPlaylist(this.currentPlaylist());
});
}
ngOnDestroy(): void {
this.stalkerStore.resetCategories();
}
private syncSectionFromRoute(): void {
const section = resolveCurrentPortalSection(
this.route,
this.router.url,
'stalker'
);
if (!section || section === this.currentSection()) {
return;
}
this.currentSection.set(section);
if (section === 'vod' || section === 'series' || section === 'itv') {
this.stalkerStore.setSelectedContentType(section);
}
}
}
@@ -0,0 +1,133 @@
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
inject,
Injectable,
Provider,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
NavigationEnd,
Router,
} from '@angular/router';
import { Store } from '@ngrx/store';
import {
PlaylistActions,
selectPlaylistById,
} from 'm3u-state';
import { filter, firstValueFrom, take } from 'rxjs';
import {
PortalRailSection,
resolveCurrentPortalPlaylistId,
resolveCurrentPortalSection,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
type StalkerContentType = 'vod' | 'series' | 'itv';
@Injectable()
export class StalkerWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly stalkerStore = inject(StalkerStore);
private readonly store = inject(Store);
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(null);
constructor() {
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
void this.syncRouteContext();
});
void this.syncRouteContext();
this.destroyRef.onDestroy(() => {
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
});
}
private async syncRouteContext(): Promise<void> {
const playlistId = resolveCurrentPortalPlaylistId(
this.route,
this.router.url,
'stalker'
);
if (playlistId && this.currentPlaylistId !== playlistId) {
this.currentPlaylistId = playlistId;
this.store.dispatch(
PlaylistActions.setActivePlaylist({ playlistId })
);
this.store.dispatch(
PlaylistActions.setCurrentPlaylistId({ playlistId })
);
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
const playlist = await firstValueFrom(
this.store.select(selectPlaylistById(playlistId)).pipe(take(1))
);
this.stalkerStore.setCurrentPlaylist(playlist);
}
this.syncRouteState();
}
private syncRouteState(): void {
const section = resolveCurrentPortalSection(
this.route,
this.router.url,
'stalker'
);
if (!section) {
return;
}
const previousSection = this.currentSection();
if (section !== previousSection) {
this.currentSection.set(section);
}
if (section === 'vod' || section === 'series' || section === 'itv') {
this.stalkerStore.setSelectedContentType(section as StalkerContentType);
}
if (section === 'itv' && previousSection !== 'itv') {
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
this.stalkerStore.setSearchPhrase('');
}
}
}
export function provideStalkerWorkspaceRouteSession(): Provider[] {
return [
StalkerWorkspaceRouteSession,
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
inject(StalkerWorkspaceRouteSession);
},
},
];
}
@@ -386,6 +386,10 @@ export function withSelection() {
* Set the content type (live, vod, series)
*/
setSelectedContentType(type: ContentType): void {
if (store.selectedContentType() === type) {
return;
}
patchState(store, {
selectedContentType: type,
selectedCategoryId: null,
+1 -3
View File
@@ -1,6 +1,5 @@
export * from './lib/account-info/account-info.component';
export * from './lib/category-management-dialog/category-management-dialog.component';
export * from './lib/favorites/favorites.component';
export * from './lib/global-recently-viewed/global-recently-viewed.component';
export * from './lib/global-search-results/global-search-results.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
@@ -12,7 +11,6 @@ export * from './lib/recently-viewed/recently-viewed.component';
export * from './lib/search-results/search-results.component';
export * from './lib/serial-details/serial-details.component';
export * from './lib/vod-details/vod-details-route.component';
export * from './lib/xtream-main-container.component';
export * from './lib/xtream-catalog-facade.service';
export * from './lib/xtream-feature.routes';
export * from './lib/xtream-shell/xtream-shell.component';
export * from './lib/xtream-workspace-route-session.service';
@@ -1,66 +0,0 @@
@if (isLiveCategory()) {
<app-portal-collection-live-shell
[title]="'CHANNELS.FAVORITES' | translate"
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[count]="liveItemsToShow().length"
[streamUrl]="liveStreamUrl()"
[isEmbeddedPlayer]="isEmbeddedPlayer()"
[hasSelection]="!!selectedLiveItem()"
[epgItems]="epgItems()"
[isLoadingEpg]="isLoadingEpg()"
>
<ng-container liveHeaderActions>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</ng-container>
<app-portal-channels-list
liveChannels
[sortMode]="liveChannelSortMode()"
[channelsOverride]="liveItemsToShow()"
(playClicked)="selectLiveItem($event)"
/>
</app-portal-collection-live-shell>
} @else {
<app-portal-collection-shell
[categories]="categories()"
[items]="filteredFavoritesToShow()"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[mode]="mode()"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromFavorites($event)"
(openItem)="openItem($event)"
/>
}
@@ -1,3 +0,0 @@
:host {
width: 100%;
}
@@ -1,330 +0,0 @@
import {
Component,
computed,
effect,
inject,
OnInit,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { selectActivePlaylist } from 'm3u-state';
import { BehaviorSubject, switchMap } from 'rxjs';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
FavoriteLayoutItem,
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import {
buildStandardCollectionCategories,
createPortalCollectionContext,
filterCollectionBucket,
PORTAL_PLAYER,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoriteItem } from '@iptvnator/portal/xtream/data-access';
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
const XTREAM_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
const XTREAM_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
};
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
const XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY =
'xtream-favorites-live-channel-sort-mode';
interface XtreamFavoriteLiveItem {
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly xtream_id: number;
}
interface XtreamFavoriteRouteItem extends FavoriteLayoutItem {
readonly content_id: number;
readonly category_id: string | number;
readonly playlist_id: string;
readonly title: string;
readonly type: FavoriteItem['type'];
readonly xtream_id: number;
}
function isXtreamFavoriteRouteItem(
item: FavoriteLayoutItem
): item is XtreamFavoriteRouteItem {
return (
typeof item.content_id === 'number' &&
item.category_id != null &&
typeof item.playlist_id === 'string' &&
typeof item.title === 'string' &&
typeof item.xtream_id === 'number' &&
typeof item.type === 'string'
);
}
@Component({
selector: 'app-favorites',
imports: [
MatButtonModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
PortalChannelsListComponent,
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
TranslatePipe,
],
templateUrl: './favorites.component.html',
styleUrls: [
'./favorites.component.scss',
'../../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
})
export class FavoritesComponent implements OnInit {
private favoritesService = inject(FavoritesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private store = inject(Store);
private xtreamStore = inject(XtreamStore);
private portalPlayer = inject(PORTAL_PLAYER);
private readonly favoritesCtx = inject(FavoritesContextService);
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly layout = XTREAM_FAVORITES_LAYOUT;
readonly playlistSubtitle = 'Xtream Code';
readonly playlistTitle = computed(
() =>
this.currentPlaylist()?.name ||
this.currentPlaylist()?.title ||
'Playlist'
);
readonly allItems = signal<FavoriteItem[]>([]);
readonly series = signal<FavoriteItem[]>([]);
readonly movies = signal<FavoriteItem[]>([]);
readonly live = signal<FavoriteItem[]>([]);
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: XTREAM_COLLECTION_LABELS,
counts: {
all: this.allItems().length,
movie: this.movies().length,
live: this.live().length,
series: this.series().length,
},
includeLive: true,
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly selectedLiveItem = signal<XtreamFavoriteLiveItem | null>(null);
readonly liveStreamUrl = signal('');
readonly mode = computed<PortalCollectionMode>(() =>
this.isLiveCategory() ? 'live' : 'grid'
);
readonly liveItemsToShow = computed(() =>
this.live().filter((item) => {
const term = this.searchTerm();
if (!term) {
return true;
}
return `${item?.title ?? ''}`.toLowerCase().includes(term);
})
);
readonly isLiveCategory = computed(
() => this.selectedCategoryId() === 'live'
);
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
readonly liveChannelSortLabel = computed(() => {
const mode = this.liveChannelSortMode();
if (mode === 'name-asc') return 'Name A-Z';
if (mode === 'name-desc') return 'Name Z-A';
return 'Server Order';
});
readonly epgItems = this.xtreamStore.epgItems;
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
/** Synced with the workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
/** Items filtered by the active category — fully reactive, no imperative calls */
readonly filteredFavoritesToShow = computed(() => {
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allItems(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
textOf: (item) => `${item?.title ?? ''}`,
});
});
private favoritesRefresh$ = new BehaviorSubject<void>(undefined);
constructor() {
effect(() => {
const selectedItem = this.selectedLiveItem();
if (!selectedItem) {
return;
}
const stillExists = this.liveItemsToShow().some(
(item) =>
Number(item.xtream_id) === Number(selectedItem.xtream_id)
);
if (!stillExists) {
this.clearLiveSelection();
}
});
effect(() => {
if (this.isLiveCategory()) {
return;
}
this.clearLiveSelection();
});
}
ngOnInit() {
this.xtreamStore.setSelectedContentType(undefined);
const savedSortMode = localStorage.getItem(
XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY
);
if (
savedSortMode === 'server' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
this.liveChannelSortMode.set(savedSortMode);
}
const playlistId = this.store.selectSignal(selectActivePlaylist)()._id;
this.favoritesRefresh$
.pipe(
switchMap(() => this.favoritesService.getFavorites(playlistId))
)
.subscribe((items) => {
this.allItems.set(items);
this.movies.set(
items.filter(
(item) => item.type === 'movie' || item.type === 'vod'
)
);
this.live.set(items.filter((item) => item.type === 'live'));
this.series.set(items.filter((item) => item.type === 'series'));
});
}
setCategoryId(categoryId: string) {
this.collectionContext.setCategoryId(categoryId);
}
async removeFromFavorites(item: FavoriteLayoutItem) {
if (!isXtreamFavoriteRouteItem(item)) {
return;
}
await this.favoritesService.removeFromFavorites(
item.content_id,
item.playlist_id
);
this.favoritesRefresh$.next();
}
openItem(item: FavoriteLayoutItem) {
if (!isXtreamFavoriteRouteItem(item)) {
return;
}
const type = item.type === 'movie' ? 'vod' : item.type;
this.xtreamStore.setSelectedContentType(type);
if (type === 'live') {
this.setCategoryId('live');
this.selectLiveItem(item);
} else {
const routePlaylistId = this.route.snapshot.params['id'];
const hasGlobalContext = this.router.url.includes(
'/workspace/global-favorites'
);
const shouldNavigateAbsolute =
hasGlobalContext ||
(item.playlist_id &&
routePlaylistId &&
item.playlist_id !== routePlaylistId);
if (shouldNavigateAbsolute && item.playlist_id) {
this.router.navigate([
'/workspace',
'xtreams',
item.playlist_id,
type,
item.category_id,
item.xtream_id,
]);
return;
}
this.router.navigate(
['..', type, item.category_id, item.xtream_id],
{
relativeTo: this.route,
}
);
}
}
selectLiveItem(item: XtreamFavoriteLiveItem) {
this.xtreamStore.setSelectedContentType('live');
this.selectedLiveItem.set(item);
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.liveStreamUrl.set(streamUrl);
if (this.isEmbeddedPlayer()) {
return;
}
this.xtreamStore.openPlayer(
streamUrl,
item.title ?? '',
item.poster_url || item.stream_icon || null
);
}
setLiveChannelSortMode(mode: LiveChannelSortMode): void {
this.liveChannelSortMode.set(mode);
localStorage.setItem(XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY, mode);
}
private clearLiveSelection() {
this.selectedLiveItem.set(null);
this.liveStreamUrl.set('');
this.xtreamStore.clearEpg();
this.xtreamStore.setSelectedItem(null);
}
}
@@ -1,6 +1,6 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@if (!isWorkspaceLayout || selectedCategoryId()) {
@if (selectedCategoryId()) {
<div
class="sidebar"
appResizable
@@ -10,98 +10,68 @@
storageKey="sidebar-width"
>
<div class="sidebar-header">
@if (selectedCategoryId()) {
<!-- <button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button> -->
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
(click)="setLiveChannelSortMode('server')"
>
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-asc')"
>
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
} @else {
@if (!isWorkspaceLayout) {
<app-playlist-switcher
class="switcher"
[currentTitle]="
currentPlaylist()?.name ||
currentPlaylist()?.title ||
'Playlist'
"
[subtitle]="'Xtream Code'"
/>
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="
'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate
"
mat-menu-item
(click)="setLiveChannelSortMode('server')"
>
<mat-icon>tune</mat-icon>
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
}
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-asc')"
>
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</div>
@if (selectedCategoryId()) {
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
(playClicked)="playLive($event)"
/>
} @else {
<app-category-view
[items]="categories()"
[itemCounts]="categoryItemCounts()"
(categoryClicked)="selectCategory($event)"
[showCounts]="true"
/>
}
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
/>
</div>
}
<div class="content-container">
@if (streamUrl) {
@if (!selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
} @else if (streamUrl) {
@if (isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
@@ -13,21 +13,10 @@
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
gap: 4px;
app-region: drag;
flex-shrink: 0;
button {
@include mat.form-field-density(-5);
app-region: no-drag;
}
.switcher {
flex: 1;
min-width: 0;
}
.manage-categories-btn {
margin-left: auto;
}
}
@@ -1,36 +1,26 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
Injector,
OnDestroy,
OnInit,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { CategoryViewComponent, PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
PORTAL_PLAYER,
getAdjacentChannelItem,
getChannelItemByNumber,
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import {
FavoriteItem,
@@ -39,22 +29,12 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { EpgItem } from 'shared-interfaces';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '../category-management-dialog/category-management-dialog.component';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute } from '@angular/router';
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
interface XtreamCategoryLike {
readonly category_id?: string | number;
readonly category_name?: string;
readonly id?: string | number;
readonly name?: string;
}
interface XtreamLiveChannelItem {
readonly name?: string;
readonly poster_url?: string;
@@ -68,18 +48,11 @@ interface XtreamLiveChannelItem {
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss'],
imports: [
CategoryViewComponent,
EpgViewComponent,
FormsModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
MatListModule,
MatMenuModule,
MatTooltipModule,
/* MpvPlayerBarComponent, */
PlaylistSwitcherComponent,
PortalChannelsListComponent,
PortalEmptyStateComponent,
ResizableDirective,
@@ -89,19 +62,25 @@ interface XtreamLiveChannelItem {
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 portalPlayer = inject(PORTAL_PLAYER);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly epgItems = this.xtreamStore.epgItems;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
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 liveChannelSortLabel = computed(() => {
const mode = this.liveChannelSortMode();
@@ -126,11 +105,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
};
});
private readonly injector = inject(Injector);
private readonly categoryView = viewChild(CategoryViewComponent, {
read: ElementRef,
});
private categoryScrollTop = 0;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
@@ -256,10 +230,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
});
});
}
const categoryId = this.route.firstChild?.snapshot.params['categoryId'];
if (categoryId)
this.xtreamStore.setSelectedCategory(Number(categoryId));
}
playLive(item: XtreamLiveChannelItem) {
@@ -275,56 +245,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
);
}
selectCategory(category: XtreamCategoryLike) {
const el = this.categoryView()?.nativeElement;
if (el) {
this.categoryScrollTop = el.scrollTop;
}
const categoryId = category.category_id ?? category.id;
if (categoryId == null) {
return;
}
this.xtreamStore.setSelectedCategory(Number(categoryId));
}
backToCategories() {
this.xtreamStore.setSelectedCategory(null);
afterNextRender(
() => {
const el = this.categoryView()?.nativeElement;
if (el) {
el.scrollTop = this.categoryScrollTop;
}
},
{ injector: this.injector }
);
}
openCategoryManagement(): void {
const playlistId = this.route.parent?.snapshot.params['id'];
const contentType = this.xtreamStore.selectedContentType();
const dialogRef = this.dialog.open<
CategoryManagementDialogComponent,
CategoryManagementDialogData,
boolean
>(CategoryManagementDialogComponent, {
data: {
playlistId,
contentType,
itemCounts: this.categoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
setLiveChannelSortMode(mode: LiveChannelSortMode): void {
this.liveChannelSortMode.set(mode);
localStorage.setItem(LIVE_CHANNEL_SORT_STORAGE_KEY, mode);
@@ -1,55 +1,42 @@
<div class="search-panel">
<label class="search-field">
<mat-icon>search</mat-icon>
<input
type="search"
spellcheck="false"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</label>
</div>
<div class="channels-list">
@if (
sortedChannels() | filterBy: searchString() : 'title';
as filteredChannels
) {
@if (filteredChannels.length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
class="scroll-viewport-portals"
>
<app-channel-list-item
*cdkVirtualFor="
let item of filteredChannels;
trackBy: trackBy;
templateCacheSize: 0
"
[name]="item.title || item.name"
[logo]="item.stream_icon ?? item.poster_url"
[selected]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
"
[epgProgram]="epgPrograms.get(item.xtream_id) ?? null"
[progressPercentage]="
currentProgramsProgress.get(item.xtream_id) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="favorites.get(item.xtream_id) ?? false"
(clicked)="playClicked.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<div class="empty-search-state">
<div class="empty-icon-wrapper">
<mat-icon>search_off</mat-icon>
</div>
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
<p>We couldn't find anything matching "{{ searchString() }}"</p>
@if (filteredChannels().length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
class="scroll-viewport-portals"
>
<app-channel-list-item
*cdkVirtualFor="
let item of filteredChannels();
trackBy: trackBy;
templateCacheSize: 0
"
[name]="item.title || item.name"
[logo]="item.stream_icon ?? item.poster_url"
[selected]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
"
[epgProgram]="epgPrograms.get(item.xtream_id) ?? null"
[progressPercentage]="
currentProgramsProgress.get(item.xtream_id) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="favorites.get(item.xtream_id) ?? false"
(clicked)="playClicked.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<div class="empty-search-state">
<div class="empty-icon-wrapper">
<mat-icon>search_off</mat-icon>
</div>
}
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
@if (searchTermInput()) {
<p>
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</p>
}
</div>
}
</div>
@@ -12,13 +12,10 @@ import {
input,
OnDestroy,
output,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
@@ -57,8 +54,6 @@ interface XtreamCategoryLike {
styleUrls: ['./portal-channels-list.component.scss'],
imports: [
ChannelListItemComponent,
FilterPipe,
FormsModule,
MatIcon,
ScrollingModule,
TranslatePipe,
@@ -68,6 +63,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
readonly playClicked = output<XtreamChannelListItem>();
readonly sortMode = input<LiveChannelSortMode>('server');
readonly channelsOverride = input<XtreamChannelListItem[] | null>(null);
readonly searchTermInput = input('');
readonly xtreamStore = inject(XtreamStore);
private readonly favoritesService = inject(FavoritesService);
@@ -101,9 +97,22 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
return mode === 'name-asc' ? result : -result;
});
});
readonly filteredChannels = computed(() => {
const term = this.searchTermInput().trim().toLowerCase();
const channels = this.sortedChannels();
if (!term) {
return channels;
}
return channels.filter((item) =>
`${item.title ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(term)
);
});
favorites = new Map<number, boolean>();
searchString = signal<string>('');
epgPrograms = new Map<number, EpgProgram>();
currentProgramsProgress = new Map<number, number>();
@@ -169,7 +178,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
vp.renderedRangeStream
.pipe(debounceTime(300))
.subscribe((range) => {
const visibleChannels = this.sortedChannels().slice(
const visibleChannels = this.filteredChannels().slice(
range.start,
range.end
);
@@ -29,6 +29,8 @@ import {
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import {
buildXtreamNavigationTarget,
getRecentItemNavigation,
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
@@ -38,7 +40,7 @@ import {
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -147,7 +149,7 @@ export class RecentlyViewedComponent {
private readonly portalPlayer = inject(PORTAL_PLAYER);
private dialogData = inject(MAT_DIALOG_DATA, { optional: true });
private readonly logger = createLogger('XtreamRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
private readonly collectionCtx = inject(PortalCollectionContextService);
readonly isGlobal = this.dialogData?.isGlobal ?? false;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
@@ -209,7 +211,7 @@ export class RecentlyViewedComponent {
});
});
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
ctx: this.collectionCtx,
categories: this.categories,
enabled: () => !this.isGlobal,
});
@@ -390,42 +392,23 @@ export class RecentlyViewedComponent {
const source = item.source ?? 'xtream';
if (source === 'stalker' && this.isGlobal) {
if ((source === 'stalker' || source === 'm3u') && this.isGlobal) {
this.dialogRef?.close();
this.router.navigate(
['/workspace', 'stalker', item.playlist_id, 'recent'],
{
state: {
openRecentItem: item.stalker_item ?? {
id: item.id,
title: item.title,
name: item.title,
category_id:
item.type === 'movie'
? 'vod'
: item.type === 'live'
? 'itv'
: 'series',
cover: item.poster_url,
},
},
}
);
return;
}
if (source === 'm3u' && this.isGlobal) {
this.dialogRef?.close();
this.router.navigate(
['/workspace', 'playlists', item.playlist_id, 'recent'],
{
state: {
openRecentChannelUrl: String(
item.xtream_id ?? item.id ?? ''
),
},
}
);
const navigation = getRecentItemNavigation({
id: item.id,
title: item.title,
type: item.type,
playlist_id: item.playlist_id,
poster_url: item.poster_url ?? '',
xtream_id: item.xtream_id,
category_id: item.category_id ?? item.type,
source,
stalker_item: item.stalker_item,
viewed_at: item.viewed_at ?? '',
});
void this.router.navigate(navigation.link, {
state: navigation.state,
});
return;
}
@@ -446,22 +429,29 @@ export class RecentlyViewedComponent {
if (this.isGlobal) {
this.dialogRef?.close();
this.router.navigate([
'/workspace',
'xtreams',
item.playlist_id,
type,
item.category_id,
item.xtream_id,
]);
const navigation = buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
});
void this.router.navigate(navigation.link, {
state: navigation.state,
});
} else {
this.router.navigate(
['..', type, item.category_id, item.xtream_id],
{
relativeTo: this.activatedRoute,
}
);
const navigation = buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
});
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
}
@@ -139,13 +139,11 @@ describe('SearchResultsComponent initialQuery contract', () => {
})
);
expect(routerNavigateMock).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'playlist-1',
'vod',
123,
456,
]);
expect(routerNavigateMock).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'playlist-1', 'vod', '123', '456'],
{
state: undefined,
}
);
});
});
@@ -25,6 +25,7 @@ import { DatabaseService } from 'services';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import {
buildXtreamNavigationTarget,
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
@@ -271,30 +272,36 @@ export class SearchResultsComponent implements AfterViewInit {
}
selectItem(item: XtreamContentItem) {
if (this.isGlobalSearch && item.playlist_id) {
this.dialogRef?.close();
const type = item.type === 'movie' ? 'vod' : item.type;
this.router.navigate([
'/workspace',
'xtreams',
item.playlist_id,
type,
item.category_id,
item.xtream_id,
]);
} else {
const type = (
item.type === 'movie' ? 'vod' : item.type
) as ContentType;
this.xtreamStore.setSelectedContentType(type);
this.router.navigate(
item.type === 'live'
? ['..', type, item.category_id]
: ['..', type, item.category_id, item.xtream_id],
{ relativeTo: this.activatedRoute }
);
const playlistId = item.playlist_id ?? this.xtreamStore.playlistId();
if (!playlistId) {
return;
}
const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType;
const navigationType =
item.type === 'movie'
? 'movie'
: item.type === 'series'
? 'series'
: 'live';
this.xtreamStore.setSelectedContentType(type);
const navigation = buildXtreamNavigationTarget({
playlistId,
type: navigationType,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
});
if (this.isGlobalSearch) {
this.dialogRef?.close();
}
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
onCloseDialog() {
@@ -0,0 +1,22 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
@Component({
selector: 'app-xtream-collection-route',
imports: [UnifiedCollectionPageComponent],
template: `
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class XtreamCollectionRouteComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('xtream');
}
@@ -1,5 +1,6 @@
import { Route } from '@angular/router';
import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
import { provideXtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
type ComponentLoader = NonNullable<Route['loadComponent']>;
@@ -8,16 +9,6 @@ const loadDownloadsComponent: ComponentLoader = () =>
(c) => c.DownloadsComponent
);
const loadXtreamShellComponent: ComponentLoader = () =>
import('./xtream-shell/xtream-shell.component').then(
(c) => c.XtreamShellComponent
);
const loadXtreamMainContainerComponent: ComponentLoader = () =>
import('./xtream-main-container.component').then(
(c) => c.XtreamMainContainerComponent
);
const loadLiveStreamLayoutComponent: ComponentLoader = () =>
import('./live-stream-layout/live-stream-layout.component').then(
(c) => c.LiveStreamLayoutComponent
@@ -28,12 +19,9 @@ const loadCategoryContentViewComponent: ComponentLoader = () =>
(c) => c.CategoryContentViewComponent
);
const loadFavoritesComponent: ComponentLoader = () =>
import('./favorites/favorites.component').then((c) => c.FavoritesComponent);
const loadRecentlyViewedComponent: ComponentLoader = () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
const loadXtreamCollectionRouteComponent: ComponentLoader = () =>
import('./xtream-collection-route.component').then(
(c) => c.XtreamCollectionRouteComponent
);
const loadSearchResultsComponent: ComponentLoader = () =>
@@ -60,7 +48,7 @@ export function createXtreamRoutes(): Route[] {
return [
{
path: 'xtreams/:id',
loadComponent: loadXtreamShellComponent,
providers: provideXtreamWorkspaceRouteSession(),
children: [
{
path: '',
@@ -70,17 +58,14 @@ export function createXtreamRoutes(): Route[] {
{
path: 'live',
loadComponent: loadLiveStreamLayoutComponent,
children: [
{
path: ':categoryId',
loadComponent: loadLiveStreamLayoutComponent,
},
],
},
{
path: 'live/:categoryId',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: 'vod',
providers: provideXtreamCatalogFacade(),
loadComponent: loadXtreamMainContainerComponent,
children: [
{
path: '',
@@ -99,7 +84,6 @@ export function createXtreamRoutes(): Route[] {
{
path: 'series',
providers: provideXtreamCatalogFacade(),
loadComponent: loadXtreamMainContainerComponent,
children: [
{
path: '',
@@ -117,11 +101,13 @@ export function createXtreamRoutes(): Route[] {
},
{
path: 'favorites',
loadComponent: loadFavoritesComponent,
loadComponent: loadXtreamCollectionRouteComponent,
data: { mode: 'favorites', portalType: 'xtream' },
},
{
path: 'recent',
loadComponent: loadRecentlyViewedComponent,
loadComponent: loadXtreamCollectionRouteComponent,
data: { mode: 'recent', portalType: 'xtream' },
},
{
path: 'search',
@@ -1,130 +0,0 @@
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div
class="sidebar-header"
[class.sidebar-header--workspace]="isWorkspaceLayout"
>
@if (!isWorkspaceLayout) {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[subtitle]="'Xtream Code'"
/>
}
<button
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
>
<mat-icon>tune</mat-icon>
</button>
</div>
<app-category-view
[items]="categories()"
[selectedCategoryId]="selectedCategoryId()"
[itemCounts]="categoryItemCounts()"
(categoryClicked)="categoryClicked($event)"
[showCounts]="true"
/>
</div>
}
<div class="main-content">
@if (
!isWorkspaceLayout &&
!xtreamStore.selectedItem() &&
!xtreamStore.isLoadingDetails()
) {
<div class="content-header">
<div class="header-left">
<h2>
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ getContentLabel() }}
</h2>
</div>
@if (canShowSortMenu()) {
<div class="header-actions">
<mat-form-field
class="category-search"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
spellcheck="false"
[ngModel]="categorySearchTerm()"
(ngModelChange)="onCategorySearchChange($event)"
placeholder="Search in category"
/>
@if (categorySearchTerm()) {
<button
mat-icon-button
matSuffix
aria-label="Clear search"
(click)="clearCategorySearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<button
mat-icon-button
[matMenuTriggerFor]="xtreamSortMenu"
[matTooltip]="'Sort: ' + contentSortLabel()"
aria-label="Sort content"
>
<mat-icon>sort</mat-icon>
</button>
</div>
<mat-menu #xtreamSortMenu="matMenu">
<button mat-menu-item (click)="setContentSortMode('date-desc')">
<span>Date Added (Latest First)</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('date-asc')">
<span>Date Added (Oldest First)</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('name-asc')">
<span>Name A-Z</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('name-desc')">
<span>Name Z-A</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
}
</div>
}
<div
class="scrollable-content"
[class.no-header]="
isWorkspaceLayout ||
xtreamStore.selectedItem() ||
xtreamStore.isLoadingDetails()
"
>
<router-outlet />
</div>
</div>
@@ -1,194 +0,0 @@
import { Component, computed, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import {
XtreamCategorySortMode,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from './category-management-dialog/category-management-dialog.component';
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
interface XtreamCategoryLike {
readonly id?: number | string;
readonly category_id?: number | string;
readonly name?: string;
}
@Component({
selector: 'app-xtream-main-container',
templateUrl: './xtream-main-container.component.html',
styleUrls: [
'../../../../shared/ui/src/lib/styles/portal-main-container.scss',
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
FormsModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
MatMenuModule,
MatTooltipModule,
PlaylistSwitcherComponent,
ResizableDirective,
RouterOutlet,
TranslateModule,
],
})
export class XtreamMainContainerComponent implements OnInit {
readonly router = inject(Router);
readonly route = inject(ActivatedRoute);
readonly translateService = inject(TranslateService);
readonly xtreamStore = inject(XtreamStore);
private readonly dialog = inject(MatDialog);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly contentSortMode = this.xtreamStore.contentSortMode;
readonly categorySearchTerm = this.xtreamStore.categorySearchTerm;
readonly canShowSortMenu = computed(() => {
const hasCategorySelected = this.selectedCategoryId() != null;
const type = this.xtreamStore.selectedContentType();
return hasCategorySelected && (type === 'vod' || type === 'series');
});
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly contentSortLabel = computed(() => {
const mode = this.contentSortMode();
if (mode === 'date-asc') return 'Date Added (Oldest First)';
if (mode === 'name-asc') return 'Name A-Z';
if (mode === 'name-desc') return 'Name Z-A';
return 'Date Added (Latest First)';
});
ngOnInit(): void {
const savedSortMode = localStorage.getItem(
XTREAM_CATEGORY_SORT_STORAGE_KEY
);
if (
savedSortMode === 'date-desc' ||
savedSortMode === 'date-asc' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
this.xtreamStore.setContentSortMode(savedSortMode);
}
const { categoryId } = this.route.snapshot.params;
if (categoryId) {
this.xtreamStore.setSelectedCategory(Number(categoryId));
}
}
categoryClicked(category: XtreamCategoryLike) {
const categoryId = category.category_id ?? category.id;
this.xtreamStore.setSelectedItem(null);
this.xtreamStore.setSelectedCategory(Number(categoryId));
this.router.navigate([categoryId], {
relativeTo: this.route,
});
}
getContentLabel(): string {
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
if (selectedCategoryId === null || selectedCategoryId === undefined) {
const recentlyAddedLabel = this.translateService.instant(
'PORTALS.SIDEBAR.RECENTLY_ADDED'
);
if (
!this.xtreamStore.selectedItem() &&
this.xtreamStore.getTotalPages() > 1
) {
const currentPage = this.xtreamStore.page() + 1;
const totalPages = this.xtreamStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${recentlyAddedLabel} (${pageLabel} ${currentPage}/${totalPages})`;
}
return recentlyAddedLabel;
}
const selectedCategory = this.xtreamStore.getSelectedCategory();
const categoryName = selectedCategory
? (selectedCategory as XtreamCategoryLike).name
: 'Category Content';
if (
!this.xtreamStore.selectedItem() &&
this.xtreamStore.getTotalPages() > 1
) {
const currentPage = this.xtreamStore.page() + 1;
const totalPages = this.xtreamStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
}
return categoryName;
}
historyBack() {
this.router.navigate(['.', this.xtreamStore.selectedCategoryId()], {
relativeTo: this.route,
});
}
openCategoryManagement(): void {
const playlistId = this.route.parent?.snapshot.params['id'];
const contentType = this.xtreamStore.selectedContentType();
const dialogRef = this.dialog.open<
CategoryManagementDialogComponent,
CategoryManagementDialogData,
boolean
>(CategoryManagementDialogComponent, {
data: {
playlistId,
contentType,
itemCounts: this.categoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
setContentSortMode(mode: XtreamCategorySortMode): void {
this.xtreamStore.setContentSortMode(mode);
localStorage.setItem(XTREAM_CATEGORY_SORT_STORAGE_KEY, mode);
}
onCategorySearchChange(term: string): void {
this.xtreamStore.setCategorySearchTerm(term);
}
clearCategorySearch(): void {
this.xtreamStore.setCategorySearchTerm('');
}
}
@@ -1,17 +0,0 @@
@if (showImportOverlay()) {
<app-loading-overlay
[current]="getImportCount()"
[total]="itemsToImport()"
/>
}
<div class="xtream-container">
@if (!isWorkspaceLayout) {
<app-navigation
class="navigation-sidebar"
[portalStatus]="portalStatus()"
[selectedContentType]="selectedSection()"
/>
}
<router-outlet />
</div>
@@ -1,21 +0,0 @@
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.xtream-container {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
> router-outlet {
min-width: 0;
}
}
.navigation-sidebar {
border-right: 1px solid var(--mat-sys-outline-variant);
}
@@ -1,135 +0,0 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
NavigationEnd,
Router,
RouterOutlet,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { filter } from 'rxjs';
import {
isWorkspaceLayoutRoute,
PortalRailSection,
resolveCurrentPortalSection,
} from '@iptvnator/portal/shared/util';
import { NavigationComponent } from '@iptvnator/portal/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { LoadingOverlayComponent } from '../loading-overlay.component';
@Component({
templateUrl: './xtream-shell.component.html',
styleUrls: ['./xtream-shell.component.scss'],
imports: [LoadingOverlayComponent, NavigationComponent, RouterOutlet],
})
export class XtreamShellComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
readonly xtreamStore = inject(XtreamStore);
readonly getImportCount = this.xtreamStore.getImportCount;
readonly isImporting = this.xtreamStore.isImporting;
readonly itemsToImport = this.xtreamStore.itemsToImport;
readonly portalStatus = this.xtreamStore.portalStatus;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly showImportOverlay = computed(() => {
const section = this.currentSection();
return this.isImporting() && this.isContentSection(section);
});
readonly selectedSection = computed<PortalRailSection | undefined>(() => {
const section = this.currentSection();
return section ?? undefined;
});
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(null);
constructor() {
this.route.params
.pipe(takeUntilDestroyed())
.subscribe(async (params) => {
const newPlaylistId = params['id'];
if (this.currentPlaylistId === newPlaylistId) {
return;
}
const isFreshInstance = this.currentPlaylistId === null;
if (
isFreshInstance ||
this.xtreamStore.playlistId() !== newPlaylistId
) {
this.xtreamStore.resetStore(newPlaylistId);
}
this.currentPlaylistId = newPlaylistId;
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: newPlaylistId,
})
);
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
});
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed()
)
.subscribe(() => {
this.syncSectionFromRoute();
});
this.syncSectionFromRoute();
effect(() => {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
const section = this.currentSection();
if (!this.isContentSection(section)) {
return;
}
if (playlist !== null && playlist.id === playlistId) {
void this.xtreamStore.initializeContent();
}
});
}
private syncSectionFromRoute(): void {
const section = resolveCurrentPortalSection(
this.route,
this.router.url,
'xtreams'
);
if (!section || section === this.currentSection()) {
return;
}
this.currentSection.set(section);
if (section === 'vod' || section === 'live' || section === 'series') {
this.xtreamStore.setSelectedContentType(section);
}
}
private isContentSection(section: PortalRailSection | null): boolean {
return (
section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added'
);
}
}
@@ -0,0 +1,124 @@
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
inject,
Injectable,
Provider,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
NavigationEnd,
Router,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { filter } from 'rxjs';
import {
PortalRailSection,
resolveCurrentPortalPlaylistId,
resolveCurrentPortalSection,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@Injectable()
export class XtreamWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly xtreamStore = inject(XtreamStore);
private currentPlaylistId: string | null = null;
constructor() {
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
void this.syncRouteContext();
});
void this.syncRouteContext();
}
private async syncRouteContext(): Promise<void> {
const playlistId = resolveCurrentPortalPlaylistId(
this.route,
this.router.url,
'xtreams'
);
if (playlistId && this.currentPlaylistId !== playlistId) {
this.xtreamStore.resetStore(playlistId);
this.currentPlaylistId = playlistId;
this.store.dispatch(
PlaylistActions.setActivePlaylist({ playlistId })
);
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
}
const section = this.syncRouteState();
await this.initializeCurrentSectionContent(section);
}
private syncRouteState(): PortalRailSection | null {
const section = resolveCurrentPortalSection(
this.route,
this.router.url,
'xtreams'
);
if (!section) {
return null;
}
if (section === 'vod' || section === 'live' || section === 'series') {
this.xtreamStore.setSelectedContentType(section);
}
return section;
}
private async initializeCurrentSectionContent(
section: PortalRailSection | null
): Promise<void> {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
if (!playlist || playlist.id !== playlistId) {
return;
}
if (
section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added'
) {
await this.xtreamStore.initializeContent();
}
}
}
export function provideXtreamWorkspaceRouteSession(): Provider[] {
return [
XtreamWorkspaceRouteSession,
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
inject(XtreamWorkspaceRouteSession);
},
},
];
}
@@ -345,6 +345,19 @@ export class DatabaseService {
}
}
/**
* Get global favorites across all playlists (all content types)
*/
async getAllGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
try {
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
} catch (error) {
console.error('Error getting all global favorites:', error);
return [];
}
}
/**
* Clear recently viewed items
*/
@@ -1,17 +1,3 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<input
matInput
class="full-width"
type="search"
spellcheck="false"
autocomplete="off"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
(input)="onSearchInput($any($event.target).value)"
/>
</mat-form-field>
</div>
<div id="all-channels" class="channel-list">
@if (enrichedChannels().length === 0 && searchTerm()) {
<div class="no-results">
@@ -4,17 +4,10 @@ import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
HostListener,
input,
OnDestroy,
output,
signal,
viewChild,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -32,16 +25,15 @@ export interface EnrichedChannel extends Channel {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ScrollingModule,
TranslatePipe,
],
})
export class AllChannelsViewComponent implements OnDestroy {
export class AllChannelsViewComponent {
/** All channels (will be filtered by search) */
readonly channels = input.required<Channel[]>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -70,28 +62,11 @@ export class AllChannelsViewComponent implements OnDestroy {
event: MouseEvent;
}>();
/** Search term signal for debounced filtering */
readonly searchTerm = signal('');
/** Debounce timeout for search */
private searchDebounceTimeout?: number;
/** Search field element */
readonly searchElement = viewChild<ElementRef<HTMLInputElement>>('search');
/** Register ctrl+f as keyboard hotkey to focus the search input field */
@HostListener('document:keypress', ['$event'])
handleKeyboardEvent(event: KeyboardEvent): void {
if (event.key === 'f' && event.ctrlKey) {
this.searchElement()?.nativeElement.focus();
}
}
/**
* Computed signal for filtered and enriched channels.
*/
readonly enrichedChannels = computed(() => {
const term = this.searchTerm().toLowerCase();
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
const epgMap = this.channelEpgMap();
// Read progressTick to create dependency for progress refresh
@@ -118,16 +93,6 @@ export class AllChannelsViewComponent implements OnDestroy {
});
});
/**
* Handles debounced search input
*/
onSearchInput(value: string): void {
clearTimeout(this.searchDebounceTimeout);
this.searchDebounceTimeout = window.setTimeout(() => {
this.searchTerm.set(value);
}, 300);
}
/**
* Calculates progress percentage for an EPG program
*/
@@ -171,10 +136,4 @@ export class AllChannelsViewComponent implements OnDestroy {
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
ngOnDestroy(): void {
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
}
}
@@ -20,6 +20,7 @@
@case ('all') {
<app-all-channels-view
[channels]="displayedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -33,6 +34,7 @@
@case ('groups') {
<app-groups-view
[groupedChannels]="groupedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -47,6 +49,7 @@
@if (favorites) {
<app-favorites-view
[favorites]="favorites"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -60,6 +63,7 @@
@case ('recent') {
<app-recent-view
[recentItems]="recentChannelItems()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -13,11 +13,15 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NavigationEnd, Router } from '@angular/router';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import {
ChannelActions,
FavoritesActions,
@@ -86,6 +90,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
@@ -108,6 +113,15 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim().toLowerCase()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
readonly currentUrl = toSignal(
this.router.events.pipe(
@@ -1,6 +1,7 @@
<div
class="channel-list"
cdkDropList
[cdkDropListDisabled]="hasSearchTerm()"
(cdkDropListDropped)="onDrop($event)"
id="favorites-list"
>
@@ -17,7 +18,7 @@
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[isDraggable]="true"
[isDraggable]="!hasSearchTerm()"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
@@ -31,10 +32,20 @@
}
} @else {
<div class="empty-favorites">
<strong>{{ 'CHANNELS.NO_FAVORITES' | translate }}</strong>
<strong>
@if (hasSearchTerm()) {
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
} @else {
{{ 'CHANNELS.NO_FAVORITES' | translate }}
}
</strong>
</div>
<div class="empty-favorites-hint">
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }}
@if (hasSearchTerm()) {
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
} @else {
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }}
}
</div>
}
</div>
@@ -25,6 +25,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
export class FavoritesViewComponent {
/** Favorite channels */
readonly favorites = input.required<Channel[]>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -50,11 +51,27 @@ export class FavoritesViewComponent {
/** Emits when favorites order changes via drag-drop */
readonly favoritesReordered = output<string[]>();
readonly hasSearchTerm = computed(() => this.searchTerm().trim().length > 0);
readonly filteredFavorites = computed(() => {
const favorites = this.favorites();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return favorites;
}
return favorites.filter((channel) =>
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
.toLowerCase()
.includes(term)
);
});
/**
* Computed signal for enriched favorites with EPG data
*/
readonly enrichedFavorites = computed(() => {
const favorites = this.favorites();
const favorites = this.filteredFavorites();
const epgMap = this.channelEpgMap();
// Read progressTick to trigger recalculation
this.progressTick();
@@ -103,6 +120,10 @@ export class FavoritesViewComponent {
}
onDrop(event: CdkDragDrop<Channel[]>): void {
if (this.hasSearchTerm()) {
return;
}
const favorites = [...this.favorites()];
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
this.favoritesReordered.emit(favorites.map((item) => item.url));
@@ -1,84 +1,97 @@
<div id="groups-list" class="channel-list">
<mat-accordion multi>
@for (
groups of groupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
@if (searchTerm() && (filteredGroupedChannels() | keyvalue).length === 0) {
<div class="no-results">
<mat-icon class="no-results-icon">search_off</mat-icon>
<div class="no-results-title">
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
</div>
<div class="no-results-hint">
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</div>
</div>
} @else {
<mat-accordion multi>
@for (
groups of filteredGroupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value, groups.key);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(
0,
limit
);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="
channel.progressPercentage
"
(clicked)="onChannelClick(channel)"
[selected]="
activeChannelUrl() === channel?.url
"
[showFavoriteButton]="true"
[isFavorite]="
favoriteIds().has(channel?.url)
"
(favoriteToggled)="
onFavoriteToggle(channel, $event)
"
></app-channel-list-item>
}
<!-- Infinite scroll sentinel - triggers loading more when visible -->
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon class="loading-more-spinner"
>sync</mat-icon
>
<span>{{
'CHANNELS.LOADING_MORE' | translate
}}</span>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value, groups.key);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(
0,
limit
);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="
channel.progressPercentage
"
(clicked)="onChannelClick(channel)"
[selected]="
activeChannelUrl() === channel?.url
"
[showFavoriteButton]="true"
[isFavorite]="
favoriteIds().has(channel?.url)
"
(favoriteToggled)="
onFavoriteToggle(channel, $event)
"
></app-channel-list-item>
}
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon
class="loading-more-spinner"
>sync</mat-icon
>
<span>{{
'CHANNELS.LOADING_MORE'
| translate
}}</span>
</div>
</div>
</div>
}
</div>
</ng-template>
</mat-expansion-panel>
}
</div>
</ng-template>
</mat-expansion-panel>
}
}
}
</mat-accordion>
</mat-accordion>
}
</div>
@@ -39,6 +39,7 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -179,8 +180,35 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
/**
* Computed signal that memoizes enriched channels for all groups
*/
private readonly enrichedGroupChannelsMap = computed(() => {
readonly filteredGroupedChannels = computed(() => {
const grouped = this.groupedChannels();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return grouped;
}
return Object.entries(grouped).reduce<Record<string, Channel[]>>(
(acc, [groupKey, channels]) => {
const groupMatches = groupKey.toLowerCase().includes(term);
const matchingChannels = groupMatches
? channels
: channels.filter((channel) =>
channel.name?.toLowerCase().includes(term)
);
if (matchingChannels.length > 0) {
acc[groupKey] = matchingChannels;
}
return acc;
},
{}
);
});
private readonly enrichedGroupChannelsMap = computed(() => {
const grouped = this.filteredGroupedChannels();
const epgMap = this.channelEpgMap();
// Read progressTick to create dependency for progress refresh
this.progressTick();
@@ -28,10 +28,20 @@
}
} @else {
<div class="empty-recent">
<strong>{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}</strong>
<strong>
@if (searchTerm()) {
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
} @else {
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}
}
</strong>
</div>
<div class="empty-recent-hint">
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
@if (searchTerm()) {
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
} @else {
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
}
</div>
}
</div>
@@ -23,6 +23,7 @@ export interface RecentViewItem {
})
export class RecentViewComponent {
readonly recentItems = input.required<RecentViewItem[]>();
readonly searchTerm = input('');
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
@@ -31,8 +32,23 @@ export class RecentViewComponent {
readonly channelSelected = output<Channel>();
readonly removeRecent = output<string>();
readonly enrichedRecentItems = computed(() => {
readonly filteredRecentItems = computed(() => {
const recentItems = this.recentItems();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return recentItems;
}
return recentItems.filter(({ channel }) =>
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly enrichedRecentItems = computed(() => {
const recentItems = this.filteredRecentItems();
const epgMap = this.channelEpgMap();
this.progressTick();
@@ -30,7 +30,8 @@ import {
import {
getGlobalFavoriteNavigation,
getRecentItemNavigation,
} from './dashboard-navigation.utils';
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import {
DashboardWidgetProvider,
DashboardWidgetScopeSettings,
@@ -327,7 +328,7 @@ export class DashboardDataService {
getRecentItemNavigationState(
item: GlobalRecentItem
): Record<string, unknown> | undefined {
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
}
@@ -405,7 +406,7 @@ export class DashboardDataService {
getGlobalFavoriteNavigationState(
item: DashboardFavoriteItem
): Record<string, unknown> | undefined {
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
}
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-shell-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/shell/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './lib/global-favorites/global-favorites-page.component';
export * from './lib/global-favorites/global-favorites-list.component';
export * from './lib/global-favorites/unified-favorite-channel.interface';
export * from './lib/workspace-shell/workspace-shell.component';
export * from './lib/workspace-sources/workspace-sources.component';
@@ -1,106 +1,4 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
currentEpgProgram: EpgProgram | null;
progressPercentage: number;
}
@Component({
selector: 'app-global-favorites-list',
templateUrl: './global-favorites-list.component.html',
styleUrl: './global-favorites-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
DragDropModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslatePipe,
],
})
export class GlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
readonly activeUid = input<string | null>(null);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly searchTerm = signal('');
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
const channels = this.channels();
const epgMap = this.epgMap();
const term = this.searchTerm().toLowerCase();
// Read progressTick to trigger periodic recalculation
this.progressTick();
const filtered = term
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
return filtered.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
const currentEpgProgram = epgKey
? (epgMap.get(epgKey) ?? null)
: null;
return {
...ch,
currentEpgProgram,
progressPercentage: this.calcProgress(currentEpgProgram),
};
});
});
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
this.favoriteToggled.emit(channel);
}
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
// Disable reorder while searching to avoid index confusion
if (this.searchTerm()) return;
const list = [...this.channels()];
moveItemInArray(list, event.previousIndex, event.currentIndex);
this.channelsReordered.emit(list);
}
trackByUid(_: number, ch: EnrichedUnifiedFavorite): string {
return ch.uid;
}
private calcProgress(program: EpgProgram | null | undefined): number {
if (!program) return 0;
const now = Date.now();
const start = new Date(program.start).getTime();
const stop = new Date(program.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
}
export {
EnrichedUnifiedFavorite,
GlobalFavoritesListComponent,
} from '@iptvnator/portal/shared/ui';
@@ -1,124 +0,0 @@
<div class="global-favorites-page">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="40" />
</div>
} @else {
<div
[class]="
activePlayback()
? isEmbeddedPlayer()
? 'itv-container'
: 'itv-container--external'
: 'itv-container-no-player'
"
>
<!-- Sidebar: resizable channel list -->
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="global-favorites-sidebar-width"
>
<div class="sidebar-header">
<span class="sidebar-title">{{
'WORKSPACE.GLOBAL_FAVORITES.TITLE' | translate
}}</span>
<span class="sidebar-subtitle">{{
'WORKSPACE.GLOBAL_FAVORITES.SUBTITLE' | translate
}}</span>
</div>
<app-global-favorites-list
[channels]="channels()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
@if (activePlayback()) {
<!-- Video player (embedded players only) -->
@if (isEmbeddedPlayer()) {
<div class="video-player">
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (player() === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src: currentStreamUrl(),
type: 'application/x-mpegURL',
},
],
}"
[volume]="1"
/>
}
@if (player() === 'html5') {
<app-html-video-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
@if (player() === 'artplayer') {
<app-art-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
</div>
}
<!-- EPG panel: spans full height for external players -->
<div class="epg">
@if (!isEmbeddedPlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
<div class="epg-content">
<app-epg-view [epgItems]="currentEpgItems()" />
</div>
</div>
} @else {
<div class="no-channel-selected">
<mat-icon>live_tv</mat-icon>
<p>
{{
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_PLAYER'
| translate
}}
</p>
</div>
}
</div>
}
</div>
@@ -1,186 +0,0 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.global-favorites-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex: 1;
}
// ─── Grid layouts ──────────────────────────────────────────────────────────────
// With embedded player: sidebar | video-player
// sidebar | epg
.itv-container {
display: grid;
grid-template-columns: auto 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
height: 100%;
}
// With external player (MPV/VLC): sidebar | epg full height
.itv-container--external {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar epg';
height: 100%;
.epg {
border-top: none;
}
}
// Without player: full-width sidebar + center prompt
.itv-container-no-player {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar main';
height: 100%;
}
// ─── Sidebar ───────────────────────────────────────────────────────────────────
.sidebar {
grid-area: sidebar;
width: 360px;
min-width: 250px;
max-width: 600px;
flex-shrink: 0;
position: relative;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px 6px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 2px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.sidebar-title {
font-size: 15px;
font-weight: 600;
color: var(--mat-sys-on-surface);
line-height: 1.3;
}
.sidebar-subtitle {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.8;
}
// ─── Video player ──────────────────────────────────────────────────────────────
.video-player {
grid-area: video-player;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.player-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 16px;
flex-shrink: 0;
background: var(--mat-sys-surface-container-low);
border-bottom: 1px solid var(--mat-sys-outline-variant);
min-height: 40px;
}
.player-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--mat-sys-on-surface);
}
.close-btn {
flex-shrink: 0;
}
.video-player > app-vjs-player,
.video-player > app-html-video-player,
.video-player > app-art-player {
flex: 1;
min-height: 0;
display: block;
}
// ─── EPG panel ─────────────────────────────────────────────────────────────────
.epg {
grid-area: epg;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
}
.epg-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
// ─── No channel selected ───────────────────────────────────────────────────────
.no-channel-selected {
grid-area: main;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
font-size: 14px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
p {
margin: 0;
}
}
@@ -1,494 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
OnInit,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from 'components';
import { firstValueFrom } from 'rxjs';
import {
DataService,
PlaylistsService,
SettingsStore,
} from 'services';
import {
Channel,
EpgItem,
EpgProgram,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
import { GlobalFavoritesService } from './global-favorites.service';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
interface StalkerCreateLinkResponse {
readonly js?: {
readonly cmd?: string;
};
}
@Component({
selector: 'app-global-favorites-page',
templateUrl: './global-favorites-page.component.html',
styleUrl: './global-favorites-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ArtPlayerComponent,
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
ResizableDirective,
TranslatePipe,
VjsPlayerComponent,
],
})
export class GlobalFavoritesPageComponent implements OnInit {
private readonly favoritesService = inject(GlobalFavoritesService);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamApi = inject(XtreamApiService);
private readonly xtreamUrl = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly destroyRef = inject(DestroyRef);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly player = this.settingsStore.player;
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly isLoading = signal(true);
readonly channels = signal<UnifiedFavoriteChannel[]>([]);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly activeUid = signal<string | null>(null);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
readonly currentEpgItems = signal<EpgItem[]>([]);
readonly currentStreamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
/** Active channel as minimal Channel for InfoOverlayComponent */
/** Minimal Channel object needed by html5/artplayer components */
readonly activeChannelForOverlay = computed((): Channel | undefined => {
const p = this.activePlayback();
if (!p) return undefined;
return {
id: this.activeUid() ?? '',
name: p.title ?? '',
url: this.currentStreamUrl(),
tvg: {
logo: p.thumbnail ?? '',
id: '',
name: '',
rec: '',
url: '',
},
group: { title: '' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
} as Channel;
});
private tickInterval: ReturnType<typeof setInterval> | null = null;
ngOnInit(): void {
this.loadFavorites();
this.tickInterval = setInterval(() => {
this.progressTick.update((t) => t + 1);
}, 30_000);
this.destroyRef.onDestroy(() => {
if (this.tickInterval) {
clearInterval(this.tickInterval);
}
});
}
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
if (this.activeUid() === channel.uid) {
// Deselect
this.activeUid.set(null);
this.activePlayback.set(null);
this.currentEpgItems.set([]);
return;
}
this.activeUid.set(channel.uid);
this.currentEpgItems.set([]);
try {
const playback = await this.resolvePlayback(channel);
this.activePlayback.set(playback);
if (!this.portalPlayer.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback);
}
this.loadEpgItemsForChannel(channel).then((items) => {
this.currentEpgItems.set(items);
});
} catch {
this.activePlayback.set(null);
this.activeUid.set(null);
}
}
onClose(): void {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
async onFavoriteToggled(channel: UnifiedFavoriteChannel): Promise<void> {
await this.favoritesService.removeFavorite(channel);
this.channels.update((chs) => chs.filter((c) => c.uid !== channel.uid));
if (this.activeUid() === channel.uid) {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
}
async onReorder(reordered: UnifiedFavoriteChannel[]): Promise<void> {
this.channels.set(reordered);
await this.favoritesService.reorder(reordered);
}
// ─── Private ────────────────────────────────────────────────────────────
private async loadFavorites(): Promise<void> {
this.isLoading.set(true);
try {
const channels =
await this.favoritesService.getUnifiedLiveFavorites();
this.channels.set(channels);
// Load EPG in background without blocking
this.loadEpgForChannels(channels);
} catch {
this.channels.set([]);
} finally {
this.isLoading.set(false);
}
}
private async loadEpgForChannels(
channels: UnifiedFavoriteChannel[]
): Promise<void> {
const epgMap = new Map<string, EpgProgram | null>(this.epgMap());
const now = Date.now();
// Group xtream channels by playlistId to batch credential lookups
const xtreamByPlaylist = new Map<string, UnifiedFavoriteChannel[]>();
for (const ch of channels) {
if (ch.sourceType === 'xtream') {
const list = xtreamByPlaylist.get(ch.playlistId) ?? [];
list.push(ch);
xtreamByPlaylist.set(ch.playlistId, list);
}
}
const tasks: Promise<void>[] = [];
// M3U EPG
for (const ch of channels) {
if (ch.sourceType !== 'm3u' || !ch.tvgId) continue;
tasks.push(this.loadM3uEpg(ch, epgMap, now));
}
// Xtream EPG – one credential lookup per playlist
for (const [playlistId, chList] of xtreamByPlaylist.entries()) {
tasks.push(
this.loadXtreamEpgForPlaylist(playlistId, chList, epgMap, now)
);
}
await Promise.all(tasks.map((t) => t.catch(() => null)));
this.epgMap.set(new Map(epgMap));
}
private async loadM3uEpg(
ch: UnifiedFavoriteChannel,
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
if (!window.electron?.getChannelPrograms || !ch.tvgId) return;
try {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
const current =
programs.find(
(p) =>
new Date(p.start).getTime() <= now &&
now < new Date(p.stop).getTime()
) ?? null;
epgMap.set(ch.tvgId.trim(), current);
} catch {
epgMap.set(ch.tvgId.trim(), null);
}
}
private async loadXtreamEpgForPlaylist(
playlistId: string,
channels: UnifiedFavoriteChannel[],
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
let playlist: Playlist | undefined;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
);
} catch {
return;
}
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
return;
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
await Promise.all(
channels
.map(async (ch) => {
if (!ch.xtreamId) return;
try {
const items: EpgItem[] =
await this.xtreamApi.getShortEpg(
credentials,
ch.xtreamId,
2
);
const nowSec = Math.floor(now / 1000);
const current =
items.find(
(p) =>
Number(p.start_timestamp) <= nowSec &&
nowSec < Number(p.stop_timestamp)
) ?? null;
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
if (epgKey) {
epgMap.set(
epgKey,
current
? {
start: new Date(
Number(current.start_timestamp) *
1000
).toISOString(),
stop: new Date(
Number(current.stop_timestamp) *
1000
).toISOString(),
channel: String(ch.xtreamId),
title: current.title,
desc: current.description ?? null,
category: null,
}
: null
);
}
} catch {
// skip
}
})
.map((t) => t.catch(() => null))
);
}
private async resolvePlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
switch (ch.sourceType) {
case 'm3u':
return this.resolveM3uPlayback(ch);
case 'xtream':
return this.resolveXtreamPlayback(ch);
case 'stalker':
return this.resolveStalkerPlayback(ch);
}
}
private async loadEpgItemsForChannel(
ch: UnifiedFavoriteChannel
): Promise<EpgItem[]> {
try {
if (
ch.sourceType === 'm3u' &&
ch.tvgId &&
window.electron?.getChannelPrograms
) {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
return programs.map((p, i) => ({
id: String(i),
epg_id: p.channel,
title: p.title,
lang: 'en',
start: p.start,
end: p.stop,
stop: p.stop,
description: p.desc ?? '',
channel_id: p.channel,
start_timestamp: String(
Math.floor(new Date(p.start).getTime() / 1000)
),
stop_timestamp: String(
Math.floor(new Date(p.stop).getTime() / 1000)
),
}));
}
if (ch.sourceType === 'xtream' && ch.xtreamId) {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
)) as Playlist | undefined;
if (
!playlist?.serverUrl ||
!playlist.username ||
!playlist.password
) {
return [];
}
return await this.xtreamApi.getShortEpg(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
ch.xtreamId,
10
);
}
} catch {
// ignore
}
return [];
}
private resolveM3uPlayback(
ch: UnifiedFavoriteChannel
): ResolvedPortalPlayback {
return {
streamUrl: ch.streamUrl ?? '',
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private async resolveXtreamPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
)) as Playlist | undefined;
if (
ch.xtreamId == null ||
!playlist?.serverUrl ||
!playlist.username ||
!playlist.password
) {
throw new Error('Missing Xtream playback credentials');
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
const streamUrl = this.xtreamUrl.constructLiveUrl(credentials, ch.xtreamId);
return {
streamUrl,
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private async resolveStalkerPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
)) as Playlist | undefined;
const portalUrl =
ch.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = ch.stalkerMacAddress ?? playlist?.macAddress ?? '';
const cmd = ch.stalkerCmd ?? '';
const params = {
action: StalkerPortalActions.CreateLink,
cmd,
type: 'itv' as const,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
};
let response: StalkerCreateLinkResponse | undefined;
if (playlist?.isFullStalkerPortal && playlist) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
}
const rawCmd = response?.js?.cmd ?? '';
const streamUrl = this.normalizeStalkerCmd(rawCmd);
return {
streamUrl,
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private normalizeStalkerCmd(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://')
) {
return candidate;
}
}
return trimmed;
}
}
@@ -26,7 +26,7 @@ import {
import {
buildFavoriteUid,
UnifiedFavoriteChannel,
} from './unified-favorite-channel.interface';
} from '@iptvnator/portal/shared/util';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -1,73 +1,5 @@
/**
* Unified interface for displaying live TV favorites from any source
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
*/
export type FavoriteSourceType = 'm3u' | 'xtream' | 'stalker';
export interface UnifiedFavoriteChannel {
/**
* Unique identifier within the global-favorites list.
* Format: `{sourceType}::{playlistId}::{sourceItemId}`
*/
uid: string;
/** Display name of the channel */
name: string;
/** Logo / thumbnail URL */
logo: string | null;
/** Source type – determines how to resolve the stream URL */
sourceType: FavoriteSourceType;
/** ID of the parent playlist */
playlistId: string;
/** Human-readable playlist name */
playlistName: string;
/** Direct stream URL (M3U channels only) */
streamUrl?: string;
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
/**
* TVG id for M3U EPG lookup.
* For Xtream channels, also used for short-EPG lookup.
*/
tvgId?: string;
/**
* The stalker cmd value needed to resolve the stream URL.
* Stored for Stalker favorites that are live channels.
*/
stalkerCmd?: string;
/** ISO timestamp when added to favorites */
addedAt: string;
/**
* Display order within the unified list.
* Lower values appear first. 0 = unset (sort by addedAt desc).
*/
position: number;
/** Xtream DB content id — used for REORDER IPC call */
contentId?: number;
/** Stalker portal credentials needed to resolve the live stream */
stalkerPortalUrl?: string;
stalkerMacAddress?: string;
}
/**
* Build a stable UID string for a unified favorite channel.
*/
export function buildFavoriteUid(
sourceType: FavoriteSourceType,
playlistId: string,
sourceItemId: string | number
): string {
return `${sourceType}::${playlistId}::${sourceItemId}`;
}
export {
buildFavoriteUid,
FavoriteSourceType,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}