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