diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9bc74167f..9ed67dbe5 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -564,14 +564,15 @@ shows nothing. `aria-busy` is set immediately, since it does not paint. A superseded reload keeps the earliest deadline and never clears the indicator; only the latest request's completion does. Reference implementation: `createCollectionReloadIndicator` in -`libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts`. +`libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts`. Controls that triggered the reload stay enabled and reflect the requested value at once (`scope.set()` runs synchronously before the load starts). Because the rows on screen then belong to the PREVIOUS request, actions on them (Clear, drag reorder) must bind to the request that loaded those rows, never to the toggle's current value — "This playlist" applied to still-mounted global rows would delete other playlists' favorites or write foreign URLs -into this playlist (`loadedRequest` on the collection page). +into this playlist (`loadedRequest` on `UnifiedCollectionDataService`, which +the collection page reads through its own `mutationRequest`). ## Navigation Lists diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 3432628fe..ca85817f1 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1227,9 +1227,11 @@ class EpgService { - The unified favorites/recent live tab (`libs/portal/shared/ui/.../unified-collection/unified-live-tab.component.ts`) hosts the same timeline but does not use the NgRx playlist state; it keeps - its own `activeTimeshift` signal, resolves the replay URL with - `resolveM3uCatchupUrl`, and swaps the inline player's playback target (or - hands the URL to the configured external player). Selecting another channel, + its own `activeTimeshift` signal and hands it to + `createUnifiedLiveCatchup` (`unified-live-catchup.ts`), which resolves the + replay URL with `resolveM3uCatchupUrl` and swaps the inline player's + playback target (or hands the URL to the configured external player). + Selecting another channel, closing the player, or "Return to live" clears the override. - Catch-up activation is never silent: if the replay URL cannot be resolved for a programme the user clicked, both hosts surface a diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts b/libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.spec.ts similarity index 100% rename from libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts rename to libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.spec.ts diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts b/libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts similarity index 100% rename from libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts rename to libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts diff --git a/libs/portal/shared/data-access/src/lib/collection/index.ts b/libs/portal/shared/data-access/src/lib/collection/index.ts index 7ae7c22f2..5c1d9fde2 100644 --- a/libs/portal/shared/data-access/src/lib/collection/index.ts +++ b/libs/portal/shared/data-access/src/lib/collection/index.ts @@ -1,3 +1,5 @@ +export * from './collection-reload-indicator'; +export * from './unified-collection-data.service'; export * from './unified-favorites-data.service'; export * from './unified-recent-data.service'; export * from './stream-resolver.service'; diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts new file mode 100644 index 000000000..1b03ae617 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts @@ -0,0 +1,204 @@ +import { DestroyRef, inject, Injectable, Signal, signal } from '@angular/core'; +import { + CollectionContentType, + CollectionScope, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { createCollectionReloadIndicator } from './collection-reload-indicator'; +import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; +import { UnifiedRecentDataService } from './unified-recent-data.service'; + +export type CollectionMode = 'favorites' | 'recent'; + +export interface CollectionLoadRequest { + scope: CollectionScope; + playlistId?: string; + portalType?: string; +} + +/** + * Reads and writes the rows of one unified collection (a favorites or a + * recently-viewed list) and owns their loading state. Not `providedIn: + * 'root'`: `UnifiedCollectionPageComponent` provides it, so every mounted + * page has its own; the page itself keeps only the view concerns on top of + * this. + */ +@Injectable() +export class UnifiedCollectionDataService { + private readonly favoritesData = inject(UnifiedFavoritesDataService); + private readonly recentData = inject(UnifiedRecentDataService); + private readonly reloadIndicator = createCollectionReloadIndicator( + inject(DestroyRef) + ); + + /** First load with nothing on screen: the skeleton replaces the content. */ + readonly isLoading = signal(true); + /** + * A reload (scope switch, favorites reload) is in flight while the + * previous items stay mounted. Never swaps to the skeleton, so a playing + * channel and the focused toggle survive. + */ + readonly isReloading = this.reloadIndicator.active; + /** `isReloading` past its grace period: progress bar + dimming render. */ + readonly showReloadIndicator = this.reloadIndicator.visible; + readonly allItems = signal([]); + readonly favoriteUidSet = signal>(new Set()); + + private readonly request = signal(null); + /** + * The request that produced `allItems`. Actions on displayed rows (Clear, + * drag reorder) must use it, not the scope toggle's current value: during + * a reload the toggle already names the requested scope while the + * previous rows are still on screen, and "This playlist" against global + * rows would delete other playlists' favorites or write foreign URLs into + * this playlist. + */ + readonly loadedRequest: Signal = + this.request.asReadonly(); + + private requestId = 0; + + /** + * Replace the collection with the rows the request resolves to. Returns + * the loaded rows, or `null` when the load was superseded by a newer one + * or failed — in both cases the caller must not post-process it. + */ + async load( + params: CollectionLoadRequest & { mode: CollectionMode } + ): Promise { + const requestId = ++this.requestId; + if (this.allItems().length === 0) { + this.isLoading.set(true); + } else { + this.reloadIndicator.begin(); + } + + try { + const items = + params.mode === 'favorites' + ? await this.favoritesData.getFavorites( + params.scope, + params.playlistId, + params.portalType + ) + : await this.recentData.getRecentItems( + params.scope, + params.playlistId, + params.portalType + ); + const favoriteUids = + params.mode === 'favorites' + ? new Set(items.map((item) => item.uid)) + : await this.loadFavoriteUidSet(params); + if (requestId !== this.requestId) { + return null; + } + this.allItems.set(items); + this.request.set({ + scope: params.scope, + playlistId: params.playlistId, + portalType: params.portalType, + }); + this.favoriteUidSet.set(favoriteUids); + return items; + } catch { + if (requestId !== this.requestId) { + return null; + } + this.allItems.set([]); + return null; + } finally { + if (requestId === this.requestId) { + this.isLoading.set(false); + this.reloadIndicator.settle(); + } + } + } + + async removeItem( + mode: CollectionMode, + item: UnifiedCollectionItem + ): Promise { + if (mode === 'favorites') { + await this.favoritesData.removeFavorite(item); + this.favoriteUidSet.update((favoriteUids) => { + const nextFavoriteUids = new Set(favoriteUids); + nextFavoriteUids.delete(item.uid); + return nextFavoriteUids; + }); + } else { + await this.recentData.removeRecentItem(item); + } + this.allItems.update((items) => + items.filter((i) => i.uid !== item.uid) + ); + } + + async toggleFavorite(item: UnifiedCollectionItem): Promise { + const nextFavoriteUids = new Set(this.favoriteUidSet()); + + if (nextFavoriteUids.has(item.uid)) { + await this.favoritesData.removeFavorite(item); + nextFavoriteUids.delete(item.uid); + } else { + await this.favoritesData.addFavorite(item); + nextFavoriteUids.add(item.uid); + } + + this.favoriteUidSet.set(nextFavoriteUids); + } + + async reorder( + items: UnifiedCollectionItem[], + request: CollectionLoadRequest + ): Promise { + const nonLive = this.allItems().filter((i) => i.contentType !== 'live'); + this.allItems.set([...items, ...nonLive]); + await this.favoritesData.reorder(items, request); + } + + /** Drop every row of one type from the list, without persisting. */ + dropContentType(contentType: CollectionContentType): void { + this.allItems.update((items) => + items.filter((item) => item.contentType !== contentType) + ); + } + + clearFavorites(items: UnifiedCollectionItem[]): Promise { + return this.favoritesData.clearFavorites(items); + } + + removeRecentItemsBatch(items: UnifiedCollectionItem[]): void { + void this.recentData.removeRecentItemsBatch(items); + } + + /** Move a just-played item back to the head of the recent list. */ + promoteRecentItem(item: UnifiedCollectionItem): void { + this.allItems.update((items) => { + const nextItems = [ + item, + ...items.filter((candidate) => candidate.uid !== item.uid), + ]; + return nextItems.sort( + (a, b) => + new Date(b.viewedAt ?? 0).getTime() - + new Date(a.viewedAt ?? 0).getTime() + ); + }); + } + + private async loadFavoriteUidSet( + params: CollectionLoadRequest + ): Promise> { + try { + const favorites = await this.favoritesData.getFavorites( + params.scope, + params.playlistId, + params.portalType + ); + return new Set(favorites.map((item) => item.uid)); + } catch { + return new Set(); + } + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts new file mode 100644 index 000000000..b86701537 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts @@ -0,0 +1,144 @@ +import { effect, inject, Signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + CollectionContentType, + UnifiedCollectionItem, + WorkspaceViewCommandService, +} from '@iptvnator/portal/shared/util'; +import { + CollectionMode, + UnifiedCollectionDataService, +} from '@iptvnator/portal/shared/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { resolveClearCollectionDialogKeys } from './unified-collection-labels'; + +export interface ClearCollectionAction { + /** Ask for confirmation, then clear every row of the tab on screen. */ + run(): void; +} + +/** + * "Clear this tab" for a unified collection. The rows leave the screen as + * soon as the user confirms; a favorites write that then fails puts the + * collection back the way storage has it. Must run in an injection context. + */ +export function createClearCollectionAction(options: { + mode: Signal; + /** Rows of the tab on screen — the ones this clears. */ + items: Signal; + typeLabelKey: Signal; + /** Whether those rows came from a single playlist; names the wording. */ + isPlaylistScope: () => boolean; + data: UnifiedCollectionDataService; + /** Drop the cleared tab and move to one that still has rows. */ + dropCurrentType: () => void; + /** Restore the collection from storage after a failed write. */ + reload: () => Promise; +}): ClearCollectionAction { + const dialogService = inject(DialogService); + const translate = inject(TranslateService); + + const clearFavorites = async ( + itemsToRemove: UnifiedCollectionItem[] + ): Promise => { + options.dropCurrentType(); + + try { + await options.data.clearFavorites(itemsToRemove); + } catch { + await options.reload(); + } + }; + + return { + run(): void { + const itemsToRemove = options.items(); + if (itemsToRemove.length === 0) { + return; + } + + const isFavorites = options.mode() === 'favorites'; + const type = translate.instant(options.typeLabelKey()); + const { titleKey, messageKey } = resolveClearCollectionDialogKeys( + options.mode(), + options.isPlaylistScope() + ); + + dialogService.openConfirmDialog({ + title: translate.instant(titleKey, { type }), + message: translate.instant(messageKey, { type }), + onConfirm: async () => { + if (isFavorites) { + await clearFavorites(itemsToRemove); + return; + } + + options.dropCurrentType(); + options.data.removeRecentItemsBatch(itemsToRemove); + }, + }); + }, + }; +} + +export interface ClearCollectionViewCommandHost { + /** Only the workspace shell renders a command palette to register into. */ + isWorkspaceLayout: boolean; + mode: Signal; + selectedContentType: Signal; + /** Rows the command would clear; with none there is nothing to offer. */ + currentTypeItems: Signal; + labelKey: Signal; + typeLabelKey: Signal; + run: () => void; +} + +/** + * Publish "clear the current tab" to the workspace command palette while the + * tab has rows. Must run in an injection context (the hosting component's + * field initializer or constructor). + */ +export function setupClearCollectionViewCommand( + host: ClearCollectionViewCommandHost +): void { + const workspaceViewCommands = inject(WorkspaceViewCommandService); + const translate = inject(TranslateService); + const typeParams = () => ({ + type: translate.instant(host.typeLabelKey()), + }); + + effect((onCleanup) => { + if (!host.isWorkspaceLayout || host.currentTypeItems().length === 0) { + return; + } + + onCleanup( + workspaceViewCommands.registerCommand({ + id: `unified-collection-clear-current-${host.mode()}`, + group: 'view', + icon: 'delete_sweep', + labelKey: host.labelKey(), + labelParams: typeParams, + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION', + descriptionParams: typeParams, + keywords: () => + host.mode() === 'favorites' + ? [ + 'clear', + 'favorites', + 'remove', + host.selectedContentType(), + ] + : [ + 'clear', + 'recent', + 'history', + host.selectedContentType(), + ], + priority: 10, + run: host.run, + }) + ); + }); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts new file mode 100644 index 000000000..1d08d84c2 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-content-type.ts @@ -0,0 +1,96 @@ +import { computed, Signal, signal, WritableSignal } from '@angular/core'; +import { + CollectionContentType, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; + +/** + * The Live / Movies / Series tab of a unified collection, plus the item + * partition it selects from. A tab exists only while the collection holds + * items of that type, so the state also owns the correction that runs after + * every load and every bulk removal. + */ +export interface CollectionContentTypeState { + readonly selected: WritableSignal; + readonly liveItems: Signal; + readonly movieItems: Signal; + readonly seriesItems: Signal; + readonly hasLive: Signal; + readonly hasMovies: Signal; + readonly hasSeries: Signal; + readonly availableTypes: Signal; + readonly showToggle: Signal; + readonly currentTypeItems: Signal; + readonly currentTypeLabelKey: Signal; + /** Move off a tab the collection no longer has items for. */ + autoSelect(): void; +} + +export function createCollectionContentTypeState( + allItems: Signal, + initialType: CollectionContentType +): CollectionContentTypeState { + const selected = signal(initialType); + const itemsOfType = (contentType: CollectionContentType) => + computed(() => + allItems().filter((item) => item.contentType === contentType) + ); + + const liveItems = itemsOfType('live'); + const movieItems = itemsOfType('movie'); + const seriesItems = itemsOfType('series'); + + const hasLive = computed(() => liveItems().length > 0); + const hasMovies = computed(() => movieItems().length > 0); + const hasSeries = computed(() => seriesItems().length > 0); + + const availableTypes = computed(() => { + const types: CollectionContentType[] = []; + if (hasLive()) types.push('live'); + if (hasMovies()) types.push('movie'); + if (hasSeries()) types.push('series'); + return types; + }); + + const currentTypeItems = computed(() => { + switch (selected()) { + case 'live': + return liveItems(); + case 'movie': + return movieItems(); + case 'series': + return seriesItems(); + } + }); + + const currentTypeLabelKey = computed(() => { + switch (selected()) { + case 'live': + return 'PORTALS.LIVE_TV'; + case 'movie': + return 'PORTALS.MOVIES'; + case 'series': + return 'PORTALS.SERIES'; + } + }); + + return { + selected, + liveItems, + movieItems, + seriesItems, + hasLive, + hasMovies, + hasSeries, + availableTypes, + showToggle: computed(() => availableTypes().length > 1), + currentTypeItems, + currentTypeLabelKey, + autoSelect(): void { + const types = availableTypes(); + if (types.length > 0 && !types.includes(selected())) { + selected.set(types[0]); + } + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts new file mode 100644 index 000000000..83149ebec --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-navigation.ts @@ -0,0 +1,88 @@ +import { + buildGlobalCollectionDetailNavigationTarget, + getUnifiedCollectionNavigation, + SeriesResumeTarget, + STALKER_RETURN_TO_STATE_KEY, + UnifiedCollectionItem, + WorkspaceNavigationTarget, +} from '@iptvnator/portal/shared/util'; + +/** + * Whether the route on screen may host this item's detail inline. A portal's + * own favorites/recent tabs render only their own provider's items, while the + * global collections host both portal providers; anything else falls back to + * the portal type the page was configured with. + */ +export function canRenderCollectionDetailOnRoute( + routerUrl: string, + portalType: string | undefined, + item: UnifiedCollectionItem +): boolean { + if (routerUrl.includes('/workspace/xtreams/')) { + return item.sourceType === 'xtream'; + } + + if (routerUrl.includes('/workspace/stalker/')) { + return item.sourceType === 'stalker'; + } + + if ( + routerUrl.includes('/workspace/global-favorites') || + routerUrl.includes('/workspace/global-recent') + ) { + return item.sourceType === 'xtream' || item.sourceType === 'stalker'; + } + + return !portalType || portalType === item.sourceType; +} + +/** + * Route to the item's detail inside the matching global collection. Live + * channels and M3U items have no such detail, so they keep the ordinary + * in-portal navigation below. + */ +export function buildCollectionDetailNavigation( + mode: 'favorites' | 'recent', + item: UnifiedCollectionItem, + seriesResume?: SeriesResumeTarget | null +): WorkspaceNavigationTarget | null { + if ( + item.contentType === 'live' || + (item.sourceType !== 'xtream' && item.sourceType !== 'stalker') + ) { + return null; + } + + return buildGlobalCollectionDetailNavigationTarget( + mode, + item, + seriesResume + ); +} + +/** + * Ordinary navigation into the item's own portal. A Stalker detail carries + * the current URL so its back affordance returns to this collection instead + * of the portal's catalog root. + */ +export function buildCollectionPortalNavigation( + item: UnifiedCollectionItem, + routerUrl: string +): WorkspaceNavigationTarget | null { + const navigation = getUnifiedCollectionNavigation(item); + if (!navigation) { + return null; + } + + if (item.sourceType !== 'stalker' || item.contentType === 'live') { + return navigation; + } + + return { + ...navigation, + state: { + ...(navigation.state ?? {}), + [STALKER_RETURN_TO_STATE_KEY]: routerUrl, + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts new file mode 100644 index 000000000..51d4659dc --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts @@ -0,0 +1,166 @@ +import { + computed, + effect, + inject, + signal, + Signal, + untracked, + WritableSignal, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + clearNavigationStateKeys, + CollectionContentType, + getOpenCollectionDetailItemState, + OPEN_COLLECTION_DETAIL_STATE_KEY, + SeriesResumeTarget, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { CollectionMode } from '@iptvnator/portal/shared/data-access'; +import { + buildCollectionDetailNavigation, + canRenderCollectionDetailOnRoute, +} from './unified-collection-detail-navigation'; +import { UnifiedCollectionDetailContext } from './unified-collection-detail.directive'; + +export interface CollectionDetailState { + readonly item: WritableSignal; + readonly seriesResume: WritableSignal; + /** Template context for the host's projected detail template. */ + readonly context: Signal; + /** Whether this route and host can render the item's detail in place. */ + canOpen(item: UnifiedCollectionItem): boolean; + open( + item: UnifiedCollectionItem, + seriesResume?: SeriesResumeTarget | null + ): void; + clear(): void; + /** Open, keep, or close the inline detail per the history entry. */ + syncFromHistory(): void; +} + +/** + * The detail a collection renders in place of its list. Only the portal + * providers have one, and only where the host projected a detail template; + * an item belonging to a provider this route cannot render is handed over to + * the matching global collection instead of being dropped. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createCollectionDetailState(options: { + mode: Signal; + portalType: Signal; + /** Absent while the host projects no detail template. */ + detailTemplate: Signal; + /** Show the tab the opened item belongs to. */ + onOpen: (contentType: CollectionContentType) => void; + /** The detail closed: fall back to a tab that still has rows. */ + onClear: () => void; +}): CollectionDetailState { + const router = inject(Router); + const item = signal(null); + const seriesResume = signal(null); + + const canRenderOnCurrentRoute = ( + candidate: UnifiedCollectionItem + ): boolean => + canRenderCollectionDetailOnRoute( + router.url, + options.portalType(), + candidate + ); + + const canOpen = (candidate: UnifiedCollectionItem): boolean => + Boolean(options.detailTemplate()) && + candidate.contentType !== 'live' && + (candidate.sourceType === 'xtream' || + candidate.sourceType === 'stalker') && + canRenderOnCurrentRoute(candidate); + + const open = ( + next: UnifiedCollectionItem, + nextSeriesResume?: SeriesResumeTarget | null + ): void => { + options.onOpen(next.contentType); + item.set(next); + seriesResume.set(nextSeriesResume ?? null); + }; + + const clear = (): void => { + item.set(null); + seriesResume.set(null); + options.onClear(); + clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]); + }; + + const requestClose = (): void => { + if (getOpenCollectionDetailItemState(window.history.state)) { + window.history.back(); + return; + } + + clear(); + }; + + // A detail this route cannot render (a Stalker item inside an Xtream + // portal's own tabs, say) belongs to the matching global collection. + effect(() => { + const current = item(); + if (!current || canRenderOnCurrentRoute(current)) { + return; + } + + const navigation = buildCollectionDetailNavigation( + options.mode(), + current, + seriesResume() + ); + if (!navigation) { + return; + } + + untracked(() => { + item.set(null); + void router.navigate(navigation.link, { + state: navigation.state, + }); + }); + }); + + return { + item, + seriesResume, + context: computed(() => { + const current = item(); + if (!current) { + return null; + } + + return { + $implicit: current, + item: current, + seriesResume: seriesResume(), + close: requestClose, + }; + }), + canOpen, + open, + clear, + syncFromHistory(): void { + const detailState = getOpenCollectionDetailItemState( + window.history.state + ); + const detailItem = detailState?.item; + + if (detailItem && canOpen(detailItem)) { + open(detailItem, detailState.seriesResume); + return; + } + + if (item()) { + clear(); + } + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts new file mode 100644 index 000000000..f425330ef --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-favorites-sort.ts @@ -0,0 +1,66 @@ +import { computed, Signal, signal } from '@angular/core'; +import { + FavoritesChannelSortMode, + getFavoritesChannelSortModeTranslationKey, + persistFavoritesChannelSortMode, + restoreFavoritesChannelSortMode, +} from '@iptvnator/portal/shared/util'; + +export interface FavoritesSortOption { + readonly mode: FavoritesChannelSortMode; + readonly translationKey: string; + readonly icon: string; +} + +export const FAVORITES_SORT_OPTIONS: readonly FavoritesSortOption[] = [ + { + mode: 'custom', + translationKey: 'WORKSPACE.SORT_CUSTOM', + icon: 'drag_indicator', + }, + { + mode: 'name-asc', + translationKey: 'WORKSPACE.SORT_NAME_ASC', + icon: 'sort_by_alpha', + }, + { + mode: 'name-desc', + translationKey: 'WORKSPACE.SORT_NAME_DESC', + icon: 'sort_by_alpha', + }, + { + mode: 'date-desc', + translationKey: 'WORKSPACE.SORT_DATE_DESC', + icon: 'schedule', + }, +]; + +export interface FavoritesSortState { + readonly mode: Signal; + readonly labelKey: Signal; + readonly options: readonly FavoritesSortOption[]; + setMode(mode: FavoritesChannelSortMode): void; +} + +/** + * Sort order of the favorites live rail. The choice is global rather than + * per collection, so it is restored from — and written straight back to — + * the shared persisted value. + */ +export function createFavoritesSortState(): FavoritesSortState { + const mode = signal( + restoreFavoritesChannelSortMode() + ); + + return { + mode: mode.asReadonly(), + labelKey: computed(() => + getFavoritesChannelSortModeTranslationKey(mode()) + ), + options: FAVORITES_SORT_OPTIONS, + setMode(next: FavoritesChannelSortMode): void { + mode.set(next); + persistFavoritesChannelSortMode(next); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts new file mode 100644 index 000000000..1a0474176 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-history.ts @@ -0,0 +1,141 @@ +import { + effect, + Signal, + signal, + untracked, + WritableSignal, +} from '@angular/core'; +import { + buildCollectionViewState, + buildOpenCollectionDetailItemState, + COLLECTION_VIEW_STATE_KEY, + CollectionContentType, + CollectionScope, + CollectionViewState, + getCollectionViewState, + OPEN_COLLECTION_DETAIL_STATE_KEY, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; + +/** + * The collection's selected tab and scope live in `window.history.state`, so + * the browser's own Back/Forward restores the view the user left instead of + * the route defaults. This owns that one key of the current entry: reading + * it back after a popstate, and replacing it in place while the user works. + */ +export class CollectionViewStateHistory { + private readonly state = signal( + getCollectionViewState(window.history.state) + ); + + /** View state of the history entry currently on screen. */ + readonly current: Signal = + this.state.asReadonly(); + + /** Re-read the entry — after a popstate, or on first content init. */ + refresh(): CollectionViewState | null { + const collectionViewState = getCollectionViewState( + window.history.state + ); + this.state.set(collectionViewState); + return collectionViewState; + } + + /** + * Replace the current entry's view state. A no-op while nothing changed, + * so a repeated read never spends a `replaceState` on an identical value. + */ + commit(next: CollectionViewState): void { + const nextCollectionViewState = buildCollectionViewState(next); + if (isSameCollectionViewState(this.state(), nextCollectionViewState)) { + return; + } + + const nextState = toHistoryStateRecord(window.history.state); + if (nextCollectionViewState) { + nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState; + } else { + delete nextState[COLLECTION_VIEW_STATE_KEY]; + } + + window.history.replaceState(nextState, document.title); + this.state.set(nextCollectionViewState); + } +} + +export interface CollectionViewStateSync { + /** Apply the entry's view state to the page (popstate, first init). */ + restore(): void; + /** Write the page's current view state before it navigates away. */ + commitCurrent(): void; +} + +/** + * Keep the history entry and the page's own view state in step: the page + * writes every change back as it happens, and reads the entry again after + * the browser moved to another one. Must run in an injection context. + */ +export function setupCollectionViewStateSync(options: { + history: CollectionViewStateHistory; + selectedContentType: WritableSignal; + /** `undefined` wherever the scope toggle is not offered. */ + scope: Signal; +}): CollectionViewStateSync { + const currentViewState = (): CollectionViewState => ({ + selectedContentType: options.selectedContentType(), + scope: options.scope(), + }); + + effect(() => { + const state = currentViewState(); + untracked(() => options.history.commit(state)); + }); + + return { + restore(): void { + const collectionViewState = options.history.refresh(); + if (collectionViewState?.selectedContentType) { + options.selectedContentType.set( + collectionViewState.selectedContentType + ); + } + }, + commitCurrent(): void { + options.history.commit(currentViewState()); + }, + }; +} + +/** + * Push a history entry for an inline detail, so every way back out of it + * (Escape, the shell's Back arrow, the browser's own Back) returns to the + * list rather than leaving the route. + */ +export function pushOpenCollectionDetailState( + item: UnifiedCollectionItem +): void { + window.history.pushState( + { + ...toHistoryStateRecord(window.history.state), + [OPEN_COLLECTION_DETAIL_STATE_KEY]: + buildOpenCollectionDetailItemState(item), + }, + document.title + ); +} + +function isSameCollectionViewState( + left: CollectionViewState | null, + right: CollectionViewState | null +): boolean { + return ( + left?.selectedContentType === right?.selectedContentType && + left?.scope === right?.scope + ); +} + +function toHistoryStateRecord(state: unknown): Record { + return state && typeof state === 'object' + ? { ...(state as Record) } + : {}; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts new file mode 100644 index 000000000..5674fd74c --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-labels.ts @@ -0,0 +1,64 @@ +import { computed, Signal } from '@angular/core'; +import { CollectionMode } from '@iptvnator/portal/shared/data-access'; + +export interface CollectionModeLabels { + readonly title: Signal; + readonly clearTooltipKey: Signal; + readonly emptyStateIcon: Signal; + readonly emptyStateTitleKey: Signal; + readonly emptyStateBodyKey: Signal; +} + +/** + * The copy a unified collection page shows for its mode. Favorites and + * recently viewed share every control, so only these strings differ. + */ +export function createCollectionModeLabels( + mode: Signal +): CollectionModeLabels { + const byMode = (favorites: T, recent: T): Signal => + computed(() => (mode() === 'favorites' ? favorites : recent)); + + return { + title: byMode('PORTALS.FAVORITES', 'PORTALS.RECENTLY_VIEWED'), + clearTooltipKey: byMode( + 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE', + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE' + ), + emptyStateIcon: byMode('favorite_border', 'history_toggle_off'), + emptyStateTitleKey: byMode( + 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE', + 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE' + ), + emptyStateBodyKey: byMode( + 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY', + 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY' + ), + }; +} + +/** + * Confirmation copy for "clear the current tab". The message names what the + * action reaches: the playlist scope removes this playlist's rows, the + * global one every playlist's. + */ +export function resolveClearCollectionDialogKeys( + mode: CollectionMode, + isPlaylistScope: boolean +): { titleKey: string; messageKey: string } { + if (mode === 'favorites') { + return { + titleKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE', + messageKey: isPlaylistScope + ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST' + : 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL', + }; + } + + return { + titleKey: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE', + messageKey: isPlaylistScope + ? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST' + : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL', + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts new file mode 100644 index 000000000..d889bbc98 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-load.ts @@ -0,0 +1,78 @@ +import { + computed, + effect, + EffectRef, + inject, + Signal, + untracked, +} from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; +import { + CollectionLoadRequest, + CollectionMode, +} from '@iptvnator/portal/shared/data-access'; +import { CollectionScope } from '@iptvnator/portal/shared/util'; + +/** + * Re-load the collection whenever what it should contain changes: its mode, + * the playlist or scope it is bound to, and — for favorites — the playlist + * rows themselves, since a heart toggled anywhere else in the app lands + * there rather than in this page's state. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function setupCollectionLoad(options: { + mode: Signal; + portalType: Signal; + playlistId: Signal; + scope: Signal; + load: (request: CollectionLoadRequest & { mode: CollectionMode }) => void; +}): EffectRef { + const store = inject(Store); + const playlists = store.selectSignal(selectAllPlaylistsMeta); + const playlistsLoaded = store.selectSignal(selectPlaylistsLoadingFlag); + + const favoritesReloadKey = computed(() => { + if (options.mode() !== 'favorites') { + return 'recent'; + } + + if (!playlistsLoaded()) { + return null; + } + + return playlists() + .map((playlist) => + [ + playlist._id, + playlist.serverUrl + ? 'xtream' + : playlist.macAddress + ? 'stalker' + : 'm3u', + JSON.stringify(playlist.favorites ?? []), + ].join('::') + ) + .join('|'); + }); + + const loadRequest = computed(() => ({ + mode: options.mode(), + portalType: options.portalType(), + playlistId: options.playlistId(), + scope: options.scope(), + reloadKey: favoritesReloadKey(), + })); + + return effect(() => { + const { mode, portalType, playlistId, scope } = loadRequest(); + untracked(() => { + options.load({ mode, portalType, playlistId, scope }); + }); + }); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index f41b3207d..c868ee6dd 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -35,7 +35,7 @@ import { import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from './collection-reload-indicator'; +import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from '@iptvnator/portal/shared/data-access'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 3900a1b20..2b0dfc2dd 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -6,14 +6,11 @@ import { computed, contentChild, DestroyRef, - effect, ElementRef, HostListener, inject, - linkedSignal, input, signal, - untracked, } from '@angular/core'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconButton } from '@angular/material/button'; @@ -22,59 +19,58 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { ChannelListSkeletonComponent, DialogService } from '@iptvnator/ui/components'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ChannelListSkeletonComponent } from '@iptvnator/ui/components'; import { - buildGlobalCollectionDetailNavigationTarget, - buildCollectionViewState, - buildOpenCollectionDetailItemState, clearNavigationStateKeys, - COLLECTION_VIEW_STATE_KEY, CollectionContentType, CollectionScope, - CollectionViewState, FavoritesChannelSortMode, - getFavoritesChannelSortModeTranslationKey, - getOpenCollectionDetailItemState, - getCollectionViewState, getOpenLiveCollectionItemState, - getUnifiedCollectionNavigation, isWorkspaceLayoutRoute, isTypingInInput, LiveLayoutSidebarStateService, - OPEN_COLLECTION_DETAIL_STATE_KEY, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, - persistFavoritesChannelSortMode, queryParamSignal, - restoreFavoritesChannelSortMode, routeParamSignal, - ScopeToggleService, - STALKER_RETURN_TO_STATE_KEY, - SeriesResumeTarget, UnifiedCollectionItem, - WorkspaceViewCommandService, } from '@iptvnator/portal/shared/util'; import { - UnifiedFavoritesDataService, - UnifiedRecentDataService, + CollectionLoadRequest, + CollectionMode, + UnifiedCollectionDataService, } from '@iptvnator/portal/shared/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; -import { createCollectionReloadIndicator } from './collection-reload-indicator'; import { - UnifiedCollectionDetailContext, - UnifiedCollectionDetailDirective, -} from './unified-collection-detail.directive'; + createClearCollectionAction, + setupClearCollectionViewCommand, +} from './unified-collection-clear-action'; +import { createCollectionContentTypeState } from './unified-collection-content-type'; +import { + buildCollectionDetailNavigation, + buildCollectionPortalNavigation, +} from './unified-collection-detail-navigation'; +import { createCollectionDetailState } from './unified-collection-detail-state'; +import { createFavoritesSortState } from './unified-collection-favorites-sort'; +import { + CollectionViewStateHistory, + pushOpenCollectionDetailState, + setupCollectionViewStateSync, +} from './unified-collection-history'; +import { createCollectionModeLabels } from './unified-collection-labels'; +import { setupCollectionLoad } from './unified-collection-load'; +import { createCollectionScopeState } from './unified-collection-scope'; +import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive'; @Component({ selector: 'app-unified-collection-page', templateUrl: './unified-collection-page.component.html', styleUrl: './unified-collection-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [UnifiedCollectionDataService], imports: [ ChannelListSkeletonComponent, EmptyStateComponent, @@ -91,7 +87,7 @@ import { ], }) export class UnifiedCollectionPageComponent implements AfterContentInit { - readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly mode = input('favorites'); readonly portalType = input(); readonly playlistIdInput = input(undefined, { alias: 'playlistId', @@ -100,32 +96,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly store = inject(Store); private readonly destroyRef = inject(DestroyRef); private readonly hostElement = inject(ElementRef); - private readonly scopeService = inject(ScopeToggleService); - private readonly favoritesData = inject(UnifiedFavoritesDataService); - private readonly recentData = inject(UnifiedRecentDataService); - private readonly dialogService = inject(DialogService); + private readonly data = inject(UnifiedCollectionDataService); private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly translate = inject(TranslateService); - private readonly workspaceViewCommands = inject(WorkspaceViewCommandService); private readonly liveSidebarStateService = inject( LiveLayoutSidebarStateService ); readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective); - private readonly playlists = this.store.selectSignal( - selectAllPlaylistsMeta - ); - private readonly playlistsLoaded = this.store.selectSignal( - selectPlaylistsLoadingFlag - ); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); - private readonly queryScope = queryParamSignal( - this.route, - 'scope', - (value) => (value === 'all' || value === 'playlist' ? value : null) - ); private readonly routeSearchTerm = queryParamSignal( this.route, 'q', @@ -142,165 +121,72 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); - private readonly historyCollectionViewState = - signal( - getCollectionViewState(window.history.state) - ); + private readonly viewStateHistory = new CollectionViewStateHistory(); - /** First load with nothing on screen: the skeleton replaces the content. */ - readonly isLoading = signal(true); - private readonly reloadIndicator = createCollectionReloadIndicator( - this.destroyRef - ); - /** - * A reload (scope switch, favorites reload) is in flight while the - * previous items stay mounted. Never swaps to the skeleton, so a playing - * channel and the focused toggle survive. - */ - readonly isReloading = this.reloadIndicator.active; - /** `isReloading` past its grace period: progress bar + dimming render. */ - readonly showReloadIndicator = this.reloadIndicator.visible; - readonly allItems = signal([]); - /** - * The request that produced `allItems`. Actions on displayed rows (Clear, - * drag reorder) must use it, not `effectiveScope()`: during a reload the - * toggle already names the requested scope while the previous rows are - * still on screen, and "This playlist" against global rows would delete - * other playlists' favorites or write foreign URLs into this playlist. - */ - private readonly loadedRequest = signal<{ - scope: CollectionScope; - playlistId?: string; - portalType?: string; - } | null>(null); - readonly favoriteUidSet = signal>(new Set()); - readonly selectedContentType = signal( - this.historyCollectionViewState()?.selectedContentType ?? 'live' - ); + readonly isLoading = this.data.isLoading; + readonly isReloading = this.data.isReloading; + readonly showReloadIndicator = this.data.showReloadIndicator; + readonly allItems = this.data.allItems; + readonly favoriteUidSet = this.data.favoriteUidSet; readonly supportsEpg = this.runtime.supportsEpg; - readonly selectedDetailItem = signal(null); - readonly selectedDetailSeriesResume = signal( - null - ); + readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); + readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); readonly pendingAutoOpenLiveItem = signal( getOpenLiveCollectionItemState(window.history.state) ); - readonly detailContext = computed( - () => { - const item = this.selectedDetailItem(); - if (!item) { - return null; - } - return { - $implicit: item, - item, - seriesResume: this.selectedDetailSeriesResume(), - close: this.requestCloseDetail, - }; - } + private readonly contentType = createCollectionContentTypeState( + this.allItems, + this.viewStateHistory.current()?.selectedContentType ?? 'live' ); - - readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); - readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); + readonly selectedContentType = this.contentType.selected; + readonly liveItems = this.contentType.liveItems; + readonly movieItems = this.contentType.movieItems; + readonly seriesItems = this.contentType.seriesItems; + readonly hasLive = this.contentType.hasLive; + readonly hasMovies = this.contentType.hasMovies; + readonly hasSeries = this.contentType.hasSeries; + readonly showContentToggle = this.contentType.showToggle; + readonly currentTypeItems = this.contentType.currentTypeItems; + readonly currentTypeLabelKey = this.contentType.currentTypeLabelKey; readonly scopeKey = computed(() => this.mode()); - private readonly persistedScope = computed(() => - this.scopeService.getScope(this.scopeKey())() - ); - readonly scope = linkedSignal(() => { - if (!this.showScopeToggle()) { - return 'all'; - } - - const queryScope = this.queryScope(); - if (queryScope) { - return queryScope; - } - - const historyScope = this.historyCollectionViewState()?.scope; - if (historyScope) { - return historyScope; - } - - const defaultScope = this.defaultScope(); - if (defaultScope) { - return defaultScope; - } - - return this.persistedScope(); + private readonly scopeState = createCollectionScopeState({ + scopeKey: this.scopeKey, + playlistId: this.playlistId, + defaultScope: this.defaultScope, + historyScope: computed(() => this.viewStateHistory.current()?.scope), }); - readonly showScopeToggle = computed(() => Boolean(this.playlistId())); - readonly effectiveScope = computed(() => - this.showScopeToggle() ? this.scope() : 'all' + readonly scope = this.scopeState.scope; + readonly showScopeToggle = this.scopeState.showToggle; + readonly effectiveScope = this.scopeState.effective; + /** + * Scope of the rows on screen. Every action on them (Clear, drag + * reorder) uses this instead of `effectiveScope()`, which already names + * the requested scope while a reload is in flight — "This playlist" + * against global rows would delete other playlists' favorites or write + * foreign URLs into this playlist. + */ + private readonly mutationRequest = computed( + () => + this.data.loadedRequest() ?? { + scope: this.effectiveScope(), + playlistId: this.playlistId(), + portalType: this.portalType(), + } ); - readonly liveItems = computed(() => - this.allItems().filter((i) => i.contentType === 'live') - ); - readonly movieItems = computed(() => - this.allItems().filter((i) => i.contentType === 'movie') - ); - readonly seriesItems = computed(() => - this.allItems().filter((i) => i.contentType === 'series') - ); + private readonly labels = createCollectionModeLabels(this.mode); + readonly title = this.labels.title; + readonly clearButtonTooltipKey = this.labels.clearTooltipKey; + readonly emptyStateIcon = this.labels.emptyStateIcon; + readonly emptyStateTitleKey = this.labels.emptyStateTitleKey; + readonly emptyStateBodyKey = this.labels.emptyStateBodyKey; - readonly hasLive = computed(() => this.liveItems().length > 0); - readonly hasMovies = computed(() => this.movieItems().length > 0); - readonly hasSeries = computed(() => this.seriesItems().length > 0); - - readonly availableTypes = computed(() => { - const types: CollectionContentType[] = []; - if (this.hasLive()) types.push('live'); - if (this.hasMovies()) types.push('movie'); - if (this.hasSeries()) types.push('series'); - return types; - }); - - readonly showContentToggle = computed( - () => this.availableTypes().length > 1 - ); - - readonly currentTypeItems = computed(() => { - switch (this.selectedContentType()) { - case 'live': - return this.liveItems(); - case 'movie': - return this.movieItems(); - case 'series': - return this.seriesItems(); - } - }); - - readonly currentTypeLabelKey = computed(() => { - switch (this.selectedContentType()) { - case 'live': - return 'PORTALS.LIVE_TV'; - case 'movie': - return 'PORTALS.MOVIES'; - case 'series': - return 'PORTALS.SERIES'; - } - }); - - readonly clearButtonTooltipKey = computed(() => - this.mode() === 'favorites' - ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE' - : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE' - ); - - readonly title = computed(() => { - return this.mode() === 'favorites' - ? 'PORTALS.FAVORITES' - : 'PORTALS.RECENTLY_VIEWED'; - }); - - readonly favSortMode = signal( - restoreFavoritesChannelSortMode() - ); - readonly favSortLabelKey = computed(() => - getFavoritesChannelSortModeTranslationKey(this.favSortMode()) - ); + private readonly favoritesSort = createFavoritesSortState(); + readonly favSortMode = this.favoritesSort.mode; + readonly favSortLabelKey = this.favoritesSort.labelKey; + readonly favSortOptions = this.favoritesSort.options; readonly showFavSortButton = computed( () => this.mode() === 'favorites' && @@ -312,148 +198,62 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly showSidebarToggle = computed( () => this.selectedContentType() === 'live' && this.hasLive() ); - readonly favSortOptions: ReadonlyArray<{ - mode: FavoritesChannelSortMode; - translationKey: string; - icon: string; - }> = [ - { - mode: 'custom', - translationKey: 'WORKSPACE.SORT_CUSTOM', - icon: 'drag_indicator', - }, - { - mode: 'name-asc', - translationKey: 'WORKSPACE.SORT_NAME_ASC', - icon: 'sort_by_alpha', - }, - { - mode: 'name-desc', - translationKey: 'WORKSPACE.SORT_NAME_DESC', - icon: 'sort_by_alpha', - }, - { - mode: 'date-desc', - translationKey: 'WORKSPACE.SORT_DATE_DESC', - icon: 'schedule', - }, - ]; - private readonly favoritesReloadKey = computed(() => { - if (this.mode() !== 'favorites') { - return 'recent'; - } - - if (!this.playlistsLoaded()) { - return null; - } - - return this.playlists() - .map((playlist) => - [ - playlist._id, - playlist.serverUrl - ? 'xtream' - : playlist.macAddress - ? 'stalker' - : 'm3u', - JSON.stringify(playlist.favorites ?? []), - ].join('::') - ) - .join('|'); + private readonly loadEffect = setupCollectionLoad({ + mode: this.mode, + portalType: this.portalType, + playlistId: this.playlistId, + scope: this.effectiveScope, + load: (request) => void this.loadData(request), }); - private readonly loadRequest = computed(() => ({ - mode: this.mode(), - portalType: this.portalType(), - playlistId: this.playlistId(), - scope: this.effectiveScope(), - reloadKey: this.favoritesReloadKey(), - })); - - private loadRequestId = 0; - - private readonly loadEffect = effect(() => { - const { mode, portalType, playlistId, scope } = this.loadRequest(); - untracked(() => { - void this.loadData({ - mode, - portalType, - playlistId, - scope, - }); - }); + private readonly viewStateSync = setupCollectionViewStateSync({ + history: this.viewStateHistory, + selectedContentType: this.selectedContentType, + scope: computed(() => + this.showScopeToggle() ? this.scope() : undefined + ), }); - private readonly persistCollectionViewState = effect(() => { - const selectedContentType = this.selectedContentType(); - const scope = this.showScopeToggle() ? this.scope() : undefined; - - untracked(() => { - this.syncCollectionViewStateToHistory({ - selectedContentType, - scope, - }); - }); - }); - private readonly crossProviderDetailRedirect = effect(() => { - const item = this.selectedDetailItem(); - if (!item || this.canRenderInlineDetailOnCurrentRoute(item)) { - return; - } - - const navigation = this.getGlobalCollectionDetailNavigation( - item, - this.selectedDetailSeriesResume() - ); - if (!navigation) { - return; - } - - untracked(() => { - this.selectedDetailItem.set(null); - void this.router.navigate(navigation.link, { - state: navigation.state, - }); - }); - }); - private readonly workspaceCommandEffect = effect((onCleanup) => { - if (!this.isWorkspaceLayout) { - return; - } - - const items = this.currentTypeItems(); - if (items.length === 0) { - return; - } - - const unregister = this.workspaceViewCommands.registerCommand({ - id: `unified-collection-clear-current-${this.mode()}`, - group: 'view', - icon: 'delete_sweep', - labelKey: this.clearButtonTooltipKey(), - labelParams: () => ({ - type: this.translate.instant(this.currentTypeLabelKey()), + private readonly clearAction = createClearCollectionAction({ + mode: this.mode, + items: this.currentTypeItems, + typeLabelKey: this.currentTypeLabelKey, + isPlaylistScope: () => this.mutationRequest().scope === 'playlist', + data: this.data, + dropCurrentType: () => this.dropCurrentContentType(), + reload: () => + this.loadData({ + mode: this.mode(), + portalType: this.portalType(), + playlistId: this.playlistId(), + scope: this.effectiveScope(), }), - descriptionKey: - 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION', - descriptionParams: () => ({ - type: this.translate.instant(this.currentTypeLabelKey()), - }), - keywords: () => - this.mode() === 'favorites' - ? ['clear', 'favorites', 'remove', this.selectedContentType()] - : ['clear', 'recent', 'history', this.selectedContentType()], - priority: 10, + }); + private readonly detailState = createCollectionDetailState({ + mode: this.mode, + portalType: this.portalType, + detailTemplate: this.detailTemplate, + onOpen: (contentType) => this.selectedContentType.set(contentType), + onClear: () => this.autoSelectContentType(), + }); + readonly selectedDetailItem = this.detailState.item; + readonly selectedDetailSeriesResume = this.detailState.seriesResume; + readonly detailContext = this.detailState.context; + + constructor() { + setupClearCollectionViewCommand({ + isWorkspaceLayout: this.isWorkspaceLayout, + mode: this.mode, + selectedContentType: this.selectedContentType, + currentTypeItems: this.currentTypeItems, + labelKey: this.clearButtonTooltipKey, + typeLabelKey: this.currentTypeLabelKey, run: () => this.clearAllCurrent(), }); - onCleanup(unregister); - }); - - constructor() { if (typeof window !== 'undefined') { const onPopState = () => { - this.syncCollectionViewStateFromHistory(); - this.syncDetailFromHistoryState(); + this.viewStateSync.restore(); + this.detailState.syncFromHistory(); }; window.addEventListener('popstate', onPopState); this.destroyRef.onDestroy(() => { @@ -463,39 +263,18 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } ngAfterContentInit(): void { - this.syncCollectionViewStateFromHistory(); - this.syncDetailFromHistoryState(); + this.viewStateSync.restore(); + this.detailState.syncFromHistory(); } onScopeChange(value: CollectionScope): void { - if (!this.showScopeToggle()) { - return; - } - - this.scope.set(value); - this.scopeService.setScope(this.scopeKey(), value); + this.scopeState.select(value); } onContentTypeChange(value: CollectionContentType): void { this.selectedContentType.set(value); } - readonly emptyStateIcon = computed(() => - this.mode() === 'favorites' ? 'favorite_border' : 'history_toggle_off' - ); - - readonly emptyStateTitleKey = computed(() => - this.mode() === 'favorites' - ? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE' - : 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE' - ); - - readonly emptyStateBodyKey = computed(() => - this.mode() === 'favorites' - ? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY' - : 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY' - ); - goToDashboard(): void { void this.router.navigate(['/workspace', 'dashboard']); } @@ -528,58 +307,32 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } setFavSortMode(mode: FavoritesChannelSortMode): void { - this.favSortMode.set(mode); - persistFavoritesChannelSortMode(mode); + this.favoritesSort.setMode(mode); } onGridItemSelected(item: UnifiedCollectionItem): void { - this.syncCurrentCollectionViewState(); + this.viewStateSync.commitCurrent(); - if (this.canOpenInlineDetail(item)) { - this.pushInlineDetailState(item); - this.openInlineDetail(item); + if (this.detailState.canOpen(item)) { + pushOpenCollectionDetailState(item); + this.detailState.open(item); return; } - const globalDetailNavigation = - this.getGlobalCollectionDetailNavigation(item); - if (globalDetailNavigation) { - void this.router.navigate(globalDetailNavigation.link, { - state: globalDetailNavigation.state, - }); - return; - } - - const navigation = getUnifiedCollectionNavigation(item); + const navigation = + buildCollectionDetailNavigation(this.mode(), item) ?? + buildCollectionPortalNavigation(item, this.router.url); if (!navigation) { return; } - const state = - item.sourceType === 'stalker' && item.contentType !== 'live' - ? { - ...(navigation.state ?? {}), - [STALKER_RETURN_TO_STATE_KEY]: this.router.url, - } - : navigation.state; - - void this.router.navigate(navigation.link, { state }); + void this.router.navigate(navigation.link, { + state: navigation.state, + }); } async onRemoveItem(item: UnifiedCollectionItem): Promise { - if (this.mode() === 'favorites') { - await this.favoritesData.removeFavorite(item); - this.favoriteUidSet.update((favoriteUids) => { - const nextFavoriteUids = new Set(favoriteUids); - nextFavoriteUids.delete(item.uid); - return nextFavoriteUids; - }); - } else { - await this.recentData.removeRecentItem(item); - } - this.allItems.update((items) => - items.filter((i) => i.uid !== item.uid) - ); + await this.data.removeItem(this.mode(), item); } async onFavoriteToggled(item: UnifiedCollectionItem): Promise { @@ -587,74 +340,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { return; } - const nextFavoriteUids = new Set(this.favoriteUidSet()); - - if (nextFavoriteUids.has(item.uid)) { - await this.favoritesData.removeFavorite(item); - nextFavoriteUids.delete(item.uid); - } else { - await this.favoritesData.addFavorite(item); - nextFavoriteUids.add(item.uid); - } - - this.favoriteUidSet.set(nextFavoriteUids); + await this.data.toggleFavorite(item); } clearAllCurrent(): void { - const itemsToRemove = this.currentTypeItems(); - if (itemsToRemove.length === 0) { - return; - } - - const isFavorites = this.mode() === 'favorites'; - const type = this.translate.instant(this.currentTypeLabelKey()); - const isPlaylistScope = - (this.loadedRequest()?.scope ?? this.effectiveScope()) === - 'playlist'; - const titleKey = isFavorites - ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE' - : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE'; - const messageKey = isFavorites - ? isPlaylistScope - ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST' - : 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL' - : isPlaylistScope - ? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST' - : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL'; - - this.dialogService.openConfirmDialog({ - title: this.translate.instant(titleKey, { type }), - message: this.translate.instant(messageKey, { type }), - onConfirm: async () => { - if (isFavorites) { - await this.clearCurrentFavorites(itemsToRemove); - return; - } - - const removedType = this.selectedContentType(); - this.allItems.update((items) => - items.filter((item) => item.contentType !== removedType) - ); - const remaining = this.availableTypes(); - if (remaining.length > 0) { - this.selectedContentType.set(remaining[0]); - } - void this.recentData.removeRecentItemsBatch(itemsToRemove); - }, - }); + this.clearAction.run(); } async onReorder(items: UnifiedCollectionItem[]): Promise { - const nonLive = this.allItems().filter((i) => i.contentType !== 'live'); - this.allItems.set([...items, ...nonLive]); - await this.favoritesData.reorder( - items, - this.loadedRequest() ?? { - scope: this.effectiveScope(), - playlistId: this.playlistId(), - portalType: this.portalType(), - } - ); + await this.data.reorder(items, this.mutationRequest()); } onItemPlayed(item: UnifiedCollectionItem): void { @@ -662,133 +356,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { return; } - this.allItems.update((items) => { - const nextItems = [ - item, - ...items.filter((candidate) => candidate.uid !== item.uid), - ]; - return nextItems.sort( - (a, b) => - new Date(b.viewedAt ?? 0).getTime() - - new Date(a.viewedAt ?? 0).getTime() - ); - }); - } - - private async clearCurrentFavorites( - itemsToRemove: UnifiedCollectionItem[] - ): Promise { - const removedType = this.selectedContentType(); - this.allItems.update((items) => - items.filter((item) => item.contentType !== removedType) - ); - const remaining = this.availableTypes(); - if (remaining.length > 0) { - this.selectedContentType.set(remaining[0]); - } - - try { - await this.favoritesData.clearFavorites(itemsToRemove); - } catch { - await this.reloadCurrentCollection(); - } - } - - private async loadData(params: { - mode: 'favorites' | 'recent'; - portalType?: string; - playlistId?: string; - scope: CollectionScope; - }): Promise { - const requestId = ++this.loadRequestId; - if (this.allItems().length === 0) { - this.isLoading.set(true); - } else { - this.reloadIndicator.begin(); - } - - try { - const items = - params.mode === 'favorites' - ? await this.favoritesData.getFavorites( - params.scope, - params.playlistId, - params.portalType - ) - : await this.recentData.getRecentItems( - params.scope, - params.playlistId, - params.portalType - ); - const favoriteUids = - params.mode === 'favorites' - ? new Set(items.map((item) => item.uid)) - : await this.loadFavoriteUidSet(params); - if (requestId !== this.loadRequestId) { - return; - } - this.allItems.set(items); - this.loadedRequest.set({ - scope: params.scope, - playlistId: params.playlistId, - portalType: params.portalType, - }); - this.favoriteUidSet.set(favoriteUids); - this.autoSelectContentType(); - if ( - this.pendingAutoOpenLiveItem() && - items.some((item) => item.contentType === 'live') - ) { - this.selectedContentType.set('live'); - } - } catch { - if (requestId !== this.loadRequestId) { - return; - } - this.allItems.set([]); - } finally { - if (requestId === this.loadRequestId) { - this.isLoading.set(false); - this.reloadIndicator.settle(); - } - } - } - - private async reloadCurrentCollection(): Promise { - await this.loadData({ - mode: this.mode(), - portalType: this.portalType(), - playlistId: this.playlistId(), - scope: this.effectiveScope(), - }); - } - - private async loadFavoriteUidSet(params: { - portalType?: string; - playlistId?: string; - scope: CollectionScope; - }): Promise> { - try { - const favorites = await this.favoritesData.getFavorites( - params.scope, - params.playlistId, - params.portalType - ); - return new Set(favorites.map((item) => item.uid)); - } catch { - return new Set(); - } - } - - private autoSelectContentType(): void { - if (this.selectedDetailItem()) { - return; - } - - const types = this.availableTypes(); - if (types.length > 0 && !types.includes(this.selectedContentType())) { - this.selectedContentType.set(types[0]); - } + this.data.promoteRecentItem(item); } onLiveAutoOpenHandled(): void { @@ -796,171 +364,35 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { clearNavigationStateKeys([OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]); } - private readonly requestCloseDetail = (): void => { - if (getOpenCollectionDetailItemState(window.history.state)) { - window.history.back(); + /** Take the just-cleared tab off screen and move to a remaining one. */ + private dropCurrentContentType(): void { + this.data.dropContentType(this.selectedContentType()); + this.contentType.autoSelect(); + } + + private async loadData( + params: CollectionLoadRequest & { mode: CollectionMode } + ): Promise { + const items = await this.data.load(params); + if (!items) { return; } - this.clearInlineDetail(); - }; - - private canOpenInlineDetail(item: UnifiedCollectionItem): boolean { - return ( - Boolean(this.detailTemplate()) && - item.contentType !== 'live' && - (item.sourceType === 'xtream' || item.sourceType === 'stalker') && - this.canRenderInlineDetailOnCurrentRoute(item) - ); - } - - private canRenderInlineDetailOnCurrentRoute( - item: UnifiedCollectionItem - ): boolean { - const url = this.router.url; - - if (url.includes('/workspace/xtreams/')) { - return item.sourceType === 'xtream'; - } - - if (url.includes('/workspace/stalker/')) { - return item.sourceType === 'stalker'; - } - - if ( - url.includes('/workspace/global-favorites') || - url.includes('/workspace/global-recent') - ) { - return ( - item.sourceType === 'xtream' || item.sourceType === 'stalker' - ); - } - - const portalType = this.portalType(); - return !portalType || portalType === item.sourceType; - } - - private getGlobalCollectionDetailNavigation( - item: UnifiedCollectionItem, - seriesResume?: SeriesResumeTarget | null - ) { - if ( - item.contentType === 'live' || - (item.sourceType !== 'xtream' && item.sourceType !== 'stalker') - ) { - return null; - } - - return buildGlobalCollectionDetailNavigationTarget( - this.mode(), - item, - seriesResume - ); - } - - private openInlineDetail( - item: UnifiedCollectionItem, - seriesResume?: SeriesResumeTarget | null - ): void { - this.selectedContentType.set(item.contentType); - this.selectedDetailItem.set(item); - this.selectedDetailSeriesResume.set(seriesResume ?? null); - } - - private clearInlineDetail(): void { - this.selectedDetailItem.set(null); - this.selectedDetailSeriesResume.set(null); this.autoSelectContentType(); - clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]); - } - - private pushInlineDetailState(item: UnifiedCollectionItem): void { - const currentState = window.history.state ?? {}; - window.history.pushState( - { - ...currentState, - [OPEN_COLLECTION_DETAIL_STATE_KEY]: - buildOpenCollectionDetailItemState(item), - }, - document.title - ); - } - - private syncCollectionViewStateFromHistory(): void { - const collectionViewState = getCollectionViewState( - window.history.state - ); - this.historyCollectionViewState.set(collectionViewState); - - if (collectionViewState?.selectedContentType) { - this.selectedContentType.set( - collectionViewState.selectedContentType - ); - } - } - - private syncCurrentCollectionViewState(): void { - this.syncCollectionViewStateToHistory({ - selectedContentType: this.selectedContentType(), - scope: this.showScopeToggle() ? this.scope() : undefined, - }); - } - - private syncCollectionViewStateToHistory(state: CollectionViewState): void { - const nextCollectionViewState = buildCollectionViewState(state); - const currentCollectionViewState = this.historyCollectionViewState(); - if ( - this.isSameCollectionViewState( - currentCollectionViewState, - nextCollectionViewState - ) + this.pendingAutoOpenLiveItem() && + items.some((item) => item.contentType === 'live') ) { - return; + this.selectedContentType.set('live'); } - - const currentState = this.toHistoryStateRecord(window.history.state); - const nextState = { ...currentState }; - - if (nextCollectionViewState) { - nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState; - } else { - delete nextState[COLLECTION_VIEW_STATE_KEY]; - } - - window.history.replaceState(nextState, document.title); - this.historyCollectionViewState.set(nextCollectionViewState); } - private syncDetailFromHistoryState(): void { - const detailState = getOpenCollectionDetailItemState( - window.history.state - ); - const detailItem = detailState?.item; - - if (detailItem && this.canOpenInlineDetail(detailItem)) { - this.openInlineDetail(detailItem, detailState.seriesResume); - return; - } - + /** Never moves off the tab an open inline detail belongs to. */ + private autoSelectContentType(): void { if (this.selectedDetailItem()) { - this.clearInlineDetail(); + return; } - } - private isSameCollectionViewState( - left: CollectionViewState | null, - right: CollectionViewState | null - ): boolean { - return ( - left?.selectedContentType === right?.selectedContentType && - left?.scope === right?.scope - ); - } - - private toHistoryStateRecord(state: unknown): Record { - return state && typeof state === 'object' - ? { ...(state as Record) } - : {}; + this.contentType.autoSelect(); } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts new file mode 100644 index 000000000..92dd86d41 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-scope.ts @@ -0,0 +1,78 @@ +import { + computed, + inject, + linkedSignal, + Signal, + WritableSignal, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { + CollectionScope, + queryParamSignal, + ScopeToggleService, +} from '@iptvnator/portal/shared/util'; + +export interface CollectionScopeState { + /** The toggle's value, even where the toggle itself is not offered. */ + readonly scope: WritableSignal; + /** Only a playlist-bound collection can narrow to one playlist. */ + readonly showToggle: Signal; + /** The scope to load with: `all` wherever the toggle is absent. */ + readonly effective: Signal; + /** The user picked a scope; remember it for the next visit. */ + select(value: CollectionScope): void; +} + +/** + * "This playlist" vs "All playlists" for a collection. The value is taken + * from the first source that states one — the `scope` query param, the + * history entry the user came back to, the route's own default — and falls + * back to what this collection was last left on. Must run in an injection + * context (the hosting component's field initializer or constructor). + */ +export function createCollectionScopeState(options: { + /** Keys the persisted scope: favorites and recent are remembered apart. */ + scopeKey: Signal; + playlistId: Signal; + defaultScope: Signal; + historyScope: Signal; +}): CollectionScopeState { + const route = inject(ActivatedRoute); + const scopeService = inject(ScopeToggleService); + const queryScope = queryParamSignal( + route, + 'scope', + (value) => (value === 'all' || value === 'playlist' ? value : null) + ); + const persistedScope = computed(() => + scopeService.getScope(options.scopeKey())() + ); + const showToggle = computed(() => Boolean(options.playlistId())); + + const scope = linkedSignal(() => { + if (!showToggle()) { + return 'all'; + } + + return ( + queryScope() ?? + options.historyScope() ?? + options.defaultScope() ?? + persistedScope() + ); + }); + + return { + scope, + showToggle, + effective: computed(() => (showToggle() ? scope() : 'all')), + select(value: CollectionScope): void { + if (!showToggle()) { + return; + } + + scope.set(value); + scopeService.setScope(options.scopeKey(), value); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts new file mode 100644 index 000000000..b161d877b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup.ts @@ -0,0 +1,183 @@ +import { computed, inject, Signal, WritableSignal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils'; +import { + ResolvedLiveCollectionDetail, + StreamResolverService, +} from '@iptvnator/portal/shared/data-access'; +import { + PORTAL_PLAYER, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { + EpgArchiveCopyService, + EpgProgramActivationEvent, +} from '@iptvnator/ui/epg'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { toEpochSeconds } from './unified-live-epg-summary.util'; +import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; + +/** Catch-up override for the active channel; `null` = live playback. */ +export interface UnifiedLiveTimeshift { + url: string; + program: EpgProgram; +} + +export interface UnifiedLiveCatchup { + readonly activeProgram: Signal; + /** Act on a programme picked in the timeline or the list view. */ + handleProgramActivation(event: EpgProgramActivationEvent): void; + returnToLive(): void; +} + +/** + * Catch-up playback for the live tab: turning a programme from the EPG + * panel into a timeshift URL, and getting back to the live edge. The + * override itself lives on the host, because the selection session has to + * swap it together with the detail it belongs to. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveCatchup(options: { + activeTimeshift: WritableSignal; + activeItem: Signal; + activeDetail: Signal; + isM3uSelection: Signal; + currentM3uChannel: Signal; + shouldUseInlinePlayer: Signal; + /** Drops a portal resolution that landed after a channel switch. */ + generation: UnifiedLiveSelectionGeneration; +}): UnifiedLiveCatchup { + const archiveCopy = inject(EpgArchiveCopyService); + const streamResolver = inject(StreamResolverService); + const portalPlayer = inject(PORTAL_PLAYER); + const snackBar = inject(MatSnackBar); + const translate = inject(TranslateService); + + const reportFailure = (): void => { + snackBar.open( + translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + }; + + /** Hand the resolved archive URL to whichever surface is playing. */ + const playArchive = (url: string, program: EpgProgram): void => { + options.activeTimeshift.set({ url, program }); + + const playback = options.activeDetail()?.playback; + if (!options.shouldUseInlinePlayer() && playback) { + void portalPlayer.openResolvedPlayback({ + ...playback, + streamUrl: url, + isLive: false, + }); + } + }; + + /** M3U catch-up: resolve via the M3U timeshift URL resolver. */ + const activateM3uCatchup = (program: EpgProgram): void => { + const playbackUrl = resolveM3uCatchupUrl( + options.currentM3uChannel(), + program + ); + if (!playbackUrl) { + reportFailure(); + return; + } + + playArchive(playbackUrl, program); + }; + + /** + * Portal (Xtream) catch-up: compute start/stop as epoch seconds and + * resolve via the provider's timeshift endpoint. + */ + const activatePortalCatchup = async ( + program: EpgProgram + ): Promise => { + const generation = options.generation.current(); + const item = options.activeItem(); + const startEpoch = toEpochSeconds( + program.startTimestamp, + program.start + ); + const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop); + if (!item?.xtreamId || startEpoch == null || stopEpoch == null) { + reportFailure(); + return; + } + + const playbackUrl = await streamResolver.resolveXtreamCatchupUrl( + item, + startEpoch, + stopEpoch + ); + if (generation !== options.generation.current()) { + return; // switched channel — discard silently + } + if (!playbackUrl) { + reportFailure(); + return; + } + + playArchive(playbackUrl, program); + }; + + const copyArchiveUrl = (program: EpgProgram): void => { + const channel = options.currentM3uChannel(); + const item = options.activeItem(); + const isM3u = options.isM3uSelection(); + void archiveCopy.copy(() => { + if (isM3u) return resolveM3uCatchupUrl(channel, program); + const start = toEpochSeconds(program.startTimestamp, program.start); + const stop = toEpochSeconds(program.stopTimestamp, program.stop); + return item?.xtreamId && + start != null && + stop != null && + stop > start + ? streamResolver.resolveXtreamCatchupUrl(item, start, stop) + : null; + }); + }; + + const returnToLive = (): void => { + options.activeTimeshift.set(null); + + // Inline player is already (back) on the live stream once the + // timeshift override is cleared. With an external player configured, + // "Watch live" must open it even when no archive was active — e.g. + // openStreamOnDoubleClick shows the guide without launching playback. + const playback = options.activeDetail()?.playback; + if (!options.shouldUseInlinePlayer() && playback) { + void portalPlayer.openResolvedPlayback(playback); + } + }; + + return { + activeProgram: computed( + () => options.activeTimeshift()?.program ?? null + ), + handleProgramActivation(event: EpgProgramActivationEvent): void { + if (event.type === 'copy-catchup-url') { + copyArchiveUrl(event.program); + return; + } + + if (event.type === 'live') { + returnToLive(); + return; + } + + if (options.isM3uSelection()) { + activateM3uCatchup(event.program); + } else { + void activatePortalCatchup(event.program); + } + }, + returnToLive, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts new file mode 100644 index 000000000..0d9745477 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts @@ -0,0 +1,84 @@ +import { computed, Signal } from '@angular/core'; +import { + deriveVisibleFavoriteChannels, + FavoritesChannelSortMode, + UnifiedCollectionItem, + UnifiedFavoriteChannel, +} from '@iptvnator/portal/shared/util'; + +export interface UnifiedLiveChannelRows { + /** Every collection row, as the shared list renders them. */ + readonly all: Signal; + /** + * The channel list in exactly the order the sidebar renders it + * (search-filtered; sorted in favorites mode) — remote-control + * navigation and channel numbers must follow what is on screen. + */ + readonly visible: Signal; + /** + * Rows for the fullscreen channel panel. Radio is withheld: it renders + * through `app-audio-player` instead of `app-web-player-view`, so + * selecting a station destroys the element that owns fullscreen and + * drops the user out of it — the opposite of what the panel exists for. + * The page's own list keeps every row. + */ + readonly fullscreenPanel: Signal; +} + +export function createUnifiedLiveChannelRows(options: { + items: Signal; + searchTerm: Signal; + mode: Signal<'favorites' | 'recent'>; + sortMode: Signal; +}): UnifiedLiveChannelRows { + const all = computed((): UnifiedFavoriteChannel[] => + options.items().map(toUnifiedFavoriteChannel) + ); + + return { + all, + visible: computed(() => + deriveVisibleFavoriteChannels(all(), { + searchTerm: options.searchTerm(), + sortMode: + options.mode() === 'favorites' ? options.sortMode() : null, + getName: (channel) => channel.name, + getAddedAt: (channel) => channel.addedAt, + }) + ), + fullscreenPanel: computed(() => + all().filter((channel) => channel.radio !== 'true') + ), + }; +} + +/** + * Project a collection row onto the channel shape the shared favorites list + * renders. The two differ because a collection row also carries VOD fields + * the live rail has no use for. + */ +export function toUnifiedFavoriteChannel( + item: UnifiedCollectionItem +): UnifiedFavoriteChannel { + return { + uid: item.uid, + name: item.name, + logo: item.logo ?? null, + sourceType: item.sourceType, + playlistId: item.playlistId, + playlistName: item.playlistName, + streamUrl: item.streamUrl, + m3uChannel: item.m3uChannel, + radio: item.radio, + xtreamId: item.xtreamId, + tvArchive: item.tvArchive ?? null, + tvArchiveDuration: item.tvArchiveDuration ?? null, + tvgId: item.tvgId, + stalkerCmd: item.stalkerCmd, + stalkerPortalUrl: item.stalkerPortalUrl, + stalkerMacAddress: item.stalkerMacAddress, + addedAt: item.addedAt ?? new Date(0).toISOString(), + position: item.position ?? 0, + contentId: item.contentId, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts new file mode 100644 index 000000000..06d6d2cbf --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-map.ts @@ -0,0 +1,63 @@ +import { effect, inject, Signal, signal, untracked } from '@angular/core'; +import { StreamResolverService } from '@iptvnator/portal/shared/data-access'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; + +export interface UnifiedLiveEpgMap { + /** What is on now, per collection row, for the channel rail. */ + readonly programs: Signal>; + load(items: UnifiedCollectionItem[]): void; + clear(): void; +} + +/** + * The "now playing" programme of every row in the live rail. Must run in an + * injection context (the hosting component's field initializer). + */ +export function createUnifiedLiveEpgMap(options: { + supportsEpg: boolean; + items: Signal; + /** EPG display offset; the map answers "what is on at that clock". */ + offsetMinutes: Signal; +}): UnifiedLiveEpgMap { + const streamResolver = inject(StreamResolverService); + const programs = signal>(new Map()); + let requestId = 0; + + const load = (items: UnifiedCollectionItem[]): void => { + // Only the latest load may install its map: an older one — started + // under a previous display offset or item set — must not overwrite + // the refreshed result when it completes last. + const currentRequestId = ++requestId; + void streamResolver.loadEpgForItems(items).then((epgMap) => { + if (currentRequestId === requestId) { + programs.set(epgMap); + } + }); + }; + + // A changed display offset reloads the map; the request id above drops + // the older load. The first run only records the initial value. + let appliedOffsetMinutes: number | null = null; + effect(() => { + const offsetMinutes = options.offsetMinutes(); + if (appliedOffsetMinutes === offsetMinutes) { + return; + } + const isInitial = appliedOffsetMinutes === null; + appliedOffsetMinutes = offsetMinutes; + if (isInitial || !options.supportsEpg) { + return; + } + load(untracked(() => options.items())); + }); + + return { + programs: programs.asReadonly(), + load, + clear(): void { + requestId += 1; + programs.set(new Map()); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts new file mode 100644 index 000000000..c66880399 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts @@ -0,0 +1,177 @@ +import { computed, inject, Signal, signal } from '@angular/core'; +import { + applyChannelNameStrip, + getM3uArchiveDays, + isM3uCatchupPlaybackSupported, +} from '@iptvnator/shared/m3u-utils'; +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; +import { + LiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { + EpgDateNavigationDirection, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '@iptvnator/ui/epg'; +import { SettingsStore } from '@iptvnator/services'; +import { + Channel, + EpgItem, + EpgProgram, + EpgViewMode, +} from '@iptvnator/shared/interfaces'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { + getLiveEpgPanelSummary, + toEpgProgram, + toLiveEpgPanelSummary, +} from './unified-live-epg-summary.util'; +import { UnifiedLiveTimeshift } from './unified-live-catchup'; + +/** + * The EPG panel under the live player: its collapsed/expanded state, the + * day it is showing, and the programme data the timeline and list views + * render for whichever channel is selected. + */ +export interface UnifiedLiveEpgView { + readonly panelState: Signal; + readonly isPanelCollapsed: Signal; + readonly selectedDate: Signal; + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly viewMode: Signal; + readonly offsetMinutes: Signal; + readonly timelinePrograms: Signal; + readonly timelineChannelName: Signal; + readonly timelineChannelLogo: Signal; + readonly timelineArchiveAvailable: Signal; + readonly timelineArchiveDays: Signal; + readonly panelSummary: Signal; + readonly panelSummaryLabelKey: Signal; + setPanelCollapsed(collapsed: boolean): void; + navigateDate(direction: EpgDateNavigationDirection): void; + selectDate(selectedDate: string): void; +} + +/** + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveEpgView(options: { + activeDetail: Signal; + activeItem: Signal; + activeTimeshift: Signal; + isM3uSelection: Signal; + currentM3uChannel: Signal; + currentM3uPrograms: Signal; + currentPortalEpgItems: Signal; + /** 30 s tick that re-evaluates "what is on now". */ + progressTick: Signal; +}): UnifiedLiveEpgView { + const settingsStore = inject(SettingsStore); + const panelState = signal(restoreLiveEpgPanelState()); + const selectedDate = signal(getTodayEpgDateKey()); + const offsetMinutes = settingsStore.resolvedEpgOffsetMinutes; + + const currentM3uArchivePlaybackAvailable = computed(() => + isM3uCatchupPlaybackSupported(options.currentM3uChannel()) + ); + + const timelinePrograms = computed(() => + options.isM3uSelection() + ? options.currentM3uPrograms() + : // Portal EPG items normalised to the timeline programme shape. + options.currentPortalEpgItems().map((item) => toEpgProgram(item)) + ); + + const timelineArchiveAvailable = computed(() => { + if (options.isM3uSelection()) { + return currentM3uArchivePlaybackAvailable(); + } + + // Portal archive: tvArchive === 1 means the provider has + // timeshift / archive enabled for this channel. + const item = options.activeItem(); + return ( + Number(item?.tvArchive ?? 0) === 1 && + Number(item?.tvArchiveDuration ?? 0) > 0 + ); + }); + + return { + panelState: panelState.asReadonly(), + isPanelCollapsed: computed(() => panelState() === 'collapsed'), + selectedDate: selectedDate.asReadonly(), + viewMode: settingsStore.resolvedEpgViewMode, + offsetMinutes, + timelinePrograms, + timelineChannelName: computed(() => + applyChannelNameStrip( + options.currentM3uChannel()?.name ?? + options.activeDetail()?.playback?.title, + settingsStore.stripCountryPrefix?.() + ) + ), + timelineChannelLogo: computed( + () => + options.currentM3uChannel()?.tvg?.logo ?? + options.activeDetail()?.playback?.thumbnail ?? + '' + ), + timelineArchiveAvailable, + /** + * Catch-up window (days) for the active channel, so the timeline can + * gate "Watch" to programmes inside it. Without this the timeline + * defaults `archiveDays` to 0 (treated as unlimited) and offers + * catch-up on programmes older than the real archive window. + * + * M3U: reads catchup-days / timeshift / tvg-rec from the channel + * attrs. Portal: tvArchiveDuration is already in days — pass it + * through, matching `live-stream-layout.controlledArchiveDays`. + */ + timelineArchiveDays: computed(() => { + if (!timelineArchiveAvailable()) return 0; + + if (options.isM3uSelection()) { + return getM3uArchiveDays(options.currentM3uChannel()); + } + + return Math.max( + 0, + Number(options.activeItem()?.tvArchiveDuration ?? 0) || 0 + ); + }), + panelSummary: computed(() => { + const timeshift = options.activeTimeshift(); + if (timeshift) { + // Archive summary is frozen — don't track the 30s tick. + return toLiveEpgPanelSummary(timeshift.program); + } + options.progressTick(); + return getLiveEpgPanelSummary( + options.activeDetail(), + offsetMinutes() + ); + }), + panelSummaryLabelKey: computed(() => + options.activeTimeshift() + ? 'EPG.ARCHIVE_PLAYBACK' + : 'EPG.CURRENT_PROGRAM' + ), + setPanelCollapsed(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed + ? 'collapsed' + : 'expanded'; + panelState.set(state); + persistLiveEpgPanelState(state); + }, + navigateDate(direction: EpgDateNavigationDirection): void { + selectedDate.set(shiftEpgDateKey(selectedDate(), direction)); + }, + selectDate(nextDate: string): void { + selectedDate.set(nextDate); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts new file mode 100644 index 000000000..0b60f28a2 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-recording-metadata.ts @@ -0,0 +1,132 @@ +import { computed, inject, Signal } from '@angular/core'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { RecordingsService } from '@iptvnator/services'; +import { + buildStalkerEpgMappingKey, + buildXtreamEpgMappingKey, + EpgProgram, + epgProviderClockMs, + filterRecordingProgramsOverlap, + playlistDisplayLabel, + RecordingStartMetadata, + RecordingStoppedEvent, + toRecordingProgramSnapshot, +} from '@iptvnator/shared/interfaces'; + +export interface UnifiedLiveRecording { + /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ + readonly metadata: Signal; + /** Stop enrichment: programs overlapping the recorded window. */ + handleRecordingStopped(event: RecordingStoppedEvent): void; +} + +/** + * What a live recording started from this tab records about its channel. + * Provider EPG never reaches SQLite, so the schedule can only be captured + * while the stream is on screen. Must run in an injection context. + */ +export function createUnifiedLiveRecording(options: { + activeItem: Signal; + timelinePrograms: Signal; + timelineChannelLogo: Signal; + epgOffsetMinutes: Signal; + /** 30 s tick; see `metadata`. */ + progressTick: Signal; +}): UnifiedLiveRecording { + const recordingsService = inject(RecordingsService); + + const metadata = computed(() => { + const item = options.activeItem(); + if (!item) { + return null; + } + // Track the 30 s tick: without it this computed caches its + // Date.now() verdict, and a recording started after an EPG boundary + // would snapshot the previous show. + options.progressTick(); + // Raw programme times vs. now in the provider's EPG clock, like the + // panel summary — the start snapshot is authoritative for the + // recording's title, so it must name the same programme. + const now = epgProviderClockMs(Date.now(), options.epgOffsetMinutes()); + const program = + options.timelinePrograms().find((candidate) => { + const start = Date.parse(candidate.start); + const stop = Date.parse(candidate.stop); + return ( + Number.isFinite(start) && + Number.isFinite(stop) && + start <= now && + now < stop + ); + }) ?? null; + return { + channelName: item.name?.trim() || 'Live TV', + channelLogoUrl: + options.timelineChannelLogo() || item.logo || undefined, + playlistId: item.playlistId, + playlistName: playlistDisplayLabel(item.playlistName) || undefined, + sourceType: item.sourceType, + epgChannelId: resolveRecordingEpgChannelId(item), + // The EPG key is not unique for M3U items (shared tvgId, or the + // display-name fallback); the uid names the exact selection. + sourceItemKey: item.uid, + currentProgram: program + ? toRecordingProgramSnapshot(program) + : undefined, + }; + }); + + return { + metadata, + handleRecordingStopped(event: RecordingStoppedEvent): void { + // A channel switch auto-stops the recording, and by now this host + // already describes the new channel — enriching then would attach + // the wrong schedule (and could promote an unrelated program to + // the recording's title). + if ( + event.epgChannelId && + event.epgChannelId !== metadata()?.epgChannelId + ) { + return; + } + // The EPG key alone cannot tell two same-keyed M3U items apart — + // the uid must also match the exact recorded selection. + if ( + event.sourceItemKey && + event.sourceItemKey !== metadata()?.sourceItemKey + ) { + return; + } + const programs = filterRecordingProgramsOverlap( + options.timelinePrograms().map(toRecordingProgramSnapshot), + event.startedAt, + event.endedAt, + options.epgOffsetMinutes() + ); + if (programs.length === 0) { + return; + } + void recordingsService.updatePrograms(event.targetPath, programs); + }, + }; +} + +function resolveRecordingEpgChannelId( + item: UnifiedCollectionItem +): string | undefined { + switch (item.sourceType) { + case 'm3u': + return item.tvgId?.trim() || item.name?.trim() || undefined; + case 'xtream': + return item.xtreamId !== undefined + ? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) + : undefined; + case 'stalker': + return item.stalkerId !== undefined + ? buildStalkerEpgMappingKey( + item.playlistId, + String(item.stalkerId) + ) + : undefined; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts new file mode 100644 index 000000000..3c223abbc --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-generation.ts @@ -0,0 +1,20 @@ +/** + * Monotonic counter shared by everything the live tab resolves for the + * selection on screen — the detail itself, its M3U programmes, a catch-up + * URL. Anything that started under an older generation landed too late and + * must be discarded instead of replacing what the user is now watching. + */ +export interface UnifiedLiveSelectionGeneration { + current(): number; + /** Invalidate everything in flight and take the next generation. */ + next(): number; +} + +export function createUnifiedLiveSelectionGeneration(): UnifiedLiveSelectionGeneration { + let generation = 0; + + return { + current: () => generation, + next: () => ++generation, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts new file mode 100644 index 000000000..e03e9f41e --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection-view.ts @@ -0,0 +1,184 @@ +import { computed, inject, Signal } from '@angular/core'; +import { isDashChannel, isDashStreamUrl } from '@iptvnator/shared/m3u-utils'; +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; +import { + getLiveCollectionPlaylistNavigation, + PORTAL_PLAYER, + UnifiedCollectionItem, + WorkspaceNavigationTarget, +} from '@iptvnator/portal/shared/util'; +import { SettingsStore } from '@iptvnator/services'; +import { + Channel, + EpgItem, + EpgProgram, + playlistDisplayLabel, + ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; +import { UnifiedLiveTimeshift } from './unified-live-catchup'; + +/** + * Everything the live tab derives from "what is selected right now": the + * source shape behind it, and which player surface it belongs on. + */ +export interface UnifiedLiveSelectionView { + readonly player: Signal; + readonly isEmbeddedPlayer: Signal; + /** Playback target for the inline player, honouring a catch-up override. */ + readonly inlinePlayback: Signal; + readonly currentStreamUrl: Signal; + readonly isM3uSelection: Signal; + readonly currentPortalEpgItems: Signal; + readonly currentM3uPrograms: Signal; + readonly currentM3uChannel: Signal; + readonly activeRadioChannel: Signal; + readonly isRadioSelection: Signal; + readonly isM3uDashSelection: Signal; + /** Player the inline surface uses, after the DASH correction. */ + readonly inlinePlayer: Signal; + readonly shouldUseInlinePlayer: Signal; + /** Identity of the selection an EPG archive action would act on. */ + readonly archiveContextKey: Signal; + /** + * "Open in playlist" for the channel on screen: the EPG panel chip + * renders only while this resolves (Xtream/M3U; Stalker has no + * open-on-arrival contract yet). Same verdict the row menu uses. + */ + readonly openInPlaylistTarget: Signal; + readonly openInPlaylistName: Signal; + readonly playbackSessionKey: Signal; + isRadioDetail( + detail: ResolvedLiveCollectionDetail | null | undefined + ): boolean; + /** Whether activating this detail should launch the external player. */ + shouldOpenExternalPlayback( + detail: ResolvedLiveCollectionDetail, + startPlayback?: boolean + ): boolean; +} + +/** + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveSelectionView(options: { + activeItem: Signal; + activeDetail: Signal; + activeTimeshift: Signal; +}): UnifiedLiveSelectionView { + const settingsStore = inject(SettingsStore); + const portalPlayer = inject(PORTAL_PLAYER); + + const player = settingsStore.player; + const isEmbeddedPlayer = computed(() => portalPlayer.isEmbeddedPlayer()); + + const inlinePlayback = computed(() => { + const playback = options.activeDetail()?.playback ?? null; + const timeshift = options.activeTimeshift(); + if (!playback || !timeshift) { + return playback; + } + + return { + ...playback, + streamUrl: timeshift.url, + isLive: false, + }; + }); + + const isM3uSelection = computed( + () => options.activeDetail()?.epgMode === 'm3u' + ); + + const currentM3uChannel = computed(() => { + const detail = options.activeDetail(); + return detail?.epgMode === 'm3u' ? (detail.channel ?? null) : null; + }); + + const currentStreamUrl = computed(() => inlinePlayback()?.streamUrl ?? ''); + + const activeRadioChannel = computed(() => { + const channel = options.activeDetail()?.channel ?? null; + return channel?.radio === 'true' ? channel : null; + }); + const isRadioSelection = computed(() => activeRadioChannel() !== null); + + // Match the M3U player: DASH needs Shaka even with Video.js or MPV/VLC + // selected. + const isM3uDashSelection = computed( + () => + isM3uSelection() && + (isDashStreamUrl(currentStreamUrl()) || + isDashChannel(currentM3uChannel())) + ); + + const isRadioDetail = ( + detail: ResolvedLiveCollectionDetail | null | undefined + ): boolean => detail?.channel?.radio === 'true'; + + return { + player, + isEmbeddedPlayer, + inlinePlayback, + currentStreamUrl, + isM3uSelection, + currentPortalEpgItems: computed( + () => options.activeDetail()?.epgItems ?? [] + ), + currentM3uPrograms: computed(() => { + const detail = options.activeDetail(); + if (detail?.epgMode !== 'm3u' || detail.channel?.radio === 'true') { + return []; + } + + return detail.epgPrograms ?? []; + }), + currentM3uChannel, + activeRadioChannel, + isRadioSelection, + isM3uDashSelection, + archiveContextKey: computed(() => + JSON.stringify([ + options.activeItem()?.uid, + options.activeItem()?.playlistId, + currentM3uChannel()?.url, + ]) + ), + openInPlaylistTarget: computed(() => { + const item = options.activeItem(); + return item ? getLiveCollectionPlaylistNavigation(item) : null; + }), + openInPlaylistName: computed(() => + playlistDisplayLabel(options.activeItem()?.playlistName) + ), + playbackSessionKey: computed(() => + createUnifiedLivePlaybackSessionKey(options.activeItem()) + ), + inlinePlayer: computed(() => + isM3uDashSelection() && player() !== VideoPlayer.ArtPlayer + ? VideoPlayer.Html5Player + : player() + ), + shouldUseInlinePlayer: computed( + () => + isRadioSelection() || isM3uDashSelection() || isEmbeddedPlayer() + ), + isRadioDetail, + shouldOpenExternalPlayback( + detail: ResolvedLiveCollectionDetail, + startPlayback = false + ): boolean { + if ( + isRadioDetail(detail) || + isM3uDashSelection() || + portalPlayer.isEmbeddedPlayer() + ) { + return false; + } + + return !settingsStore.openStreamOnDoubleClick() || startPlayback; + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts new file mode 100644 index 000000000..aa91b7fe9 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -0,0 +1,273 @@ +import { inject, WritableSignal } from '@angular/core'; +import { + ResolvedLiveCollectionDetail, + StreamResolverService, + UnifiedRecentDataService, +} from '@iptvnator/portal/shared/data-access'; +import { + PORTAL_PLAYER, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; +import { UnifiedLiveTimeshift } from './unified-live-catchup'; +import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; + +export interface UnifiedLiveSelection { + /** + * Activate a collection row. `startPlayback` forces the external player + * in double-click-to-play mode; `isAutoOpen` reports back to the host + * once the row is really on screen. + */ + activate( + item: UnifiedCollectionItem, + isAutoOpen?: boolean, + startPlayback?: boolean + ): Promise; + /** Tear the selection down and release anything scoped to it. */ + close(): void; + /** Release the radio header override; for the host's destroy hook. */ + dispose(): void; +} + +/** + * Resolving the selected live row into something playable, and keeping the + * player that is already mounted alive while the next one resolves. + * + * `activeUid` is the pending selection (row highlight). `activeItem` stays + * paired with `activeDetail`: the previous detail stays mounted while the + * next one resolves, because the player it renders owns DOM fullscreen and a + * selection from the fullscreen channel panel must not unmount that element + * (which would end fullscreen) for the resolution round-trip — and + * everything derived from the item (`playbackSessionKey`, recording/archive + * metadata) must keep describing the stream that player is still showing. + * The catch-up override belongs to that detail too: clearing it early would + * drop the mounted player back to the old channel's live URL for the gap. + * All of it swaps together when the new detail is in; a failed replacement + * keeps a previously mounted video and restores its row. + * + * Must run in an injection context (the hosting component's field + * initializer or constructor). + */ +export function createUnifiedLiveSelection(options: { + activeUid: WritableSignal; + activeItem: WritableSignal; + activeDetail: WritableSignal; + activeTimeshift: WritableSignal; + isSelecting: WritableSignal; + generation: UnifiedLiveSelectionGeneration; + supportsEpg: boolean; + isRadioDetail( + detail: ResolvedLiveCollectionDetail | null | undefined + ): boolean; + shouldOpenExternalPlayback( + detail: ResolvedLiveCollectionDetail, + startPlayback: boolean + ): boolean; + /** The row became the latest watched item; the host refreshes its list. */ + onItemPlayed(item: UnifiedCollectionItem): void; + onAutoOpenHandled(): void; +}): UnifiedLiveSelection { + const streamResolver = inject(StreamResolverService); + const recentData = inject(UnifiedRecentDataService); + const streamHeaders = inject(ElectronStreamHeadersService); + const portalPlayer = inject(PORTAL_PLAYER); + + /** Stream URL of the radio playback whose header override this configured. */ + let radioHeaderScopeUrl: string | null = null; + /** + * The selection still resolving. `activeUid` alone cannot tell a pending + * highlight from the stream on screen — `activeItem`/`activeDetail` stay + * paired with the retained player until the replacement is in — so a + * second activation of the same pending row (a double-click's second + * click, an auto-open) folds its intent in here rather than restarting + * the request or, worse, launching the retained stream in its place. + */ + let pendingActivation: { + readonly uid: string; + startPlayback: boolean; + isAutoOpen: boolean; + } | null = null; + + const releaseRadioHeaders = (): void => { + streamHeaders.clear(radioHeaderScopeUrl); + radioHeaderScopeUrl = null; + }; + + const hydrateSelectedM3uPrograms = async ( + item: UnifiedCollectionItem, + detail: ResolvedLiveCollectionDetail, + generation: number + ): Promise => { + if (detail.epgMode !== 'm3u' || detail.channel?.radio === 'true') { + return; + } + + const epgPrograms = await streamResolver.loadM3uProgramsForItem( + item, + detail.channel + ); + if (generation !== options.generation.current()) { + return; + } + + options.activeDetail.update((currentDetail) => { + if (!currentDetail || currentDetail.epgMode !== 'm3u') { + return currentDetail; + } + + return { ...currentDetail, epgPrograms }; + }); + }; + + const close = (): void => { + options.generation.next(); + pendingActivation = null; + options.isSelecting.set(false); + options.activeDetail.set(null); + options.activeUid.set(null); + options.activeItem.set(null); + options.activeTimeshift.set(null); + // Radio credentials must not outlive the closed player; the service + // no-ops when a newer playback already owns the override slot. + releaseRadioHeaders(); + }; + + async function activate( + item: UnifiedCollectionItem, + isAutoOpen = false, + startPlayback = false + ): Promise { + const activeDetail = options.activeDetail(); + // The row is on screen only when it is both the highlight and the + // resolved item: while a replacement resolves, `activeUid` already + // points at it but `activeDetail` still belongs to the retained + // stream, and launching that here would open the wrong channel. + if ( + options.activeUid() === item.uid && + options.activeItem()?.uid === item.uid && + activeDetail + ) { + if ( + startPlayback && + options.shouldOpenExternalPlayback(activeDetail, true) + ) { + void portalPlayer.openResolvedPlayback(activeDetail.playback); + } + if (isAutoOpen) { + options.onAutoOpenHandled(); + } + return; + } + + if (pendingActivation?.uid === item.uid && options.isSelecting()) { + // The same row is still resolving: fold this activation's intent + // into that request so its detail launches (or reports the + // auto-open handled) when it lands, without a second round-trip. + pendingActivation.startPlayback ||= startPlayback; + pendingActivation.isAutoOpen ||= isAutoOpen; + return; + } + + const generation = options.generation.next(); + const activation = { uid: item.uid, startPlayback, isAutoOpen }; + pendingActivation = activation; + options.activeUid.set(item.uid); + options.isSelecting.set(true); + // A previously owned radio override must not survive into a + // selection that never mounts a player surface of its own — external + // video playback and failed resolutions would otherwise keep the old + // radio credentials installed for that origin. + releaseRadioHeaders(); + + try { + const detail = + item.sourceType === 'm3u' + ? await streamResolver.resolveM3uPlaybackDetail(item) + : await streamResolver.resolveLiveDetail(item); + if (generation !== options.generation.current()) { + return; + } + + if (item.radio === 'true') { + // Radio renders the dedicated audio player, never + // WebPlayerViewComponent, so the scoped Electron header + // override (portal cookie/token for auth-gated streams) is + // configured here BEFORE the audio element gets the URL. + // Ownership is claimed synchronously so a close/destroy + // during the pending IPC can still clear the credentials. + const headerSync = streamHeaders.apply(detail.playback); + radioHeaderScopeUrl = detail.playback.streamUrl; + const stillCurrent = headerSync ? await headerSync : true; + if ( + !stillCurrent || + generation !== options.generation.current() + ) { + return; + } + } + + options.activeTimeshift.set(null); + options.activeItem.set(item); + options.activeDetail.set(detail); + + if (options.supportsEpg && detail.epgMode === 'm3u') { + void hydrateSelectedM3uPrograms(item, detail, generation); + } + + if ( + options.shouldOpenExternalPlayback( + detail, + activation.startPlayback + ) + ) { + void portalPlayer.openResolvedPlayback(detail.playback); + } + + try { + const updatedItem = await recentData.recordLivePlayback(item); + if (generation === options.generation.current()) { + options.onItemPlayed(updatedItem); + } + } catch { + // Keep playback/EPG visible even if history persistence fails. + } + + if ( + generation === options.generation.current() && + activation.isAutoOpen + ) { + options.onAutoOpenHandled(); + } + } catch { + if (generation === options.generation.current()) { + // The fullscreen panel only offers video rows. Its previous + // stream is still valid when resolution of a replacement + // fails, so retain the player and its catch-up/session state. + // Radio's scoped headers were released above; that path keeps + // its existing reset behavior instead of reviving that scope. + if (!activeDetail || options.isRadioDetail(activeDetail)) { + options.activeTimeshift.set(null); + options.activeDetail.set(null); + options.activeItem.set(null); + } + options.activeUid.set(options.activeItem()?.uid ?? null); + } + } finally { + if (generation === options.generation.current()) { + pendingActivation = null; + options.isSelecting.set(false); + } + } + } + + return { + activate, + close, + dispose(): void { + // Invalidate a playback continuation still awaiting its header + // IPC and drop any radio credentials owned by this tab. + options.generation.next(); + streamHeaders.clear(radioHeaderScopeUrl); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 3183806a5..ee6eeefe5 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -10,65 +10,37 @@ import { output, signal, TemplateRef, - untracked, viewChild, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { - applyChannelNameStrip, - getM3uArchiveDays, - isDashChannel, - isDashStreamUrl, - isM3uCatchupPlaybackSupported, - resolveM3uCatchupUrl, -} from '@iptvnator/shared/m3u-utils'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, - deriveVisibleFavoriteChannels, FavoritesChannelSortMode, getLiveCollectionPlaylistNavigation, - LiveEpgPanelState, matchesOpenLiveCollectionItem, OpenLiveCollectionItemState, PORTAL_PLAYER, - persistLiveEpgPanelState, - restoreLiveEpgPanelState, UnifiedCollectionItem, UnifiedFavoriteChannel, } from '@iptvnator/portal/shared/util'; import { setupUnifiedLiveTabRemoteControl } from './unified-live-tab-remote-control'; +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; import { - ResolvedLiveCollectionDetail, - StreamResolverService, - UnifiedRecentDataService, -} from '@iptvnator/portal/shared/data-access'; -import { - EpgArchiveCopyService, EpgDateNavigationDirection, EpgListViewComponent, EpgProgramActivationEvent, EpgTimelineComponent, - getTodayEpgDateKey, - shiftEpgDateKey, } from '@iptvnator/ui/epg'; -import { - getLiveEpgPanelSummary, - toEpgProgram, - toEpochSeconds, - toLiveEpgPanelSummary, -} from './unified-live-epg-summary.util'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-playlist-chip.component'; import { ChannelListHiddenStateComponent } from '../channel-list-hidden-state/channel-list-hidden-state.component'; import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component'; import { AudioPlayerComponent, - ElectronStreamHeadersService, FULLSCREEN_CHANNEL_PANEL, type FullscreenChannelPanelContext, type FullscreenChannelPanelHost, @@ -76,24 +48,19 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { ResizableDirective } from '@iptvnator/ui/components'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { RecordingStoppedEvent } from '@iptvnator/shared/interfaces'; import { - RecordingsService, - RuntimeCapabilitiesService, - SettingsStore, -} from '@iptvnator/services'; -import { - buildStalkerEpgMappingKey, - buildXtreamEpgMappingKey, - EpgProgram, - epgProviderClockMs, - filterRecordingProgramsOverlap, - playlistDisplayLabel, - RecordingStartMetadata, - RecordingStoppedEvent, - toRecordingProgramSnapshot, - VideoPlayer, -} from '@iptvnator/shared/interfaces'; -import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; + createUnifiedLiveCatchup, + UnifiedLiveTimeshift, +} from './unified-live-catchup'; +import { createUnifiedLiveChannelRows } from './unified-live-channel-rows'; +import { createUnifiedLiveEpgMap } from './unified-live-epg-map'; +import { createUnifiedLiveEpgView } from './unified-live-epg-view'; +import { createUnifiedLiveRecording } from './unified-live-recording-metadata'; +import { createUnifiedLiveSelection } from './unified-live-selection'; +import { createUnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; +import { createUnifiedLiveSelectionView } from './unified-live-selection-view'; @Component({ selector: 'app-unified-live-tab', @@ -145,32 +112,14 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { /** The rail is owned by the page header toggle; ask it to expand. */ readonly restoreSidebarRequested = output(); - readonly archiveContextKey = computed(() => - JSON.stringify([ - this.activeItem()?.uid, - this.activeItem()?.playlistId, - this.currentM3uChannel()?.url, - ]) - ); - private readonly archiveCopy = inject(EpgArchiveCopyService); - private readonly streamResolver = inject(StreamResolverService); - private readonly recentData = inject(UnifiedRecentDataService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); - private readonly portalPlayer = inject(PORTAL_PLAYER); - private readonly streamHeaders = inject(ElectronStreamHeadersService); private readonly destroyRef = inject(DestroyRef); - private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); private readonly router = inject(Router); - /** Stream URL of the radio playback whose header override this tab configured. */ - private radioHeaderScopeUrl: string | null = null; + private readonly portalPlayer = inject(PORTAL_PLAYER); - readonly player = this.settingsStore.player; readonly supportsEpg = this.runtime.supportsEpg; - readonly isEmbeddedPlayer = computed(() => - this.portalPlayer.isEmbeddedPlayer() - ); private readonly fullscreenChannelPanelTemplate = viewChild< TemplateRef @@ -195,351 +144,109 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { * portal archive fields (tvArchive/tvArchiveDuration) and to * supply credentials for Xtream catch-up URL resolution. */ readonly activeItem = signal(null); - /** - * "Open in playlist" for the channel on screen: the EPG panel chip - * renders only while this resolves (Xtream/M3U; Stalker has no - * open-on-arrival contract yet). Same verdict the row menu uses. - */ - readonly openInPlaylistTarget = computed(() => { - const item = this.activeItem(); - return item ? getLiveCollectionPlaylistNavigation(item) : null; - }); - readonly openInPlaylistName = computed(() => - playlistDisplayLabel(this.activeItem()?.playlistName) - ); - readonly playbackSessionKey = computed(() => - createUnifiedLivePlaybackSessionKey(this.activeItem()) - ); readonly isSelecting = signal(false); - readonly epgMap = signal>(new Map()); + /** Catch-up override for the active channel; null = live playback. */ + readonly activeTimeshift = signal(null); readonly progressTick = signal(0); - readonly liveEpgPanelState = signal( - restoreLiveEpgPanelState() - ); - readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); - /** Catch-up override for the active M3U channel; null = live playback. */ - readonly activeTimeshift = signal<{ - url: string; - program: EpgProgram; - } | null>(null); - readonly activeTimeshiftProgram = computed( - () => this.activeTimeshift()?.program ?? null - ); - /** Playback target for the inline player, honouring a catch-up override. */ - readonly inlinePlayback = computed(() => { - const playback = this.activeDetail()?.playback ?? null; - const timeshift = this.activeTimeshift(); - if (!playback || !timeshift) { - return playback; - } + private readonly generation = createUnifiedLiveSelectionGeneration(); - return { - ...playback, - streamUrl: timeshift.url, - isLive: false, - }; + private readonly view = createUnifiedLiveSelectionView({ + activeItem: this.activeItem, + activeDetail: this.activeDetail, + activeTimeshift: this.activeTimeshift, }); - readonly currentStreamUrl = computed( - () => this.inlinePlayback()?.streamUrl ?? '' - ); - readonly isM3uSelection = computed( - () => this.activeDetail()?.epgMode === 'm3u' - ); - readonly currentPortalEpgItems = computed( - () => this.activeDetail()?.epgItems ?? [] - ); - readonly currentM3uPrograms = computed(() => { - const detail = this.activeDetail(); - if (detail?.epgMode !== 'm3u') { - return []; - } + readonly inlinePlayback = this.view.inlinePlayback; + readonly currentStreamUrl = this.view.currentStreamUrl; + readonly inlinePlayer = this.view.inlinePlayer; + readonly shouldUseInlinePlayer = this.view.shouldUseInlinePlayer; + readonly activeRadioChannel = this.view.activeRadioChannel; + readonly isRadioSelection = this.view.isRadioSelection; + readonly archiveContextKey = this.view.archiveContextKey; + readonly openInPlaylistTarget = this.view.openInPlaylistTarget; + readonly openInPlaylistName = this.view.openInPlaylistName; + readonly playbackSessionKey = this.view.playbackSessionKey; - if (detail.channel?.radio === 'true') { - return []; - } + private readonly catchup = createUnifiedLiveCatchup({ + activeTimeshift: this.activeTimeshift, + activeItem: this.activeItem, + activeDetail: this.activeDetail, + isM3uSelection: this.view.isM3uSelection, + currentM3uChannel: this.view.currentM3uChannel, + shouldUseInlinePlayer: this.view.shouldUseInlinePlayer, + generation: this.generation, + }); + readonly activeTimeshiftProgram = this.catchup.activeProgram; - return detail.epgPrograms ?? []; + private readonly epgView = createUnifiedLiveEpgView({ + activeDetail: this.activeDetail, + activeItem: this.activeItem, + activeTimeshift: this.activeTimeshift, + isM3uSelection: this.view.isM3uSelection, + currentM3uChannel: this.view.currentM3uChannel, + currentM3uPrograms: this.view.currentM3uPrograms, + currentPortalEpgItems: this.view.currentPortalEpgItems, + progressTick: this.progressTick, }); - readonly currentM3uChannel = computed(() => { - const detail = this.activeDetail(); - if (detail?.epgMode !== 'm3u') { - return null; - } + readonly epgViewMode = this.epgView.viewMode; + readonly epgOffsetMinutes = this.epgView.offsetMinutes; + readonly selectedLiveEpgDate = this.epgView.selectedDate; + readonly isLiveEpgPanelCollapsed = this.epgView.isPanelCollapsed; + readonly liveEpgPanelSummary = this.epgView.panelSummary; + readonly liveEpgPanelSummaryLabelKey = this.epgView.panelSummaryLabelKey; + readonly timelinePrograms = this.epgView.timelinePrograms; + readonly timelineChannelName = this.epgView.timelineChannelName; + readonly timelineChannelLogo = this.epgView.timelineChannelLogo; + readonly timelineArchiveAvailable = this.epgView.timelineArchiveAvailable; + readonly timelineArchiveDays = this.epgView.timelineArchiveDays; - return detail.channel ?? null; + private readonly recording = createUnifiedLiveRecording({ + activeItem: this.activeItem, + timelinePrograms: this.epgView.timelinePrograms, + timelineChannelLogo: this.epgView.timelineChannelLogo, + epgOffsetMinutes: this.epgView.offsetMinutes, + progressTick: this.progressTick, }); - readonly currentM3uArchivePlaybackAvailable = computed(() => - isM3uCatchupPlaybackSupported(this.currentM3uChannel()) - ); - /** Portal EPG items normalised to the timeline programme shape. */ - readonly currentPortalEpgPrograms = computed(() => - this.currentPortalEpgItems().map((item) => toEpgProgram(item)) - ); - readonly timelinePrograms = computed(() => - this.isM3uSelection() - ? this.currentM3uPrograms() - : this.currentPortalEpgPrograms() - ); - readonly timelineChannelName = computed(() => - applyChannelNameStrip( - this.currentM3uChannel()?.name ?? - this.activeDetail()?.playback?.title, - this.settingsStore.stripCountryPrefix?.() - ) - ); - readonly timelineChannelLogo = computed( - () => - this.currentM3uChannel()?.tvg?.logo ?? - this.activeDetail()?.playback?.thumbnail ?? - '' - ); - readonly timelineArchiveAvailable = computed(() => { - if (this.isM3uSelection()) { - return this.currentM3uArchivePlaybackAvailable(); - } + readonly recordingMetadata = this.recording.metadata; - // Portal archive: tvArchive === 1 means the provider has - // timeshift / archive enabled for this channel. - const item = this.activeItem(); - return ( - Number(item?.tvArchive ?? 0) === 1 && - Number(item?.tvArchiveDuration ?? 0) > 0 - ); + private readonly epgPrograms = createUnifiedLiveEpgMap({ + supportsEpg: this.supportsEpg, + items: this.items, + offsetMinutes: this.epgView.offsetMinutes, }); - /** - * Catch-up window (days) for the active channel, so the timeline can - * gate "Watch" to programmes inside it. Without this the timeline defaults - * `archiveDays` to 0 (treated as unlimited) and offers catch-up on - * programmes older than the real archive window. - * - * M3U: reads catchup-days / timeshift / tvg-rec from the channel attrs. - * Portal: tvArchiveDuration is already in days — pass it through, - * matching `live-stream-layout.controlledArchiveDays`. - */ - readonly timelineArchiveDays = computed(() => { - if (!this.timelineArchiveAvailable()) return 0; + readonly epgMap = this.epgPrograms.programs; - if (this.isM3uSelection()) { - return getM3uArchiveDays(this.currentM3uChannel()); - } - - return Math.max( - 0, - Number(this.activeItem()?.tvArchiveDuration ?? 0) || 0 - ); - }); - readonly activeRadioChannel = computed(() => { - const channel = this.activeDetail()?.channel ?? null; - return channel?.radio === 'true' ? channel : null; - }); - readonly isRadioSelection = computed( - () => this.activeRadioChannel() !== null - ); - // Match the M3U player: DASH needs Shaka even with Video.js or MPV/VLC selected. - readonly isM3uDashSelection = computed( - () => - this.isM3uSelection() && - (isDashStreamUrl(this.currentStreamUrl()) || - isDashChannel(this.currentM3uChannel())) - ); - readonly inlinePlayer = computed(() => - this.isM3uDashSelection() && this.player() !== VideoPlayer.ArtPlayer - ? VideoPlayer.Html5Player - : this.player() - ); - readonly shouldUseInlinePlayer = computed( - () => - this.isRadioSelection() || - this.isM3uDashSelection() || - this.isEmbeddedPlayer() - ); - readonly isLiveEpgPanelCollapsed = computed( - () => this.liveEpgPanelState() === 'collapsed' - ); - /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; - readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; - readonly liveEpgPanelSummary = computed(() => { - const timeshift = this.activeTimeshift(); - if (timeshift) { - // Archive summary is frozen — don't track the 30s progress tick. - return toLiveEpgPanelSummary(timeshift.program); - } - this.progressTick(); - return getLiveEpgPanelSummary( - this.activeDetail(), - this.epgOffsetMinutes() - ); - }); - readonly liveEpgPanelSummaryLabelKey = computed(() => - this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM' - ); - private readonly recordingsService = inject(RecordingsService); - /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ - readonly recordingMetadata = computed(() => { - const item = this.activeItem(); - if (!item) { - return null; - } - // Track the 30 s tick: without it this computed caches its - // Date.now() verdict, and a recording started after an EPG boundary - // would snapshot the previous show. - this.progressTick(); - // Raw programme times vs. now in the provider's EPG clock, like the - // panel summary — the start snapshot is authoritative for the - // recording's title, so it must name the same programme. - const now = epgProviderClockMs(Date.now(), this.epgOffsetMinutes()); - const program = - this.timelinePrograms().find((candidate) => { - const start = Date.parse(candidate.start); - const stop = Date.parse(candidate.stop); - return ( - Number.isFinite(start) && - Number.isFinite(stop) && - start <= now && - now < stop - ); - }) ?? null; - return { - channelName: item.name?.trim() || 'Live TV', - channelLogoUrl: - this.timelineChannelLogo() || item.logo || undefined, - playlistId: item.playlistId, - playlistName: playlistDisplayLabel(item.playlistName) || undefined, - sourceType: item.sourceType, - epgChannelId: this.recordingEpgChannelId(item), - // The EPG key is not unique for M3U items (shared tvgId, or the - // display-name fallback); the uid names the exact selection. - sourceItemKey: item.uid, - currentProgram: program - ? toRecordingProgramSnapshot(program) - : undefined, - }; + private readonly selection = createUnifiedLiveSelection({ + activeUid: this.activeUid, + activeItem: this.activeItem, + activeDetail: this.activeDetail, + activeTimeshift: this.activeTimeshift, + isSelecting: this.isSelecting, + generation: this.generation, + supportsEpg: this.supportsEpg, + isRadioDetail: (detail) => this.view.isRadioDetail(detail), + shouldOpenExternalPlayback: (detail, startPlayback) => + this.view.shouldOpenExternalPlayback(detail, startPlayback), + onItemPlayed: (item) => this.itemPlayed.emit(item), + onAutoOpenHandled: () => this.autoOpenHandled.emit(), }); - /** Stop enrichment: programs overlapping the recorded window. */ - onRecordingStopped(event: RecordingStoppedEvent): void { - // A channel switch auto-stops the recording, and by now this host - // already describes the new channel — enriching then would attach the - // wrong schedule (and could promote an unrelated program to the - // recording's title). - if ( - event.epgChannelId && - event.epgChannelId !== this.recordingMetadata()?.epgChannelId - ) { - return; - } - // The EPG key alone cannot tell two same-keyed M3U items apart — - // the uid must also match the exact recorded selection. - if ( - event.sourceItemKey && - event.sourceItemKey !== this.recordingMetadata()?.sourceItemKey - ) { - return; - } - const programs = filterRecordingProgramsOverlap( - this.timelinePrograms().map(toRecordingProgramSnapshot), - event.startedAt, - event.endedAt, - this.epgOffsetMinutes() - ); - if (programs.length === 0) { - return; - } - void this.recordingsService.updatePrograms(event.targetPath, programs); - } - - private recordingEpgChannelId( - item: UnifiedCollectionItem - ): string | undefined { - switch (item.sourceType) { - case 'm3u': - return item.tvgId?.trim() || item.name?.trim() || undefined; - case 'xtream': - return item.xtreamId !== undefined - ? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId) - : undefined; - case 'stalker': - return item.stalkerId !== undefined - ? buildStalkerEpgMappingKey( - item.playlistId, - String(item.stalkerId) - ) - : undefined; - } - } - - /** - * The channel list in exactly the order the sidebar renders it - * (search-filtered; sorted in favorites mode) — remote-control - * navigation and channel numbers must follow what is on screen. - */ - readonly visibleChannels = computed(() => - deriveVisibleFavoriteChannels(this.channelsForList(), { - searchTerm: this.searchTerm(), - sortMode: this.mode() === 'favorites' ? this.sortMode() : null, - getName: (channel) => channel.name, - getAddedAt: (channel) => channel.addedAt, - }) - ); - - readonly channelsForList = computed((): UnifiedFavoriteChannel[] => - this.items().map((item) => ({ - uid: item.uid, - name: item.name, - logo: item.logo ?? null, - sourceType: item.sourceType, - playlistId: item.playlistId, - playlistName: item.playlistName, - streamUrl: item.streamUrl, - m3uChannel: item.m3uChannel, - radio: item.radio, - xtreamId: item.xtreamId, - tvArchive: item.tvArchive ?? null, - tvArchiveDuration: item.tvArchiveDuration ?? null, - tvgId: item.tvgId, - stalkerCmd: item.stalkerCmd, - stalkerPortalUrl: item.stalkerPortalUrl, - stalkerMacAddress: item.stalkerMacAddress, - addedAt: item.addedAt ?? new Date(0).toISOString(), - position: item.position ?? 0, - contentId: item.contentId, - })) - ); - - /** - * Rows for the fullscreen channel panel. Radio is withheld: it renders - * through `app-audio-player` instead of `app-web-player-view`, so - * selecting a station destroys the element that owns fullscreen and drops - * the user out of it — the opposite of what the panel exists for. The - * page's own list keeps every row. - */ - readonly fullscreenPanelChannels = computed(() => - this.channelsForList().filter((channel) => channel.radio !== 'true') - ); - - private selectionRequestId = 0; - /** - * The selection still resolving. `activeUid` alone cannot tell a pending - * highlight from the stream on screen — `activeItem`/`activeDetail` stay - * paired with the retained player until the replacement is in — so a - * second activation of the same pending row (a double-click's second - * click, an auto-open) folds its intent in here rather than restarting - * the request or, worse, launching the retained stream in its place. - */ - private pendingActivation: { - readonly uid: string; - startPlayback: boolean; - isAutoOpen: boolean; - } | null = null; + private readonly channelRows = createUnifiedLiveChannelRows({ + items: this.items, + searchTerm: this.searchTerm, + mode: this.mode, + sortMode: this.sortMode, + }); + readonly channelsForList = this.channelRows.all; + readonly visibleChannels = this.channelRows.visible; + readonly fullscreenPanelChannels = this.channelRows.fullscreenPanel; constructor() { effect(() => { const items = this.items(); if (this.supportsEpg) { - void this.loadEpgMap(items); + this.epgPrograms.load(items); } else { - this.epgMap.set(new Map()); + this.epgPrograms.clear(); } const activeUid = this.activeUid(); @@ -548,23 +255,6 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } }); - // The map answers "what is on at the provider clock", so a changed - // display offset reloads it; the request id in loadEpgMap drops the - // older load. The first run only records the initial value. - let appliedEpgOffsetMinutes: number | null = null; - effect(() => { - const offsetMinutes = this.epgOffsetMinutes(); - if (appliedEpgOffsetMinutes === offsetMinutes) { - return; - } - const isInitial = appliedEpgOffsetMinutes === null; - appliedEpgOffsetMinutes = offsetMinutes; - if (isInitial || !this.supportsEpg) { - return; - } - void this.loadEpgMap(untracked(() => this.items())); - }); - effect(() => { const target = this.autoOpenItem(); const items = this.items(); @@ -581,8 +271,8 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { // Handled only once the row is on screen: while it is merely the // pending highlight, `activeDetail` still belongs to the retained - // stream, and `activateItem` folds this intent into the request - // in flight instead of restarting it. + // stream, and `activate` folds this intent into the request in + // flight instead of restarting it. if ( this.activeUid() === matchedItem.uid && this.activeItem()?.uid === matchedItem.uid && @@ -592,7 +282,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { return; } - void this.activateItem(matchedItem, true); + void this.selection.activate(matchedItem, true); }); setupUnifiedLiveTabRemoteControl({ @@ -615,43 +305,36 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { ); this.destroyRef.onDestroy(() => { clearInterval(tickInterval); - // Invalidate a playback continuation still awaiting its header - // IPC and drop any radio credentials owned by this tab. - this.selectionRequestId += 1; - this.streamHeaders.clear(this.radioHeaderScopeUrl); + this.selection.dispose(); }); } async onChannelSelected(channel: UnifiedFavoriteChannel): Promise { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { - await this.activateItem(item); + await this.selection.activate(item); } } async onChannelPlaybackRequested( channel: UnifiedFavoriteChannel ): Promise { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { - await this.activateItem(item, false, true); + await this.selection.activate(item, false, true); } } onFavoriteToggled(channel: UnifiedFavoriteChannel): void { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); - if (item) { - if (this.mode() === 'favorites') { - this.removeItem.emit(item); - } else { - this.favoriteToggled.emit(item); - } + const item = this.itemFor(channel); + if (!item) { + return; + } + + if (this.mode() === 'favorites') { + this.removeItem.emit(item); + } else { + this.favoriteToggled.emit(item); } } @@ -663,25 +346,14 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onOpenInPlaylistRequested(channel: UnifiedFavoriteChannel): void { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { this.openInPlaylist(item); } } - private openInPlaylist(item: UnifiedCollectionItem): void { - const target = getLiveCollectionPlaylistNavigation(item); - if (target) { - void this.router.navigate(target.link, { state: target.state }); - } - } - onRemoveRequested(channel: UnifiedFavoriteChannel): void { - const item = this.items().find( - (candidate) => candidate.uid === channel.uid - ); + const item = this.itemFor(channel); if (item) { this.removeItem.emit(item); } @@ -697,160 +369,28 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onLiveEpgPanelCollapsedChange(collapsed: boolean): void { - const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; - this.liveEpgPanelState.set(state); - persistLiveEpgPanelState(state); + this.epgView.setPanelCollapsed(collapsed); } onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { - this.selectedLiveEpgDate.set( - shiftEpgDateKey(this.selectedLiveEpgDate(), direction) - ); + this.epgView.navigateDate(direction); } onLiveEpgSelectedDateChange(selectedDate: string): void { - this.selectedLiveEpgDate.set(selectedDate); + this.epgView.selectDate(selectedDate); } onTimelineProgramActivated(event: EpgProgramActivationEvent): void { - if (event.type === 'copy-catchup-url') { - const channel = this.currentM3uChannel(); - const item = this.activeItem(); - const isM3u = this.isM3uSelection(); - void this.archiveCopy.copy(() => { - if (isM3u) return resolveM3uCatchupUrl(channel, event.program); - const start = toEpochSeconds( - event.program.startTimestamp, - event.program.start - ); - const stop = toEpochSeconds( - event.program.stopTimestamp, - event.program.stop - ); - return item?.xtreamId && - start != null && - stop != null && - stop > start - ? this.streamResolver.resolveXtreamCatchupUrl( - item, - start, - stop - ) - : null; - }); - return; - } - if (event.type === 'live') { - this.returnToLivePlayback(); - return; - } - - if (this.isM3uSelection()) { - this.activateM3uCatchup(event.program); - } else { - void this.activatePortalCatchup(event.program); - } - } - - /** - * M3U catch-up: resolve via the M3U timeshift URL resolver. - */ - private activateM3uCatchup(program: EpgProgram): void { - const playbackUrl = resolveM3uCatchupUrl( - this.currentM3uChannel(), - program - ); - if (!playbackUrl) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - this.activeTimeshift.set({ url: playbackUrl, program }); - - const playback = this.activeDetail()?.playback; - if (!this.shouldUseInlinePlayer() && playback) { - void this.portalPlayer.openResolvedPlayback({ - ...playback, - streamUrl: playbackUrl, - isLive: false, - }); - } - } - - /** - * Portal (Xtream) catch-up: compute start/stop as epoch seconds and - * resolve via the provider's timeshift endpoint. - */ - private async activatePortalCatchup(program: EpgProgram): Promise { - const requestId = this.selectionRequestId; - const item = this.activeItem(); - if (!item?.xtreamId) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - const startEpoch = toEpochSeconds( - program.startTimestamp, - program.start - ); - const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop); - if (startEpoch == null || stopEpoch == null) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - const playbackUrl = await this.streamResolver.resolveXtreamCatchupUrl( - item, - startEpoch, - stopEpoch - ); - if (requestId !== this.selectionRequestId) { - return; // switched channel — discard silently - } - if (!playbackUrl) { - this.snackBar.open( - this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), - undefined, - { duration: 4000 } - ); - return; - } - - this.activeTimeshift.set({ url: playbackUrl, program }); - - const playback = this.activeDetail()?.playback; - if (!this.shouldUseInlinePlayer() && playback) { - void this.portalPlayer.openResolvedPlayback({ - ...playback, - streamUrl: playbackUrl, - isLive: false, - }); - } + this.catchup.handleProgramActivation(event); } returnToLivePlayback(): void { - this.activeTimeshift.set(null); + this.catchup.returnToLive(); + } - // Inline player is already (back) on the live stream once the - // timeshift override is cleared. With an external player configured, - // "Watch live" must open it even when no archive was active — e.g. - // openStreamOnDoubleClick shows the guide without launching playback. - const playback = this.activeDetail()?.playback; - if (!this.shouldUseInlinePlayer() && playback) { - void this.portalPlayer.openResolvedPlayback(playback); - } + /** Stop enrichment: programs overlapping the recorded window. */ + onRecordingStopped(event: RecordingStoppedEvent): void { + this.recording.handleRecordingStopped(event); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { @@ -863,229 +403,24 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } onClose(): void { - this.selectionRequestId += 1; - this.pendingActivation = null; - this.isSelecting.set(false); - this.activeDetail.set(null); - this.activeUid.set(null); - this.activeItem.set(null); - this.activeTimeshift.set(null); - // Radio credentials must not outlive the closed player; the service - // no-ops when a newer playback already owns the override slot. - this.streamHeaders.clear(this.radioHeaderScopeUrl); - this.radioHeaderScopeUrl = null; + this.selection.close(); } onEpgMappingChanged(): void { - void this.loadEpgMap(this.items()); + this.epgPrograms.load(this.items()); } - private epgMapRequestId = 0; - private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { - // Only the latest load may install its map: an older one — started - // under a previous display offset or item set — must not overwrite - // the refreshed result when it completes last. - const requestId = ++this.epgMapRequestId; - const epgMap = await this.streamResolver.loadEpgForItems(items); - if (requestId !== this.epgMapRequestId) { - return; - } - this.epgMap.set(epgMap); - } - - private async activateItem( - item: UnifiedCollectionItem, - isAutoOpen = false, - startPlayback = false - ): Promise { - const activeDetail = this.activeDetail(); - // The row is on screen only when it is both the highlight and the - // resolved item: while a replacement resolves, `activeUid` already - // points at it but `activeDetail` still belongs to the retained - // stream, and launching that here would open the wrong channel. - if ( - this.activeUid() === item.uid && - this.activeItem()?.uid === item.uid && - activeDetail - ) { - if ( - startPlayback && - this.shouldOpenExternalPlayback(activeDetail, true) - ) { - void this.portalPlayer.openResolvedPlayback( - activeDetail.playback - ); - } - if (isAutoOpen) { - this.autoOpenHandled.emit(); - } - return; - } - - const pending = this.pendingActivation; - if (pending?.uid === item.uid && this.isSelecting()) { - // The same row is still resolving: fold this activation's intent - // into that request so its detail launches (or reports the - // auto-open handled) when it lands, without a second round-trip. - pending.startPlayback ||= startPlayback; - pending.isAutoOpen ||= isAutoOpen; - return; - } - - const requestId = ++this.selectionRequestId; - const activation = { uid: item.uid, startPlayback, isAutoOpen }; - this.pendingActivation = activation; - // `activeUid` is the pending selection (row highlight). `activeItem` - // stays paired with `activeDetail`: the previous detail stays mounted - // while the next one resolves, because the player it renders owns - // DOM fullscreen and a selection from the fullscreen channel panel - // must not unmount that element (which would end fullscreen) for the - // resolution round-trip — and everything derived from the item - // (`playbackSessionKey`, recording/archive metadata) must keep - // describing the stream that player is still showing. The catch-up - // override belongs to that detail too: clearing it early would drop - // the mounted player back to the old channel's live URL for the - // gap. All of it swaps together when the new detail is in; a failed - // replacement keeps a previously mounted video and restores its row. - this.activeUid.set(item.uid); - this.isSelecting.set(true); - // A previously owned radio override must not survive into a - // selection that never mounts a player surface of its own — external - // video playback and failed resolutions would otherwise keep the old - // radio credentials installed for that origin. - this.streamHeaders.clear(this.radioHeaderScopeUrl); - this.radioHeaderScopeUrl = null; - - try { - const detail = - item.sourceType === 'm3u' - ? await this.streamResolver.resolveM3uPlaybackDetail(item) - : await this.streamResolver.resolveLiveDetail(item); - if (requestId !== this.selectionRequestId) { - return; - } - - if (item.radio === 'true') { - // Radio renders the dedicated audio player, never - // WebPlayerViewComponent, so the scoped Electron header - // override (portal cookie/token for auth-gated streams) is - // configured here BEFORE the audio element gets the URL. - // Ownership is claimed synchronously so a close/destroy - // during the pending IPC can still clear the credentials. - const headerSync = this.streamHeaders.apply(detail.playback); - this.radioHeaderScopeUrl = detail.playback.streamUrl; - const stillCurrent = headerSync ? await headerSync : true; - if (!stillCurrent || requestId !== this.selectionRequestId) { - return; - } - } - - this.activeTimeshift.set(null); - this.activeItem.set(item); - this.activeDetail.set(detail); - - if (this.supportsEpg && detail.epgMode === 'm3u') { - void this.hydrateSelectedM3uPrograms(item, detail, requestId); - } - - if ( - this.shouldOpenExternalPlayback( - detail, - activation.startPlayback - ) - ) { - void this.portalPlayer.openResolvedPlayback(detail.playback); - } - - try { - const updatedItem = - await this.recentData.recordLivePlayback(item); - if (requestId === this.selectionRequestId) { - this.itemPlayed.emit(updatedItem); - } - } catch { - // Keep playback/EPG visible even if history persistence fails. - } - - if ( - requestId === this.selectionRequestId && - activation.isAutoOpen - ) { - this.autoOpenHandled.emit(); - } - } catch { - if (requestId === this.selectionRequestId) { - // The fullscreen panel only offers video rows. Its previous - // stream is still valid when resolution of a replacement - // fails, so retain the player and its catch-up/session state. - // Radio's scoped headers were released above; that path keeps - // its existing reset behavior instead of reviving that scope. - if (!activeDetail || this.isRadioDetail(activeDetail)) { - this.activeTimeshift.set(null); - this.activeDetail.set(null); - this.activeItem.set(null); - } - this.activeUid.set(this.activeItem()?.uid ?? null); - } - } finally { - if (requestId === this.selectionRequestId) { - this.pendingActivation = null; - this.isSelecting.set(false); - } + private openInPlaylist(item: UnifiedCollectionItem): void { + const target = getLiveCollectionPlaylistNavigation(item); + if (target) { + void this.router.navigate(target.link, { state: target.state }); } } - private shouldOpenExternalPlayback( - detail: ResolvedLiveCollectionDetail, - startPlayback = false - ): boolean { - if ( - this.isRadioDetail(detail) || - this.isM3uDashSelection() || - this.portalPlayer.isEmbeddedPlayer() - ) { - return false; - } - - return !this.settingsStore.openStreamOnDoubleClick() || startPlayback; - } - - private isRadioDetail( - detail: ResolvedLiveCollectionDetail | null | undefined - ): boolean { - return detail?.channel?.radio === 'true'; - } - - private async hydrateSelectedM3uPrograms( - item: UnifiedCollectionItem, - detail: ResolvedLiveCollectionDetail, - requestId: number - ): Promise { - if (detail.epgMode !== 'm3u') { - return; - } - - if (detail.channel?.radio === 'true') { - return; - } - - const epgPrograms = await this.streamResolver.loadM3uProgramsForItem( - item, - detail.channel - ); - if (requestId !== this.selectionRequestId) { - return; - } - - this.activeDetail.update((currentDetail) => { - if (!currentDetail || currentDetail.epgMode !== 'm3u') { - return currentDetail; - } - - return { - ...currentDetail, - epgPrograms, - }; - }); + /** The collection row the rendered channel row stands for. */ + private itemFor( + channel: UnifiedFavoriteChannel + ): UnifiedCollectionItem | undefined { + return this.items().find((candidate) => candidate.uid === channel.uid); } } diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 1a44727aa..26f1032a9 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -33,8 +33,6 @@ export const maxLinesBaseline = [ 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts', 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts', 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts', - 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts', - 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts', 'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',