From a987e10a5904fd82dff5abd756ba03b7e956218a Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 25 Mar 2026 19:20:59 +0100 Subject: [PATCH] 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 --- .../src/electron-test-fixtures.ts | 169 +- apps/electron-backend-e2e/src/search.e2e.ts | 1504 +++++++++++++++++ .../src/app/api/main.preload.ts | 1 + .../app/events/database/favorites.events.ts | 46 + apps/web/src/app/app.component.ts | 38 +- apps/web/src/app/app.config.ts | 4 +- apps/web/src/app/app.routes.ts | 34 +- apps/web/src/assets/i18n/de.json | 7 +- apps/web/src/assets/i18n/en.json | 20 +- apps/web/src/assets/i18n/ru.json | 7 +- apps/web/src/typings.d.ts | 1 + global.d.ts | 1 + .../video-player/video-player.component.ts | 4 +- .../feature/src/lib/downloads.component.html | 20 - .../feature/src/lib/downloads.component.ts | 26 +- libs/portal/shared/ui/src/index.ts | 2 + .../category-view/category-view.component.ts | 21 +- .../global-favorites-list.component.html | 49 + .../global-favorites-list.component.scss | 74 + .../global-favorites-list.component.ts | 108 ++ .../grid-list/grid-list.component.scss | 95 +- .../unified-collection-page.component.html | 151 ++ .../unified-collection-page.component.scss | 86 + .../unified-collection-page.component.ts | 187 ++ .../unified-grid-tab.component.html | 39 + .../unified-grid-tab.component.scss | 71 + .../unified-grid-tab.component.ts | 66 + .../unified-live-tab.component.html | 105 ++ .../unified-live-tab.component.scss | 115 ++ .../unified-live-tab.component.ts | 167 ++ 30 files changed, 3000 insertions(+), 218 deletions(-) create mode 100644 apps/electron-backend-e2e/src/search.e2e.ts create mode 100644 libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html create mode 100644 libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss create mode 100644 libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.scss create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 634ede935..ea3d87c73 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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 { + 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 { await page.waitForURL(/\/workspace\/dashboard$/); } +export async function waitForM3uCatalog(page: Page): Promise { + await page.waitForURL(/\/workspace\/playlists\/.+\/all$/); + await expect(page.getByTestId('channel-item').first()).toBeVisible({ + timeout: 20000, + }); +} + export async function waitForXtreamCatalog(page: Page): Promise { 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 { + 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 { @@ -286,6 +346,43 @@ export async function waitForStalkerCatalog(page: Page): Promise { await expect(contentItems.first()).toBeVisible({ timeout: 20000 }); } +export async function fillWorkspaceSearch( + page: Page, + term: string, + options: { + submit?: boolean; + } = {} +): Promise { + 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 { + await expect( + page.locator('app-workspace-shell-header .search-chip--scope') + ).toHaveText(expected); +} + +export async function expectWorkspaceSearchStatus( + page: Page, + expected: RegExp | string +): Promise { + await expect( + page.locator('app-workspace-shell-header .search-chip--status') + ).toHaveText(expected); +} + async function startPortalDebugCapture(page: Page): Promise { 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 { + 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[] diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts new file mode 100644 index 000000000..b9c0cc693 --- /dev/null +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -0,0 +1,1504 @@ +import { + APIRequestContext, + Page, +} from '@playwright/test'; +import { readFileSync } from 'fs'; +import { + addStalkerPortal, + addXtreamPortal, + closeElectronApp, + defaultStalkerMacAddress, + expect, + expectWorkspaceSearchScope, + expectWorkspaceSearchStatus, + fillWorkspaceSearch, + goToDashboard, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + m3uFixturePath, + resetMockServers, + stalkerMockServer, + test, + waitForM3uCatalog, + waitForPortalDebugEvent, + waitForStalkerCatalog, + waitForXtreamImportToFinish, + xtreamMockServer, +} from './electron-test-fixtures'; + +type XtreamCategory = { + category_id: string; + category_name: string; +}; + +type XtreamVodStream = { + category_id?: string; + id?: number | string; + name?: string; + series_id?: number | string; + stream_id?: number | string; + title?: string; + xtream_id?: number | string; +}; + +type StalkerCategory = { + id: string; + title: string; +}; + +type StalkerProxyPayload = { + payload: { + js: T; + }; +}; + +type StalkerOrderedList = { + cur_page: number; + data: T[]; + max_page_items: number; + total_items: number; + total_pages: number; +}; + +type StalkerContentItem = { + id?: number | string; + name?: string; + o_name?: string; +}; + +type XtreamVodFixture = { + candidateTitles: string[]; + categoryId: string; + categoryName: string; + controlTitle: string; + itemId: string; + targetTitle: string; +}; + +type StalkerCategoryFixture = { + categoryId: string; + categoryName: string; + controlTitle: string; + targetTitle: string; +}; + +type M3uFixtureItem = { + groupTitle: string; + name: string; + url: string; +}; + +type M3uSearchFixture = { + controlTitle: string; + groupTitle: string; + siblingTitle: string; + targetTitle: string; +}; + +const headerSearchSelector = + 'app-workspace-shell-header .search-field input[type="search"]'; +const xtreamSearchUsername = 'minimal'; +const xtreamSearchPassword = 'minimal'; + +test.describe('Electron Workspace Search', () => { + test('@search @m3u filters all channels from the workspace header on playlist routes', async ({ + dataDir, + }) => { + const sample = loadM3uSearchFixture(); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await waitForM3uCatalog(app.mainWindow); + + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/all$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope(app.mainWindow, 'All channels'); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @m3u filters grouped channels by group and channel title from the workspace header', async ({ + dataDir, + }) => { + const sample = loadM3uSearchFixture(); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await waitForM3uCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Groups'); + + await fillWorkspaceSearch(app.mainWindow, sample.groupTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/groups$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.groupTitle); + await expectWorkspaceSearchScope(app.mainWindow, 'Groups'); + await expect(groupPanelHeader(app.mainWindow, sample.groupTitle)).toHaveCount( + 1 + ); + await expect( + groupPanelHeader(app.mainWindow, sample.groupTitle).first() + ).toContainText(`${sample.groupTitle} (2)`); + await ensureGroupExpanded(app.mainWindow, sample.groupTitle); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.siblingTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expect( + groupPanelHeader(app.mainWindow, sample.groupTitle).first() + ).toContainText(`${sample.groupTitle} (1)`); + await ensureGroupExpanded(app.mainWindow, sample.groupTitle); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.siblingTitle) + ).toHaveCount(0); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @m3u filters playlist favorites from the workspace header', async ({ + dataDir, + }) => { + const sample = loadM3uSearchFixture(); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await waitForM3uCatalog(app.mainWindow); + await toggleFavoriteForChannel(app.mainWindow, sample.targetTitle); + await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle); + await openWorkspaceSection(app.mainWindow, 'Favorites'); + + await fillWorkspaceSearch(app.mainWindow, sample.groupTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/favorites$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.groupTitle); + await expectWorkspaceSearchScope(app.mainWindow, 'Favorites'); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @m3u filters global favorites from the workspace header without leaving the persisted query model', async ({ + dataDir, + }) => { + const sample = loadM3uSearchFixture(); + const app = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await waitForM3uCatalog(app.mainWindow); + await toggleFavoriteForChannel(app.mainWindow, sample.targetTitle); + await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle); + await app.mainWindow + .getByRole('button', { + name: 'All favorites (all playlists)', + exact: true, + }) + .click(); + + await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + 'Global favorites' + ); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream filters VOD content from the workspace header on a category route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamVodFixture(request); + const xtreamQueries = buildXtreamSearchQueries( + sample.targetTitle, + sample.controlTitle + ); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await clickCategoryByNameExact(app.mainWindow, sample.categoryName); + await expect( + app.mainWindow.locator('.category-content-layout mat-card').first() + ).toBeVisible({ + timeout: 20000, + }); + const resolvedTitles = await resolveVisibleXtreamTitles( + app.mainWindow, + sample + ); + await fillWorkspaceSearch(app.mainWindow, resolvedTitles.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+$/ + ); + await expectQueryParam( + app.mainWindow, + 'q', + resolvedTitles.targetTitle + ); + await expectWorkspaceSearchScope( + app.mainWindow, + `Movies / ${sample.categoryName}` + ); + const contentLayout = app.mainWindow.locator( + '.category-content-layout' + ); + await expect(contentLayout).toContainText( + flexibleTextPattern(resolvedTitles.targetTitle) + ); + await expect(contentLayout).not.toContainText( + flexibleTextPattern(resolvedTitles.controlTitle) + ); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream filters series content from the workspace header on a category route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamSeriesFixture(request); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Series'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/series$/ + ); + + await clickCategoryByNameExact(app.mainWindow, sample.categoryName); + try { + await expect( + app.mainWindow + .locator('.category-content-layout mat-card') + .first() + ).toBeVisible({ + timeout: 5000, + }); + } catch { + await openWorkspaceSection(app.mainWindow, 'Series'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/series$/ + ); + await expect( + app.mainWindow + .locator('.category-content-layout mat-card') + .first() + ).toBeVisible({ + timeout: 20000, + }); + } + const resolvedTitles = await resolveVisibleXtreamTitles( + app.mainWindow, + sample + ); + await fillWorkspaceSearch(app.mainWindow, resolvedTitles.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/series(?:\/[^/]+)?$/ + ); + await expectQueryParam( + app.mainWindow, + 'q', + resolvedTitles.targetTitle + ); + await expectWorkspaceSearchScope( + app.mainWindow, + `Series / ${sample.categoryName}` + ); + const contentLayout = app.mainWindow.locator( + '.category-content-layout' + ); + await expect(contentLayout).toContainText( + flexibleTextPattern(resolvedTitles.targetTitle) + ); + await expect(contentLayout).not.toContainText( + flexibleTextPattern(resolvedTitles.controlTitle) + ); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream promotes dashboard header search into advanced playlist search', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamVodFixture(request); + const xtreamQueries = buildXtreamSearchQueries( + sample.targetTitle, + sample.controlTitle + ); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await goToDashboard(app.mainWindow); + const xtreamQuery = await performXtreamPlaylistSearch( + app.mainWindow, + xtreamQueries + ); + await expectWorkspaceSearchScope( + app.mainWindow, + 'Advanced search' + ); + await expect( + app.mainWindow.locator('app-search-layout app-search-form') + ).toHaveCount(0); + await expect( + xtreamSearchResultCards(app.mainWindow).first() + ).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream restores the search term and results after returning from a result detail route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamVodFixture(request); + const xtreamQueries = buildXtreamSearchQueries( + sample.targetTitle, + sample.controlTitle + ); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await goToDashboard(app.mainWindow); + const xtreamQuery = await performXtreamPlaylistSearch( + app.mainWindow, + xtreamQueries + ); + + const resultCard = xtreamSearchResultCards(app.mainWindow).first(); + await expect(resultCard).toBeVisible({ timeout: 20000 }); + await resultCard.click(); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/(?:vod|live|series)\/[^/]+\/[^/]+$/ + ); + + await app.mainWindow.goBack(); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/search$/ + ); + await expectQueryParam(app.mainWindow, 'q', xtreamQuery); + await expect( + app.mainWindow.locator(headerSearchSelector) + ).toHaveValue(xtreamQuery); + await expect( + xtreamSearchResultCards(app.mainWindow).first() + ).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream filters playlist favorites from the workspace header', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamVodFixture(request); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await clickCategoryByNameExact(app.mainWindow, sample.categoryName); + await expect( + app.mainWindow.locator('.category-content-layout mat-card').first() + ).toBeVisible({ timeout: 20000 }); + const resolvedTitles = await resolveVisibleXtreamTitles( + app.mainWindow, + sample + ); + await clickGridListCardByTitle( + app.mainWindow, + resolvedTitles.targetTitle + ); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + await addCurrentDetailToFavorites(app.mainWindow); + await goBackFromDetail(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+$/ + ); + + await clickGridListCardByTitle( + app.mainWindow, + resolvedTitles.controlTitle + ); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + await addCurrentDetailToFavorites(app.mainWindow); + await goBackFromDetail(app.mainWindow); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+$/ + ); + + await openWorkspaceSection(app.mainWindow, 'Favorites'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/favorites$/ + ); + await expect( + contentCardByTitle( + app.mainWindow, + resolvedTitles.targetTitle + ).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + contentCardByTitle( + app.mainWindow, + resolvedTitles.controlTitle + ).first() + ).toBeVisible({ timeout: 20000 }); + + await fillWorkspaceSearch(app.mainWindow, resolvedTitles.targetTitle); + + await expectQueryParam( + app.mainWindow, + 'q', + resolvedTitles.targetTitle + ); + await expectWorkspaceSearchScope(app.mainWindow, 'Favorites'); + await expect( + contentCardByTitle( + app.mainWindow, + resolvedTitles.targetTitle + ).first() + ).toBeVisible(); + await expect( + contentCardByTitle(app.mainWindow, resolvedTitles.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @stalker sends category search through Electron IPC on the VOD route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const sample = await fetchStalkerCategoryFixture(request, 'vod'); + + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow); + await waitForStalkerCatalog(app.mainWindow); + await clickCategoryById(app.mainWindow, sample.categoryId); + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + new RegExp( + `/workspace/stalker/[^/]+/vod/${escapeRegex( + sample.categoryId + )}$` + ) + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + `Movies / ${sample.categoryName}` + ); + await waitForPortalDebugEvent(app.mainWindow, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === 'vod' && + String(requestPayload.params?.['category']) === + sample.categoryId && + requestPayload.params?.['search'] === sample.targetTitle + ); + }, + }); + const contentLayout = app.mainWindow.locator( + '.category-content-layout' + ); + await expect(contentLayout).toContainText( + flexibleTextPattern(sample.targetTitle) + ); + await expect(contentLayout).not.toContainText( + flexibleTextPattern(sample.controlTitle) + ); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @stalker sends category search through Electron IPC on the series route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const sample = await fetchStalkerCategoryFixture(request, 'series'); + + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow); + await waitForStalkerCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Series'); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/series$/ + ); + await clickCategoryById(app.mainWindow, sample.categoryId); + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + new RegExp( + `/workspace/stalker/[^/]+/series/${escapeRegex( + sample.categoryId + )}$` + ) + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + `Series / ${sample.categoryName}` + ); + await waitForPortalDebugEvent(app.mainWindow, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === 'series' && + String(requestPayload.params?.['category']) === + sample.categoryId && + requestPayload.params?.['search'] === sample.targetTitle + ); + }, + }); + const contentLayout = app.mainWindow.locator( + '.category-content-layout' + ); + await expect(contentLayout).toContainText( + flexibleTextPattern(sample.targetTitle) + ); + await expect(contentLayout).not.toContainText( + flexibleTextPattern(sample.controlTitle) + ); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @stalker drives advanced search from the workspace header without an inline form', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const sample = await fetchStalkerCategoryFixture(request, 'vod'); + + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow); + await waitForStalkerCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Advanced search'); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/search$/ + ); + await expectWorkspaceSearchScope( + app.mainWindow, + 'Advanced search' + ); + await expect( + app.mainWindow.locator('app-search-layout app-search-form') + ).toHaveCount(0); + + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await waitForPortalDebugEvent(app.mainWindow, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === 'vod' && + requestPayload.params?.['search'] === sample.targetTitle + ); + }, + }); + await expect( + contentCardByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @stalker filters playlist favorites from the workspace header', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const sample = await fetchStalkerCategoryFixture(request, 'itv'); + + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow); + await waitForStalkerCatalog(app.mainWindow); + await app.mainWindow + .getByRole('link', { name: 'Live TV', exact: true }) + .click(); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/itv$/ + ); + await clickCategoryById(app.mainWindow, sample.categoryId); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle).first() + ).toBeVisible({ timeout: 20000 }); + await toggleFavoriteForChannel(app.mainWindow, sample.targetTitle); + await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle); + + await openWorkspaceSection(app.mainWindow, 'Favorites'); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/favorites$/ + ); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle).first() + ).toBeVisible({ timeout: 20000 }); + + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope(app.mainWindow, 'Favorites'); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const sample = await fetchStalkerCategoryFixture(request, 'itv'); + const itvQuery = createUniquePrefix( + sample.targetTitle, + sample.controlTitle + ); + + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow); + await waitForStalkerCatalog(app.mainWindow); + await app.mainWindow + .getByRole('link', { name: 'Live TV', exact: true }) + .click(); + await expectPathname(app.mainWindow, /\/workspace\/stalker\/[^/]+\/itv$/); + + await clickCategoryById(app.mainWindow, sample.categoryId); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle).first() + ).toBeVisible(); + + await fillWorkspaceSearch(app.mainWindow, itvQuery); + + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/itv$/ + ); + await expectQueryParam(app.mainWindow, 'q', itvQuery); + await expectWorkspaceSearchScope( + app.mainWindow, + `Live TV / ${sample.categoryName}` + ); + await expectWorkspaceSearchStatus( + app.mainWindow, + 'Loaded channels only' + ); + await waitForPortalDebugEvent(app.mainWindow, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === 'itv' && + String(requestPayload.params?.['category']) === + sample.categoryId && + requestPayload.params?.['search'] === itvQuery + ); + }, + }); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); +}); + +async function fetchXtreamVodFixture( + request: APIRequestContext +): Promise { + return fetchXtreamFixture(request, { + categoriesAction: 'get_vod_categories', + itemsAction: 'get_vod_streams', + }); +} + +async function fetchXtreamSeriesFixture( + request: APIRequestContext +): Promise { + return fetchXtreamFixture(request, { + categoriesAction: 'get_series_categories', + itemsAction: 'get_series', + }); +} + +async function fetchXtreamFixture( + request: APIRequestContext, + options: { + categoriesAction: string; + itemsAction: string; + } +): Promise { + const { categoriesAction, itemsAction } = options; + const categories = await fetchJson( + request, + `${xtreamMockServer}/player_api.php?action=${encodeURIComponent( + categoriesAction + )}&username=${encodeURIComponent( + xtreamSearchUsername + )}&password=${encodeURIComponent(xtreamSearchPassword)}` + ); + if (categories.length === 0) { + throw new Error( + `Xtream mock server returned no categories for ${categoriesAction}.` + ); + } + let fallbackFixture: XtreamVodFixture | null = null; + + for (const category of categories) { + const items = await fetchJson( + request, + `${xtreamMockServer}/player_api.php?action=${encodeURIComponent( + itemsAction + )}&username=${encodeURIComponent( + xtreamSearchUsername + )}&password=${encodeURIComponent( + xtreamSearchPassword + )}&category_id=${encodeURIComponent(category.category_id)}` + ); + const fixture = buildXtreamVodFixture(category, items); + + if (!fixture) { + continue; + } + + if (!fallbackFixture) { + fallbackFixture = fixture; + } + + if (isStableXtreamSearchTitle(fixture.targetTitle)) { + return fixture; + } + } + + if (fallbackFixture) { + return fallbackFixture; + } + + throw new Error( + `Xtream mock server returned no usable fixture for ${itemsAction}.` + ); +} + +function buildXtreamVodFixture( + category: XtreamCategory, + items: XtreamVodStream[] +): XtreamVodFixture | null { + try { + const distinctItems = items.filter((item, index, allItems) => { + const title = getXtreamTitle(item); + if (!title) { + return false; + } + + return ( + allItems.findIndex( + (candidate) => + normalizeSearchText(getXtreamTitle(candidate)) === + normalizeSearchText(title) + ) === index + ); + }); + const [targetItem, controlItem] = pickDistinctItems( + distinctItems, + getXtreamTitle, + isStableXtreamSearchTitle + ); + const itemId = getXtreamItemId(targetItem); + + if (!itemId) { + return null; + } + + return { + candidateTitles: distinctItems.map((item) => getXtreamTitle(item)), + categoryId: String(category.category_id), + categoryName: category.category_name, + controlTitle: getXtreamTitle(controlItem), + itemId, + targetTitle: getXtreamTitle(targetItem), + }; + } catch { + return null; + } +} + +async function fetchStalkerCategoryFixture( + request: APIRequestContext, + type: 'itv' | 'series' | 'vod' +): Promise { + const categoriesResponse = await fetchJson>( + request, + buildStalkerProxyUrl('get_categories', { + type, + }) + ); + const category = categoriesResponse.payload.js[0]; + + if (!category) { + throw new Error(`Stalker mock server returned no ${type} categories.`); + } + + const itemsResponse = await fetchJson + >>( + request, + buildStalkerProxyUrl('get_ordered_list', { + category: category.id, + p: '1', + type, + }) + ); + const items = itemsResponse.payload.js.data ?? []; + const [targetItem, controlItem] = pickDistinctItems(items, getStalkerTitle); + + return { + categoryId: String(category.id), + categoryName: category.title, + controlTitle: getStalkerTitle(controlItem), + targetTitle: getStalkerTitle(targetItem), + }; +} + +async function fetchJson( + request: APIRequestContext, + url: string +): Promise { + const response = await request.get(url); + + expect(response.ok()).toBeTruthy(); + + return (await response.json()) as T; +} + +function buildStalkerProxyUrl( + action: string, + params: Record +): string { + const searchParams = new URLSearchParams({ + action, + macAddress: defaultStalkerMacAddress, + url: `${stalkerMockServer}/portal.php`, + ...params, + }); + + return `${stalkerMockServer}/stalker?${searchParams.toString()}`; +} + +function loadM3uSearchFixture(): M3uSearchFixture { + const items = parseM3uFixture(m3uFixturePath); + const groups = items.reduce>((acc, item) => { + const existing = acc.get(item.groupTitle) ?? []; + existing.push(item); + acc.set(item.groupTitle, existing); + return acc; + }, new Map()); + + const groupedSample = [...groups.entries()].find( + ([groupTitle, channels]) => groupTitle.length > 0 && channels.length >= 2 + ); + + if (!groupedSample) { + throw new Error( + 'Expected the M3U fixture to contain at least one group with two channels.' + ); + } + + const [groupTitle, groupedChannels] = groupedSample; + const [sibling, target] = groupedChannels; + const control = items.find((item) => item.groupTitle !== groupTitle); + + if (!target || !sibling || !control) { + throw new Error( + 'Expected the M3U fixture to contain both grouped and cross-group channels.' + ); + } + + return { + controlTitle: control.name, + groupTitle, + siblingTitle: sibling.name, + targetTitle: target.name, + }; +} + +function parseM3uFixture(filePath: string): M3uFixtureItem[] { + const content = readFileSync(filePath, 'utf8'); + const items: M3uFixtureItem[] = []; + const lines = content.split(/\r?\n/); + let pending: + | { + groupTitle: string; + name: string; + } + | undefined; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + if (line.startsWith('#EXTINF:')) { + const groupTitle = + line.match(/group-title="([^"]*)"/)?.[1]?.trim() ?? ''; + const name = line.split(',').at(-1)?.trim() ?? ''; + + pending = { + groupTitle, + name, + }; + continue; + } + + if (!pending || line.startsWith('#')) { + continue; + } + + items.push({ + ...pending, + url: line, + }); + pending = undefined; + } + + return items.filter((item) => item.name.length > 0); +} + +async function clickCategoryById(page: Page, categoryId: string): Promise { + const category = page.locator( + `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible` + ); + + await expect(category.first()).toBeVisible(); + await category.first().click(); +} + +async function clickCategoryByNameExact( + page: Page, + categoryName: string +): Promise { + const category = page + .locator('app-workspace-context-panel .category-item:visible') + .filter({ + has: page.locator('.nav-item-label', { + hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`), + }), + }) + .first(); + + await expect(category).toBeVisible(); + await category.click(); +} + +async function openWorkspaceSection(page: Page, label: string): Promise { + await page.getByRole('link', { name: label, exact: true }).click(); +} + +async function waitForXtreamWorkspaceReady(page: Page): Promise { + await expectPathname(page, /\/workspace\/xtreams\/[^/]+\/vod$/); + await expect( + page.locator('app-workspace-context-panel .category-item').first() + ).toBeVisible({ timeout: 20000 }); + await waitForXtreamImportToFinish(page); +} + +function groupPanelHeader(page: Page, groupTitle: string) { + return page.locator('mat-expansion-panel-header').filter({ + hasText: flexibleTextPattern(groupTitle), + }); +} + +async function ensureGroupExpanded( + page: Page, + groupTitle: string +): Promise { + const header = groupPanelHeader(page, groupTitle).first(); + + await expect(header).toBeVisible(); + + if ((await header.getAttribute('aria-expanded')) !== 'true') { + await header.click(); + } +} + +function xtreamSearchResultCards(page: Page) { + return page.locator('app-search-results .results-grid .content-card'); +} + +function gridListCardByTitle(page: Page, title: string) { + return page.locator('.category-content-layout mat-card').filter({ + has: page.locator('.title', { + hasText: flexibleTextPattern(title), + }), + }); +} + +async function clickGridListCardByTitle( + page: Page, + title: string +): Promise { + const card = gridListCardByTitle(page, title).first(); + + await expect(card).toBeVisible({ timeout: 20000 }); + await card.click(); +} + +function contentCardByTitle(page: Page, title: string) { + return page.locator('app-content-card').filter({ + has: page.locator('h3', { + hasText: flexibleTextPattern(title), + }), + }); +} + +async function resolveVisibleXtreamTitles( + page: Page, + fixture: XtreamVodFixture +): Promise<{ + controlTitle: string; + targetTitle: string; +}> { + const visibleTitles = (await page + .locator('.category-content-layout mat-card .title') + .allTextContents()) + .map((title) => title.trim()) + .filter((title) => title.length > 0); + const matchedTitles = fixture.candidateTitles + .map((title) => { + const normalizedTitle = normalizeSearchText(title); + return ( + visibleTitles.find( + (visibleTitle) => + normalizeSearchText(visibleTitle) === normalizedTitle + ) ?? null + ); + }) + .filter((title): title is string => Boolean(title)); + const uniqueMatchedTitles = [...new Set(matchedTitles)]; + + if (uniqueMatchedTitles.length < 2) { + throw new Error( + `Expected at least two visible Xtream titles from fixture candidates ${fixture.candidateTitles.join( + ', ' + )}, but found ${visibleTitles.join(', ')}` + ); + } + + return { + controlTitle: uniqueMatchedTitles[1], + targetTitle: uniqueMatchedTitles[0], + }; +} + +async function addCurrentDetailToFavorites(page: Page): Promise { + const addButton = page + .locator('button.favorite-btn') + .filter({ hasText: /Add to favorites/i }) + .first(); + + await expect(addButton).toBeVisible({ timeout: 20000 }); + await addButton.click(); + await expect( + page + .locator('button.favorite-btn.favorite-btn--active') + .filter({ hasText: /Remove from favorites/i }) + .first() + ).toBeVisible({ timeout: 20000 }); +} + +async function goBackFromDetail(page: Page): Promise { + const backButton = page.locator('app-content-hero .hero__back-button').first(); + + await expect(backButton).toBeVisible({ timeout: 20000 }); + await backButton.click(); +} + +async function performXtreamPlaylistSearch( + page: Page, + queries: string[] +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + for (const query of queries) { + await fillWorkspaceSearch(page, query, { + submit: true, + }); + await expectPathname(page, /\/workspace\/xtreams\/[^/]+\/search$/); + await expectQueryParam(page, 'q', query); + + try { + await expect(xtreamSearchResultCards(page).first()).toBeVisible({ + timeout: 5000, + }); + return query; + } catch { + // Try the next candidate token. + } + } + + if (attempt < 2) { + await page.waitForTimeout(1500); + } + } + + throw new Error( + `Expected Xtream playlist search to return results for at least one candidate query: ${queries.join( + ', ' + )}` + ); +} + +function channelItemByTitle(page: Page, title: string) { + return page.getByTestId('channel-item').filter({ + has: page.locator('.channel-name', { + hasText: flexibleTextPattern(title), + }), + }); +} + +async function toggleFavoriteForChannel( + page: Page, + title: string +): Promise { + const item = channelItemByTitle(page, title).first(); + + await expect(item).toBeVisible(); + await item.hover(); + const favoriteButton = item.locator('.favorite-button').first(); + + await expect(favoriteButton).toBeVisible(); + await favoriteButton.click(); + await expect(favoriteButton.locator('mat-icon')).toHaveText(/star/); +} + +function getXtreamTitle(item: XtreamVodStream): string { + return `${item.title ?? item.name ?? ''}`.trim(); +} + +function getXtreamItemId(item: XtreamVodStream): string { + return String( + item.xtream_id ?? item.series_id ?? item.stream_id ?? item.id ?? '' + ).trim(); +} + +function getStalkerTitle(item: StalkerContentItem): string { + return `${item.o_name ?? item.name ?? ''}`.trim(); +} + +function pickDistinctItems( + items: T[], + getTitle: (item: T) => string, + preferredTitle?: (title: string) => boolean +): [T, T] { + const titledItems = items.filter((item) => getTitle(item).length > 0); + const preferredItems = preferredTitle + ? titledItems.filter((item) => preferredTitle(getTitle(item))) + : titledItems; + const target = (preferredItems[0] ?? titledItems[0]) as T | undefined; + + if (!target) { + throw new Error('Expected at least two items with distinct titles.'); + } + + const control = + preferredItems.find((item) => getTitle(item) !== getTitle(target)) ?? + titledItems.find( + (item) => getTitle(item) !== getTitle(target) + ); + + if (!control) { + throw new Error('Expected at least two items with distinct titles.'); + } + + return [target, control]; +} + +function createUniquePrefix(targetTitle: string, controlTitle: string): string { + const normalizedControl = controlTitle.toLowerCase(); + const trimmedTarget = targetTitle.trim(); + + for (let length = 3; length <= trimmedTarget.length; length += 1) { + const candidate = trimmedTarget.slice(0, length).trim(); + if (candidate.length < 3) { + continue; + } + + if (!normalizedControl.includes(candidate.toLowerCase())) { + return candidate; + } + } + + return trimmedTarget; +} + +function createUniqueSearchToken( + targetTitle: string, + controlTitle: string +): string { + const normalizedControl = normalizeSearchText(controlTitle); + const targetWords = normalizeSearchText(targetTitle) + .split(/\s+/) + .filter((word) => word.length >= 3); + + for (const word of targetWords) { + if (!normalizedControl.includes(word)) { + return word; + } + } + + return createUniquePrefix( + normalizeSearchText(targetTitle), + normalizeSearchText(controlTitle) + ); +} + +function buildXtreamSearchQueries( + targetTitle: string, + controlTitle: string +): string[] { + const normalizedTarget = normalizeSearchText(targetTitle); + const normalizedControl = normalizeSearchText(controlTitle); + const queries = new Set(); + + queries.add(createUniqueSearchToken(targetTitle, controlTitle)); + + for (const word of normalizedTarget.split(/\s+/)) { + if (word.length < 3) { + continue; + } + + if (!normalizedControl.includes(word)) { + queries.add(word); + } + } + + for (const word of normalizedTarget.split(/\s+/)) { + if (word.length >= 3) { + queries.add(word); + } + } + + queries.add(normalizedTarget); + + return [...queries].filter((query) => query.trim().length >= 3); +} + +function normalizeSearchText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function isStableXtreamSearchTitle(title: string): boolean { + return ( + /^[a-z0-9 ]+$/i.test(title.trim()) && + normalizeSearchText(title) + .split(/\s+/) + .some((word) => word.length >= 3) + ); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function flexibleTextPattern(value: string): RegExp { + return new RegExp( + value + .trim() + .split(/\s+/) + .map((part) => escapeRegex(part)) + .join('\\s+') + ); +} + +async function expectPathname(page: Page, pattern: RegExp): Promise { + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(pattern); +} + +async function expectQueryParam( + page: Page, + name: string, + value: string +): Promise { + await expect + .poll(() => new URL(page.url()).searchParams.get(name)) + .toBe(value); +} diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6a722195c..677985293 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts index 8ad93e1da..4498ae424 100644 --- a/apps/electron-backend/src/app/events/database/favorites.events.ts +++ b/apps/electron-backend/src/app/events/database/favorites.events.ts @@ -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. diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 7579cb1de..425abcefa 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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. diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 543d42b0e..9979cdec5 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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({ diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 8ec57b176..917aa98aa 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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 diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4afcd338a..6b43852d8 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b24d14ddb..4413db1ce 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index fbd394235..be35bb150 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Открыть загрузки", diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 337fa0f4b..152bd6cea 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -221,6 +221,7 @@ declare global { ) => Promise; dbGetFavorites: (playlistId: string) => Promise; dbGetGlobalFavorites: () => Promise; + dbGetAllGlobalFavorites: () => Promise; dbReorderGlobalFavorites: ( updates: { content_id: number; position: number }[] ) => Promise<{ success: boolean }>; diff --git a/global.d.ts b/global.d.ts index 0d7ac7d6d..80d1e55d4 100644 --- a/global.d.ts +++ b/global.d.ts @@ -200,6 +200,7 @@ declare global { ) => Promise; dbGetFavorites: (playlistId: string) => Promise; dbGetGlobalFavorites: () => Promise; + dbGetAllGlobalFavorites: () => Promise; dbReorderGlobalFavorites: ( updates: { content_id: number; position: number }[] ) => Promise<{ success: boolean }>; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 97125ef4f..a4bde13ed 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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; diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index 6b481bc0d..3f0062731 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -1,24 +1,4 @@
- @if (!isWorkspaceLayout) { - - } -
diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 53125f350..dee7b4999 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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 ): Array { 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( diff --git a/libs/portal/shared/ui/src/index.ts b/libs/portal/shared/ui/src/index.ts index 75df182b8..2009f319b 100644 --- a/libs/portal/shared/ui/src/index.ts +++ b/libs/portal/shared/ui/src/index.ts @@ -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'; diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts index 3d27378fb..1e9c0211a 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts @@ -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)), }); }); }); diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html new file mode 100644 index 000000000..94a4ec7f5 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -0,0 +1,49 @@ + diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss new file mode 100644 index 000000000..00c161f49 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss @@ -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; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts new file mode 100644 index 000000000..3b984cd79 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -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(); + readonly epgMap = input>(new Map()); + readonly progressTick = input(0); + readonly activeUid = input(null); + readonly searchTermInput = input(''); + + readonly channelSelected = output(); + readonly channelsReordered = output(); + readonly favoriteToggled = output(); + + 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): 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)); + } +} diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index eeb545e83..c2f0571ff 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -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 { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html new file mode 100644 index 000000000..4c0d73e59 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -0,0 +1,151 @@ +@if (isLoading()) { +
+ {{ title() | translate }} +
+ @if (selectedContentType() === 'live') { +
+ @for (row of skeletonRows; track row) { + + } +
+ } @else { +
+ @for (card of skeletonCards; track card) { + + } +
+ } +} @else { +
+ {{ title() | translate }} + + @if (showContentToggle()) { + + @if (hasLive()) { + + {{ 'PORTALS.LIVE_TV' | translate }} + + } + @if (hasMovies()) { + + {{ 'PORTALS.MOVIES' | translate }} + + } + @if (hasSeries()) { + + {{ 'PORTALS.SERIES' | translate }} + + } + + } + + + + playlist_play + {{ + 'PORTALS.THIS_PLAYLIST' | translate + }} + + + public + {{ + 'PORTALS.ALL_PLAYLISTS' | translate + }} + + +
+ + @switch (selectedContentType()) { + @case ('live') { + @if (hasLive()) { + + } + } + @case ('movie') { + @if (hasMovies()) { + + } + } + @case ('series') { + @if (hasSeries()) { + + } + } + } + + @if (!hasLive() && !hasMovies() && !hasSeries()) { +
+ {{ + workspaceSearchTerm() + ? 'search_off' + : mode() === 'favorites' + ? 'favorite_border' + : 'history' + }} +

+ @if (workspaceSearchTerm()) { + {{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }} + } @else { + {{ + (mode() === 'favorites' + ? 'PORTALS.NO_FAVORITES' + : 'PORTALS.NO_RECENT_ITEMS' + ) | translate + }} + } +

+
+ } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss new file mode 100644 index 000000000..f1ab9d91b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss @@ -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; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts new file mode 100644 index 000000000..7932e1d3d --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -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(); + readonly defaultScope = input(); + + 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([]); + readonly selectedContentType = signal('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('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 { + 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 { + const nonLive = this.allItems().filter( + (i) => i.contentType !== 'live' + ); + this.allItems.set([...items, ...nonLive]); + await this.favoritesData.reorder(items); + } + + private async loadData(): Promise { + 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]); + } + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html new file mode 100644 index 000000000..be6ee50a7 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html @@ -0,0 +1,39 @@ +@if (filteredItems().length === 0) { +
+ {{ + contentType() === 'movie' ? 'movie' : 'tv' + }} +

+ @if (searchTerm()) { + {{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }} + } @else { + {{ + (mode() === 'favorites' + ? 'PORTALS.NO_FAVORITES' + : 'PORTALS.NO_RECENT_ITEMS' + ) | translate + }} + } +

+
+} @else { +
+ @for (item of filteredItems(); track trackByUid($index, item)) { + + } +
+} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.scss new file mode 100644 index 000000000..612055b3b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.scss @@ -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; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts new file mode 100644 index 000000000..8af63ad20 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -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(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly contentType = input('movie'); + readonly searchTerm = input(''); + + readonly removeItem = output(); + + 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; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html new file mode 100644 index 000000000..1b8350263 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -0,0 +1,105 @@ +@if (activePlayback()) { +
+ + + @if (isEmbeddedPlayer()) { +
+
+ {{ + activePlayback()?.title + }} + +
+ + @if (player() === 'videojs') { + + } + @if (player() === 'html5') { + + } + @if (player() === 'artplayer') { + + } +
+ } + +
+ @if (!isEmbeddedPlayer()) { +
+ {{ + activePlayback()?.title + }} + +
+ } +
+ +
+
+
+} @else { +
+ +
+} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss new file mode 100644 index 000000000..8acec06f2 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss @@ -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; +} + diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts new file mode 100644 index 000000000..e85cfe06b --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly searchTerm = input(''); + + readonly removeItem = output(); + readonly reorderItems = output(); + + 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(null); + readonly activeUid = signal(null); + readonly epgMap = signal>(new Map()); + readonly progressTick = signal(0); + readonly currentEpgItems = signal([]); + 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 | 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 { + 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 { + const map = await this.streamResolver.loadEpgForItems(this.items()); + this.epgMap.set(map); + } +}