mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть загрузки",
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+49
@@ -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>
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+108
@@ -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 {
|
||||
|
||||
+151
@@ -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>
|
||||
}
|
||||
}
|
||||
+86
@@ -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;
|
||||
}
|
||||
}
|
||||
+187
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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>
|
||||
}
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+105
@@ -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>
|
||||
}
|
||||
+115
@@ -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;
|
||||
}
|
||||
|
||||
+167
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user