diff --git a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss index 6a8bb41c2..dda2ea519 100644 --- a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss +++ b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.scss @@ -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, diff --git a/libs/portal/shared/ui/src/lib/styles/_skeleton.scss b/libs/portal/shared/ui/src/lib/styles/_skeleton.scss new file mode 100644 index 000000000..2ac9351d5 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/styles/_skeleton.scss @@ -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%); + } +} diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 4411bb1f2..6c02b6681 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts new file mode 100644 index 000000000..72167a8ce --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts @@ -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'; +} diff --git a/libs/portal/shared/util/src/lib/collection/index.ts b/libs/portal/shared/util/src/lib/collection/index.ts new file mode 100644 index 000000000..50330be65 --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/collection/scope-toggle.service.ts b/libs/portal/shared/util/src/lib/collection/scope-toggle.service.ts new file mode 100644 index 000000000..1acd6904d --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/scope-toggle.service.ts @@ -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>>(); + + getScope(viewKey: string): ReturnType> { + if (!this.scopes.has(viewKey)) { + const persisted = this.readFromStorage(viewKey); + this.scopes.set(viewKey, signal(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'; + } +} diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts new file mode 100644 index 000000000..b8577bc2b --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts @@ -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 { + 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 { + 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> { + const epgMap = new Map(); + const now = Date.now(); + + const xtreamByPlaylist = new Map(); + 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[] = []; + 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 { + 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 { + 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, + now: number + ): Promise { + 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, + now: number + ): Promise { + 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; + } +} diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts new file mode 100644 index 000000000..1d554d78b --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts @@ -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}`; +} diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts new file mode 100644 index 000000000..3f9b888c3 --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -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}`; +} diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts new file mode 100644 index 000000000..8a2cc1488 --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts @@ -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 { + if (scope === 'playlist' && playlistId) { + return this.getPlaylistFavorites(playlistId, portalType); + } + return this.getAllFavorites(); + } + + async removeFavorite(item: UnifiedCollectionItem): Promise { + 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 { + 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 { + 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 { + if (portalType === 'xtream') return this.getXtreamPlaylistFavorites(playlistId); + if (portalType === 'stalker') return this.getStalkerPlaylistFavorites(playlistId); + return this.getM3uPlaylistFavorites(playlistId); + } + + private async getM3uFavorites(): Promise { + 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 { + const meta = await this.getPlaylistMeta(id); + return meta ? this.extractM3uFavorites(meta) : []; + } + + private async extractM3uFavorites(meta: PlaylistMeta): Promise { + 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 { + 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 { + 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 { + 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 { + const meta = await this.getPlaylistMeta(id); + return meta ? this.extractStalkerFavorites(meta) : []; + } + + private async extractStalkerFavorites(meta: PlaylistMeta): Promise { + 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 { + return firstValueFrom( + this.store.select(selectAllPlaylistsMeta).pipe(map((m) => m as PlaylistMeta[])) + ); + } + + private async getPlaylistMeta(id: string): Promise { + return (await this.getAllMeta()).find((p) => p._id === id); + } + + private async getSavedOrder(): Promise { + 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 { + 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]; + } +} diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts new file mode 100644 index 000000000..be8ae36d2 --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts @@ -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 { + if (scope === 'playlist' && playlistId) { + return this.getPlaylistRecentItems(playlistId, portalType); + } + return this.getAllRecentItems(); + } + + async removeRecentItem(item: UnifiedCollectionItem): Promise { + 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 { + 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 { + 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 { + if (portalType === 'xtream') { + return this.getXtreamPlaylistRecent(playlistId); + } + if (portalType === 'stalker') { + return this.getStalkerPlaylistRecent(playlistId); + } + return this.getM3uPlaylistRecent(playlistId); + } + + private async getXtreamGlobalRecent(): Promise { + 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 { + 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 { + 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 { + const all = await this.getPlaylistBasedRecent(); + return all.filter( + (i) => i.sourceType === 'stalker' && i.playlistId === playlistId + ); + } + + private async getM3uPlaylistRecent( + playlistId: string + ): Promise { + const all = await this.getPlaylistBasedRecent(); + return all.filter( + (i) => i.sourceType === 'm3u' && i.playlistId === playlistId + ); + } + + private async getPlaylistMeta( + id: string + ): Promise { + const all = await firstValueFrom( + this.store.select(selectAllPlaylistsMeta) + .pipe(map((m) => m as PlaylistMeta[])) + ); + return all.find((p) => p._id === id); + } +} diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index 189a91264..038cc79bc 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -195,7 +195,7 @@ export function buildPortalRailLinks( section: 'recent', }, { - icon: 'star', + icon: 'favorite', tooltip: 'Favorites (this playlist)', path: [...root, 'favorites'], exact: true, diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index 493ff6a4a..dfd4eefa9 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -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({}); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts index f570e0cff..1cdea2834 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts @@ -9,8 +9,30 @@ import { type QueryParamNormalizer = (value: string | null) => T; +const PORTAL_SECTIONS = new Set([ + '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( @@ -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); } diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts new file mode 100644 index 000000000..4e5c8cecc --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -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'], + }); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts similarity index 76% rename from libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts rename to libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index d94870714..3cebf5b28 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -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; } -// ────── 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(); } diff --git a/libs/portal/shared/util/src/lib/favorites-context.service.ts b/libs/portal/shared/util/src/lib/portal-collection-context.service.ts similarity index 86% rename from libs/portal/shared/util/src/lib/favorites-context.service.ts rename to libs/portal/shared/util/src/lib/portal-collection-context.service.ts index fc4186e4d..9105daac6 100644 --- a/libs/portal/shared/util/src/lib/favorites-context.service.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-context.service.ts @@ -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([]); diff --git a/libs/portal/shared/util/src/lib/portal-collection-context.spec.ts b/libs/portal/shared/util/src/lib/portal-collection-context.spec.ts index ddfbbd756..abcd203d9 100644 --- a/libs/portal/shared/util/src/lib/portal-collection-context.spec.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-context.spec.ts @@ -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, diff --git a/libs/portal/shared/util/src/lib/portal-collection-context.ts b/libs/portal/shared/util/src/lib/portal-collection-context.ts index f3169057d..70dfabee9 100644 --- a/libs/portal/shared/util/src/lib/portal-collection-context.ts +++ b/libs/portal/shared/util/src/lib/portal-collection-context.ts @@ -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; @@ -8,7 +8,7 @@ export interface PortalCollectionContextBridge { } interface CreatePortalCollectionContextOptions { - ctx: FavoritesContextService; + ctx: PortalCollectionContextService; categories: () => XtreamCategory[]; enabled?: () => boolean; } diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts index 62d7464e5..644b6b87c 100644 --- a/libs/portal/stalker/feature/src/index.ts +++ b/libs/portal/stalker/feature/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.html b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.html deleted file mode 100644 index c228a603b..000000000 --- a/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.html +++ /dev/null @@ -1,49 +0,0 @@ -@if (isLiveCategory()) { - - - -} @else { - - - -} diff --git a/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts deleted file mode 100644 index fdd19496b..000000000 --- a/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts +++ /dev/null @@ -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(false); - readonly selectedLiveItem = signal(null); - readonly liveStreamUrl = signal(''); - readonly epgItems = signal([]); - 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) { - 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, 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(); - 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 }); - } -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts new file mode 100644 index 000000000..a3fa11a89 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts @@ -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: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StalkerCollectionRouteComponent { + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly portalType = input('stalker'); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.html b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.html deleted file mode 100644 index 1fce9f6a6..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.html +++ /dev/null @@ -1,48 +0,0 @@ -@if (isLiveCategory()) { - - - -} @else { - - - -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts deleted file mode 100644 index 8558b6c60..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts +++ /dev/null @@ -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(false); - readonly selectedLiveItem = signal(null); - readonly liveStreamUrl = signal(''); - readonly epgItems = signal([]); - 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(() => - ((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, - onDone?: () => void - ) { - this.stalkerStore.removeFromFavorites( - normalizeStalkerEntityId(item.id), - () => { - this.favoritesRefresh.refresh(); - this.syncSelectedVodFavorite(); - onDone?.(); - } - ); - } - - addToFavorites(item: Record, 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(); - 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 }); - } -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index 52e6f9e6f..682f78298 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -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; @@ -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', diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index b963e1f95..b8bc4c7e2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -1,6 +1,6 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); -@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) { +@if (stalkerStore.selectedCategoryId()) { - } @else { - @if (isCategoryLoading()) { - - } @else if (isCategoryFailed()) { - - } @else { - } - } + + @if (isLoadingMore()) { +
+ +
+ } + }
- @if (streamUrl && isEmbeddedPlayer) { -
- -
- } - @if (stalkerStore.selectedItem()) { + @if (!stalkerStore.selectedCategoryId()) { + + } @else if (stalkerStore.selectedItem()) { + @if (streamUrl && isEmbeddedPlayer) { +
+ +
+ }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 2510afa0f..40bb6596d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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('scrollContainer'); private scrollListener: (() => void) | null = null; - private searchDebounceTimer: ReturnType | 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) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.html b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.html deleted file mode 100644 index de3ca7c0d..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.html +++ /dev/null @@ -1,78 +0,0 @@ -@if (!isWorkspaceLayout) { - -} - -
- @if (!isWorkspaceLayout && !stalkerStore.selectedItem()) { -
-

- - - {{ getContentLabel() }} - - @if ( - !contentItems() && - !isContentLoading() && - !stalkerStore.selectedCategoryId() - ) { -

- {{ 'PORTALS.SELECT_CATEGORY' | translate }} -

- } - -
- } - -
- -
-
diff --git a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.scss b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.scss deleted file mode 100644 index 0fd124370..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.scss +++ /dev/null @@ -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%); - } -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts deleted file mode 100644 index dfe1358c1..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts +++ /dev/null @@ -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 = { - 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; - } -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.html b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.html deleted file mode 100644 index ad444df94..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
- @if (!isWorkspaceLayout) { - - } - -
diff --git a/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.scss b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.scss deleted file mode 100644 index 14f152faf..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.scss +++ /dev/null @@ -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); -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.ts b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.ts deleted file mode 100644 index 50d351217..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.ts +++ /dev/null @@ -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(() => { - const section = this.currentSection(); - return section ?? undefined; - }); - - private readonly currentSection = signal(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); - } - } -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts new file mode 100644 index 000000000..86218dd74 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -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(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 { + 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); + }, + }, + ]; +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 4470b4501..376ffbe58 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index 8d90adee5..ecf6a3fe5 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -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'; diff --git a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.html b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.html deleted file mode 100644 index 2e8ac6e93..000000000 --- a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.html +++ /dev/null @@ -1,66 +0,0 @@ -@if (isLiveCategory()) { - - - - - - - - - - - - -} @else { - -} diff --git a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.scss b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.scss deleted file mode 100644 index 658f7ee0a..000000000 --- a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - width: 100%; -} diff --git a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts deleted file mode 100644 index e3dee940f..000000000 --- a/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts +++ /dev/null @@ -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([]); - readonly series = signal([]); - readonly movies = signal([]); - readonly live = signal([]); - 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(null); - readonly liveStreamUrl = signal(''); - readonly mode = computed(() => - 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('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(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); - } -} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 45117093d..6ab8a57e5 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,6 +1,6 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); -@if (!isWorkspaceLayout || selectedCategoryId()) { +@if (selectedCategoryId()) { }
- @if (streamUrl) { + @if (!selectedCategoryId()) { + + } @else if (streamUrl) { @if (isEmbeddedPlayer) {
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index 8ce052c26..bc9c9db00 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -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; } } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 0d692ba38..d2c8d95ba 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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('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(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); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index a2b50438b..17cf69250 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -1,55 +1,42 @@ -
- -
-
- @if ( - sortedChannels() | filterBy: searchString() : 'title'; - as filteredChannels - ) { - @if (filteredChannels.length > 0) { - - - - } @else { -
-
- search_off -
-

{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}

-

We couldn't find anything matching "{{ searchString() }}"

+ @if (filteredChannels().length > 0) { + + + + } @else { +
+
+ search_off
- } +

{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}

+ @if (searchTermInput()) { +

+ {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} +

+ } +
}
diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index eda772d7d..6549e0849 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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(); readonly sortMode = input('server'); readonly channelsOverride = input(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(); - searchString = signal(''); epgPrograms = new Map(); currentProgramsProgress = new Map(); @@ -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 ); diff --git a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts index e2246e269..465eda0b2 100644 --- a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts @@ -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, + }); } } diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts index 700cd6fcb..206a667a1 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts @@ -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, + } + ); }); }); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index ccee03ecc..8cad8caca 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -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() { diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts new file mode 100644 index 000000000..6eb9284cd --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts @@ -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: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class XtreamCollectionRouteComponent { + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly portalType = input('xtream'); +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index ace223ea4..ce1c58321 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -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; @@ -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', diff --git a/libs/portal/xtream/feature/src/lib/xtream-main-container.component.html b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.html deleted file mode 100644 index b42cab02b..000000000 --- a/libs/portal/xtream/feature/src/lib/xtream-main-container.component.html +++ /dev/null @@ -1,130 +0,0 @@ -@if (!isWorkspaceLayout) { - -} - -
- @if ( - !isWorkspaceLayout && - !xtreamStore.selectedItem() && - !xtreamStore.isLoadingDetails() - ) { -
-
-

- - {{ getContentLabel() }} -

-
- @if (canShowSortMenu()) { -
- - search - - @if (categorySearchTerm()) { - - } - - -
- - - - - - - } -
- } -
- -
-
diff --git a/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts deleted file mode 100644 index 55cc13b28..000000000 --- a/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts +++ /dev/null @@ -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(''); - } -} diff --git a/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.html b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.html deleted file mode 100644 index 1561a7ada..000000000 --- a/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.html +++ /dev/null @@ -1,17 +0,0 @@ -@if (showImportOverlay()) { - -} - -
- @if (!isWorkspaceLayout) { - - } - -
diff --git a/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.scss b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.scss deleted file mode 100644 index 01afc3c7c..000000000 --- a/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.scss +++ /dev/null @@ -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); -} diff --git a/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.ts b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.ts deleted file mode 100644 index 7bdf1ea3b..000000000 --- a/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.ts +++ /dev/null @@ -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(() => { - const section = this.currentSection(); - return section ?? undefined; - }); - - private currentPlaylistId: string | null = null; - private readonly currentSection = signal(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' - ); - } -} diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts new file mode 100644 index 000000000..64c8f4786 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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 { + 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 { + 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); + }, + }, + ]; +} diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 91170db43..c22038980 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -345,6 +345,19 @@ export class DatabaseService { } } + /** + * Get global favorites across all playlists (all content types) + */ + async getAllGlobalFavorites(): Promise { + try { + const items = await window.electron.dbGetAllGlobalFavorites(); + return items || []; + } catch (error) { + console.error('Error getting all global favorites:', error); + return []; + } + } + /** * Clear recently viewed items */ diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 71ed28750..0943a4947 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -1,17 +1,3 @@ -
- - - -
@if (enrichedChannels().length === 0 && searchTerm()) {
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index 956d5545d..3fd4f57b4 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -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(); + readonly searchTerm = input(''); /** EPG map for channel enrichment */ readonly channelEpgMap = input.required>(); @@ -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>('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); - } - } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 614358967..34a122e87 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -20,6 +20,7 @@ @case ('all') { ()); @@ -108,6 +113,15 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Active view (all, groups, favorites, recent) */ readonly activeView = input('all'); readonly recentItems = input([]); + 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( diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html index a30bce896..d5e2c12d9 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html @@ -1,6 +1,7 @@
@@ -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 {
- {{ 'CHANNELS.NO_FAVORITES' | translate }} + + @if (hasSearchTerm()) { + {{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }} + } @else { + {{ 'CHANNELS.NO_FAVORITES' | translate }} + } +
- {{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }} + @if (hasSearchTerm()) { + {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} + } @else { + {{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }} + }
}
diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index a8d45f25d..554d3ff7d 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -25,6 +25,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item export class FavoritesViewComponent { /** Favorite channels */ readonly favorites = input.required(); + readonly searchTerm = input(''); /** EPG map for channel enrichment */ readonly channelEpgMap = input.required>(); @@ -50,11 +51,27 @@ export class FavoritesViewComponent { /** Emits when favorites order changes via drag-drop */ readonly favoritesReordered = output(); + 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): void { + if (this.hasSearchTerm()) { + return; + } + const favorites = [...this.favorites()]; moveItemInArray(favorites, event.previousIndex, event.currentIndex); this.favoritesReordered.emit(favorites.map((item) => item.url)); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 80a8edd61..699034b24 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -1,84 +1,97 @@
- - @for ( - groups of groupedChannels() | keyvalue: groupsComparator; - track $index - ) { - @if (groups.value.length > 0) { - - - {{ - groups.key || ('CHANNELS.UNGROUPED' | translate) - | titlecase - }} - ({{ groups.value.length }}) - + @if (searchTerm() && (filteredGroupedChannels() | keyvalue).length === 0) { +
+ search_off +
+ {{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }} +
+
+ {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} +
+
+ } @else { + + @for ( + groups of filteredGroupedChannels() | keyvalue: groupsComparator; + track $index + ) { + @if (groups.value.length > 0) { + + + {{ + groups.key || ('CHANNELS.UNGROUPED' | translate) + | titlecase + }} + ({{ groups.value.length }}) + - - @let enrichedGroupChannels = - getEnrichedGroupChannels(groups.value, groups.key); - @let limit = getGroupLimit(groups.key); -
- @for ( - channel of enrichedGroupChannels.slice( - 0, - limit - ); - track trackByFn($index, channel); - let i = $index - ) { - - } - - @if (groups.value.length > limit) { -
-
- sync - {{ - 'CHANNELS.LOADING_MORE' | translate - }} + + @let enrichedGroupChannels = + getEnrichedGroupChannels(groups.value, groups.key); + @let limit = getGroupLimit(groups.key); +
+ @for ( + channel of enrichedGroupChannels.slice( + 0, + limit + ); + track trackByFn($index, channel); + let i = $index + ) { + + } + @if (groups.value.length > limit) { +
+
+ sync + {{ + 'CHANNELS.LOADING_MORE' + | translate + }} +
-
- } -
- - + } +
+ + + } } - } - + + }
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 922e4dd9a..93f44ddcb 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -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>(); @@ -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>( + (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(); diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html index 522cb90fa..2c1464baa 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html @@ -28,10 +28,20 @@ } } @else {
- {{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }} + + @if (searchTerm()) { + {{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }} + } @else { + {{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }} + } +
- {{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }} + @if (searchTerm()) { + {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} + } @else { + {{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }} + }
}
diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index 99b86996d..12efab0a4 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -23,6 +23,7 @@ export interface RecentViewItem { }) export class RecentViewComponent { readonly recentItems = input.required(); + readonly searchTerm = input(''); readonly channelEpgMap = input.required>(); readonly progressTick = input.required(); readonly shouldShowEpg = input.required(); @@ -31,8 +32,23 @@ export class RecentViewComponent { readonly channelSelected = output(); readonly removeRecent = output(); - 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(); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 2407d0701..a9fa50887 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 | undefined { + ): WorkspaceNavigationTarget['state'] { return getRecentItemNavigation(item).state; } @@ -405,7 +406,7 @@ export class DashboardDataService { getGlobalFavoriteNavigationState( item: DashboardFavoriteItem - ): Record | undefined { + ): WorkspaceNavigationTarget['state'] { return getGlobalFavoriteNavigation(item).state; } diff --git a/libs/workspace/shell/feature/jest.config.ts b/libs/workspace/shell/feature/jest.config.ts new file mode 100644 index 000000000..d89762d65 --- /dev/null +++ b/libs/workspace/shell/feature/jest.config.ts @@ -0,0 +1,22 @@ +export default { + displayName: 'workspace-shell-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/workspace/shell/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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'], +}; diff --git a/libs/workspace/shell/feature/src/index.ts b/libs/workspace/shell/feature/src/index.ts index 38b685be7..7d2dace56 100644 --- a/libs/workspace/shell/feature/src/index.ts +++ b/libs/workspace/shell/feature/src/index.ts @@ -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'; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts index a1061d6b6..cadecccc7 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts @@ -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(); - readonly epgMap = input>(new Map()); - readonly progressTick = input(0); - readonly activeUid = input(null); - - readonly channelSelected = output(); - readonly channelsReordered = output(); - readonly favoriteToggled = output(); - - 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): 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'; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.html b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.html deleted file mode 100644 index 76189c970..000000000 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.html +++ /dev/null @@ -1,124 +0,0 @@ -
- @if (isLoading()) { -
- -
- } @else { -
- - - - @if (activePlayback()) { - - @if (isEmbeddedPlayer()) { -
-
- {{ - activePlayback()?.title - }} - -
- - @if (player() === 'videojs') { - - } - @if (player() === 'html5') { - - } - @if (player() === 'artplayer') { - - } -
- } - - -
- @if (!isEmbeddedPlayer()) { -
- {{ - activePlayback()?.title - }} - -
- } -
- -
-
- } @else { -
- live_tv -

- {{ - 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_PLAYER' - | translate - }} -

-
- } -
- } -
diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.scss b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.scss deleted file mode 100644 index a8d04abdf..000000000 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.scss +++ /dev/null @@ -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; - } -} diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts deleted file mode 100644 index 0823b945a..000000000 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts +++ /dev/null @@ -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([]); - readonly activePlayback = signal(null); - readonly activeUid = signal(null); - readonly epgMap = signal>(new Map()); - readonly progressTick = signal(0); - readonly currentEpgItems = signal([]); - 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 | 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 { - 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 { - 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 { - this.channels.set(reordered); - await this.favoritesService.reorder(reordered); - } - - // ─── Private ──────────────────────────────────────────────────────────── - - private async loadFavorites(): Promise { - 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 { - const epgMap = new Map(this.epgMap()); - const now = Date.now(); - - // Group xtream channels by playlistId to batch credential lookups - const xtreamByPlaylist = new Map(); - 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[] = []; - - // 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, - now: number - ): Promise { - 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, - now: number - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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; - } -} diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index 8047bb8ef..aeb7e348d 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -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'; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts b/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts index 3f9b888c3..e642808ce 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts @@ -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'; diff --git a/libs/workspace/shell/feature/src/test-setup.ts b/libs/workspace/shell/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/workspace/shell/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/workspace/shell/feature/tsconfig.json b/libs/workspace/shell/feature/tsconfig.json new file mode 100644 index 000000000..dd63ae991 --- /dev/null +++ b/libs/workspace/shell/feature/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/workspace/shell/feature/tsconfig.lib.json b/libs/workspace/shell/feature/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/workspace/shell/feature/tsconfig.lib.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"] +} diff --git a/libs/workspace/shell/feature/tsconfig.spec.json b/libs/workspace/shell/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/workspace/shell/feature/tsconfig.spec.json @@ -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" + ] +}