feat: Implement global favorites list component with drag-and-drop support

- Added `GlobalFavoritesListComponent` to display and manage favorite channels.
- Integrated drag-and-drop functionality for reordering favorite channels.
- Created HTML and SCSS files for the global favorites list layout and styling.
- Implemented logic to handle empty states and search functionality within the favorites list.

feat: Enhance unified collection page with live and grid tabs

- Developed `UnifiedCollectionPageComponent` to manage different content types (live, movies, series).
- Added `UnifiedGridTabComponent` and `UnifiedLiveTabComponent` for displaying content in grid and live formats.
- Implemented loading states and empty states for better user experience.
- Integrated EPG (Electronic Program Guide) functionality for live channels.

style: Refactor styles for unified collection components

- Updated SCSS styles for `UnifiedGridTabComponent` and `UnifiedLiveTabComponent` to improve layout and responsiveness.
- Added skeleton loading states for better visual feedback during data fetching.

fix: Improve scrolling behavior in category view component

- Adjusted scrolling logic in `CategoryViewComponent` to ensure selected categories are centered in the view.

Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
4gray committed 2026-03-25 19:20:59 +01:00
1 parent 8874e4a17e
commit a987e10a59
30 files changed
+3000 -218

No files matched your search

@@ -3,6 +3,7 @@ import {
APIRequestContext,
ElectronApplication,
expect,
Locator,
Page,
test as base,
} from '@playwright/test';
@@ -29,13 +30,13 @@ export const defaultXtreamUsername = 'user1';
export const defaultXtreamPassword = 'pass1';
export const defaultStalkerMacAddress = '00:1A:79:00:00:01';
type PortalProvider = 'stalker' | 'xtream';
export type PortalProvider = 'stalker' | 'xtream';
type ElectronFixtures = {
dataDir: string;
};
type PortalDebugEvent = {
export type PortalDebugEvent = {
durationMs: number;
operation: string;
provider: PortalProvider;
@@ -197,14 +198,45 @@ export async function addXtreamPortal(
await page.getByRole('menuitem', { name: 'Add Xtreme Code' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(serverUrl);
await dialog.locator('#username').fill(username);
await dialog.locator('#password').fill(password);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await setInputValue(dialog.locator('#title'), name);
await setInputValue(dialog.locator('#serverUrl'), serverUrl);
await setInputValue(dialog.locator('#username'), username);
await setInputValue(dialog.locator('#password'), password);
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
await expect(addButton).toBeEnabled({ timeout: 10000 });
await addButton.click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
async function setInputValue(input: Locator, value: string): Promise<void> {
await input.fill('');
await input.fill(value);
if ((await input.inputValue()) === value) {
return;
}
await input.click();
await input.press('Control+A');
await input.press('Backspace');
await input.type(value);
if ((await input.inputValue()) === value) {
return;
}
await input.evaluate((element, nextValue) => {
const inputElement = element as HTMLInputElement;
inputElement.value = nextValue;
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
inputElement.dispatchEvent(new Event('blur', { bubbles: true }));
}, value);
await expect(input).toHaveValue(value);
}
export async function addStalkerPortal(
page: Page,
options: {
@@ -260,17 +292,45 @@ export async function goToDashboard(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/dashboard$/);
}
export async function waitForM3uCatalog(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await expect(page.getByTestId('channel-item').first()).toBeVisible({
timeout: 20000,
});
}
export async function waitForXtreamCatalog(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/xtreams\/.+/);
await waitForXtreamImportToFinish(page);
const categories = page.locator('.category-item');
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
await expect(categories.first()).toBeVisible({ timeout: 20000 });
await categories.first().click();
const contentItems = page.locator(
'.content-card, [data-test-id="channel-item"], mat-card'
);
await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
try {
await expect(contentItems.first()).toBeVisible({ timeout: 5000 });
return;
} catch {
await categories.first().click();
await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
}
}
export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
const overlay = page.locator('.workspace-loading-overlay');
try {
await overlay.waitFor({ state: 'visible', timeout: 5000 });
} catch {
// The overlay may already be gone by the time the test reaches this point.
}
await expect(overlay).toHaveCount(0, { timeout: 30000 });
}
export async function waitForStalkerCatalog(page: Page): Promise<void> {
@@ -286,6 +346,43 @@ export async function waitForStalkerCatalog(page: Page): Promise<void> {
await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
}
export async function fillWorkspaceSearch(
page: Page,
term: string,
options: {
submit?: boolean;
} = {}
): Promise<void> {
const input = page.locator(
'app-workspace-shell-header .search-field input[type="search"]'
);
await expect(input).toBeEnabled();
await input.fill(term);
if (options.submit) {
await input.press('Enter');
}
}
export async function expectWorkspaceSearchScope(
page: Page,
expected: RegExp | string
): Promise<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--scope')
).toHaveText(expected);
}
export async function expectWorkspaceSearchStatus(
page: Page,
expected: RegExp | string
): Promise<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--status')
).toHaveText(expected);
}
async function startPortalDebugCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const electronApi = window.electron as typeof window.electron & {
@@ -332,6 +429,58 @@ export async function expectPortalDebugSuccess(
.toBeGreaterThan(0);
}
export async function waitForPortalDebugEvent(
page: Page,
options: {
operation: string;
predicate?: (event: PortalDebugEvent) => boolean;
provider: PortalProvider;
timeoutMs?: number;
}
): Promise<PortalDebugEvent> {
const {
operation,
predicate,
provider,
timeoutMs = 20000,
} = options;
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
const events = await page.evaluate(() => window.__portalDebugEvents ?? []);
const match = events.find((event) => {
if (
event.provider !== provider ||
event.operation !== operation ||
event.status !== 'success' ||
event.transport !== 'electron-main'
) {
return false;
}
return predicate ? predicate(event) : true;
});
if (match) {
return match;
}
await page.waitForTimeout(200);
}
const recentEvents = await page.evaluate(() =>
(window.__portalDebugEvents ?? []).slice(-10)
);
throw new Error(
`Timed out waiting for ${provider}:${operation}. Recent events: ${JSON.stringify(
recentEvents,
null,
2
)}`
);
}
export async function resetMockServers(
request: APIRequestContext,
providers: PortalProvider[]
File diff suppressed because it is too large. Load diff
@@ -293,6 +293,7 @@ contextBridge.exposeInMainWorld('electron', {
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
dbGetAllGlobalFavorites: () => ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
@@ -156,6 +156,52 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
}
});
/**
* Get global favorites across all playlists (all content types)
*/
ipcMain.handle('DB_GET_ALL_GLOBAL_FAVORITES', async () => {
try {
const db = await getDatabase();
const result = await db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.orderBy(
asc(schema.favorites.position),
desc(schema.favorites.addedAt)
)
.limit(500);
return result;
} catch (error) {
console.error('Error getting all global favorites:', error);
throw error;
}
});
/**
* Reorder global favorites by updating the position field on each Xtream favorite row.
* Accepts an array of { content_id, position } pairs.
+4 -34
View File
@@ -1,11 +1,11 @@
import { Component, HostBinding, inject, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { filter, take } from 'rxjs';
@@ -33,13 +33,13 @@ export class AppComponent implements OnInit {
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private dialog = inject(MatDialog);
private epgService = inject(EpgService);
private snackBar = inject(MatSnackBar);
private router = inject(Router);
private store = inject(Store);
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
/** Default language as fallback */
private readonly DEFAULT_LANG = Language.ENGLISH;
@@ -69,10 +69,10 @@ export class AppComponent implements OnInit {
if (event.ctrlKey || event.metaKey) {
if (event.key === 'f') {
event.preventDefault();
this.openGlobalSearch();
this.workspaceShellActions.openGlobalSearch();
} else if (event.key === 'r') {
event.preventDefault();
this.openGlobalRecent();
this.workspaceShellActions.openGlobalRecent();
}
}
});
@@ -137,36 +137,6 @@ export class AppComponent implements OnInit {
this.router.navigateByUrl(route);
}
openGlobalSearch(initialQuery = ''): void {
void import('@iptvnator/portal/xtream/feature').then(
({ GlobalSearchResultsComponent }) => {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobalSearch: true, initialQuery },
});
}
);
}
openGlobalRecent(): void {
void import('@iptvnator/portal/xtream/feature').then(
({ GlobalRecentlyViewedComponent }) => {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
);
}
/**
* Fetches EPG data only for URLs that have stale or missing data.
* Data is considered fresh if updated within the last 12 hours.
+2 -2
View File
@@ -9,7 +9,7 @@ import {
provideZoneChangeDetection,
} from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter } from '@angular/router';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { provideEffects } from '@ngrx/effects';
import { provideRouterStore, routerReducer } from '@ngrx/router-store';
@@ -59,7 +59,7 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideRouter(routes, withComponentInputBinding()),
provideAnimations(),
provideHttpClient(withInterceptorsFromDi()),
provideStore({
+10 -24
View File
@@ -1,17 +1,4 @@
import { Route, Routes } from '@angular/router';
function withWorkspaceLayout(routes: Route[]): Route[] {
return routes.map((route) => ({
...route,
data: {
...(route.data || {}),
layout: 'workspace',
},
children: route.children
? withWorkspaceLayout(route.children)
: undefined,
}));
}
import { Routes } from '@angular/router';
export const routes: Routes = [
{
@@ -21,6 +8,9 @@ export const routes: Routes = [
},
{
path: 'workspace',
data: {
layout: 'workspace',
},
loadComponent: () =>
import('@iptvnator/workspace/shell/feature').then(
(c) => c.WorkspaceShellComponent
@@ -52,9 +42,6 @@ export const routes: Routes = [
},
{
path: 'playlists/:id/:view',
data: {
layout: 'workspace',
},
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.VideoPlayerComponent
@@ -63,16 +50,16 @@ export const routes: Routes = [
{
path: 'global-favorites',
data: {
layout: 'workspace',
mode: 'favorites',
defaultScope: 'all',
},
loadComponent: () =>
import('@iptvnator/workspace/shell/feature').then(
(c) => c.GlobalFavoritesPageComponent
import('@iptvnator/portal/shared/ui').then(
(c) => c.UnifiedCollectionPageComponent
),
},
{
path: 'downloads',
data: { layout: 'workspace' },
loadComponent: () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
@@ -82,19 +69,18 @@ export const routes: Routes = [
path: '',
loadChildren: () =>
import('@iptvnator/portal/xtream/feature').then((m) =>
withWorkspaceLayout(m.createXtreamRoutes())
m.createXtreamRoutes()
),
},
{
path: '',
loadChildren: () =>
import('@iptvnator/portal/stalker/feature').then((m) =>
withWorkspaceLayout(m.createStalkerRoutes())
m.createStalkerRoutes()
),
},
{
path: 'settings',
data: { layout: 'workspace' },
loadComponent: () =>
import('./settings/settings.component').then(
(c) => c.SettingsComponent
+4 -3
View File
@@ -584,7 +584,7 @@
"RAIL_LIVE": "Live-TV",
"RAIL_SERIES": "Serien",
"RAIL_RECENTLY_ADDED": "Zuletzt hinzugefügt",
"RAIL_SEARCH": "Suche",
"RAIL_SEARCH": "Erweiterte Suche",
"RAIL_RECENT": "Zuletzt angesehen",
"RAIL_FAVORITES": "Favoriten",
"RAIL_DOWNLOADS": "Downloads",
@@ -596,6 +596,7 @@
"SEARCH_PLAYLIST_PLACEHOLDER": "In dieser Playlist suchen...",
"SEARCH_SECTION_PLACEHOLDER": "In diesem Abschnitt suchen...",
"FILTER_SECTION_PLACEHOLDER": "Diesen Abschnitt filtern...",
"SEARCH_STATUS_LOADED_ONLY": "Nur geladene Sender",
"COMMAND_PALETTE_SHORTCUT": "Befehlspalette (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Befehlspalette öffnen",
"ADD_PLAYLIST_ALL": "Playlist hinzufügen (alle Playlists)",
@@ -621,8 +622,8 @@
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Alle Xtream-Playlists durchsuchen",
"GLOBAL_SEARCH_DESCRIPTION": "Globale Suchüberlagerung öffnen",
"PLAYLIST_SEARCH_LABEL": "Diese Playlist durchsuchen",
"PLAYLIST_SEARCH_DESCRIPTION": "Suchroute der Playlist öffnen",
"PLAYLIST_SEARCH_LABEL": "Erweiterte Suche in dieser Playlist",
"PLAYLIST_SEARCH_DESCRIPTION": "Erweiterte Suchansicht der Playlist öffnen",
"OPEN_GLOBAL_FAVORITES_LABEL": "Globale Favoriten öffnen",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Zur aggregierten Favoritenansicht wechseln",
"OPEN_DOWNLOADS_LABEL": "Downloads öffnen",
+16 -4
View File
@@ -430,6 +430,17 @@
"REMOVE_FROM_FAVORITES": "Remove from favorites",
"REMOVED_FROM_FAVORITES": "Removed from favorites",
"ADDED_TO_FAVORITES": "Added to favorites",
"FAVORITES": "Favorites",
"RECENTLY_VIEWED": "Recently viewed",
"THIS_PLAYLIST": "This playlist",
"ALL_PLAYLISTS": "All playlists",
"LIVE_TV": "Live TV",
"MOVIES": "Movies",
"SERIES": "Series",
"SEARCH": "Search",
"NO_FAVORITES": "No favorites yet",
"NO_RECENT_ITEMS": "No recently viewed items",
"REMOVE_FROM_RECENT": "Remove from recently viewed",
"SIDEBAR": {
"MAIN": "Main",
"MOVIES": "Movies",
@@ -643,7 +654,7 @@
"RAIL_LIVE": "Live TV",
"RAIL_SERIES": "Series",
"RAIL_RECENTLY_ADDED": "Recently added",
"RAIL_SEARCH": "Search",
"RAIL_SEARCH": "Advanced search",
"RAIL_RECENT": "Recently viewed",
"RAIL_FAVORITES": "Favorites",
"RAIL_DOWNLOADS": "Downloads",
@@ -655,11 +666,12 @@
"SEARCH_PLAYLIST_PLACEHOLDER": "Search in this playlist...",
"SEARCH_SECTION_PLACEHOLDER": "Search in this section...",
"FILTER_SECTION_PLACEHOLDER": "Filter this section...",
"SEARCH_STATUS_LOADED_ONLY": "Loaded channels only",
"COMMAND_PALETTE_SHORTCUT": "Command palette (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Open command palette",
"ADD_PLAYLIST_ALL": "Add playlist (all playlists)",
"ADD_PLAYLIST": "Add playlist",
"OPEN_GLOBAL_FAVORITES": "Open global favorites",
"OPEN_GLOBAL_FAVORITES": "All favorites (all playlists)",
"OPEN_DOWNLOADS": "Open downloads",
"OPEN_CONTEXT_ACTIONS": "Open section actions",
"NO_SECTION_ACTIONS": "No actions for this section",
@@ -680,8 +692,8 @@
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Search all Xtream playlists",
"GLOBAL_SEARCH_DESCRIPTION": "Open global search overlay",
"PLAYLIST_SEARCH_LABEL": "Search this playlist",
"PLAYLIST_SEARCH_DESCRIPTION": "Open playlist search route",
"PLAYLIST_SEARCH_LABEL": "Advanced search this playlist",
"PLAYLIST_SEARCH_DESCRIPTION": "Open advanced playlist search",
"OPEN_GLOBAL_FAVORITES_LABEL": "Open global favorites",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Navigate to aggregated favorites",
"OPEN_DOWNLOADS_LABEL": "Open downloads",
+4 -3
View File
@@ -561,7 +561,7 @@
"RAIL_LIVE": "ТВ",
"RAIL_SERIES": "Сериалы",
"RAIL_RECENTLY_ADDED": "Недавно добавленные",
"RAIL_SEARCH": "Поиск",
"RAIL_SEARCH": "Расширенный поиск",
"RAIL_RECENT": "Недавно просмотренные",
"RAIL_FAVORITES": "Избранное",
"RAIL_DOWNLOADS": "Загрузки",
@@ -573,6 +573,7 @@
"SEARCH_PLAYLIST_PLACEHOLDER": "Искать в этом плейлисте...",
"SEARCH_SECTION_PLACEHOLDER": "Искать в этом разделе...",
"FILTER_SECTION_PLACEHOLDER": "Фильтровать этот раздел...",
"SEARCH_STATUS_LOADED_ONLY": "Только загруженные каналы",
"COMMAND_PALETTE_SHORTCUT": "Палитра команд (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Открыть палитру команд",
"ADD_PLAYLIST_ALL": "Добавить плейлист (все плейлисты)",
@@ -598,8 +599,8 @@
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Искать во всех плейлистах Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "Открыть глобальный поиск",
"PLAYLIST_SEARCH_LABEL": "Искать в этом плейлисте",
"PLAYLIST_SEARCH_DESCRIPTION": "Открыть маршрут поиска плейлиста",
"PLAYLIST_SEARCH_LABEL": "Расширенный поиск в этом плейлисте",
"PLAYLIST_SEARCH_DESCRIPTION": "Открыть расширенный поиск плейлиста",
"OPEN_GLOBAL_FAVORITES_LABEL": "Открыть глобальные избранные",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Перейти к объединенному избранному",
"OPEN_DOWNLOADS_LABEL": "Открыть загрузки",
+1
View File
@@ -221,6 +221,7 @@ declare global {
) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<XtreamContent[]>;
dbGetGlobalFavorites: () => Promise<GlobalFavoriteItem[]>;
dbGetAllGlobalFavorites: () => Promise<GlobalFavoriteItem[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
Vendored
+1
View File
@@ -200,6 +200,7 @@ declare global {
) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetGlobalFavorites: () => Promise<any[]>;
dbGetAllGlobalFavorites: () => Promise<any[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
@@ -47,6 +47,7 @@ import {
import {
getAdjacentChannelItem,
getChannelItemByNumber,
isWorkspaceLayoutRoute,
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
@@ -143,8 +144,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
};
readonly isDesktop = !!window['electron'];
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
/** EPG overlay reference */
private overlayRef!: OverlayRef;
@@ -1,24 +1,4 @@
<div class="downloads-layout">
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="320"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<h2>{{ 'DOWNLOADS.TITLE' | translate }}</h2>
</div>
<app-category-view
[items]="categories()"
[selectedCategoryId]="selectedCategoryId()"
(categoryClicked)="setCategoryId($any($event).category_id)"
/>
</div>
}
<div class="downloads">
<header class="downloads__header">
<div class="downloads__header-left">
@@ -14,7 +14,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService, ResizableDirective } from 'components';
import { DialogService } from 'components';
import { firstValueFrom, map } from 'rxjs';
import {
DatabaseService,
@@ -22,17 +22,13 @@ import {
DownloadsService,
PlaylistsService,
} from 'services';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { Playlist } from 'shared-interfaces';
type PortalSource = 'xtream' | 'stalker';
@@ -52,12 +48,10 @@ const DOWNLOAD_COLLECTION_LABELS = {
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CategoryViewComponent,
MatButtonModule,
MatIcon,
MatProgressBarModule,
MatTooltip,
ResizableDirective,
TranslatePipe,
],
})
@@ -66,12 +60,11 @@ export class DownloadsComponent {
private readonly router = inject(Router);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly favoritesCtx = inject(FavoritesContextService);
private readonly collectionCtx = inject(PortalCollectionContextService);
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly downloadsService = inject(DownloadsService);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly downloads = this.downloadsService.downloads;
readonly downloadFolder = this.downloadsService.downloadFolder;
@@ -116,7 +109,7 @@ export class DownloadsComponent {
});
});
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
ctx: this.collectionCtx,
categories: this.categories,
});
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
@@ -166,10 +159,6 @@ export class DownloadsComponent {
});
}
setCategoryId(categoryId: string) {
this.collectionContext.setCategoryId(categoryId);
}
getProgress(item: DownloadItem): number {
return this.downloadsService.getProgressPercent(item);
}
@@ -359,10 +348,7 @@ export class DownloadsComponent {
segments: Array<string | number>
): Array<string | number> {
const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams';
if (this.isWorkspaceLayout) {
return ['/workspace', sourceSegment, playlistId, ...segments];
}
return [`/${sourceSegment}`, playlistId, ...segments];
return ['/workspace', sourceSegment, playlistId, ...segments];
}
private async resolveSourceType(
+2
View File
@@ -1,6 +1,7 @@
export * from './lib/components/category-view/category-view.component';
export * from './lib/components/content-card/content-card.component';
export * from './lib/components/favorites-layout/favorites-layout.component';
export * from './lib/components/global-favorites-list/global-favorites-list.component';
export * from './lib/components/grid-list/grid-list.component';
export * from './lib/components/playlist-error-view/playlist-error-view.component';
export { ProgressCapsuleComponent } from 'components';
@@ -10,5 +11,6 @@ export * from './lib/components/portal-empty-state/portal-empty-state.component'
export * from './lib/components/search-form/search-form.component';
export * from './lib/components/search-layout/search-layout.component';
export { WatchedBadgeComponent } from 'components';
export * from './lib/components/unified-collection/unified-collection-page.component';
export * from './lib/navigation/navigation.component';
export * from './lib/navigation/portal-rail-links.component';
@@ -48,10 +48,25 @@ export class CategoryViewComponent {
const selected = candidates.find(
(el) => el.dataset.categoryId === String(selectedCategory)
);
selected?.scrollIntoView({
if (!selected) {
return;
}
const containerRect = container.getBoundingClientRect();
const selectedRect = selected.getBoundingClientRect();
const targetTop =
container.scrollTop +
(selectedRect.top - containerRect.top) -
container.clientHeight / 2 +
selectedRect.height / 2;
const maxScrollTop = Math.max(
0,
container.scrollHeight - container.clientHeight
);
container.scrollTo({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
top: Math.min(maxScrollTop, Math.max(0, targetTop)),
});
});
});
@@ -0,0 +1,49 @@
<div
class="nav-list"
cdkDropList
[cdkDropListDisabled]="hasSearchTerm()"
(cdkDropListDropped)="onDrop($event)"
>
@if (enrichedChannels().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">star_border</mat-icon>
@if (hasSearchTerm()) {
<p class="empty-title">
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
</p>
<p class="empty-hint">
{{
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
| translate
}}
</p>
} @else {
<p class="empty-title">
{{
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
}}
</p>
<p class="empty-hint">
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
</p>
}
</div>
}
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="!hasSearchTerm()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
[showEpg]="true"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
/>
}
</div>
@@ -0,0 +1,74 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
::ng-deep {
.search-bar div {
border-radius: 20px;
}
div[matformfieldlineripple],
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 14px;
font-weight: 500;
margin: 0 0 8px;
}
.empty-hint {
font-size: 12px;
opacity: 0.7;
margin: 0;
max-width: 260px;
}
}
@@ -0,0 +1,108 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
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,
MatIconModule,
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 searchTermInput = input('');
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly hasSearchTerm = computed(
() => this.searchTermInput().trim().length > 0
);
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
const channels = this.channels();
const epgMap = this.epgMap();
const term = this.searchTermInput().trim().toLowerCase();
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 {
if (this.hasSearchTerm()) {
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));
}
}
@@ -1,3 +1,5 @@
@use '../../styles/skeleton';
// ─── Card base ───────────────────────────────────────────────────────────────
mat-card {
overflow: hidden;
@@ -107,10 +109,6 @@ mat-card:hover {
}
}
.grid-skeleton-card {
width: 100%;
}
.grid-empty-state {
grid-column: 1 / -1;
min-height: clamp(320px, 56vh, 620px);
@@ -133,95 +131,6 @@ mat-card:hover {
}
}
.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%;
}
}
.grid-skeleton-thumb,
.grid-skeleton-badge,
.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: grid-skeleton-shimmer 1.2s ease-in-out infinite;
}
}
@keyframes grid-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@media (max-width: 768px) {
.grid-empty-state {
@@ -0,0 +1,151 @@
@if (isLoading()) {
<div class="collection-header">
<span class="collection-title">{{ title() | translate }}</span>
</div>
@if (selectedContentType() === 'live') {
<div class="skeleton-list">
@for (row of skeletonRows; track row) {
<div class="list-skeleton-row" aria-hidden="true">
<span class="list-skeleton-handle"></span>
<span class="list-skeleton-avatar"></span>
<div class="list-skeleton-text">
<span
class="grid-skeleton-line grid-skeleton-line--primary"
></span>
<span
class="grid-skeleton-line grid-skeleton-line--secondary"
></span>
</div>
<span class="list-skeleton-star"></span>
</div>
}
</div>
} @else {
<div class="skeleton-grid">
@for (card of skeletonCards; track card) {
<div class="grid-skeleton-card" aria-hidden="true">
<div class="grid-skeleton-thumb">
<span class="grid-skeleton-badge"></span>
</div>
<div class="grid-skeleton-title">
<span
class="grid-skeleton-line grid-skeleton-line--primary"
></span>
<span
class="grid-skeleton-line grid-skeleton-line--secondary"
></span>
</div>
</div>
}
</div>
}
} @else {
<div class="collection-header">
<span class="collection-title">{{ title() | translate }}</span>
@if (showContentToggle()) {
<mat-button-toggle-group
class="content-toggle"
[value]="selectedContentType()"
(change)="onContentTypeChange($event.value)"
hideSingleSelectionIndicator
>
@if (hasLive()) {
<mat-button-toggle value="live">
{{ 'PORTALS.LIVE_TV' | translate }}
</mat-button-toggle>
}
@if (hasMovies()) {
<mat-button-toggle value="movie">
{{ 'PORTALS.MOVIES' | translate }}
</mat-button-toggle>
}
@if (hasSeries()) {
<mat-button-toggle value="series">
{{ 'PORTALS.SERIES' | translate }}
</mat-button-toggle>
}
</mat-button-toggle-group>
}
<mat-button-toggle-group
class="scope-toggle"
[value]="scope()"
(change)="onScopeChange($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle value="playlist">
<mat-icon>playlist_play</mat-icon>
<span class="toggle-label">{{
'PORTALS.THIS_PLAYLIST' | translate
}}</span>
</mat-button-toggle>
<mat-button-toggle value="all">
<mat-icon>public</mat-icon>
<span class="toggle-label">{{
'PORTALS.ALL_PLAYLISTS' | translate
}}</span>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
@switch (selectedContentType()) {
@case ('live') {
@if (hasLive()) {
<app-unified-live-tab
[items]="liveItems()"
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
(removeItem)="onRemoveItem($event)"
(reorderItems)="onReorder($event)"
/>
}
}
@case ('movie') {
@if (hasMovies()) {
<app-unified-grid-tab
[items]="movieItems()"
[mode]="mode()"
[contentType]="'movie'"
[searchTerm]="workspaceSearchTerm()"
(removeItem)="onRemoveItem($event)"
/>
}
}
@case ('series') {
@if (hasSeries()) {
<app-unified-grid-tab
[items]="seriesItems()"
[mode]="mode()"
[contentType]="'series'"
[searchTerm]="workspaceSearchTerm()"
(removeItem)="onRemoveItem($event)"
/>
}
}
}
@if (!hasLive() && !hasMovies() && !hasSeries()) {
<div class="empty-state">
<mat-icon class="empty-icon">{{
workspaceSearchTerm()
? 'search_off'
: mode() === 'favorites'
? 'favorite_border'
: 'history'
}}</mat-icon>
<p class="empty-title">
@if (workspaceSearchTerm()) {
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
} @else {
{{
(mode() === 'favorites'
? 'PORTALS.NO_FAVORITES'
: 'PORTALS.NO_RECENT_ITEMS'
) | translate
}}
}
</p>
</div>
}
}
@@ -0,0 +1,86 @@
@use '../../styles/skeleton';
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.skeleton-list {
flex: 1;
overflow: hidden;
}
.skeleton-grid {
flex: 1;
overflow: hidden;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 16px;
padding: 16px;
align-content: start;
}
.collection-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
flex-shrink: 0;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.collection-title {
font-size: 15px;
font-weight: 600;
color: var(--mat-sys-on-surface);
margin-right: auto;
}
.content-toggle,
.scope-toggle {
.toggle-label {
margin-left: 4px;
font-size: 12px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
app-unified-live-tab,
app-unified-grid-tab {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
flex: 1;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 56px;
height: 56px;
width: 56px;
margin-bottom: 16px;
opacity: 0.4;
}
.empty-title {
font-size: 14px;
font-weight: 500;
margin: 0;
}
}
@@ -0,0 +1,187 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
OnInit,
signal,
} from '@angular/core';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import {
CollectionContentType,
CollectionScope,
isWorkspaceLayoutRoute,
queryParamSignal,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
@Component({
selector: 'app-unified-collection-page',
templateUrl: './unified-collection-page.component.html',
styleUrl: './unified-collection-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonToggleModule,
MatIconModule,
TranslatePipe,
UnifiedGridTabComponent,
UnifiedLiveTabComponent,
],
})
export class UnifiedCollectionPageComponent implements OnInit {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input<string>();
readonly defaultScope = input<CollectionScope>();
private readonly route = inject(ActivatedRoute);
private readonly scopeService = inject(ScopeToggleService);
private readonly favoritesData = inject(UnifiedFavoritesDataService);
private readonly recentData = inject(UnifiedRecentDataService);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
readonly isLoading = signal(true);
readonly allItems = signal<UnifiedCollectionItem[]>([]);
readonly selectedContentType = signal<CollectionContentType>('live');
readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i);
readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i);
readonly playlistId = computed(() => {
let current = this.route.snapshot;
while (current) {
if (current.params['id']) {
return current.params['id'] as string;
}
current = current.parent!;
}
return undefined;
});
readonly scopeKey = computed(() => this.mode());
readonly scope = signal<CollectionScope>('playlist');
readonly liveItems = computed(() =>
this.allItems().filter((i) => i.contentType === 'live')
);
readonly movieItems = computed(() =>
this.allItems().filter((i) => i.contentType === 'movie')
);
readonly seriesItems = computed(() =>
this.allItems().filter((i) => i.contentType === 'series')
);
readonly hasLive = computed(() => this.liveItems().length > 0);
readonly hasMovies = computed(() => this.movieItems().length > 0);
readonly hasSeries = computed(() => this.seriesItems().length > 0);
readonly availableTypes = computed(() => {
const types: CollectionContentType[] = [];
if (this.hasLive()) types.push('live');
if (this.hasMovies()) types.push('movie');
if (this.hasSeries()) types.push('series');
return types;
});
readonly showContentToggle = computed(
() => this.availableTypes().length > 1
);
readonly title = computed(() => {
return this.mode() === 'favorites'
? 'PORTALS.FAVORITES'
: 'PORTALS.RECENTLY_VIEWED';
});
constructor() {
effect(() => {
this.scope();
this.loadData();
});
}
ngOnInit(): void {
const queryScope = this.route.snapshot.queryParams['scope'] as
| CollectionScope
| undefined;
if (queryScope === 'all' || queryScope === 'playlist') {
this.scope.set(queryScope);
} else if (this.defaultScope()) {
this.scope.set(this.defaultScope()!);
} else {
const persisted = this.scopeService.getScope(this.scopeKey());
this.scope.set(persisted());
}
}
onScopeChange(value: CollectionScope): void {
this.scope.set(value);
this.scopeService.setScope(this.scopeKey(), value);
}
onContentTypeChange(value: CollectionContentType): void {
this.selectedContentType.set(value);
}
async onRemoveItem(item: UnifiedCollectionItem): Promise<void> {
if (this.mode() === 'favorites') {
await this.favoritesData.removeFavorite(item);
} else {
await this.recentData.removeRecentItem(item);
}
this.allItems.update((items) =>
items.filter((i) => i.uid !== item.uid)
);
}
async onReorder(items: UnifiedCollectionItem[]): Promise<void> {
const nonLive = this.allItems().filter(
(i) => i.contentType !== 'live'
);
this.allItems.set([...items, ...nonLive]);
await this.favoritesData.reorder(items);
}
private async loadData(): Promise<void> {
this.isLoading.set(true);
try {
const s = this.scope();
const pid = this.playlistId();
const pt = this.portalType();
const items =
this.mode() === 'favorites'
? await this.favoritesData.getFavorites(s, pid, pt)
: await this.recentData.getRecentItems(s, pid, pt);
this.allItems.set(items);
this.autoSelectContentType();
} catch {
this.allItems.set([]);
} finally {
this.isLoading.set(false);
}
}
private autoSelectContentType(): void {
const types = this.availableTypes();
if (types.length > 0 && !types.includes(this.selectedContentType())) {
this.selectedContentType.set(types[0]);
}
}
}
@@ -0,0 +1,39 @@
@if (filteredItems().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">{{
contentType() === 'movie' ? 'movie' : 'tv'
}}</mat-icon>
<p class="empty-title">
@if (searchTerm()) {
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
} @else {
{{
(mode() === 'favorites'
? 'PORTALS.NO_FAVORITES'
: 'PORTALS.NO_RECENT_ITEMS'
) | translate
}}
}
</p>
</div>
} @else {
<div class="grid-container">
@for (item of filteredItems(); track trackByUid($index, item)) {
<app-content-card
[posterUrl]="item.posterUrl ?? ''"
[title]="item.name"
[type]="item.contentType"
[date]="item.addedAt ?? item.viewedAt"
[showRemoveButton]="true"
[removeTooltip]="
(mode() === 'favorites'
? 'PORTALS.REMOVE_FROM_FAVORITES'
: 'PORTALS.REMOVE_FROM_RECENT'
) | translate
"
(cardClick)="onCardClick(item)"
(remove)="onRemove(item)"
/>
}
</div>
}
@@ -0,0 +1,71 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
padding: 0 16px;
}
.search-bar {
width: 100%;
max-width: 400px;
@include mat.form-field-density(-5);
}
::ng-deep {
.search-bar div {
border-radius: 20px;
}
div[matformfieldlineripple],
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.grid-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
padding: 16px;
align-content: start;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
flex: 1;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 14px;
font-weight: 500;
margin: 0;
}
}
@@ -0,0 +1,66 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import {
CollectionContentType,
getUnifiedCollectionNavigation,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { ContentCardComponent } from '../content-card/content-card.component';
@Component({
selector: 'app-unified-grid-tab',
templateUrl: './unified-grid-tab.component.html',
styleUrl: './unified-grid-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ContentCardComponent,
MatIconModule,
TranslatePipe,
],
})
export class UnifiedGridTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly contentType = input<CollectionContentType>('movie');
readonly searchTerm = input('');
readonly removeItem = output<UnifiedCollectionItem>();
private readonly router = inject(Router);
readonly filteredItems = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const all = this.items();
return term
? all.filter((i) => i.name.toLowerCase().includes(term))
: all;
});
onCardClick(item: UnifiedCollectionItem): void {
const navigation = getUnifiedCollectionNavigation(item);
if (!navigation) {
return;
}
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
onRemove(item: UnifiedCollectionItem): void {
this.removeItem.emit(item);
}
trackByUid(_: number, item: UnifiedCollectionItem): string {
return item.uid;
}
}
@@ -0,0 +1,105 @@
@if (activePlayback()) {
<div
[class]="
isEmbeddedPlayer() ? 'itv-container' : 'itv-container--external'
"
>
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="unified-live-tab-sidebar-width"
>
<app-global-favorites-list
[channels]="channelsForList()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
[searchTermInput]="searchTerm()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
@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>
}
<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>
</div>
} @else {
<div class="full-list">
<app-global-favorites-list
[channels]="channelsForList()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
[searchTermInput]="searchTerm()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
}
@@ -0,0 +1,115 @@
:host {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.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%;
}
.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;
}
}
.full-list {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.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;
}
.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 {
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;
}
@@ -0,0 +1,167 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
input,
OnInit,
output,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
StreamResolverService,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import {
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from 'components';
import { SettingsStore } from 'services';
import { Channel, EpgItem, EpgProgram, ResolvedPortalPlayback } from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
@Component({
selector: 'app-unified-live-tab',
templateUrl: './unified-live-tab.component.html',
styleUrl: './unified-live-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ArtPlayerComponent,
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
ResizableDirective,
VjsPlayerComponent,
],
})
export class UnifiedLiveTabComponent implements OnInit {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
private readonly streamResolver = inject(StreamResolverService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly destroyRef = inject(DestroyRef);
readonly player = this.settingsStore.player;
readonly isEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer());
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 ?? '');
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;
});
/** Map UnifiedCollectionItem[] to UnifiedFavoriteChannel[] for the list component */
readonly channelsForList = computed((): UnifiedFavoriteChannel[] => {
return this.items().map((i) => ({
uid: i.uid,
name: i.name,
logo: i.logo ?? null,
sourceType: i.sourceType,
playlistId: i.playlistId,
playlistName: i.playlistName,
streamUrl: i.streamUrl,
xtreamId: i.xtreamId,
tvgId: i.tvgId,
stalkerCmd: i.stalkerCmd,
stalkerPortalUrl: i.stalkerPortalUrl,
stalkerMacAddress: i.stalkerMacAddress,
addedAt: i.addedAt ?? new Date(0).toISOString(),
position: i.position ?? 0,
contentId: i.contentId,
}));
});
private tickInterval: ReturnType<typeof setInterval> | null = null;
ngOnInit(): void {
this.loadEpg();
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> {
const item = this.items().find((i) => i.uid === channel.uid);
if (!item) return;
if (this.activeUid() === item.uid) {
this.activeUid.set(null);
this.activePlayback.set(null);
this.currentEpgItems.set([]);
return;
}
this.activeUid.set(item.uid);
this.currentEpgItems.set([]);
try {
const playback = await this.streamResolver.resolvePlayback(item);
this.activePlayback.set(playback);
if (!this.portalPlayer.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback);
}
this.streamResolver.loadEpgItems(item).then((epgItems) => {
this.currentEpgItems.set(epgItems);
});
} catch {
this.activePlayback.set(null);
this.activeUid.set(null);
}
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
const item = this.items().find((i) => i.uid === channel.uid);
if (item) this.removeItem.emit(item);
}
onReorder(channels: UnifiedFavoriteChannel[]): void {
const reordered = channels
.map((ch) => this.items().find((i) => i.uid === ch.uid))
.filter(Boolean) as UnifiedCollectionItem[];
this.reorderItems.emit(reordered);
}
onClose(): void {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
private async loadEpg(): Promise<void> {
const map = await this.streamResolver.loadEpgForItems(this.items());
this.epgMap.set(map);
}
}