mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(collections): split the unified collection page and live tab (#1642)
* refactor(collections): split the unified collection page and live tab Both files sat far above the workspace's 400-line hard maximum and were only green because `tools/eslint/max-lines-baseline.mjs` exempted them. No behavior change: every template binding, public signal and handler the components exposed still resolves the same way, and the two baseline entries are gone. `unified-collection-page.component.ts` (966 → 398 lines) keeps the view surface and delegates: - `unified-collection-data.service.ts` — component-provided; owns the rows, the favorite uid set, the loading/reload indicators and every mutation, plus `loadedRequest` (the PR #1636 invariant, now read through one `mutationRequest` computed instead of two ad-hoc fallbacks) - `unified-collection-load.ts` — the reload key and the load effect - `unified-collection-scope.ts` — the This-playlist/All-playlists toggle - `unified-collection-content-type.ts` — the Live/Movies/Series tab - `unified-collection-history.ts` — the `window.history.state` view entry - `unified-collection-detail-state.ts` / `-detail-navigation.ts` — the inline detail and where an item this route cannot render goes instead - `unified-collection-clear-action.ts`, `-labels.ts`, `-favorites-sort.ts` `unified-live-tab.component.ts` (1051 → 426 lines) continues the pattern its siblings already established: - `unified-live-selection.ts` (+ `-selection-generation.ts`) — activating a row while the mounted player stays alive - `unified-live-selection-view.ts` — what the selection implies about the source and the player surface - `unified-live-epg-view.ts`, `unified-live-epg-map.ts` — the EPG panel and the rail's now-playing map - `unified-live-catchup.ts` — the timeshift override - `unified-live-recording-metadata.ts`, `unified-live-channel-rows.ts` Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(collections): move the collection data service into data-access Codex review on PR #1642: `UnifiedCollectionDataService` loads and persists favorites/recent rows, which CLAUDE.md places in `@iptvnator/portal/shared/data-access`, not in the `type:ui` project that renders them — and the dialog-scoped `SourceCleanupService` already sets that precedent for a component-provided stateful collection service. It was also the only non-root `@Injectable()` in `portal-shared-ui`. `collection-reload-indicator.ts` moves with it: `type:data-access` may not depend on `type:ui`, and the indicator is the service's own loading state machine rather than a view. Both are exported from the data-access collection barrel, so the boundary is now lint-enforced instead of conventional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1471e7af36
commit
975b1f7f74
27 files changed
+2552
-1541
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -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';
|
||||
@@ -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<UnifiedCollectionItem[]>([]);
|
||||
readonly favoriteUidSet = signal<ReadonlySet<string>>(new Set<string>());
|
||||
|
||||
private readonly request = signal<CollectionLoadRequest | null>(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<CollectionLoadRequest | null> =
|
||||
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<UnifiedCollectionItem[] | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ReadonlySet<string>> {
|
||||
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<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -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<CollectionMode>;
|
||||
/** Rows of the tab on screen — the ones this clears. */
|
||||
items: Signal<UnifiedCollectionItem[]>;
|
||||
typeLabelKey: Signal<string>;
|
||||
/** 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<void>;
|
||||
}): ClearCollectionAction {
|
||||
const dialogService = inject(DialogService);
|
||||
const translate = inject(TranslateService);
|
||||
|
||||
const clearFavorites = async (
|
||||
itemsToRemove: UnifiedCollectionItem[]
|
||||
): Promise<void> => {
|
||||
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<CollectionMode>;
|
||||
selectedContentType: Signal<CollectionContentType>;
|
||||
/** Rows the command would clear; with none there is nothing to offer. */
|
||||
currentTypeItems: Signal<UnifiedCollectionItem[]>;
|
||||
labelKey: Signal<string>;
|
||||
typeLabelKey: Signal<string>;
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
+96
@@ -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<CollectionContentType>;
|
||||
readonly liveItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly movieItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly seriesItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly hasLive: Signal<boolean>;
|
||||
readonly hasMovies: Signal<boolean>;
|
||||
readonly hasSeries: Signal<boolean>;
|
||||
readonly availableTypes: Signal<CollectionContentType[]>;
|
||||
readonly showToggle: Signal<boolean>;
|
||||
readonly currentTypeItems: Signal<UnifiedCollectionItem[]>;
|
||||
readonly currentTypeLabelKey: Signal<string>;
|
||||
/** Move off a tab the collection no longer has items for. */
|
||||
autoSelect(): void;
|
||||
}
|
||||
|
||||
export function createCollectionContentTypeState(
|
||||
allItems: Signal<readonly UnifiedCollectionItem[]>,
|
||||
initialType: CollectionContentType
|
||||
): CollectionContentTypeState {
|
||||
const selected = signal<CollectionContentType>(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]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+88
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+166
@@ -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<UnifiedCollectionItem | null>;
|
||||
readonly seriesResume: WritableSignal<SeriesResumeTarget | null>;
|
||||
/** Template context for the host's projected detail template. */
|
||||
readonly context: Signal<UnifiedCollectionDetailContext | null>;
|
||||
/** 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<CollectionMode>;
|
||||
portalType: Signal<string | undefined>;
|
||||
/** Absent while the host projects no detail template. */
|
||||
detailTemplate: Signal<unknown>;
|
||||
/** 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<UnifiedCollectionItem | null>(null);
|
||||
const seriesResume = signal<SeriesResumeTarget | null>(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<UnifiedCollectionDetailContext | null>(() => {
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+66
@@ -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<FavoritesChannelSortMode>;
|
||||
readonly labelKey: Signal<string>;
|
||||
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<FavoritesChannelSortMode>(
|
||||
restoreFavoritesChannelSortMode()
|
||||
);
|
||||
|
||||
return {
|
||||
mode: mode.asReadonly(),
|
||||
labelKey: computed(() =>
|
||||
getFavoritesChannelSortModeTranslationKey(mode())
|
||||
),
|
||||
options: FAVORITES_SORT_OPTIONS,
|
||||
setMode(next: FavoritesChannelSortMode): void {
|
||||
mode.set(next);
|
||||
persistFavoritesChannelSortMode(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
+141
@@ -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<CollectionViewState | null>(
|
||||
getCollectionViewState(window.history.state)
|
||||
);
|
||||
|
||||
/** View state of the history entry currently on screen. */
|
||||
readonly current: Signal<CollectionViewState | null> =
|
||||
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<CollectionContentType>;
|
||||
/** `undefined` wherever the scope toggle is not offered. */
|
||||
scope: Signal<CollectionScope | undefined>;
|
||||
}): 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<string, unknown> {
|
||||
return state && typeof state === 'object'
|
||||
? { ...(state as Record<string, unknown>) }
|
||||
: {};
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { computed, Signal } from '@angular/core';
|
||||
import { CollectionMode } from '@iptvnator/portal/shared/data-access';
|
||||
|
||||
export interface CollectionModeLabels {
|
||||
readonly title: Signal<string>;
|
||||
readonly clearTooltipKey: Signal<string>;
|
||||
readonly emptyStateIcon: Signal<string>;
|
||||
readonly emptyStateTitleKey: Signal<string>;
|
||||
readonly emptyStateBodyKey: Signal<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CollectionMode>
|
||||
): CollectionModeLabels {
|
||||
const byMode = <T>(favorites: T, recent: T): Signal<T> =>
|
||||
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',
|
||||
};
|
||||
}
|
||||
+78
@@ -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<CollectionMode>;
|
||||
portalType: Signal<string | undefined>;
|
||||
playlistId: Signal<string | undefined>;
|
||||
scope: Signal<CollectionScope>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+164
-732
File diff suppressed because it is too large.
Load diff
+78
@@ -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<CollectionScope>;
|
||||
/** Only a playlist-bound collection can narrow to one playlist. */
|
||||
readonly showToggle: Signal<boolean>;
|
||||
/** The scope to load with: `all` wherever the toggle is absent. */
|
||||
readonly effective: Signal<CollectionScope>;
|
||||
/** 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<string>;
|
||||
playlistId: Signal<string | undefined>;
|
||||
defaultScope: Signal<CollectionScope | undefined>;
|
||||
historyScope: Signal<CollectionScope | undefined>;
|
||||
}): CollectionScopeState {
|
||||
const route = inject(ActivatedRoute);
|
||||
const scopeService = inject(ScopeToggleService);
|
||||
const queryScope = queryParamSignal<CollectionScope | null>(
|
||||
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<CollectionScope>(() => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<EpgProgram | null>;
|
||||
/** 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<UnifiedLiveTimeshift | null>;
|
||||
activeItem: Signal<UnifiedCollectionItem | null>;
|
||||
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
|
||||
isM3uSelection: Signal<boolean>;
|
||||
currentM3uChannel: Signal<Channel | null>;
|
||||
shouldUseInlinePlayer: Signal<boolean>;
|
||||
/** 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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+84
@@ -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<UnifiedFavoriteChannel[]>;
|
||||
/**
|
||||
* 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<readonly UnifiedFavoriteChannel[]>;
|
||||
/**
|
||||
* 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<UnifiedFavoriteChannel[]>;
|
||||
}
|
||||
|
||||
export function createUnifiedLiveChannelRows(options: {
|
||||
items: Signal<UnifiedCollectionItem[]>;
|
||||
searchTerm: Signal<string>;
|
||||
mode: Signal<'favorites' | 'recent'>;
|
||||
sortMode: Signal<FavoritesChannelSortMode>;
|
||||
}): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<Map<string, EpgProgram | null>>;
|
||||
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<UnifiedCollectionItem[]>;
|
||||
/** EPG display offset; the map answers "what is on at that clock". */
|
||||
offsetMinutes: Signal<number>;
|
||||
}): UnifiedLiveEpgMap {
|
||||
const streamResolver = inject(StreamResolverService);
|
||||
const programs = signal<Map<string, EpgProgram | null>>(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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<LiveEpgPanelState>;
|
||||
readonly isPanelCollapsed: Signal<boolean>;
|
||||
readonly selectedDate: Signal<string>;
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly viewMode: Signal<EpgViewMode>;
|
||||
readonly offsetMinutes: Signal<number>;
|
||||
readonly timelinePrograms: Signal<EpgProgram[]>;
|
||||
readonly timelineChannelName: Signal<string>;
|
||||
readonly timelineChannelLogo: Signal<string>;
|
||||
readonly timelineArchiveAvailable: Signal<boolean>;
|
||||
readonly timelineArchiveDays: Signal<number>;
|
||||
readonly panelSummary: Signal<LiveEpgPanelSummary | null>;
|
||||
readonly panelSummaryLabelKey: Signal<string>;
|
||||
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<ResolvedLiveCollectionDetail | null>;
|
||||
activeItem: Signal<UnifiedCollectionItem | null>;
|
||||
activeTimeshift: Signal<UnifiedLiveTimeshift | null>;
|
||||
isM3uSelection: Signal<boolean>;
|
||||
currentM3uChannel: Signal<Channel | null>;
|
||||
currentM3uPrograms: Signal<EpgProgram[]>;
|
||||
currentPortalEpgItems: Signal<EpgItem[]>;
|
||||
/** 30 s tick that re-evaluates "what is on now". */
|
||||
progressTick: Signal<number>;
|
||||
}): UnifiedLiveEpgView {
|
||||
const settingsStore = inject(SettingsStore);
|
||||
const panelState = signal<LiveEpgPanelState>(restoreLiveEpgPanelState());
|
||||
const selectedDate = signal(getTodayEpgDateKey());
|
||||
const offsetMinutes = settingsStore.resolvedEpgOffsetMinutes;
|
||||
|
||||
const currentM3uArchivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(options.currentM3uChannel())
|
||||
);
|
||||
|
||||
const timelinePrograms = computed<EpgProgram[]>(() =>
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+132
@@ -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<RecordingStartMetadata | null>;
|
||||
/** 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<UnifiedCollectionItem | null>;
|
||||
timelinePrograms: Signal<EpgProgram[]>;
|
||||
timelineChannelLogo: Signal<string>;
|
||||
epgOffsetMinutes: Signal<number>;
|
||||
/** 30 s tick; see `metadata`. */
|
||||
progressTick: Signal<number>;
|
||||
}): UnifiedLiveRecording {
|
||||
const recordingsService = inject(RecordingsService);
|
||||
|
||||
const metadata = computed<RecordingStartMetadata | null>(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
};
|
||||
}
|
||||
+184
@@ -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<VideoPlayer>;
|
||||
readonly isEmbeddedPlayer: Signal<boolean>;
|
||||
/** Playback target for the inline player, honouring a catch-up override. */
|
||||
readonly inlinePlayback: Signal<ResolvedPortalPlayback | null>;
|
||||
readonly currentStreamUrl: Signal<string>;
|
||||
readonly isM3uSelection: Signal<boolean>;
|
||||
readonly currentPortalEpgItems: Signal<EpgItem[]>;
|
||||
readonly currentM3uPrograms: Signal<EpgProgram[]>;
|
||||
readonly currentM3uChannel: Signal<Channel | null>;
|
||||
readonly activeRadioChannel: Signal<Channel | null>;
|
||||
readonly isRadioSelection: Signal<boolean>;
|
||||
readonly isM3uDashSelection: Signal<boolean>;
|
||||
/** Player the inline surface uses, after the DASH correction. */
|
||||
readonly inlinePlayer: Signal<VideoPlayer>;
|
||||
readonly shouldUseInlinePlayer: Signal<boolean>;
|
||||
/** Identity of the selection an EPG archive action would act on. */
|
||||
readonly archiveContextKey: Signal<string>;
|
||||
/**
|
||||
* "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<WorkspaceNavigationTarget | null>;
|
||||
readonly openInPlaylistName: Signal<string>;
|
||||
readonly playbackSessionKey: Signal<string>;
|
||||
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<UnifiedCollectionItem | null>;
|
||||
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
|
||||
activeTimeshift: Signal<UnifiedLiveTimeshift | null>;
|
||||
}): 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+273
@@ -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<void>;
|
||||
/** 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<string | null>;
|
||||
activeItem: WritableSignal<UnifiedCollectionItem | null>;
|
||||
activeDetail: WritableSignal<ResolvedLiveCollectionDetail | null>;
|
||||
activeTimeshift: WritableSignal<UnifiedLiveTimeshift | null>;
|
||||
isSelecting: WritableSignal<boolean>;
|
||||
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<void> => {
|
||||
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<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+136
-801
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user