From 401295c092329f589315b5f2196fd2f504726fcd Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 21:33:30 +0200 Subject: [PATCH] feat(sources): rebuild the Sources page on the shared source row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Side list (UI-kit side list): title with the playlist count and an Add button, a Type group, and on desktop a Status group (Any / Responding / Not responding). The two filters combine and persist; each group counts within the other's choice. The chosen row uses one selected-row token (soft tint, outlined in light theme). - Page: a 28px title with the visible count instead of the bordered header bar; Clean up and the sort menu are secondary buttons. Clean up shows how many sources do not respond. Sort adds Last updated, Most channels and (desktop) Not responding first, and keeps Name Z-A and Custom order. - List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing row keeps Retry in view. "No matches" offers Reset filters. Dragging in custom order no longer reuses positions of rows a filter hides. - Delete with Undo: no confirmation; the row leaves at once and the delete runs when the toast closes (its close button commits at once). Deleting the active source switches to the next row. A large Xtream delete shows its row as "Removing…" with progress and Cancel, as before. - The list's refresh flows moved into SourceListRefreshService; E2E and performance drivers follow the new markup and delete flow. Co-Authored-By: Claude Opus 5.5 --- .../src/electron-test-fixtures.ts | 73 +- .../src/file-import.e2e.ts | 2 +- .../src/keyboard-focus-ring.e2e.ts | 8 +- .../journey-renderer-probe.spec.ts | 4 +- .../src/performance/journey-renderer-probe.ts | 4 +- .../m3u-refresh-cancellation.benchmark.ts | 2 +- .../m3u-refresh-renderer-capture.ts | 2 +- .../src/performance/xtream-renderer-probe.ts | 4 +- .../xtream-scenario-driver-wiring.spec.ts | 12 +- .../xtream-scenario-driver.spec.ts | 13 +- .../src/performance/xtream-scenario-ui.ts | 59 +- .../xtream-ui-action-probe.spec.ts | 2 +- .../src/performance/xtream-ui-action-probe.ts | 4 +- .../src/sources-keyboard.e2e.ts | 15 +- apps/electron-backend-e2e/src/sources.e2e.ts | 28 +- .../src/xtream-responsiveness.e2e.ts | 25 +- apps/web-e2e/src/icon-button-names.e2e.ts | 8 +- apps/web-e2e/src/self-hosted.e2e.ts | 2 +- apps/web-e2e/src/sources-pwa.helpers.ts | 7 +- apps/web/src/assets/i18n/ar.json | 16 +- apps/web/src/assets/i18n/ary.json | 16 +- apps/web/src/assets/i18n/by.json | 16 +- apps/web/src/assets/i18n/de.json | 16 +- apps/web/src/assets/i18n/el.json | 16 +- apps/web/src/assets/i18n/en.json | 16 +- apps/web/src/assets/i18n/es.json | 16 +- apps/web/src/assets/i18n/fr.json | 16 +- apps/web/src/assets/i18n/hu.json | 16 +- apps/web/src/assets/i18n/it.json | 16 +- apps/web/src/assets/i18n/ja.json | 16 +- apps/web/src/assets/i18n/ko.json | 16 +- apps/web/src/assets/i18n/nl.json | 16 +- apps/web/src/assets/i18n/pl.json | 16 +- apps/web/src/assets/i18n/pt.json | 16 +- apps/web/src/assets/i18n/ru.json | 16 +- apps/web/src/assets/i18n/tr.json | 16 +- apps/web/src/assets/i18n/zh.json | 16 +- apps/web/src/assets/i18n/zhtw.json | 16 +- libs/playlist/shared/ui/src/index.ts | 1 + .../recent-playlists.component.html | 93 +- .../recent-playlists.component.scss | 60 +- .../recent-playlists.component.spec.ts | 847 ++++-------------- .../recent-playlists.component.ts | 761 ++++------------ .../source-list-refresh.service.spec.ts | 590 ++++++++++++ .../source-list-refresh.service.ts | 293 ++++++ .../source-delete-progress.service.ts | 105 +++ .../source-removal-toast.component.scss | 29 + .../source-removal-toast.component.ts | 62 ++ .../source-removal.service.spec.ts | 273 ++++++ .../source-removal/source-removal.service.ts | 229 +++++ .../lib/source-row/source-row.component.html | 6 +- .../lib/source-row/source-row.component.scss | 28 +- .../lib/source-row/source-tile.component.scss | 10 +- libs/playlist/shared/util/src/index.ts | 2 + .../lib/source-display/source-list.spec.ts | 134 +++ .../src/lib/source-display/source-list.ts | 124 +++ .../sources-view-state.service.ts | 63 ++ libs/services/src/lib/sort.service.spec.ts | 84 ++ libs/services/src/lib/sort.service.ts | 32 +- libs/ui/styles/_app-controls.scss | 23 + libs/ui/styles/_side-list.scss | 102 +++ ...space-sources-filters-panel.component.html | 104 ++- ...space-sources-filters-panel.component.scss | 120 ++- ...ce-sources-filters-panel.component.spec.ts | 149 +++ ...rkspace-sources-filters-panel.component.ts | 165 ++-- .../workspace-sources-sort-options.ts | 53 ++ .../workspace-sources.component.html | 123 ++- .../workspace-sources.component.scss | 137 +-- .../workspace-sources.component.spec.ts | 136 ++- .../workspace-sources.component.ts | 344 ++++--- 70 files changed, 3837 insertions(+), 2023 deletions(-) create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-list.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-list.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/sources-view-state.service.ts create mode 100644 libs/services/src/lib/sort.service.spec.ts create mode 100644 libs/ui/styles/_side-list.scss create mode 100644 libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources-sort-options.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index bc8f0e6d1..bef0b4f6e 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl( playlistUrl ); if (userAgent !== undefined) { - await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent); + await setInputValue( + dialog.getByRole('textbox', { name: 'User agent', exact: true }), + userAgent + ); } await dialog.getByRole('button', { name: /Add playlist/i }).click(); await dialog.waitFor({ state: 'detached' }); @@ -1187,7 +1190,10 @@ export async function createMutableTextServer( return; } - if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) { + if ( + options.requiredUserAgent && + req.headers['user-agent'] !== options.requiredUserAgent + ) { res.writeHead(403); res.end('User-Agent required'); return; @@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact( } export function sourceRowByTitle(page: Page, title: string): Locator { - return page.locator('app-playlist-item').filter({ + return page.locator('app-workspace-sources app-source-row').filter({ hasText: flexibleTextPattern(title), }); } export async function getVisibleSourceTitles(page: Page): Promise { - return page.locator('app-playlist-item').evaluateAll((elements) => - elements - .map((element) => { - const titleElement = element.querySelector('.playlist-title'); - return titleElement?.textContent?.trim() ?? ''; - }) - .filter((title) => title.length > 0) - ); + return page + .locator('app-workspace-sources app-source-row') + .evaluateAll((elements) => + elements + .map((element) => { + const titleElement = + element.querySelector('.source-row__name'); + return titleElement?.textContent?.trim() ?? ''; + }) + .filter((title) => title.length > 0) + ); } export async function selectSourceTypeFilter( @@ -1497,8 +1506,10 @@ export async function selectSourceSort( sortLabel: | 'Date added (Newest first)' | 'Date added (Oldest first)' + | 'Last updated' | 'Name (A-Z)' | 'Name (Z-A)' + | 'Most channels' | 'Custom order' ): Promise { const sortTrigger = page.locator('app-workspace-sources .sort-trigger'); @@ -1523,10 +1534,10 @@ export async function dragSourceBefore( sourceTitle: string, targetTitle: string ): Promise { - const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon'); - const target = sourceRowByTitle(page, targetTitle).locator( - '.playlist-item' + const source = sourceRowByTitle(page, sourceTitle).locator( + '.source-row__handle' ); + const target = sourceRowByTitle(page, targetTitle).locator('.source-row'); await expect(source.first()).toBeVisible(); await expect(target.first()).toBeVisible(); @@ -1560,15 +1571,18 @@ export async function openSourceEditor( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.edit-btn').click(); + await row.hover(); + await row.locator('.source-row__edit').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); // Material applies its initial focus after the opening transition. await dialog.evaluate(async (element) => { - await Promise.all(element.getAnimations({ subtree: true }).map((animation) => - animation.finished.catch(() => undefined) - )); + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)) + ); }); return dialog; } @@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues( } } +/** + * Deletes a source from its ⋯ menu. The row leaves at once; the delete + * itself runs when the Undo toast closes, so this waits for the toast to go + * before the caller checks persisted state. + */ export async function deleteSource(page: Page, title: string): Promise { const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.delete-btn').click(); - await confirmDialog(page, 'Remove playlist'); + await row.hover(); + await row.locator('.source-row__more').click(); + await page + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) + .click(); + const toast = page.locator('mat-snack-bar-container.app-toast'); + await expect(toast).toContainText('Removed'); + await expect(toast).toBeHidden({ timeout: 15000 }); } export async function refreshSource( @@ -1697,7 +1723,8 @@ export async function refreshSource( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.refresh-btn').click(); + await row.hover(); + await row.locator('.source-row__refresh').click(); if (confirm) { await confirmDialog(page, 'Refresh playlist'); @@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await expect(row.locator('.busy-state__message')).toHaveCount(0, { + await expect(row.locator('.source-row__busy')).toHaveCount(0, { timeout: 60000, }); - await expect(row.locator('.action-spinner')).toHaveCount(0, { + await expect(row.locator('mat-spinner')).toHaveCount(0, { timeout: 60000, }); } diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index 22f264e56..1aab6dc95 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => { }); await openSources(app.mainWindow); await expect( - app.mainWindow.locator('app-playlist-item') + app.mainWindow.locator('app-workspace-sources app-source-row') ).toHaveCount(2, { timeout: 30000 }); } finally { await closeElectronApp(app); diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index 80ff98a98..1a8793710 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -550,19 +550,21 @@ test.describe('Keyboard focus ring', () => { // Sources: the type filters, then the list's row actions. await openSources(page); await expect( - page.locator('app-playlist-item').first() + page.locator('app-workspace-sources app-source-row').first() ).toBeVisible(); + // Add, the Type and Status rows, Clean up, Sort, then the first + // row and its actions; Check again lives in the row's menu. await walk( 'sources list', page.locator('app-workspace-shell'), page .locator('app-workspace-sources-filters-panel button') .first(), - 12 + 16 ); expect( report['sources list (dark)'].map((stop) => stop.label) - ).toContain('Check again'); + ).toContain('Playlist actions'); // The live EPG list: its rows are Tab stops (`role="button"`) // with their own inset ring, then the row's info button. diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index 37f86d4d5..1770decf5 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel', assert.equal(options.splashId, 'initial-splash'); assert.equal(options.routeFragment, '/workspace'); assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/); - assert.match(options.cardSelector, /app-playlist-item/); + assert.match(options.cardSelector, /app-source-row/); assert.deepEqual(options.settle, { capMs: JOURNEY_SETTLE_CAP_MS, quietMs: JOURNEY_SETTLE_QUIET_MS, @@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route', options.startClick?.selector ?? '', /dashboard-recent-sources-rail-card/ ); - assert.match(options.startClick?.selector ?? '', /app-playlist-item/); + assert.match(options.startClick?.selector ?? '', /app-source-row/); assert.equal(options.routeFragment, '/workspace/xtreams/'); assert.match(options.cardSelector, /app-grid-list mat-card/); assert.match(options.cardSelector, /channel-item/); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 7ee9bac31..f6d764981 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR = export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video'; /** The Xtream portal card on the dashboard or its row on /workspace/sources. */ export const JOURNEY_OPEN_SOURCE_START_SELECTOR = - '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item'; + '[data-test-id="dashboard-recent-sources-rail-card"], app-source-row'; /** * Bridge method used for the sentinels. The preload emits the trace event * before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id @@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions( ): JourneyRendererProbeOptions { return { cardSelector: - '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item', + '[data-test-id="dashboard-recent-sources-rail-card"], app-source-row', cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, ...(idleWindowMs === null ? {} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index bcb95d21d..ba5f745fc 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -262,7 +262,7 @@ async function runIteration( await renderer.markOperationStart(); const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); - await row.locator('.refresh-btn').click(); + await row.locator('.source-row__refresh').click(); await renderer.waitForCancelClick(); await Promise.all([ renderer.waitForTerminal(), diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts index e61c1e6c9..b7889cdf7 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -301,7 +301,7 @@ async function installRendererProbe( const epoch = (): number => performance.timeOrigin + performance.now(); const sourceRow = (): Element | undefined => - Array.from(document.querySelectorAll('app-playlist-item')).find( + Array.from(document.querySelectorAll('app-source-row')).find( (element) => element.textContent?.includes(title) ); const state = { diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts index ea06941c6..795021039 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -152,11 +152,11 @@ export async function installXtreamRendererProbe( ); const sourceRowAbsent = (): boolean => !Array.from( - document.querySelectorAll('app-playlist-item') + document.querySelectorAll('app-source-row') ).some( (row) => row - .querySelector('.playlist-title') + .querySelector('.source-row__name') ?.textContent?.trim() === state.sourceTitle ); const terminalDomGate = (): boolean => { diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index 31782aebc..dd2d6ac52 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => { "'#serverUrl'", "'#username'", "'#password'", - "'app-playlist-item'", - "'.refresh-btn'", - "'.delete-btn'", + "'app-workspace-sources app-source-row'", + "'.source-row__refresh'", + "'.source-row__more'", "'Refresh playlist'", "'Remove playlist'", - "'.busy-state__message'", - "'.action-spinner'", - "'.cancel-btn'", + "'.source-row__busy'", + "'mat-spinner'", + "'.source-row__cancel'", "'.workspace-loading-overlay'", ]) { assert.ok(ui.includes(selector), `missing ${selector}`); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts index 17e187503..51d2b5f98 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => { }); it('does not require a misleading portal-status badge before refresh or delete', async () => { - for (const [scenarioId, selector, confirmLabel] of [ + for (const [scenarioId, selector, confirmClick] of [ [ XTREAM_SCENARIO_ID.REFRESH_LARGE, - '.refresh-btn', - 'Refresh playlist', + '.source-row__refresh', + 'name=Refresh playlist;exact=true', + ], + [ + XTREAM_SCENARIO_ID.DELETE_LARGE, + '.source-row__more', + '.source-removal-toast__close', ], - [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'], ] as const) { - const confirmClick = `name=${confirmLabel};exact=true`; const fake = new FakePage(); const prepared = await prepareXtreamScenario( fake.asPage(), diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts index e2c7b83e0..4d5b97cc6 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -68,28 +68,42 @@ export async function prepareXtreamSourceActionTrigger( const row = xtreamSourceRow(page, sourceTitle); await row.waitFor({ state: 'visible' }); for (const selector of [ - '.busy-state__message', - '.action-spinner', - '.cancel-btn', + '.source-row__busy', + 'mat-spinner', + '.source-row__cancel', ]) { await row.locator(selector).waitFor({ state: 'detached' }); } - const actionSelector = - action === 'refresh' ? '.refresh-btn' : '.delete-btn'; - const actionButton = row.locator(actionSelector); - await assertTriggerReady(actionButton, `xtream-source-${action}`); - await actionButton.click(); + if (action === 'refresh') { + const refresh = row.locator('.source-row__refresh'); + await assertTriggerReady(refresh, 'xtream-source-refresh'); + await refresh.click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + const confirmation = dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .last(); + await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm'); + return confirmation; + } - const dialog = page.locator('mat-dialog-container').last(); - await dialog.waitFor({ state: 'visible' }); - const confirmation = dialog - .getByRole('button', { - name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist', - exact: true, - }) - .last(); - await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); - return confirmation; + // Delete leaves the list at once and offers Undo; the toast's close + // button commits it without waiting out the Undo window, so the + // measurement starts when the delete does. + const more = row.locator('.source-row__more'); + await assertTriggerReady(more, 'xtream-source-delete-menu'); + await more.click(); + const remove = page + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) + .first(); + await assertTriggerReady(remove, 'xtream-source-delete'); + await remove.click(); + const commit = page.locator( + 'mat-snack-bar-container.app-toast .source-removal-toast__close' + ); + await assertTriggerReady(commit, 'xtream-source-delete-confirm'); + return commit; } export async function waitForXtreamCatalogTerminal(page: Page): Promise { @@ -129,7 +143,10 @@ export async function waitForXtreamDeleteTerminal( page: Page, sourceTitle: string ): Promise { - await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' }); + const row = xtreamSourceRow(page, sourceTitle); + // The row returns as "Removing…" once the delete starts, then leaves. + await row.waitFor({ state: 'attached' }); + await row.waitFor({ state: 'detached' }); } async function selectXtreamMethod(dialog: Locator): Promise { @@ -157,9 +174,9 @@ async function selectXtreamMethod(dialog: Locator): Promise { function xtreamSourceRow(page: Page, sourceTitle: string): Locator { const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u'); return page - .locator('app-playlist-item') + .locator('app-workspace-sources app-source-row') .filter({ - has: page.locator('.playlist-title', { + has: page.locator('.source-row__name', { hasText: exactTitle, }), }) diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts index aefb1725c..f152c54bb 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts @@ -178,7 +178,7 @@ class SearchCompletionFakePage { } this.searchGateObserved = /searchParams\.get\((['"])q\1\)/u.test(source) && - source.includes('app-playlist-item') && + source.includes('app-source-row') && source.includes('no-results-state'); if (this.searchGateObserved) { this.searchResultsApplied = true; diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts index 579ece981..f7198880e 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions( ); const resultRows = Array.from( document.querySelectorAll( - 'app-workspace-sources app-playlist-item' + 'app-workspace-sources app-source-row' ) ); const resultPainted = resultRows.length > 0 ? resultRows.every((row) => ( - row.querySelector('.playlist-title') + row.querySelector('.source-row__name') ?.textContent ?? '' ) .trim() diff --git a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts index 83dc3802d..0ca32b047 100644 --- a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts +++ b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts @@ -31,7 +31,7 @@ type RowRing = { function readRowRing(page: Page): Promise { return page.evaluate(() => { const focused = document.activeElement as HTMLElement; - const row = focused.closest('.playlist-item') as HTMLElement; + const row = focused.closest('.source-row') as HTMLElement; const probe = document.createElement('span'); probe.style.color = 'var(--app-focus-ring)'; row.append(probe); @@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => { timeout: 30_000, }); await openSources(page); - await expect(page.locator('app-playlist-item')).toHaveCount(2, { + await expect( + page.locator('app-workspace-sources app-source-row') + ).toHaveCount(2, { timeout: 30_000, }); // Bravo opened last, so it is the active source; open the other. const row = sourceRowByTitle(page, 'keyboard-alpha').first(); - await expect(row.locator('.playlist-item')).not.toHaveClass( - /\bselected\b/ + await expect(row.locator('.source-row__body')).not.toHaveAttribute( + 'aria-current', + 'true' ); const title = ( - await row.locator('.playlist-title').textContent() + await row.locator('.source-row__name').textContent() )?.trim(); expect(title).toBeTruthy(); @@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => { await expect(open).not.toBeFocused(); expect( await row - .locator('.playlist-item') + .locator('.source-row') .evaluate((el) => getComputedStyle(el).outlineStyle) ).toBe('none'); await page.keyboard.press('Shift+Tab'); diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 7c21e103d..780daafe5 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator( - 'app-source-health-indicator [data-state]' + '.source-tile__dot[data-state]' ) - ).toHaveAttribute('data-state', 'active', { timeout: 20000 }); + ).toHaveAttribute('data-state', 'ok', { timeout: 20000 }); await expect( sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator( - 'app-source-health-indicator [data-state]' + '.source-tile__dot[data-state]' ) - ).toHaveAttribute('data-state', 'active', { timeout: 20000 }); + ).toHaveAttribute('data-state', 'ok', { timeout: 20000 }); await deleteSource(app.mainWindow, deletableLocalSourceDisplayName); await expect( @@ -822,8 +822,8 @@ https://streams.example.test/original-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName) .first() - .locator('.refresh-btn') - ).toBeVisible(); + .locator('.source-row__refresh') + ).toBeAttached(); writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [ { @@ -865,8 +865,8 @@ https://streams.example.test/refreshed-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u') .first() - .locator('.meta') - ).toContainText('Updated:'); + .locator('.source-row__meta') + ).toContainText('updated'); await waitForSourceRowIdle( app.mainWindow, 'refresh-url-source.m3u' @@ -876,9 +876,11 @@ https://streams.example.test/refreshed-url.m3u8 'refresh-url-source.m3u' ).first(); await expect( - refreshedUrlRow.locator('.playlist-item') - ).not.toHaveClass(/is-busy/); - await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled(); + refreshedUrlRow.locator('.source-row') + ).not.toHaveClass(/source-row--busy/); + await expect( + refreshedUrlRow.locator('.source-row__refresh') + ).toBeEnabled(); await refreshedUrlRow .getByText('refresh-url-source.m3u', { exact: true }) .click(); @@ -904,8 +906,8 @@ https://streams.example.test/refreshed-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source') .first() - .locator('.meta') - ).toContainText('Updated:'); + .locator('.source-row__meta') + ).toContainText('updated'); } finally { await closeElectronApp(app); await urlServer.close(); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 0b97a93f3..b13738ea2 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20'; const dbWorkerRowsPerTransaction = '100'; function playlistRowByName(name: string) { - return `app-playlist-item:has-text("${name}")`; + return `app-workspace-sources app-source-row:has-text("${name}")`; } async function openSources(page: Parameters[0]) { @@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => { ); await expect(playlistRow).toBeVisible({ timeout: 20000 }); - await playlistRow.locator('.delete-btn').click(); - - const confirmDialog = app.mainWindow.locator( - 'mat-dialog-container' - ); - await expect(confirmDialog).toBeVisible(); - await confirmDialog - .getByRole('button', { name: 'Remove playlist', exact: true }) + // Delete from the row menu; the delete starts when the Undo + // toast closes, and the row comes back to show its progress. + await playlistRow.hover(); + await playlistRow.locator('.source-row__more').click(); + await app.mainWindow + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) .click(); await waitForDbOperationEvent(app.mainWindow, { @@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => { timeoutMs: 20000, }); - await expect( - playlistRow.locator('.busy-state__message') - ).toBeVisible({ timeout: 20000 }); - const cancel = playlistRow.locator('.cancel-btn'); + await expect(playlistRow.locator('.source-row__busy')).toBeVisible({ + timeout: 20000, + }); + const cancel = playlistRow.locator('.source-row__cancel'); await expect(cancel).toBeVisible(); // The row's action buttons inherit its color; the cancel action // must still read as destructive on the selected row. diff --git a/apps/web-e2e/src/icon-button-names.e2e.ts b/apps/web-e2e/src/icon-button-names.e2e.ts index 18e7b997d..13ad647b7 100644 --- a/apps/web-e2e/src/icon-button-names.e2e.ts +++ b/apps/web-e2e/src/icon-button-names.e2e.ts @@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog' }) => { await addM3uUrlPlaylist(page, M3U_TITLE); - // Sources list: refresh, details and remove on each row. + // Sources list: refresh, edit and the actions menu on each row. await openSources(page); const row = sourceRowByTitle(page, M3U_TITLE); await expect(row).toBeVisible({ timeout: 15_000 }); await row.hover(); - for (const name of [ - 'Refresh playlist', - 'Show playlist details', - 'Remove playlist', - ]) { + for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) { await expect(row.getByRole('button', { name })).toBeAttached(); } await expectNamedButtons(page, 3); diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index afa09de53..be4981db7 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a channel = 'Refreshed UA Channel'; const row = sourceRowByTitle(page, 'Protected M3U'); await row.hover(); - await row.locator('.refresh-btn').click(); + await row.locator('.source-row__refresh').click(); await expect( page.locator('.mat-mdc-snack-bar-label').last() ).toContainText('updated'); diff --git a/apps/web-e2e/src/sources-pwa.helpers.ts b/apps/web-e2e/src/sources-pwa.helpers.ts index ea393d357..848feef97 100644 --- a/apps/web-e2e/src/sources-pwa.helpers.ts +++ b/apps/web-e2e/src/sources-pwa.helpers.ts @@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise { } export function sourceRowByTitle(page: Page, title: string): Locator { - return page.locator('app-playlist-item').filter({ hasText: title }).first(); + return page + .locator('app-workspace-sources app-source-row') + .filter({ hasText: title }) + .first(); } export async function openSourceEditor( @@ -200,7 +203,7 @@ export async function openSourceEditor( const row = sourceRowByTitle(page, title); await expect(row).toBeVisible({ timeout: 15_000 }); await row.hover(); - await row.locator('.edit-btn').click(); + await row.locator('.source-row__edit').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); return dialog; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 713e4341e..17f016b79 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -260,7 +260,10 @@ "NAME_DESC": "الاسم (ي-أ)", "NEWEST": "تاريخ الإضافة (الأحدث أولاً)", "OLDEST": "تاريخ الإضافة (الأقدم أولاً)", - "CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)" + "CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)", + "UPDATED": "آخر تحديث", + "CHANNELS": "الأكثر قنوات", + "FAILING_FIRST": "غير المستجيبة أولاً" }, "PLAYLIST_TYPES": { "M3U": "M3U (محلي، رابط، نص)", @@ -1915,7 +1918,16 @@ "EDIT": "تعديل…", "COPY_URL": "نسخ الرابط", "OPEN": "فتح" - } + }, + "STATUS": "الحالة", + "STATUS_ANY": "أي حالة", + "STATUS_OK": "تستجيب", + "STATUS_BAD": "لا تستجيب", + "NO_MATCHES": "لا توجد قوائم تطابق عوامل التصفية هذه", + "RESET_FILTERS": "إعادة تعيين عوامل التصفية", + "REMOVED": "تمت إزالة «{{name}}»", + "REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»", + "REMOVE_FAILED": "تعذّرت إزالة «{{name}}»" }, "CONTEXT": { "CATEGORIES": "الفئات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index d2906296d..0b249a785 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -260,7 +260,10 @@ "NAME_DESC": "الاسم (ي-أ)", "NEWEST": "تاريخ الإضافة (الأحدث أولاً)", "OLDEST": "تاريخ الإضافة (الأقدم أولاً)", - "CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)" + "CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)", + "UPDATED": "آخر تحديث", + "CHANNELS": "اللي فيها كتر القنوات", + "FAILING_FIRST": "اللي ما كيجاوبوش اللولين" }, "PLAYLIST_TYPES": { "M3U": "M3U (محلي، رابط، نص)", @@ -1915,7 +1918,16 @@ "EDIT": "بدّل…", "COPY_URL": "نسخ الرابط", "OPEN": "حلّ" - } + }, + "STATUS": "الحالة", + "STATUS_ANY": "أي حالة", + "STATUS_OK": "كيجاوبو", + "STATUS_BAD": "ما كيجاوبوش", + "NO_MATCHES": "حتى ليستة ما كتوافق هاد الفلاتر", + "RESET_FILTERS": "رجّع الفلاتر", + "REMOVED": "تحيّدات «{{name}}»", + "REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»", + "REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»" }, "CONTEXT": { "CATEGORIES": "الفئات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4bda49a6f..7d21491d6 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -260,7 +260,10 @@ "NAME_DESC": "Назва (Я-А)", "NEWEST": "Дата дадання (спачатку новыя)", "OLDEST": "Дата дадання (спачатку старыя)", - "CUSTOM_ORDER": "Уласны парадак (перацягванне)" + "CUSTOM_ORDER": "Уласны парадак (перацягванне)", + "UPDATED": "Апошняе абнаўленне", + "CHANNELS": "Колькасць каналаў", + "FAILING_FIRST": "Спачатку тыя, што не адказваюць" }, "PLAYLIST_TYPES": { "M3U": "M3U (лакальны, URL, тэкст)", @@ -1915,7 +1918,16 @@ "EDIT": "Змяніць…", "COPY_URL": "Скапіяваць URL", "OPEN": "Адкрыць" - } + }, + "STATUS": "Статус", + "STATUS_ANY": "Любы", + "STATUS_OK": "Адказваюць", + "STATUS_BAD": "Не адказваюць", + "NO_MATCHES": "Няма плэйлістоў пад гэтыя фільтры", + "RESET_FILTERS": "Скінуць фільтры", + "REMOVED": "Выдалены «{{name}}»", + "REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»", + "REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»" }, "CONTEXT": { "CATEGORIES": "Катэгорыі", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 1d731feb3..2ca5d5452 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -260,7 +260,10 @@ "NAME_DESC": "Name (Z-A)", "NEWEST": "Hinzugefügt (Neueste zuerst)", "OLDEST": "Hinzugefügt (Älteste zuerst)", - "CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)" + "CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)", + "UPDATED": "Zuletzt aktualisiert", + "CHANNELS": "Meiste Sender", + "FAILING_FIRST": "Nicht erreichbare zuerst" }, "PLAYLIST_TYPES": { "M3U": "M3U (lokal, URL, Text)", @@ -1915,7 +1918,16 @@ "EDIT": "Bearbeiten…", "COPY_URL": "URL kopieren", "OPEN": "Öffnen" - } + }, + "STATUS": "Status (Erreichbarkeit)", + "STATUS_ANY": "Alle", + "STATUS_OK": "Erreichbar", + "STATUS_BAD": "Nicht erreichbar", + "NO_MATCHES": "Keine Playlists passen zu diesen Filtern", + "RESET_FILTERS": "Filter zurücksetzen", + "REMOVED": "„{{name}}“ entfernt", + "REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt", + "REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden" }, "CONTEXT": { "CATEGORIES": "Kategorien", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2387bc764..d42aecd92 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -260,7 +260,10 @@ "NAME_DESC": "Όνομα (Z-A)", "NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)", "OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)", - "CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)" + "CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)", + "UPDATED": "Τελευταία ενημέρωση", + "CHANNELS": "Περισσότερα κανάλια", + "FAILING_FIRST": "Πρώτα όσα δεν αποκρίνονται" }, "PLAYLIST_TYPES": { "M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)", @@ -1915,7 +1918,16 @@ "EDIT": "Επεξεργασία…", "COPY_URL": "Αντιγραφή URL", "OPEN": "Άνοιγμα" - } + }, + "STATUS": "Κατάσταση", + "STATUS_ANY": "Οποιαδήποτε", + "STATUS_OK": "Αποκρίνονται", + "STATUS_BAD": "Δεν αποκρίνονται", + "NO_MATCHES": "Καμία λίστα δεν ταιριάζει με αυτά τα φίλτρα", + "RESET_FILTERS": "Επαναφορά φίλτρων", + "REMOVED": "Καταργήθηκε το «{{name}}»", + "REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»", + "REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»" }, "CONTEXT": { "CATEGORIES": "Κατηγορίες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 211e780f2..2cc0bcee2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -260,7 +260,10 @@ "NAME_DESC": "Name (Z-A)", "NEWEST": "Date added (Newest first)", "OLDEST": "Date added (Oldest first)", - "CUSTOM_ORDER": "Custom order (Drag & Drop)" + "CUSTOM_ORDER": "Custom order (Drag & Drop)", + "UPDATED": "Last updated", + "CHANNELS": "Most channels", + "FAILING_FIRST": "Not responding first" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, url, text)", @@ -1915,7 +1918,16 @@ "EDIT": "Edit…", "COPY_URL": "Copy URL", "OPEN": "Open" - } + }, + "STATUS": "Status", + "STATUS_ANY": "Any", + "STATUS_OK": "Responding", + "STATUS_BAD": "Not responding", + "NO_MATCHES": "No playlists match these filters", + "RESET_FILTERS": "Reset filters", + "REMOVED": "Removed “{{name}}”", + "REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”", + "REMOVE_FAILED": "Could not remove “{{name}}”" }, "CONTEXT": { "CATEGORIES": "Categories", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 815d8cc9c..0f18458c9 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nombre (Z-A)", "NEWEST": "Fecha de adición (más recientes primero)", "OLDEST": "Fecha de adición (más antiguas primero)", - "CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)" + "CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)", + "UPDATED": "Última actualización", + "CHANNELS": "Más canales", + "FAILING_FIRST": "Primero las que no responden" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, url, texto)", @@ -1915,7 +1918,16 @@ "EDIT": "Editar…", "COPY_URL": "Copiar URL", "OPEN": "Abrir" - } + }, + "STATUS": "Estado", + "STATUS_ANY": "Cualquiera", + "STATUS_OK": "Responden", + "STATUS_BAD": "No responden", + "NO_MATCHES": "Ninguna lista coincide con estos filtros", + "RESET_FILTERS": "Restablecer filtros", + "REMOVED": "Se eliminó «{{name}}»", + "REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»", + "REMOVE_FAILED": "No se pudo eliminar «{{name}}»" }, "CONTEXT": { "CATEGORIES": "Categorías", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index eb873a768..a0b3d43ac 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nom (Z-A)", "NEWEST": "Date d'ajout (Plus récent)", "OLDEST": "Date d'ajout (Plus ancien)", - "CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)" + "CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)", + "UPDATED": "Dernière mise à jour", + "CHANNELS": "Plus de chaînes", + "FAILING_FIRST": "Celles qui ne répondent pas d'abord" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, URL, texte)", @@ -1915,7 +1918,16 @@ "EDIT": "Modifier…", "COPY_URL": "Copier l’URL", "OPEN": "Ouvrir" - } + }, + "STATUS": "État", + "STATUS_ANY": "Tous", + "STATUS_OK": "Répondent", + "STATUS_BAD": "Ne répondent pas", + "NO_MATCHES": "Aucune playlist ne correspond à ces filtres", + "RESET_FILTERS": "Réinitialiser les filtres", + "REMOVED": "« {{name}} » supprimée", + "REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »", + "REMOVE_FAILED": "Impossible de supprimer « {{name}} »" }, "CONTEXT": { "CATEGORIES": "Catégories", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index f2d60447b..1a3452a2e 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -260,7 +260,10 @@ "NAME_DESC": "Név szerint (Z–A)", "NEWEST": "Hozzáadás dátuma (legújabb elöl)", "OLDEST": "Hozzáadás dátuma (legrégebbi elöl)", - "CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)" + "CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)", + "UPDATED": "Legutóbbi frissítés", + "CHANNELS": "Legtöbb csatorna", + "FAILING_FIRST": "Nem válaszolók elöl" }, "PLAYLIST_TYPES": { "M3U": "M3U (helyi fájl, URL vagy szöveg)", @@ -1915,7 +1918,16 @@ "EDIT": "Szerkesztés…", "COPY_URL": "URL másolása", "OPEN": "Megnyitás" - } + }, + "STATUS": "Állapot", + "STATUS_ANY": "Bármelyik", + "STATUS_OK": "Válaszolnak", + "STATUS_BAD": "Nem válaszolnak", + "NO_MATCHES": "Egyik lejátszási lista sem felel meg ezeknek a szűrőknek", + "RESET_FILTERS": "Szűrők törlése", + "REMOVED": "„{{name}}” eltávolítva", + "REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”", + "REMOVE_FAILED": "„{{name}}” nem távolítható el" }, "CONTEXT": { "CATEGORIES": "Kategóriák", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index d717c46de..51aaa43c9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nome (Z-A)", "NEWEST": "Data aggiunta (prima più recenti)", "OLDEST": "Data aggiunta (prima più vecchi)", - "CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)" + "CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)", + "UPDATED": "Ultimo aggiornamento", + "CHANNELS": "Più canali", + "FAILING_FIRST": "Prima quelle che non rispondono" }, "PLAYLIST_TYPES": { "M3U": "M3U (locale, URL, testo)", @@ -1915,7 +1918,16 @@ "EDIT": "Modifica…", "COPY_URL": "Copia URL", "OPEN": "Apri" - } + }, + "STATUS": "Stato", + "STATUS_ANY": "Qualsiasi", + "STATUS_OK": "Rispondono", + "STATUS_BAD": "Non rispondono", + "NO_MATCHES": "Nessuna playlist corrisponde a questi filtri", + "RESET_FILTERS": "Reimposta filtri", + "REMOVED": "«{{name}}» rimossa", + "REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»", + "REMOVE_FAILED": "Impossibile rimuovere «{{name}}»" }, "CONTEXT": { "CATEGORIES": "Categorie", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 88dae1f48..69d2dd7f7 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -260,7 +260,10 @@ "NAME_DESC": "名前(Z-A)", "NEWEST": "追加日(新しい順)", "OLDEST": "追加日(古い順)", - "CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)" + "CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)", + "UPDATED": "最終更新", + "CHANNELS": "チャンネル数", + "FAILING_FIRST": "応答なしを先頭に" }, "PLAYLIST_TYPES": { "M3U": "M3U(ローカル、URL、テキスト)", @@ -1915,7 +1918,16 @@ "EDIT": "編集…", "COPY_URL": "URLをコピー", "OPEN": "開く" - } + }, + "STATUS": "状態", + "STATUS_ANY": "すべて", + "STATUS_OK": "応答あり", + "STATUS_BAD": "応答なし", + "NO_MATCHES": "この条件に一致するプレイリストはありません", + "RESET_FILTERS": "フィルターをリセット", + "REMOVED": "「{{name}}」を削除しました", + "REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました", + "REMOVE_FAILED": "「{{name}}」を削除できませんでした" }, "CONTEXT": { "CATEGORIES": "カテゴリー", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 995294133..19b7937f8 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -260,7 +260,10 @@ "NAME_DESC": "이름 (Z-A)", "NEWEST": "추가 날짜 (최신순)", "OLDEST": "추가 날짜 (오래된순)", - "CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)" + "CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)", + "UPDATED": "최근 업데이트", + "CHANNELS": "채널 많은 순", + "FAILING_FIRST": "응답 없는 항목 먼저" }, "PLAYLIST_TYPES": { "M3U": "M3U (로컬, URL, 텍스트)", @@ -1915,7 +1918,16 @@ "EDIT": "편집…", "COPY_URL": "URL 복사", "OPEN": "열기" - } + }, + "STATUS": "상태", + "STATUS_ANY": "전체", + "STATUS_OK": "응답함", + "STATUS_BAD": "응답 없음", + "NO_MATCHES": "이 필터와 일치하는 재생목록이 없습니다", + "RESET_FILTERS": "필터 초기화", + "REMOVED": "'{{name}}' 삭제됨", + "REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨", + "REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다" }, "CONTEXT": { "CATEGORIES": "카테고리", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 48573d999..903151104 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -260,7 +260,10 @@ "NAME_DESC": "Naam (Z-A)", "NEWEST": "Datum toegevoegd (Nieuwste eerst)", "OLDEST": "Datum toegevoegd (Oudste eerst)", - "CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)" + "CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)", + "UPDATED": "Laatst bijgewerkt", + "CHANNELS": "Meeste zenders", + "FAILING_FIRST": "Niet-reagerende eerst" }, "PLAYLIST_TYPES": { "M3U": "M3U (lokaal, URL, tekst)", @@ -1915,7 +1918,16 @@ "EDIT": "Bewerken…", "COPY_URL": "URL kopiëren", "OPEN": "Openen" - } + }, + "STATUS": "Bereikbaarheid", + "STATUS_ANY": "Alle", + "STATUS_OK": "Reageren", + "STATUS_BAD": "Reageren niet", + "NO_MATCHES": "Geen afspeellijsten voldoen aan deze filters", + "RESET_FILTERS": "Filters wissen", + "REMOVED": "‘{{name}}’ verwijderd", + "REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’", + "REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd" }, "CONTEXT": { "CATEGORIES": "Categorieën", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1aab4a126..54cec632a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nazwa (Z-A)", "NEWEST": "Data dodania (najnowsze pierwsze)", "OLDEST": "Data dodania (najstarsze pierwsze)", - "CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)" + "CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)", + "UPDATED": "Ostatnia aktualizacja", + "CHANNELS": "Najwięcej kanałów", + "FAILING_FIRST": "Najpierw nieodpowiadające" }, "PLAYLIST_TYPES": { "M3U": "M3U (lokalny, URL, tekst)", @@ -1915,7 +1918,16 @@ "EDIT": "Edytuj…", "COPY_URL": "Kopiuj URL", "OPEN": "Otwórz" - } + }, + "STATUS": "Stan", + "STATUS_ANY": "Dowolny", + "STATUS_OK": "Odpowiadają", + "STATUS_BAD": "Nie odpowiadają", + "NO_MATCHES": "Żadna playlista nie pasuje do tych filtrów", + "RESET_FILTERS": "Wyczyść filtry", + "REMOVED": "Usunięto „{{name}}”", + "REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”", + "REMOVE_FAILED": "Nie udało się usunąć „{{name}}”" }, "CONTEXT": { "CATEGORIES": "Kategorie", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 599db4189..3894e958b 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nome (Z-A)", "NEWEST": "Data de adição (Mais recente primeiro)", "OLDEST": "Data de adição (Mais antigo primeiro)", - "CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)" + "CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)", + "UPDATED": "Última atualização", + "CHANNELS": "Mais canais", + "FAILING_FIRST": "Primeiro as que não respondem" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, url, texto)", @@ -1915,7 +1918,16 @@ "EDIT": "Editar…", "COPY_URL": "Copiar URL", "OPEN": "Abrir" - } + }, + "STATUS": "Estado", + "STATUS_ANY": "Qualquer", + "STATUS_OK": "Respondem", + "STATUS_BAD": "Não respondem", + "NO_MATCHES": "Nenhuma lista corresponde a estes filtros", + "RESET_FILTERS": "Repor filtros", + "REMOVED": "«{{name}}» removida", + "REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»", + "REMOVE_FAILED": "Não foi possível remover «{{name}}»" }, "CONTEXT": { "CATEGORIES": "Categorias", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index a6ae67173..2b4a23b9c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -260,7 +260,10 @@ "NAME_DESC": "Имени (Я-А)", "NEWEST": "Дате добавления (сначала новые)", "OLDEST": "Дате добавления (сначала старые)", - "CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)" + "CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)", + "UPDATED": "Последнему обновлению", + "CHANNELS": "Числу каналов", + "FAILING_FIRST": "Сначала неактивные" }, "PLAYLIST_TYPES": { "M3U": "M3U (локально, URL, текст)", @@ -1915,7 +1918,16 @@ "EDIT": "Изменить…", "COPY_URL": "Копировать URL", "OPEN": "Открыть" - } + }, + "STATUS": "Статус", + "STATUS_ANY": "Любой", + "STATUS_OK": "Отвечают", + "STATUS_BAD": "Не отвечают", + "NO_MATCHES": "Нет плейлистов с такими фильтрами", + "RESET_FILTERS": "Сбросить фильтры", + "REMOVED": "Удалён «{{name}}»", + "REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»", + "REMOVE_FAILED": "Не удалось удалить «{{name}}»" }, "CONTEXT": { "CATEGORIES": "Категории", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 4bcde1825..f09c4c0c6 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -260,7 +260,10 @@ "NAME_DESC": "İsim (Z-A)", "NEWEST": "Eklenme tarihi (En yeni önce)", "OLDEST": "Eklenme tarihi (En eski önce)", - "CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)" + "CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)", + "UPDATED": "Son güncelleme", + "CHANNELS": "En çok kanal", + "FAILING_FIRST": "Önce yanıt vermeyenler" }, "PLAYLIST_TYPES": { "M3U": "M3U (yerel, url, metin)", @@ -1915,7 +1918,16 @@ "EDIT": "Düzenle…", "COPY_URL": "URL'yi kopyala", "OPEN": "Aç" - } + }, + "STATUS": "Durum", + "STATUS_ANY": "Tümü", + "STATUS_OK": "Yanıt veriyor", + "STATUS_BAD": "Yanıt vermiyor", + "NO_MATCHES": "Bu filtrelere uyan oynatma listesi yok", + "RESET_FILTERS": "Filtreleri sıfırla", + "REMOVED": "“{{name}}” kaldırıldı", + "REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı", + "REMOVE_FAILED": "“{{name}}” kaldırılamadı" }, "CONTEXT": { "CATEGORIES": "Kategoriler", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 44c6d4671..b1121e378 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -260,7 +260,10 @@ "NAME_DESC": "名称(Z-A)", "NEWEST": "添加日期(最新优先)", "OLDEST": "添加日期(最旧优先)", - "CUSTOM_ORDER": "自定义顺序(拖放)" + "CUSTOM_ORDER": "自定义顺序(拖放)", + "UPDATED": "最近更新", + "CHANNELS": "频道数量", + "FAILING_FIRST": "无响应的优先" }, "PLAYLIST_TYPES": { "M3U": "M3U(本地、URL、文本)", @@ -1915,7 +1918,16 @@ "EDIT": "编辑…", "COPY_URL": "复制 URL", "OPEN": "打开" - } + }, + "STATUS": "状态", + "STATUS_ANY": "全部", + "STATUS_OK": "有响应", + "STATUS_BAD": "无响应", + "NO_MATCHES": "没有符合这些筛选条件的播放列表", + "RESET_FILTERS": "重置筛选", + "REMOVED": "已删除“{{name}}”", + "REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”", + "REMOVE_FAILED": "无法删除“{{name}}”" }, "CONTEXT": { "CATEGORIES": "分类", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index aafae2899..5b66b3abc 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -260,7 +260,10 @@ "NAME_DESC": "名稱(Z-A)", "NEWEST": "新增日期(最新優先)", "OLDEST": "新增日期(最舊優先)", - "CUSTOM_ORDER": "自訂順序(拖放)" + "CUSTOM_ORDER": "自訂順序(拖放)", + "UPDATED": "最近更新", + "CHANNELS": "頻道數量", + "FAILING_FIRST": "無回應的優先" }, "PLAYLIST_TYPES": { "M3U": "M3U(本機、網址、文字)", @@ -1915,7 +1918,16 @@ "EDIT": "編輯…", "COPY_URL": "複製 URL", "OPEN": "開啟" - } + }, + "STATUS": "狀態", + "STATUS_ANY": "全部", + "STATUS_OK": "有回應", + "STATUS_BAD": "無回應", + "NO_MATCHES": "沒有符合這些篩選條件的播放清單", + "RESET_FILTERS": "重設篩選", + "REMOVED": "已移除「{{name}}」", + "REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」", + "REMOVE_FAILED": "無法移除「{{name}}」" }, "CONTEXT": { "CATEGORIES": "類別", diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts index f44b09eef..57bd51a0c 100644 --- a/libs/playlist/shared/ui/src/index.ts +++ b/libs/playlist/shared/ui/src/index.ts @@ -10,3 +10,4 @@ export * from './lib/xtream-refresh-flow.service'; export * from './lib/source-health/source-cleanup-dialog.component'; export * from './lib/source-row/source-row.component'; export * from './lib/source-row/source-tile.component'; +export * from './lib/source-removal/source-removal.service'; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html index b795a9e09..976e86ad4 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html @@ -1,45 +1,52 @@ -@if (playlistsData(); as data) { - @if (data.playlists.length === 0) { - @if (data.totalCount === 0) { - - - } @else { - - - } - } @else { -
- @for ( - item of data.playlists; - track item._id; - let last = $last - ) { - - } -
+@if (playlists().length === 0) { + @if (allPlaylistsLoaded()) { + } +} @else if (visiblePlaylists().length === 0) { +
+

+ {{ 'WORKSPACE.SOURCES.NO_MATCHES' | translate }} +

+ @if (hasFilters()) { + + } +
+} @else { +
+ @for (item of visiblePlaylists(); track item._id) { + + } +
} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss index ccb5152f8..118a1ff05 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss @@ -1,7 +1,8 @@ +@use '../../../../../../ui/styles/app-controls' as controls; + :host { display: flex; flex-direction: column; - align-items: center; width: 100%; min-width: 0; min-height: 0; @@ -15,35 +16,40 @@ min-height: 0; } -.playlists-list { - padding: 0 6px; +.sources-list { + display: flex; + flex-direction: column; width: 100%; - overflow-x: hidden; - box-sizing: border-box; } -#skeleton-container { - padding-top: 10px; - width: 100%; - - .skeleton-item { - display: flex; - padding: 0 20px; - height: 72px; - - .text { - flex: 1; - align-self: center; - padding-left: 6px; - flex-direction: column; - display: flex; - } - } +// A 1px line between rows, hidden around the hovered row so its fill +// reads as one shape. +app-source-row + app-source-row { + border-top: 1px solid var(--app-widget-header-border); } -@media only screen and (max-width: 480px) { - .favorites-icon { - padding-left: 0 !important; - display: none; - } +app-source-row:hover, +app-source-row:hover + app-source-row, +app-source-row:focus-within, +app-source-row:focus-within + app-source-row { + border-top-color: transparent; +} + +.sources-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 64px 16px; + text-align: center; +} + +.sources-empty__message { + margin: 0; + font-size: 13px; + color: var(--app-body-color); +} + +.sources-empty__reset { + @include controls.ghost-button(sm); } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 9db1e2121..4d4ea682d 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -1,742 +1,225 @@ import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { TranslateService } from '@ngx-translate/core'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { PlaylistActions, - selectActiveTypeFilters, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; -import { of } from 'rxjs'; -import { DialogService } from '@iptvnator/ui/components'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + PlaylistContextFacade, + SourcesViewStateService, +} from '@iptvnator/playlist/shared/util'; import { - DatabaseService, - DataService, - DbOperationEvent, - PlaybackPositionService, - PlaylistDeleteActionService, - PlaylistRefreshService, RuntimeCapabilitiesService, SortBy, + SortOptions, SortOrder, SortService, } from '@iptvnator/services'; -import { - CONNECTIVITY_GUARD_RESET, - PLAYLIST_UPDATE, - PlaylistMeta, -} from '@iptvnator/shared/interfaces'; -import { - RENDERER_PERFORMANCE_PHASE_HOOK_KEY, - type RendererPerformancePhaseEvent, -} from '@iptvnator/shared/logging'; +import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; import { RecentPlaylistsComponent } from './recent-playlists.component'; +import { SourceListRefreshService } from './source-list-refresh.service'; -const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); +const source = ( + id: string, + fields: Partial = {} +): PlaylistMeta => ({ + _id: id, + title: id, + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + ...fields, +}); -function setPerformanceHook( - hook: ((event: RendererPerformancePhaseEvent) => void) | null -): void { - const target = globalThis as unknown as Record; - if (hook === null) { - delete target[performanceHookSymbol]; - } else { - target[performanceHookSymbol] = hook; - } -} +const M3U = source('m3u', { url: 'https://cdn.example/list.m3u', position: 0 }); +const XTREAM = source('xtream', { + serverUrl: 'http://x.example', + username: 'u', + password: 'p', + position: 1, +}); +const FILE = source('file', { position: 2 }); -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} - -function createAbortError(): Error { - const error = new Error('Cancelled'); - error.name = 'AbortError'; - return error; -} - -function createPlaylistMeta( - overrides: Partial = {} -): PlaylistMeta { - return { - _id: 'playlist-1', - title: 'Xtream Playlist', - count: 0, - importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), - autoRefresh: false, - serverUrl: 'http://localhost:8080', - username: 'demo', - password: 'secret', - ...overrides, - } as PlaylistMeta; -} - -describe('RecentPlaylistsComponent busy state', () => { +describe('RecentPlaylistsComponent', () => { + let fixture: ComponentFixture; let component: RecentPlaylistsComponent; - let databaseService: { - cancelOperation: jest.Mock; - createOperationId: jest.Mock; - deletePlaylist: jest.Mock; - deleteXtreamPlaylistContent: jest.Mock; - updateXtreamPlaylistDetails: jest.Mock; - }; - let dialogService: { - openConfirmDialog: jest.Mock; - }; - let dataService: { - sendIpcEvent: jest.Mock; - }; - let playbackPositionService: { - getAllPlaybackPositions: jest.Mock; - }; - let playlistDeleteAction: { - deletePlaylist: jest.Mock; - }; - let playlistRefreshService: { - cancelRefresh: jest.Mock; - refreshPlaylist: jest.Mock; - }; - let runtime: { - isElectron: boolean; - supportsPlaylistRefresh: boolean; - supportsXtreamSqliteDataSource: boolean; - }; - let router: { - navigate: jest.Mock; - }; - let snackBar: { - open: jest.Mock; - }; let store: MockStore; + let sort: BehaviorSubject; + let statuses: Map; + let pending: ReturnType>>; + let removal: { + pendingIds: typeof pending; + remove: jest.Mock; + isPending: jest.Mock; + progress: object; + }; + let viewState: SourcesViewStateService; - beforeEach(async () => { - databaseService = { - cancelOperation: jest.fn().mockResolvedValue(true), - createOperationId: jest.fn((prefix: string) => `${prefix}-op`), - deletePlaylist: jest.fn(), - deleteXtreamPlaylistContent: jest.fn(), - updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined), + beforeEach(() => { + localStorage.clear(); + sort = new BehaviorSubject({ + by: SortBy.CUSTOM, + order: SortOrder.ASC, + }); + statuses = new Map([ + ['m3u', { reachability: 'ok' }], + ['xtream', { reachability: 'bad' }], + ]); + pending = signal>(new Set()); + removal = { + pendingIds: pending, + remove: jest.fn(), + isPending: jest.fn((id: string) => pending().has(id)), + progress: { + isDeleting: () => false, + message: () => '', + progress: () => null, + canCancel: () => false, + }, }; - dialogService = { - openConfirmDialog: jest.fn(), - }; - dataService = { - sendIpcEvent: jest.fn(), - }; - playbackPositionService = { - getAllPlaybackPositions: jest.fn().mockResolvedValue([]), - }; - playlistDeleteAction = { - deletePlaylist: jest.fn().mockResolvedValue(true), - }; - playlistRefreshService = { - cancelRefresh: jest.fn().mockResolvedValue(undefined), - refreshPlaylist: jest.fn().mockResolvedValue({ - id: 'playlist-1', - items: [], - }), - }; - runtime = { - isElectron: true, - supportsPlaylistRefresh: true, - supportsXtreamSqliteDataSource: true, - }; - router = { - navigate: jest.fn(), - }; - snackBar = { - open: jest.fn(), - }; - await TestBed.configureTestingModule({ - imports: [RecentPlaylistsComponent], + TestBed.configureTestingModule({ + imports: [RecentPlaylistsComponent, TranslateModule.forRoot()], providers: [ provideMockStore({ selectors: [ { selector: selectPlaylistsLoadingFlag, value: true }, { selector: selectAllPlaylistsMeta, - value: [], - }, - { - selector: selectActiveTypeFilters, - value: ['xtream', 'm3u', 'stalker'], + value: [M3U, XTREAM, FILE], }, ], }), { - provide: DatabaseService, - useValue: databaseService, + provide: SortService, + useValue: { + getSortOptions: () => sort.asObservable(), + sortPlaylists: new SortService().sortPlaylists, + }, }, { - provide: DialogService, - useValue: dialogService, + provide: SourceStatusService, + useValue: { + supportsStatusFilter: true, + statusOf: (p: PlaylistMeta) => + statuses.get(p._id) ?? null, + tracks: (p: PlaylistMeta) => statuses.has(p._id), + ensureChecked: jest.fn(), + recheck: jest.fn(), + }, }, + { provide: SourceRemovalService, useValue: removal }, { - provide: DataService, - useValue: dataService, - }, - { - provide: PlaybackPositionService, - useValue: playbackPositionService, - }, - { - provide: PlaylistDeleteActionService, - useValue: playlistDeleteAction, - }, - { - provide: PlaylistRefreshService, - useValue: playlistRefreshService, + provide: PlaylistContextFacade, + useValue: { resolvedPlaylistId: signal('xtream') }, }, { provide: RuntimeCapabilitiesService, - useValue: runtime, - }, - { - provide: PlaylistContextFacade, useValue: { - resolvedPlaylistId: signal(null), - }, - }, - { - provide: MatDialog, - useValue: { - open: jest.fn(), - }, - }, - { - provide: MatSnackBar, - useValue: snackBar, - }, - { - provide: Router, - useValue: router, - }, - { - provide: SortService, - useValue: { - getSortOptions: jest.fn(() => - of({ - by: SortBy.DATE_ADDED, - order: SortOrder.DESC, - }) - ), - sortPlaylists: jest.fn( - (playlists: PlaylistMeta[]) => playlists - ), - }, - }, - { - provide: TranslateService, - useValue: { - instant: jest.fn((key: string) => key), + isElectron: true, + supportsPlaylistRefresh: true, + supportsXtreamSqliteDataSource: true, }, }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: Router, useValue: { navigate: jest.fn() } }, ], - }).compileComponents(); - - component = TestBed.createComponent( - RecentPlaylistsComponent - ).componentInstance; + }); + TestBed.overrideComponent(RecentPlaylistsComponent, { + set: { + providers: [ + { + provide: SourceListRefreshService, + useValue: { + isRefreshing: () => false, + canRefresh: () => true, + message: () => '', + progress: () => null, + canCancel: () => false, + }, + }, + ], + }, + }); + fixture = TestBed.createComponent(RecentPlaylistsComponent); + component = fixture.componentInstance; store = TestBed.inject(MockStore); + viewState = TestBed.inject(SourcesViewStateService); jest.spyOn(store, 'dispatch'); + fixture.detectChanges(); }); - afterEach(() => { - setPerformanceHook(null); - jest.restoreAllMocks(); - localStorage.clear(); - }); + const ids = () => component.visiblePlaylists().map((p) => p._id); - it('tracks delete progress and clears the busy row after completion', async () => { - const events: RendererPerformancePhaseEvent[] = []; - setPerformanceHook((event) => events.push(event)); - const item = createPlaylistMeta({ _id: 'playlist-delete-1' }); - const deletion = createDeferred(); - - playlistDeleteAction.deletePlaylist.mockImplementation( - ( - _playlist: PlaylistMeta, - options?: { - onEvent?: (event: DbOperationEvent) => void; - } - ) => { - options?.onEvent?.({ - operation: 'delete-playlist', - operationId: 'playlist-delete-op', - status: 'progress', - phase: 'deleting-content', - current: 25, - total: 100, - }); - return deletion.promise; - } - ); - - const removalPromise = component.removePlaylist(item); - - expect(component.isDeletePending(item._id)).toBe(true); - expect(component.getBusyMessage(item)).toBe( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT' - ); - expect(component.getBusyProgress(item._id)).toBe(25); - expect(component.canCancelBusyOperation(item)).toBe(true); - expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, { - onEvent: expect.any(Function), - }); - - await component.cancelBusyOperation(item); - expect(databaseService.cancelOperation).toHaveBeenCalledWith( - 'playlist-delete-op' - ); - - deletion.resolve(true); - await removalPromise; - - expect(component.isDeletePending(item._id)).toBe(false); - expect(component.getBusyProgress(item._id)).toBeNull(); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.playlistRemovalCommitted({ playlistId: item._id }) - ); - expect(snackBar.open).toHaveBeenCalledWith( - 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', - undefined, - { duration: 2000 } - ); + it('lists every source as a row in the chosen order', () => { + expect(ids()).toEqual(['m3u', 'xtream', 'file']); expect( - events.map(({ boundary, metadata, outcome, phase }) => ({ - boundary, - items: metadata?.items, - outcome, - phase, - })) - ).toEqual([ - { - boundary: 'start', - items: 1, - outcome: undefined, - phase: 'store.xtream-delete-row', - }, - { - boundary: 'end', - items: 1, - outcome: 'success', - phase: 'store.xtream-delete-row', - }, - ]); + (fixture.nativeElement as HTMLElement).querySelectorAll( + 'app-source-row' + ).length + ).toBe(3); }); - it('delegates playlist deletion and updates local UI state after success', async () => { - const events: RendererPerformancePhaseEvent[] = []; - setPerformanceHook((event) => events.push(event)); - const item = createPlaylistMeta({ - _id: 'pwa-playlist-1', - serverUrl: undefined, - username: undefined, - password: undefined, - url: 'https://example.com/playlist.m3u', - }); - playlistDeleteAction.deletePlaylist.mockResolvedValue(true); + it('applies the type and status filters together', () => { + viewState.setType('m3u'); + expect(ids()).toEqual(['m3u', 'file']); + viewState.setStatus('ok'); + expect(ids()).toEqual(['m3u']); + viewState.setType('all'); + viewState.setStatus('bad'); + expect(ids()).toEqual(['xtream']); + }); - await component.removePlaylist(item); + it('offers to reset filters when nothing matches', () => { + viewState.setType('stalker'); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + expect(element.textContent).toContain('WORKSPACE.SOURCES.NO_MATCHES'); + element + .querySelector('.sources-empty__reset') + ?.click(); + expect(viewState.filter()).toEqual({ type: 'all', status: 'all' }); + }); - expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, { - onEvent: expect.any(Function), + it('puts failing sources first on request', () => { + sort.next({ + by: SortBy.NAME, + order: SortOrder.ASC, + failingFirst: true, }); - expect(databaseService.deletePlaylist).not.toHaveBeenCalled(); + expect(ids()).toEqual(['xtream', 'file', 'm3u']); + }); + + it('hides sources whose delete is pending', () => { + pending.set(new Set(['m3u'])); + expect(ids()).toEqual(['xtream', 'file']); + }); + + it('hands the active source over to the next row on delete', () => { + component.remove(XTREAM); + expect(removal.remove).toHaveBeenCalledWith(XTREAM, FILE); + component.remove(FILE); + expect(removal.remove).toHaveBeenLastCalledWith(FILE, XTREAM); + }); + + it('reorders the shown rows without moving the hidden ones', () => { + viewState.setType('m3u'); + component.drop({ previousIndex: 1, currentIndex: 0 } as never); expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.playlistRemovalCommitted({ playlistId: item._id }) - ); - expect(snackBar.open).toHaveBeenCalledWith( - 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', - undefined, - { duration: 2000 } - ); - expect(events).toEqual([]); - }); - - it('records dispatch failure and still clears delete-row busy state', async () => { - const events: RendererPerformancePhaseEvent[] = []; - const item = createPlaylistMeta({ _id: 'dispatch-failure' }); - const dispatchError = new Error('dispatch failed'); - setPerformanceHook((event) => events.push(event)); - (store.dispatch as jest.Mock).mockImplementation(() => { - throw dispatchError; - }); - - await expect(component.removePlaylist(item)).rejects.toBe( - dispatchError - ); - - expect(component.isDeletePending(item._id)).toBe(false); - expect(component.getBusyProgress(item._id)).toBeNull(); - expect(snackBar.open).not.toHaveBeenCalled(); - expect( - events.map(({ boundary, outcome, phase }) => ({ - boundary, - outcome, - phase, - })) - ).toEqual([ - { - boundary: 'start', - outcome: undefined, - phase: 'store.xtream-delete-row', - }, - { - boundary: 'end', - outcome: 'error', - phase: 'store.xtream-delete-row', - }, - ]); - }); - - it('tracks Xtream refresh progress and clears the busy row after abort', async () => { - const item = createPlaylistMeta({ _id: 'playlist-refresh-1' }); - const refresh = createDeferred<{ - success: boolean; - favorites: Array<{ xtreamId: number; contentType: string }>; - recentlyViewed: Array<{ - xtreamId: number; - contentType: string; - viewedAt: string; - }>; - hiddenCategories: Array<{ - xtreamId: number; - categoryType: string; - }>; - }>(); - let confirmPromise: Promise | undefined; - // The shared flow awaits the connectivity-guard reset before it starts - // deleting, so the delete is several ticks away and counting them is - // fragile. The mock fires onEvent synchronously and the component - // applies it synchronously, so resolving this deferred inside the mock - // is a deterministic "the busy row has been updated" signal. - const workerEventDelivered = createDeferred(); - - dialogService.openConfirmDialog.mockImplementation( - ({ onConfirm }: { onConfirm?: () => Promise }) => { - confirmPromise = onConfirm?.(); - } - ); - - databaseService.deleteXtreamPlaylistContent.mockImplementation( - ( - _playlistId: string, - options?: { - onEvent?: (event: DbOperationEvent) => void; - operationId?: string; - } - ) => { - options?.onEvent?.({ - operation: 'delete-xtream-content', - operationId: 'xtream-refresh-op', - status: 'progress', - phase: 'collecting-user-data', - current: 1, - total: 4, - }); - workerEventDelivered.resolve(); - - return refresh.promise; - } - ); - - component.refreshXtreamPlaylist(item); - await workerEventDelivered.promise; - - expect(component.isRefreshPending(item._id)).toBe(true); - expect(component.getBusyMessage(item)).toBe( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA' - ); - expect(component.getBusyProgress(item._id)).toBe(25); - expect(component.canCancelBusyOperation(item)).toBe(true); - - await component.cancelBusyOperation(item); - expect(databaseService.cancelOperation).toHaveBeenCalledWith( - 'xtream-refresh-op' - ); - - refresh.reject(createAbortError()); - await confirmPromise; - - expect(component.isRefreshPending(item._id)).toBe(false); - expect(component.getBusyProgress(item._id)).toBeNull(); - expect(router.navigate).not.toHaveBeenCalled(); - }); - - it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => { - const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); - let confirmPromise: Promise | undefined; - const executionOrder: string[] = []; - const performanceEvents: RendererPerformancePhaseEvent[] = []; - setPerformanceHook((event) => { - performanceEvents.push(event); - executionOrder.push(event.boundary); - }); - const dateNowSpy = jest - .spyOn(Date, 'now') - .mockReturnValue(1712145600000); - const originalSetItem = Storage.prototype.setItem; - const setItemSpy = jest - .spyOn(Storage.prototype, 'setItem') - .mockImplementation(function ( - this: Storage, - key: string, - value: string - ) { - executionOrder.push('setItem'); - return originalSetItem.call(this, key, value); - }); - - dialogService.openConfirmDialog.mockImplementation( - ({ onConfirm }: { onConfirm?: () => Promise }) => { - confirmPromise = onConfirm?.(); - } - ); - (store.dispatch as jest.Mock).mockImplementation((action: unknown) => { - executionOrder.push('dispatch'); - return action; - }); - router.navigate.mockImplementation((commands: unknown[]) => { - executionOrder.push('navigate'); - return Promise.resolve(Boolean(commands)); - }); - databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ - success: true, - favorites: [ - { xtreamId: 101, contentType: 'live' }, - { xtreamId: 202, contentType: 'movie' }, - ], - recentlyViewed: [ - { - xtreamId: 303, - contentType: 'series', - viewedAt: '2026-04-03T11:15:00.000Z', - }, - ], - hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], - }); - - component.refreshXtreamPlaylist(item); - await confirmPromise; - - expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( - expect.objectContaining({ - width: '400px', + PlaylistActions.updatePlaylistPositions({ + positionUpdates: [ + { id: 'file', changes: { position: 0 } }, + { id: 'xtream', changes: { position: 1 } }, + { id: 'm3u', changes: { position: 2 } }, + ], }) ); - expect( - databaseService.updateXtreamPlaylistDetails - ).toHaveBeenCalledWith({ - id: item._id, - updateDate: 1712145600000, - }); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.updatePlaylistMeta({ - playlist: { ...item, updateDate: 1712145600000 }, - }) - ); - expect(setItemSpy).toHaveBeenCalledWith( - `xtream-restore-${item._id}`, - expect.any(String) - ); - const persistedRestoreState = JSON.parse( - setItemSpy.mock.calls.find( - ([key]) => key === `xtream-restore-${item._id}` - )?.[1] ?? 'null' - ); - expect(persistedRestoreState).toEqual({ - hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], - favorites: [ - { xtreamId: 101, contentType: 'live' }, - { xtreamId: 202, contentType: 'movie' }, - ], - recentlyViewed: [ - { - xtreamId: 303, - contentType: 'series', - viewedAt: '2026-04-03T11:15:00.000Z', - }, - ], - playbackPositions: [], - }); - expect(router.navigate).toHaveBeenCalledWith([ - '/workspace', - 'xtreams', - item._id, - ]); - expect(executionOrder).toEqual([ - 'setItem', - 'start', - 'dispatch', - 'end', - 'navigate', - ]); - expect(performanceEvents).toHaveLength(2); - expect(performanceEvents[0]?.phaseId).toBe( - performanceEvents[1]?.phaseId - ); - expect( - performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ - boundary, - metadata, - outcome, - phase, - })) - ).toEqual([ - { - boundary: 'start', - metadata: { items: 1 }, - outcome: undefined, - phase: 'store.xtream-refresh-meta', - }, - { - boundary: 'end', - metadata: { items: 1 }, - outcome: 'success', - phase: 'store.xtream-refresh-meta', - }, - ]); - - setItemSpy.mockRestore(); - dateNowSpy.mockRestore(); - }); - - it('uses the legacy IPC refresh flow for non-Xtream playlists', () => { - runtime.supportsPlaylistRefresh = false; - const item = createPlaylistMeta({ - _id: 'playlist-m3u-1', - serverUrl: undefined, - username: undefined, - password: undefined, - filePath: undefined, - url: 'https://example.com/test.m3u', - userAgent: 'IPTVnator-Test/1.0', - }); - - component.refreshPlaylist(item); - - expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { - id: item._id, - title: item.title, - url: item.url, - userAgent: item.userAgent, - }); - }); - - it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => { - runtime.supportsPlaylistRefresh = false; - const item = createPlaylistMeta({ - _id: 'playlist-m3u-1', - serverUrl: undefined, - username: undefined, - password: undefined, - filePath: '/tmp/test.m3u', - }); - - component.refreshPlaylist(item); - - expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); - expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); - }); - - it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => { - runtime.supportsPlaylistRefresh = false; - const lateComponent = TestBed.createComponent( - RecentPlaylistsComponent - ).componentInstance; - runtime.supportsPlaylistRefresh = true; - const item = createPlaylistMeta({ - _id: 'playlist-m3u-2', - serverUrl: undefined, - username: undefined, - password: undefined, - filePath: '/tmp/test.m3u', - userAgent: 'IPTVnator-Test/1.0', - }); - - lateComponent.refreshPlaylist(item); - - expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( - { - operationId: 'playlist-refresh-op', - playlistId: item._id, - title: item.title, - url: item.url, - userAgent: item.userAgent, - filePath: item.filePath, - }, - { - onEvent: expect.any(Function), - } - ); - expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); - - await Promise.resolve(); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.updatePlaylist({ - playlist: expect.objectContaining({ - _id: item._id, - }), - playlistId: item._id, - refreshEpg: true, - operationId: 'playlist-refresh-op', - }) - ); - }); - - it('clears the connectivity guard before deleting the cached Xtream catalog', async () => { - // Second, independent implementation of the destructive refresh (the - // Workspace sources page). Same consequence as the shared action: the - // catalog is already gone by the time an open guard fast-fails the - // re-import bootstrap. - const item = { - _id: 'xtream-guard', - title: 'Guarded Xtream', - serverUrl: 'http://panel.example:8080', - } as PlaylistMeta; - const order: string[] = []; - let confirmPromise: Promise | undefined; - - dataService.sendIpcEvent.mockImplementation((event: string) => { - order.push(`ipc:${event}`); - return Promise.resolve({ success: true }); - }); - databaseService.deleteXtreamPlaylistContent.mockImplementation(() => { - order.push('deleteXtreamPlaylistContent'); - return Promise.resolve({ - hiddenCategories: [], - favorites: [], - recentlyViewed: [], - sourcePins: [], - }); - }); - dialogService.openConfirmDialog.mockImplementation( - ({ onConfirm }: { onConfirm?: () => Promise }) => { - confirmPromise = onConfirm?.(); - } - ); - - component.refreshXtreamPlaylist(item); - await confirmPromise; - - expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`); - expect(order).toContain('deleteXtreamPlaylistContent'); - expect(dataService.sendIpcEvent).toHaveBeenCalledWith( - CONNECTIVITY_GUARD_RESET, - { url: item.serverUrl } - ); }); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 674a3b366..451b0860b 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -6,97 +6,76 @@ import { import { ChangeDetectionStrategy, Component, - effect, + computed, inject, input, output, - signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButton } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatIcon } from '@angular/material/icon'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { TranslateService } from '@ngx-translate/core'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistActions, - selectActiveTypeFilters, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; -import { BehaviorSubject, combineLatest, map } from 'rxjs'; -import { DialogService } from '@iptvnator/ui/components'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + filterSources, + orderFailingFirst, + PlaylistContextFacade, + SourceBucketOf, + SourcesViewStateService, +} from '@iptvnator/playlist/shared/util'; import { - DatabaseService, - DataService, - DbOperationEvent, - isDbAbortError, - PlaylistDeleteActionService, - PlaylistRefreshService, RuntimeCapabilitiesService, SortBy, SortService, } from '@iptvnator/services'; -import { - foldSearchText, - PLAYLIST_UPDATE, - PlaylistMeta, - PlaylistRefreshEvent, -} from '@iptvnator/shared/interfaces'; -import { - measureRendererPerformancePhase, - RENDERER_PERFORMANCE_PHASE, -} from '@iptvnator/shared/logging'; - -import { - XtreamRefreshFlowService, - type XtreamRefreshProgressReporter, -} from '../xtream-refresh-flow.service'; -import { EmptyStateComponent } from './empty-state/empty-state.component'; +import { PlaylistMeta, sourceStatusBucket } from '@iptvnator/shared/interfaces'; import type { PlaylistType } from '../add-playlist-menu/playlist-type'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { SourceRowComponent } from '../source-row/source-row.component'; +import { EmptyStateComponent } from './empty-state/empty-state.component'; import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; -import { PlaylistItemComponent } from './playlist-item/playlist-item.component'; - -type PlaylistBusyOperation = { - current?: number; - operation: string; - operationId?: string; - phase?: string; - status: DbOperationEvent['status']; - total?: number; -}; +import { SourceListRefreshService } from './source-list-refresh.service'; +/** + * The Sources page list: every source that passes the side list's type and + * status filters and the header search, in the chosen sort order, as + * `app-source-row`s. Sources being deleted (Undo pending) are left out. + */ @Component({ selector: 'app-recent-playlists', templateUrl: './recent-playlists.component.html', styleUrls: ['./recent-playlists.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [SourceListRefreshService], imports: [ DragDropModule, EmptyStateComponent, - MatInputModule, - PlaylistItemComponent, + MatButton, + MatIcon, + SourceRowComponent, + TranslatePipe, ], }) export class RecentPlaylistsComponent { - private readonly databaseService = inject(DatabaseService); private readonly dialog = inject(MatDialog); - private readonly dialogService = inject(DialogService); - private readonly dataService = inject(DataService); - private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly router = inject(Router); - private readonly snackBar = inject(MatSnackBar); private readonly sortService = inject(SortService); private readonly store = inject(Store); private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly translate = inject(TranslateService); private readonly playlistContext = inject(PlaylistContextFacade); - private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); - private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService); + private readonly statusService = inject(SourceStatusService); + private readonly viewState = inject(SourcesViewStateService); + readonly removal = inject(SourceRemovalService); + readonly refresh = inject(SourceListRefreshService); - readonly sidebarMode = input(false); readonly searchQueryInput = input(''); readonly playlistClicked = output(); readonly addPlaylistClicked = output(); @@ -105,115 +84,77 @@ export class RecentPlaylistsComponent { return this.runtime.isElectron; } - get supportsPlaylistRefresh(): boolean { - return this.runtime.supportsPlaylistRefresh; - } - - get supportsXtreamSqliteDataSource(): boolean { - return this.runtime.supportsXtreamSqliteDataSource; - } - readonly allPlaylistsLoaded = this.store.selectSignal( selectPlaylistsLoadingFlag ); + private readonly allPlaylists = this.store.selectSignal( + selectAllPlaylistsMeta + ); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; - - private readonly currentSortOptions = toSignal( - this.sortService.getSortOptions(), - { requireSync: true } + private readonly sortOptions = toSignal(this.sortService.getSortOptions(), { + requireSync: true, + }); + readonly isCustomSort = computed( + () => this.sortOptions().by === SortBy.CUSTOM ); - - readonly isCustomSortActive = () => - this.currentSortOptions().by === SortBy.CUSTOM; - - readonly searchQuery = new BehaviorSubject(''); - readonly pendingDeletionIds = signal>(new Set()); - readonly pendingRefreshIds = signal>(new Set()); - readonly busyOperations = signal>( - new Map() - ); - - constructor() { - // Update searchQuery when input changes - effect(() => { - this.searchQuery.next(this.searchQueryInput()); - }); - } - - readonly playlistsData$ = combineLatest([ - this.store.select(selectAllPlaylistsMeta), - this.searchQuery, - this.store.select(selectActiveTypeFilters), - this.sortService.getSortOptions(), - ]).pipe( - map(([playlists, searchQuery, filters, sortOptions]) => { - const filteredPlaylists = playlists - .filter((item) => { - const isStalkerFilter = - item.macAddress && filters.includes('stalker'); - const isXtreamFilter = - item.username && - item.password && - item.serverUrl && - filters.includes('xtream'); - const isM3uFilter = - !item.username && - !item.password && - !item.serverUrl && - !item.macAddress && - filters.includes('m3u'); - - return ( - (isStalkerFilter && filters.includes('stalker')) || - (isXtreamFilter && filters.includes('xtream')) || - (isM3uFilter && filters.includes('m3u')) - ); - }) - .filter((item) => - foldSearchText(item.title || '').includes( - foldSearchText(searchQuery) - ) - ); - - // Apply sorting using the SortService - const sortedPlaylists = this.sortService.sortPlaylists( - filteredPlaylists, - sortOptions - ); - - return { - playlists: sortedPlaylists, - totalCount: playlists.length, - }; - }) - ); - - readonly playlistsData = toSignal(this.playlistsData$, { - initialValue: { - playlists: [] as PlaylistMeta[], - totalCount: 0, - }, + readonly hasFilters = computed(() => { + const filter = this.viewState.filter(); + return filter.type !== 'all' || filter.status !== 'all'; }); - /** - * Opens the details dialog with the information about the provided playlist - * @param data selected playlist - */ - openInfoDialog(data: PlaylistMeta): void { - this.dialog.open(PlaylistInfoComponent, { - data, - }); + /** Every source except those whose delete is pending. */ + readonly playlists = computed(() => { + const pending = this.removal.pendingIds(); + return this.allPlaylists().filter( + (playlist) => !pending.has(playlist._id) + ); + }); + + readonly visiblePlaylists = computed(() => { + const bucketOf: SourceBucketOf = (playlist) => { + const status = this.statusService.statusOf(playlist); + return status ? sourceStatusBucket(status.reachability) : null; + }; + const options = this.sortOptions(); + const filter = this.viewState.filter(); + const filtered = filterSources( + this.playlists(), + this.statusService.supportsStatusFilter + ? filter + : { ...filter, status: 'all' }, + this.searchQueryInput(), + bucketOf + ); + const sorted = this.sortService.sortPlaylists(filtered, options); + return options.failingFirst && options.by !== SortBy.CUSTOM + ? orderFailingFirst(sorted, bucketOf) + : sorted; + }); + + resetFilters(): void { + this.viewState.reset(); } /** - * Drop event handler - applies the new sort order to the playlists array - * @param event drop event + * Applies a drag in custom order to the whole library: the shown rows + * trade places among the slots they occupy, and sources a filter hides + * keep theirs, so every position stays unique. */ - drop(event: CdkDragDrop, playlists: PlaylistMeta[]): void { - moveItemInArray(playlists, event.previousIndex, event.currentIndex); + drop(event: CdkDragDrop): void { + const visible = [...this.visiblePlaylists()]; + moveItemInArray(visible, event.previousIndex, event.currentIndex); + const visibleIds = new Set(visible.map((item) => item._id)); + const ordered = this.sortService.sortPlaylists( + this.allPlaylists(), + this.sortOptions() + ); + let shown = 0; + const merged = ordered.map((item) => + visibleIds.has(item._id) ? visible[shown++] : item + ); this.store.dispatch( PlaylistActions.updatePlaylistPositions({ - positionUpdates: playlists.map((item, index) => ({ + positionUpdates: merged.map((item, index) => ({ id: item._id, changes: { position: index }, })), @@ -221,475 +162,73 @@ export class RecentPlaylistsComponent { ); } - onAddPlaylist(type?: PlaylistType) { + open(playlist: PlaylistMeta): void { + if (playlist.serverUrl) { + void this.router.navigate(['/workspace', 'xtreams', playlist._id]); + } else if (playlist.macAddress) { + void this.router.navigate(['/workspace', 'stalker', playlist._id]); + } else { + void this.router.navigate([ + '/workspace', + 'playlists', + playlist._id, + ]); + this.playlistClicked.emit(playlist._id); + } + } + + edit(playlist: PlaylistMeta): void { + this.dialog.open(PlaylistInfoComponent, { data: playlist }); + } + + /** Deletes with Undo; the active source hands over to its neighbour. */ + remove(playlist: PlaylistMeta): void { + if (this.refresh.isRefreshing(playlist._id)) { + return; + } + const rows = this.visiblePlaylists(); + const index = rows.findIndex((item) => item._id === playlist._id); + const next = rows[index + 1] ?? rows[index - 1] ?? null; + this.removal.remove(playlist, next); + } + + busyMessage(item: PlaylistMeta): string { + return ( + this.removal.progress.message(item._id) || + this.refresh.message(item) + ); + } + + busyProgress(item: PlaylistMeta): number | null { + return this.removal.progress.isDeleting(item._id) + ? this.removal.progress.progress(item._id) + : this.refresh.progress(item._id); + } + + canCancelBusy(item: PlaylistMeta): boolean { + return ( + this.removal.progress.canCancel(item._id) || + this.refresh.canCancel(item) + ); + } + + cancelBusy(item: PlaylistMeta): void { + if (this.removal.progress.isDeleting(item._id)) { + void this.removal.progress.cancel(item._id); + } else { + void this.refresh.cancel(item); + } + } + + onAddPlaylist(type?: PlaylistType): void { this.addPlaylistClicked.emit(type); } - getPlaylist(playlistMeta: PlaylistMeta): void { - if (playlistMeta.serverUrl) { - this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]); - } else if (playlistMeta.macAddress) { - this.router.navigate(['/workspace', 'stalker', playlistMeta._id]); - } else { - this.router.navigate(['/workspace', 'playlists', playlistMeta._id]); - this.playlistClicked.emit(playlistMeta._id); - } - } - - /** - * Triggers on remove click - * @param playlistId playlist id to remove - */ - removeClicked(item: PlaylistMeta): void { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.dialogService.openConfirmDialog({ - title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'), - message: this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' - ), - confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'), - tone: 'destructive', - onConfirm: () => { - this.removePlaylist(item); - }, - }); - } - - /** - * Removes the provided playlist from the database - * @param playlistId playlist id to remove - */ - async removePlaylist(item: PlaylistMeta) { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.setPendingDeletion(item._id, true); - try { - const deleted = await this.playlistDeleteAction.deletePlaylist( - item, - { - onEvent: (event) => - this.updateBusyOperation(item._id, event), - } - ); - if (deleted) { - if (item.serverUrl) { - measureRendererPerformancePhase( - RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW, - () => - this.store.dispatch( - PlaylistActions.playlistRemovalCommitted({ - playlistId: item._id, - }) - ), - () => ({ items: 1 }) - ); - } else { - this.store.dispatch( - PlaylistActions.playlistRemovalCommitted({ - playlistId: item._id, - }) - ); - } - this.snackBar.open( - this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS' - ), - undefined, - { - duration: 2000, - } - ); - } - } finally { - this.clearBusyOperation(item._id); - this.setPendingDeletion(item._id, false); - } - } - - /** - * Sends an IPC event with the playlist details to the main process to trigger the refresh operation - * @param item playlist to update - */ - refreshPlaylist(item: PlaylistMeta) { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - if (item.serverUrl && this.supportsXtreamSqliteDataSource) { - // For Xtream playlists, delete and re-import - this.refreshXtreamPlaylist(item); - } else if ( - this.supportsPlaylistRefresh && - (item.url || item.filePath) - ) { - void this.refreshM3uPlaylist(item); - } else if (item.url) { - // Browser/PWA URL refresh uses the PWA data service path. - this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { - id: item._id, - title: item.title, - url: item.url, - ...(item.userAgent ? { userAgent: item.userAgent } : {}), - }); - } - } - - /** - * Refresh Xtream playlist by deleting all data and re-importing from remote - * @param item Xtream playlist to refresh - */ - refreshXtreamPlaylist(item: PlaylistMeta): void { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.xtreamRefreshFlow.confirmAndRefresh( - item, - this.xtreamRefreshReporter - ); - } - - /** - * Reports the shared destructive refresh into this page's per-row busy - * indicators, which are keyed by playlist and shared with deletion — hence - * the same `updateBusyOperation`/`clearBusyOperation` pair used there. - */ - private readonly xtreamRefreshReporter: XtreamRefreshProgressReporter = { - isBusy: (playlistId) => - this.isDeletePending(playlistId) || - this.isRefreshPending(playlistId), - begin: ({ playlistId }) => this.setPendingRefresh(playlistId, true), - report: ({ playlistId }, event) => - this.updateBusyOperation(playlistId, event), - end: ({ playlistId }) => { - this.clearBusyOperation(playlistId); - this.setPendingRefresh(playlistId, false); - }, - }; - - private async refreshM3uPlaylist(item: PlaylistMeta): Promise { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.setPendingRefresh(item._id, true); - const operationId = - this.databaseService.createOperationId('playlist-refresh'); - - try { - const refreshedPlaylist = - await this.playlistRefreshService.refreshPlaylist( - { - operationId, - playlistId: item._id, - title: item.title, - url: item.url, - ...(item.userAgent - ? { userAgent: item.userAgent } - : {}), - filePath: item.filePath, - }, - { - onEvent: (event) => - this.updateBusyOperation( - item._id, - this.toPlaylistRefreshBusyEvent(event) - ), - } - ); - - this.updateBusyOperation(item._id, { - operationId, - operation: 'playlist-refresh', - playlistId: item._id, - phase: 'saving', - status: 'progress', - }); - - this.store.dispatch( - PlaylistActions.updatePlaylist({ - playlist: { - ...refreshedPlaylist, - _id: item._id, - }, - playlistId: item._id, - refreshEpg: true, - operationId, - }) - ); - - this.clearBusyOperation(item._id); - this.snackBar.open( - this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' - ), - undefined, - { duration: 2000 } - ); - } catch (error) { - if (!isDbAbortError(error)) { - console.error('Error refreshing playlist:', error); - this.snackBar.open( - this.getPlaylistRefreshErrorMessage(error, item), - this.translate.instant('CLOSE'), - { duration: 5000 } - ); - } - } finally { - this.clearBusyOperation(item._id); - this.setPendingRefresh(item._id, false); - } - } - isDeletePending(playlistId: string): boolean { - return this.pendingDeletionIds().has(playlistId); + return this.removal.isPending(playlistId); } isRefreshPending(playlistId: string): boolean { - return this.pendingRefreshIds().has(playlistId); - } - - getBusyMessage(item: PlaylistMeta): string { - const operation = this.busyOperations().get(item._id); - if (!operation) { - return ''; - } - - switch (operation.operation) { - case 'delete-playlist': - return this.translateDeletePhase(operation.phase); - case 'delete-xtream-content': - return this.translateRefreshPhase(operation.phase); - case 'playlist-refresh': - return this.translatePlaylistRefreshPhase(operation.phase); - default: - return ''; - } - } - - getBusyProgress(playlistId: string): number | null { - const operation = this.busyOperations().get(playlistId); - if ( - !operation || - operation.current == null || - operation.total == null || - operation.total <= 0 - ) { - return null; - } - - return Math.min( - 100, - Math.round((operation.current / operation.total) * 100) - ); - } - - canCancelBusyOperation(item: PlaylistMeta): boolean { - const operation = this.busyOperations().get(item._id); - if (!operation?.operationId) { - return false; - } - - if (operation.operation === 'playlist-refresh') { - return true; - } - - return Boolean(item.serverUrl); - } - - async cancelBusyOperation(item: PlaylistMeta): Promise { - const operation = this.busyOperations().get(item._id); - if (!operation?.operationId) { - return; - } - - if (operation.operation === 'playlist-refresh') { - await this.playlistRefreshService.cancelRefresh( - operation.operationId - ); - return; - } - - await this.databaseService.cancelOperation(operation.operationId); - } - - private setPendingDeletion(playlistId: string, isPending: boolean): void { - this.pendingDeletionIds.update((current) => { - const next = new Set(current); - if (isPending) { - next.add(playlistId); - } else { - next.delete(playlistId); - } - return next; - }); - } - - private setPendingRefresh(playlistId: string, isPending: boolean): void { - this.pendingRefreshIds.update((current) => { - const next = new Set(current); - if (isPending) { - next.add(playlistId); - } else { - next.delete(playlistId); - } - return next; - }); - } - - private updateBusyOperation( - playlistId: string, - event: DbOperationEvent - ): void { - this.busyOperations.update((current) => { - const next = new Map(current); - - if ( - event.status === 'completed' || - event.status === 'cancelled' || - event.status === 'error' - ) { - next.delete(playlistId); - return next; - } - - next.set(playlistId, { - operation: event.operation, - operationId: event.operationId, - phase: event.phase, - current: event.current, - total: event.total, - status: event.status, - }); - return next; - }); - } - - private clearBusyOperation(playlistId: string): void { - this.busyOperations.update((current) => { - const next = new Map(current); - next.delete(playlistId); - return next; - }); - } - - private toPlaylistRefreshBusyEvent( - event: PlaylistRefreshEvent - ): DbOperationEvent { - return { - operationId: event.operationId, - operation: 'playlist-refresh', - playlistId: event.playlistId, - phase: event.phase, - status: event.status, - error: event.error, - }; - } - - private translateDeletePhase(phase?: string): string { - switch (phase) { - case 'deleting-favorites': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES' - ); - case 'deleting-recently-viewed': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT' - ); - case 'deleting-playback-positions': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS' - ); - case 'deleting-downloads': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS' - ); - case 'deleting-content': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT' - ); - case 'deleting-categories': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES' - ); - case 'deleting-playlist': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST' - ); - default: - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS' - ); - } - } - - private translateRefreshPhase(phase?: string): string { - switch (phase) { - case 'collecting-user-data': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA' - ); - case 'deleting-content': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT' - ); - case 'deleting-categories': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES' - ); - default: - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS' - ); - } - } - - private translatePlaylistRefreshPhase(phase?: string): string { - switch (phase) { - case 'fetching': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_FETCHING' - ); - case 'reading-file': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_READING_FILE' - ); - case 'parsing': - return this.translate.instant('HOME.PLAYLISTS.REFRESH_PARSING'); - case 'saving': - return this.translate.instant('HOME.PLAYLISTS.REFRESH_SAVING'); - default: - return this.translate.instant('HOME.PLAYLISTS.REFRESH'); - } - } - - private getPlaylistRefreshErrorMessage( - error: unknown, - item: PlaylistMeta - ): string { - if (item.filePath) { - const message = String( - (error as { message?: string })?.message ?? error - ); - - if (/(ENOENT|no such file or directory|not found)/i.test(message)) { - return this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND' - ); - } - - if (/(EACCES|EPERM|permission denied)/i.test(message)) { - return this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR' - ); - } - } - - return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); + return this.refresh.isRefreshing(playlistId); } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts new file mode 100644 index 000000000..ca5a0f4ee --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts @@ -0,0 +1,590 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + PlaylistActions, + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; +import { of } from 'rxjs'; +import { DialogService } from '@iptvnator/ui/components'; +import { + DatabaseService, + DataService, + DbOperationEvent, + PlaybackPositionService, + PlaylistDeleteActionService, + PlaylistRefreshService, + RuntimeCapabilitiesService, + SortBy, + SortOrder, + SortService, +} from '@iptvnator/services'; +import { + CONNECTIVITY_GUARD_RESET, + PLAYLIST_UPDATE, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { SourceListRefreshService } from './source-list-refresh.service'; + +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createAbortError(): Error { + const error = new Error('Cancelled'); + error.name = 'AbortError'; + return error; +} + +function createPlaylistMeta( + overrides: Partial = {} +): PlaylistMeta { + return { + _id: 'playlist-1', + title: 'Xtream Playlist', + count: 0, + importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), + autoRefresh: false, + serverUrl: 'http://localhost:8080', + username: 'demo', + password: 'secret', + ...overrides, + } as PlaylistMeta; +} + +describe('SourceListRefreshService', () => { + let component: SourceListRefreshService; + let databaseService: { + cancelOperation: jest.Mock; + createOperationId: jest.Mock; + deletePlaylist: jest.Mock; + deleteXtreamPlaylistContent: jest.Mock; + updateXtreamPlaylistDetails: jest.Mock; + }; + let dialogService: { + openConfirmDialog: jest.Mock; + }; + let dataService: { + sendIpcEvent: jest.Mock; + }; + let playbackPositionService: { + getAllPlaybackPositions: jest.Mock; + }; + let playlistDeleteAction: { + deletePlaylist: jest.Mock; + }; + let playlistRefreshService: { + cancelRefresh: jest.Mock; + refreshPlaylist: jest.Mock; + }; + let runtime: { + isElectron: boolean; + supportsPlaylistRefresh: boolean; + supportsXtreamSqliteDataSource: boolean; + }; + let router: { + navigate: jest.Mock; + }; + let snackBar: { + open: jest.Mock; + }; + let store: MockStore; + + beforeEach(async () => { + databaseService = { + cancelOperation: jest.fn().mockResolvedValue(true), + createOperationId: jest.fn((prefix: string) => `${prefix}-op`), + deletePlaylist: jest.fn(), + deleteXtreamPlaylistContent: jest.fn(), + updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined), + }; + dialogService = { + openConfirmDialog: jest.fn(), + }; + dataService = { + sendIpcEvent: jest.fn(), + }; + playbackPositionService = { + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + }; + playlistDeleteAction = { + deletePlaylist: jest.fn().mockResolvedValue(true), + }; + playlistRefreshService = { + cancelRefresh: jest.fn().mockResolvedValue(undefined), + refreshPlaylist: jest.fn().mockResolvedValue({ + id: 'playlist-1', + items: [], + }), + }; + runtime = { + isElectron: true, + supportsPlaylistRefresh: true, + supportsXtreamSqliteDataSource: true, + }; + router = { + navigate: jest.fn(), + }; + snackBar = { + open: jest.fn(), + }; + await TestBed.configureTestingModule({ + providers: [ + SourceListRefreshService, + provideMockStore({ + selectors: [ + { selector: selectPlaylistsLoadingFlag, value: true }, + { + selector: selectAllPlaylistsMeta, + value: [], + }, + ], + }), + { + provide: DatabaseService, + useValue: databaseService, + }, + { + provide: DialogService, + useValue: dialogService, + }, + { + provide: DataService, + useValue: dataService, + }, + { + provide: PlaybackPositionService, + useValue: playbackPositionService, + }, + { + provide: PlaylistDeleteActionService, + useValue: playlistDeleteAction, + }, + { + provide: PlaylistRefreshService, + useValue: playlistRefreshService, + }, + { + provide: RuntimeCapabilitiesService, + useValue: runtime, + }, + { + provide: PlaylistContextFacade, + useValue: { + resolvedPlaylistId: signal(null), + }, + }, + { + provide: MatDialog, + useValue: { + open: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: snackBar, + }, + { + provide: Router, + useValue: router, + }, + { + provide: SortService, + useValue: { + getSortOptions: jest.fn(() => + of({ + by: SortBy.DATE_ADDED, + order: SortOrder.DESC, + }) + ), + sortPlaylists: jest.fn( + (playlists: PlaylistMeta[]) => playlists + ), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + ], + }).compileComponents(); + + component = TestBed.inject(SourceListRefreshService); + store = TestBed.inject(MockStore); + jest.spyOn(store, 'dispatch'); + }); + + afterEach(() => { + setPerformanceHook(null); + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('tracks Xtream refresh progress and clears the busy row after abort', async () => { + const item = createPlaylistMeta({ _id: 'playlist-refresh-1' }); + const refresh = createDeferred<{ + success: boolean; + favorites: Array<{ xtreamId: number; contentType: string }>; + recentlyViewed: Array<{ + xtreamId: number; + contentType: string; + viewedAt: string; + }>; + hiddenCategories: Array<{ + xtreamId: number; + categoryType: string; + }>; + }>(); + let confirmPromise: Promise | undefined; + // The shared flow awaits the connectivity-guard reset before it starts + // deleting, so the delete is several ticks away and counting them is + // fragile. The mock fires onEvent synchronously and the component + // applies it synchronously, so resolving this deferred inside the mock + // is a deterministic "the busy row has been updated" signal. + const workerEventDelivered = createDeferred(); + + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + databaseService.deleteXtreamPlaylistContent.mockImplementation( + ( + _playlistId: string, + options?: { + onEvent?: (event: DbOperationEvent) => void; + operationId?: string; + } + ) => { + options?.onEvent?.({ + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-op', + status: 'progress', + phase: 'collecting-user-data', + current: 1, + total: 4, + }); + workerEventDelivered.resolve(); + + return refresh.promise; + } + ); + + component.refresh(item); + await workerEventDelivered.promise; + + expect(component.isRefreshing(item._id)).toBe(true); + expect(component.message(item)).toBe( + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA' + ); + expect(component.progress(item._id)).toBe(25); + expect(component.canCancel(item)).toBe(true); + + await component.cancel(item); + expect(databaseService.cancelOperation).toHaveBeenCalledWith( + 'xtream-refresh-op' + ); + + refresh.reject(createAbortError()); + await confirmPromise; + + expect(component.isRefreshing(item._id)).toBe(false); + expect(component.progress(item._id)).toBeNull(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => { + const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); + let confirmPromise: Promise | undefined; + const executionOrder: string[] = []; + const performanceEvents: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => { + performanceEvents.push(event); + executionOrder.push(event.boundary); + }); + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(1712145600000); + const originalSetItem = Storage.prototype.setItem; + const setItemSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function ( + this: Storage, + key: string, + value: string + ) { + executionOrder.push('setItem'); + return originalSetItem.call(this, key, value); + }); + + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + (store.dispatch as jest.Mock).mockImplementation((action: unknown) => { + executionOrder.push('dispatch'); + return action; + }); + router.navigate.mockImplementation((commands: unknown[]) => { + executionOrder.push('navigate'); + return Promise.resolve(Boolean(commands)); + }); + databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ + success: true, + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ + { + xtreamId: 303, + contentType: 'series', + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + }); + + component.refresh(item); + await confirmPromise; + + expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + width: '400px', + }) + ); + expect( + databaseService.updateXtreamPlaylistDetails + ).toHaveBeenCalledWith({ + id: item._id, + updateDate: 1712145600000, + }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate: 1712145600000 }, + }) + ); + expect(setItemSpy).toHaveBeenCalledWith( + `xtream-restore-${item._id}`, + expect.any(String) + ); + const persistedRestoreState = JSON.parse( + setItemSpy.mock.calls.find( + ([key]) => key === `xtream-restore-${item._id}` + )?.[1] ?? 'null' + ); + expect(persistedRestoreState).toEqual({ + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ + { + xtreamId: 303, + contentType: 'series', + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + playbackPositions: [], + }); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + item._id, + ]); + expect(executionOrder).toEqual([ + 'setItem', + 'start', + 'dispatch', + 'end', + 'navigate', + ]); + expect(performanceEvents).toHaveLength(2); + expect(performanceEvents[0]?.phaseId).toBe( + performanceEvents[1]?.phaseId + ); + expect( + performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + metadata, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + metadata: { items: 1 }, + outcome: undefined, + phase: 'store.xtream-refresh-meta', + }, + { + boundary: 'end', + metadata: { items: 1 }, + outcome: 'success', + phase: 'store.xtream-refresh-meta', + }, + ]); + + setItemSpy.mockRestore(); + dateNowSpy.mockRestore(); + }); + + it('uses the legacy IPC refresh flow for non-Xtream playlists', () => { + runtime.supportsPlaylistRefresh = false; + const item = createPlaylistMeta({ + _id: 'playlist-m3u-1', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: undefined, + url: 'https://example.com/test.m3u', + userAgent: 'IPTVnator-Test/1.0', + }); + + component.refresh(item); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url: item.url, + userAgent: item.userAgent, + }); + }); + + it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => { + runtime.supportsPlaylistRefresh = false; + const item = createPlaylistMeta({ + _id: 'playlist-m3u-1', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/test.m3u', + }); + + component.refresh(item); + + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); + }); + + it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => { + runtime.supportsPlaylistRefresh = false; + const lateComponent = TestBed.inject(SourceListRefreshService); + runtime.supportsPlaylistRefresh = true; + const item = createPlaylistMeta({ + _id: 'playlist-m3u-2', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/test.m3u', + userAgent: 'IPTVnator-Test/1.0', + }); + + lateComponent.refresh(item); + + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( + { + operationId: 'playlist-refresh-op', + playlistId: item._id, + title: item.title, + url: item.url, + userAgent: item.userAgent, + filePath: item.filePath, + }, + { + onEvent: expect.any(Function), + } + ); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + + await Promise.resolve(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylist({ + playlist: expect.objectContaining({ + _id: item._id, + }), + playlistId: item._id, + refreshEpg: true, + operationId: 'playlist-refresh-op', + }) + ); + }); + + it('clears the connectivity guard before deleting the cached Xtream catalog', async () => { + // Second, independent implementation of the destructive refresh (the + // Workspace sources page). Same consequence as the shared action: the + // catalog is already gone by the time an open guard fast-fails the + // re-import bootstrap. + const item = { + _id: 'xtream-guard', + title: 'Guarded Xtream', + serverUrl: 'http://panel.example:8080', + } as PlaylistMeta; + const order: string[] = []; + let confirmPromise: Promise | undefined; + + dataService.sendIpcEvent.mockImplementation((event: string) => { + order.push(`ipc:${event}`); + return Promise.resolve({ success: true }); + }); + databaseService.deleteXtreamPlaylistContent.mockImplementation(() => { + order.push('deleteXtreamPlaylistContent'); + return Promise.resolve({ + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + sourcePins: [], + }); + }); + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + component.refresh(item); + await confirmPromise; + + expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`); + expect(order).toContain('deleteXtreamPlaylistContent'); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + CONNECTIVITY_GUARD_RESET, + { url: item.serverUrl } + ); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts new file mode 100644 index 000000000..514be6a04 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts @@ -0,0 +1,293 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { + DatabaseService, + DataService, + DbOperationEvent, + isDbAbortError, + PlaylistRefreshService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { + PLAYLIST_UPDATE, + PlaylistMeta, + PlaylistRefreshEvent, +} from '@iptvnator/shared/interfaces'; +import { + XtreamRefreshFlowService, + type XtreamRefreshProgressReporter, +} from '../xtream-refresh-flow.service'; + +interface BusyOperation { + current?: number; + operation: string; + operationId?: string; + phase?: string; + status: DbOperationEvent['status']; + total?: number; +} + +const REFRESH_PHASE_KEYS: Record = { + fetching: 'HOME.PLAYLISTS.REFRESH_FETCHING', + 'reading-file': 'HOME.PLAYLISTS.REFRESH_READING_FILE', + parsing: 'HOME.PLAYLISTS.REFRESH_PARSING', + saving: 'HOME.PLAYLISTS.REFRESH_SAVING', +}; +const XTREAM_PHASE_KEYS: Record = { + 'collecting-user-data': + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA', + 'deleting-content': 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT', + 'deleting-categories': + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES', +}; + +/** + * The Sources page's per-row refresh: which rows refresh, their phase and + * progress, and cancelling. M3U refreshes in place, Xtream re-imports + * through the shared confirmation flow, and the browser build sends the + * URL refresh event. Provided by the list component. + */ +@Injectable() +export class SourceListRefreshService { + private readonly databaseService = inject(DatabaseService); + private readonly dataService = inject(DataService); + private readonly playlistRefreshService = inject(PlaylistRefreshService); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly snackBar = inject(MatSnackBar); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); + private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService); + + private readonly refreshingIds = signal>(new Set()); + private readonly operations = signal>( + new Map() + ); + + isRefreshing(id: string): boolean { + return this.refreshingIds().has(id); + } + + canRefresh(item: PlaylistMeta): boolean { + if (item.serverUrl) { + return this.runtime.supportsXtreamSqliteDataSource; + } + return ( + !!item.url || + (!!item.filePath && this.runtime.supportsPlaylistRefresh) + ); + } + + refresh(item: PlaylistMeta): void { + if (this.isRefreshing(item._id)) { + return; + } + if (item.serverUrl && this.runtime.supportsXtreamSqliteDataSource) { + this.xtreamRefreshFlow.confirmAndRefresh(item, this.xtreamReporter); + } else if ( + this.runtime.supportsPlaylistRefresh && + (item.url || item.filePath) + ) { + void this.refreshM3u(item); + } else if (item.url) { + this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url: item.url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), + }); + } + } + + message(item: PlaylistMeta): string { + const operation = this.operations().get(item._id); + if (!operation) { + return ''; + } + if (operation.operation === 'playlist-refresh') { + return this.translate.instant( + REFRESH_PHASE_KEYS[operation.phase ?? ''] ?? + 'HOME.PLAYLISTS.REFRESH' + ); + } + if (operation.operation === 'delete-xtream-content') { + return this.translate.instant( + XTREAM_PHASE_KEYS[operation.phase ?? ''] ?? + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS' + ); + } + return ''; + } + + progress(id: string): number | null { + const operation = this.operations().get(id); + if ( + !operation || + operation.current == null || + operation.total == null || + operation.total <= 0 + ) { + return null; + } + return Math.min( + 100, + Math.round((operation.current / operation.total) * 100) + ); + } + + canCancel(item: PlaylistMeta): boolean { + const operation = this.operations().get(item._id); + if (!operation?.operationId) { + return false; + } + return ( + operation.operation === 'playlist-refresh' || + Boolean(item.serverUrl) + ); + } + + async cancel(item: PlaylistMeta): Promise { + const operation = this.operations().get(item._id); + if (!operation?.operationId) { + return; + } + if (operation.operation === 'playlist-refresh') { + await this.playlistRefreshService.cancelRefresh( + operation.operationId + ); + return; + } + await this.databaseService.cancelOperation(operation.operationId); + } + + /** The shared Xtream re-import reports into this list's row state. */ + private readonly xtreamReporter: XtreamRefreshProgressReporter = { + isBusy: (playlistId) => this.isRefreshing(playlistId), + begin: ({ playlistId }) => this.setRefreshing(playlistId, true), + report: ({ playlistId }, event) => this.track(playlistId, event), + end: ({ playlistId }) => { + this.clear(playlistId); + this.setRefreshing(playlistId, false); + }, + }; + + private async refreshM3u(item: PlaylistMeta): Promise { + this.setRefreshing(item._id, true); + const operationId = + this.databaseService.createOperationId('playlist-refresh'); + try { + const refreshed = await this.playlistRefreshService.refreshPlaylist( + { + operationId, + playlistId: item._id, + title: item.title, + url: item.url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), + filePath: item.filePath, + }, + { + onEvent: (event) => + this.track(item._id, this.toBusyEvent(event)), + } + ); + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist: { ...refreshed, _id: item._id }, + playlistId: item._id, + refreshEpg: true, + operationId, + }) + ); + this.snackBar.open( + this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' + ), + undefined, + { duration: 2000 } + ); + } catch (error) { + if (!isDbAbortError(error)) { + console.error('Error refreshing playlist:', error); + this.snackBar.open( + this.refreshErrorMessage(error, item), + this.translate.instant('CLOSE'), + { duration: 5000 } + ); + } + } finally { + this.clear(item._id); + this.setRefreshing(item._id, false); + } + } + + private refreshErrorMessage(error: unknown, item: PlaylistMeta): string { + if (item.filePath) { + const message = String( + (error as { message?: string })?.message ?? error + ); + if (/(ENOENT|no such file or directory|not found)/i.test(message)) { + return this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND' + ); + } + if (/(EACCES|EPERM|permission denied)/i.test(message)) { + return this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR' + ); + } + } + return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); + } + + private toBusyEvent(event: PlaylistRefreshEvent): DbOperationEvent { + return { + operationId: event.operationId, + operation: 'playlist-refresh', + playlistId: event.playlistId, + phase: event.phase, + status: event.status, + error: event.error, + }; + } + + private track(id: string, event: DbOperationEvent): void { + this.operations.update((current) => { + const next = new Map(current); + if (['completed', 'cancelled', 'error'].includes(event.status)) { + next.delete(id); + } else { + next.set(id, { + operation: event.operation, + operationId: event.operationId, + phase: event.phase, + current: event.current, + total: event.total, + status: event.status, + }); + } + return next; + }); + } + + private clear(id: string): void { + this.operations.update((current) => { + const next = new Map(current); + next.delete(id); + return next; + }); + } + + private setRefreshing(id: string, refreshing: boolean): void { + this.refreshingIds.update((current) => { + const next = new Set(current); + if (refreshing) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts new file mode 100644 index 000000000..802a2a249 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts @@ -0,0 +1,105 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { DatabaseService, DbOperationEvent } from '@iptvnator/services'; + +interface DeleteOperation { + operationId?: string; + phase?: string; + current?: number; + total?: number; +} + +const PHASE_KEYS: Record = { + 'deleting-favorites': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES', + 'deleting-recently-viewed': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT', + 'deleting-playback-positions': + 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS', + 'deleting-downloads': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS', + 'deleting-content': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT', + 'deleting-categories': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES', + 'deleting-playlist': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST', +}; + +/** + * Progress of a committed delete that reports it: an Xtream catalog in the + * desktop database can take a while, so its row comes back as "Removing…" + * with progress and a Cancel action that keeps the source. + */ +@Injectable({ providedIn: 'root' }) +export class SourceDeleteProgressService { + private readonly database = inject(DatabaseService); + private readonly translate = inject(TranslateService); + private readonly operations = signal>( + new Map() + ); + private readonly cancelled = new Set(); + + isDeleting(id: string): boolean { + return this.operations().has(id); + } + + message(id: string): string { + const operation = this.operations().get(id); + if (!operation) { + return ''; + } + return this.translate.instant( + PHASE_KEYS[operation.phase ?? ''] ?? + 'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS' + ); + } + + progress(id: string): number | null { + const operation = this.operations().get(id); + if (!operation?.total || operation.current == null) { + return null; + } + return Math.min( + 100, + Math.round((operation.current / operation.total) * 100) + ); + } + + canCancel(id: string): boolean { + return !!this.operations().get(id)?.operationId; + } + + async cancel(id: string): Promise { + const operationId = this.operations().get(id)?.operationId; + if (operationId) { + this.cancelled.add(id); + await this.database.cancelOperation(operationId); + } + } + + /** True once, after a cancelled delete: the caller skips its error. */ + takeCancelled(id: string): boolean { + return this.cancelled.delete(id); + } + + track(id: string, event: DbOperationEvent): void { + if (['completed', 'cancelled', 'error'].includes(event.status)) { + this.clear(id); + return; + } + this.operations.update((current) => + new Map(current).set(id, { + operationId: event.operationId, + phase: event.phase, + current: event.current, + total: event.total, + }) + ); + } + + clear(id: string): void { + if (!this.operations().has(id)) { + return; + } + this.operations.update((current) => { + const next = new Map(current); + next.delete(id); + return next; + }); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss new file mode 100644 index 000000000..7626aaa3a --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss @@ -0,0 +1,29 @@ +@use '@angular/material' as mat; + +.source-removal-toast__undo { + font-weight: 600; +} + +.source-removal-toast__close { + color: var(--app-body-color); +} + +// The toast frame (`panelClass: 'app-toast'`): bottom centre, one step +// above the page. The container renders in the overlay, so the rule is +// global; it ships with this component instead of the initial stylesheet. +::ng-deep .mat-mdc-snack-bar-container.app-toast { + @include mat.snack-bar-overrides( + ( + container-color: var(--app-widget-bg), + container-shape: 10px, + supporting-text-color: var(--app-heading-color), + supporting-text-size: 13px, + button-color: var(--app-selection-color), + ) + ); + + .mat-mdc-snackbar-surface { + border: 1px solid var(--app-line-strong); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts new file mode 100644 index 000000000..21c230170 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { + MAT_SNACK_BAR_DATA, + MatSnackBarAction, + MatSnackBarActions, + MatSnackBarLabel, + MatSnackBarRef, +} from '@angular/material/snack-bar'; + +export interface SourceRemovalToastData { + message: string; + undoLabel: string; + closeLabel: string; +} + +/** + * "Removed …" with Undo, and a close button that commits the delete at + * once instead of waiting out the toast. + */ +@Component({ + selector: 'app-source-removal-toast', + imports: [ + MatButton, + MatIcon, + MatIconButton, + MatSnackBarAction, + MatSnackBarActions, + MatSnackBarLabel, + ], + template: ` + {{ data.message }} + + + + + `, + styleUrl: './source-removal-toast.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SourceRemovalToastComponent { + readonly data = inject(MAT_SNACK_BAR_DATA); + readonly ref = + inject>(MatSnackBarRef); +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts new file mode 100644 index 000000000..322bf6b95 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts @@ -0,0 +1,273 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + DatabaseService, + PlaylistDeleteActionService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { SourceRemovalService } from './source-removal.service'; + +const playlist = (id: string): PlaylistMeta => ({ + _id: id, + title: id, + count: 0, + importDate: '', + autoRefresh: false, +}); + +describe('SourceRemovalService', () => { + let toasts: Array<{ + message: string; + action: Subject; + dismissed: Subject; + dismiss: jest.Mock; + }>; + let deletePlaylist: jest.Mock; + let cancelOperation: jest.Mock; + let dispatch: jest.Mock; + let resolvedPlaylistId: ReturnType>; + let routePlaylistId: ReturnType>; + let context: { + resolvedPlaylistId: typeof resolvedPlaylistId; + routePlaylistId: typeof routePlaylistId; + selectPlaylist: jest.Mock; + activatePlaylist: jest.Mock; + }; + let router: { url: string; navigate: jest.Mock; navigateByUrl: jest.Mock }; + let service: SourceRemovalService; + + const toast = (message: string) => { + const dismissed = new Subject(); + const entry: (typeof toasts)[number] = { + message, + action: new Subject(), + dismissed, + dismiss: jest.fn(() => dismissed.next()), + }; + toasts.push(entry); + return { + onAction: () => entry.action, + afterDismissed: () => entry.dismissed, + dismiss: entry.dismiss, + }; + }; + + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + beforeEach(() => { + toasts = []; + deletePlaylist = jest.fn().mockResolvedValue(true); + cancelOperation = jest.fn().mockResolvedValue(true); + dispatch = jest.fn(); + resolvedPlaylistId = signal(null); + routePlaylistId = signal(null); + context = { + resolvedPlaylistId, + routePlaylistId, + selectPlaylist: jest.fn((p: PlaylistMeta) => + resolvedPlaylistId.set(p._id) + ), + activatePlaylist: jest.fn(), + }; + router = { + url: '/workspace/sources', + navigate: jest.fn().mockResolvedValue(true), + navigateByUrl: jest.fn().mockResolvedValue(true), + }; + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + SourceRemovalService, + { + provide: MatSnackBar, + useValue: { + open: jest.fn((message: string) => toast(message)), + openFromComponent: jest.fn( + ( + _component: unknown, + config: { data: { message: string } } + ) => toast(config.data.message) + ), + }, + }, + { + provide: PlaylistDeleteActionService, + useValue: { deletePlaylist }, + }, + { provide: PlaylistContextFacade, useValue: context }, + { provide: Router, useValue: router }, + { provide: Store, useValue: { dispatch } }, + { provide: DatabaseService, useValue: { cancelOperation } }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource: true }, + }, + ], + }); + service = TestBed.inject(SourceRemovalService); + }); + + it('hides the source and deletes it only when the toast closes', async () => { + const a = playlist('a'); + service.remove(a); + expect(service.isPending('a')).toBe(true); + expect(deletePlaylist).not.toHaveBeenCalled(); + expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED'); + + toasts[0].dismissed.next(); + await flush(); + expect(deletePlaylist).toHaveBeenCalledWith(a, { + onEvent: expect.any(Function), + }); + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.playlistRemovalCommitted({ playlistId: 'a' }) + ); + expect(service.isPending('a')).toBe(false); + }); + + it('restores the source on Undo without deleting anything', async () => { + service.remove(playlist('a')); + toasts[0].action.next(); + toasts[0].dismissed.next(); + await flush(); + expect(service.isPending('a')).toBe(false); + expect(deletePlaylist).not.toHaveBeenCalled(); + }); + + it('commits an earlier removal when a second one starts', async () => { + service.remove(playlist('a')); + service.remove(playlist('b')); + await flush(); + expect(toasts[0].dismiss).toHaveBeenCalled(); + expect(deletePlaylist).toHaveBeenCalledTimes(1); + expect(service.isPending('b')).toBe(true); + }); + + it('switches away from the active source and back on Undo', () => { + resolvedPlaylistId.set('a'); + service.remove(playlist('a'), playlist('b')); + expect(context.selectPlaylist).toHaveBeenCalledWith(playlist('b')); + expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED_SWITCHED'); + + toasts[0].action.next(); + expect(context.activatePlaylist).toHaveBeenCalledWith('a'); + }); + + it('leaves a switch the user made after the delete alone on Undo', () => { + resolvedPlaylistId.set('a'); + service.remove(playlist('a'), playlist('b')); + resolvedPlaylistId.set('c'); + toasts[0].action.next(); + expect(context.activatePlaylist).not.toHaveBeenCalled(); + }); + + it('leaves the deleted source’s own page and returns on Undo', () => { + resolvedPlaylistId.set('a'); + routePlaylistId.set('a'); + router.url = '/workspace/xtreams/a/vod'; + service.remove(playlist('a')); + expect(router.navigate).toHaveBeenCalledWith(['/workspace', 'sources']); + + toasts[0].action.next(); + expect(router.navigateByUrl).toHaveBeenCalledWith( + '/workspace/xtreams/a/vod' + ); + }); + + it('shows a desktop Xtream row as removing as soon as the delete starts', async () => { + deletePlaylist.mockReturnValue(new Promise(() => undefined)); + service.remove({ ...playlist('x'), serverUrl: 'http://x.test' }); + expect(service.isPending('x')).toBe(true); + toasts[0].dismissed.next(); + await flush(); + expect(service.isPending('x')).toBe(false); + expect(service.progress.isDeleting('x')).toBe(true); + expect(service.progress.message('x')).toBe( + 'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS' + ); + }); + + it('times an Xtream row leaving the store for the performance journeys', async () => { + const events: RendererPerformancePhaseEvent[] = []; + const hook = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const target = globalThis as unknown as Record; + target[hook] = (event: RendererPerformancePhaseEvent) => + events.push(event); + try { + service.remove({ ...playlist('x'), serverUrl: 'http://x.test' }); + toasts[0].dismissed.next(); + await flush(); + } finally { + delete target[hook]; + } + expect(events.map(({ boundary, phase }) => [boundary, phase])).toEqual([ + ['start', 'store.xtream-delete-row'], + ['end', 'store.xtream-delete-row'], + ]); + }); + + it('shows a long delete on its row and keeps the source on Cancel', async () => { + let finish!: (deleted: boolean) => void; + deletePlaylist.mockImplementation( + ( + _playlist: PlaylistMeta, + options: { onEvent: (event: object) => void } + ) => { + options.onEvent({ + operation: 'delete-playlist', + operationId: 'op-1', + status: 'progress', + phase: 'deleting-content', + current: 1, + total: 4, + }); + return new Promise((resolve) => (finish = resolve)); + } + ); + service.remove(playlist('x')); + toasts[0].dismissed.next(); + await flush(); + + // The row is back, in its busy state. + expect(service.isPending('x')).toBe(false); + expect(service.progress.isDeleting('x')).toBe(true); + expect(service.progress.message('x')).toBe( + 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT' + ); + expect(service.progress.progress('x')).toBe(25); + + await service.progress.cancel('x'); + expect(cancelOperation).toHaveBeenCalledWith('op-1'); + finish(false); + await flush(); + expect(service.progress.isDeleting('x')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + // A cancel is not a failure. + expect(toasts).toHaveLength(1); + }); + + it('brings the source back and says so when the delete fails', async () => { + deletePlaylist.mockResolvedValue(false); + service.remove(playlist('a')); + toasts[0].dismissed.next(); + await flush(); + expect(dispatch).not.toHaveBeenCalled(); + expect(service.isPending('a')).toBe(false); + expect(toasts[1].message).toBe('WORKSPACE.SOURCES.REMOVE_FAILED'); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts new file mode 100644 index 000000000..6753766f1 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts @@ -0,0 +1,229 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { + PlaylistContextFacade, + sourceDisplayName, +} from '@iptvnator/playlist/shared/util'; +import { + PlaylistDeleteActionService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { SourceDeleteProgressService } from './source-delete-progress.service'; +import { SourceRemovalToastComponent } from './source-removal-toast.component'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; + +/** How long Undo stays available before the delete is committed. */ +export const SOURCE_REMOVAL_UNDO_MS = 4000; + +interface PendingRemoval { + playlist: PlaylistMeta; + snackBar: MatSnackBarRef; + /** The playlist was the active one; `next` replaced it, if any. */ + wasActive: boolean; + wasOnItsRoute: boolean; + previousUrl: string; + next: PlaylistMeta | null; + undone: boolean; +} + +/** + * Single-source delete with Undo, for the Sources page and the switcher. + * + * Deleting a source drops its favourites, history and playback progress, so + * nothing is deleted while Undo is offered: the source only leaves the + * lists (`pendingIds`), and the real delete runs when the toast closes + * without Undo. Quitting the app inside those four seconds keeps the + * source. One removal is pending at a time; a second delete commits the + * first. A delete that reports progress (a large Xtream catalog) brings its + * row back as "Removing…" with Cancel, which keeps the source. Bulk cleanup + * keeps its confirmation dialog instead. + */ +@Injectable({ providedIn: 'root' }) +export class SourceRemovalService { + private readonly deleteAction = inject(PlaylistDeleteActionService); + private readonly playlistContext = inject(PlaylistContextFacade); + private readonly router = inject(Router); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly snackBar = inject(MatSnackBar); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); + /** Progress of a long delete; its row comes back to show it. */ + readonly progress = inject(SourceDeleteProgressService); + + private readonly pendingState = signal>(new Set()); + /** Sources hidden from every list while Undo or the delete runs. */ + readonly pendingIds = this.pendingState.asReadonly(); + private pending: PendingRemoval | null = null; + + isPending(id: string): boolean { + return this.pendingState().has(id); + } + + /** + * @param next The source to switch to when `playlist` is the active one: + * the caller's next row, so the switch follows the list on screen. + */ + remove(playlist: PlaylistMeta, next: PlaylistMeta | null = null): void { + if (this.isPending(playlist._id)) { + return; + } + // The toast for an earlier removal closes here and commits it. + this.pending?.snackBar.dismiss(); + + const wasActive = + this.playlistContext.resolvedPlaylistId() === playlist._id; + const wasOnItsRoute = + this.playlistContext.routePlaylistId() === playlist._id; + const previousUrl = this.router.url; + const replacement = wasActive ? next : null; + this.setPending(playlist._id, true); + + if (replacement) { + this.playlistContext.selectPlaylist(replacement); + } else if (wasOnItsRoute) { + void this.router.navigate(['/workspace', 'sources']); + } + + const name = this.nameOf(playlist); + const message = replacement + ? this.translate.instant('WORKSPACE.SOURCES.REMOVED_SWITCHED', { + name, + next: this.nameOf(replacement), + }) + : this.translate.instant('WORKSPACE.SOURCES.REMOVED', { name }); + const snackBar = this.snackBar.openFromComponent( + SourceRemovalToastComponent, + { + data: { + message, + undoLabel: this.translate.instant( + 'PORTALS.MULTI_SOURCE.UNDO' + ), + closeLabel: this.translate.instant('CLOSE'), + }, + duration: SOURCE_REMOVAL_UNDO_MS, + panelClass: 'app-toast', + } + ); + const removal: PendingRemoval = { + playlist, + snackBar, + wasActive, + wasOnItsRoute, + previousUrl, + next: replacement, + undone: false, + }; + this.pending = removal; + + snackBar.onAction().subscribe(() => this.undo(removal)); + snackBar.afterDismissed().subscribe(() => { + if (this.pending === removal) { + this.pending = null; + } + if (!removal.undone) { + void this.commit(removal); + } + }); + } + + private undo(removal: PendingRemoval): void { + removal.undone = true; + this.setPending(removal.playlist._id, false); + if (!removal.wasActive) { + return; + } + // Only take the user back if they are still where the delete left + // them; a switch they made since then wins. + const stillReplaced = + this.playlistContext.resolvedPlaylistId() === + (removal.next?._id ?? null); + if (!stillReplaced && removal.next) { + return; + } + if (removal.wasOnItsRoute) { + void this.router.navigateByUrl(removal.previousUrl); + } else { + this.playlistContext.activatePlaylist(removal.playlist._id); + } + } + + private async commit(removal: PendingRemoval): Promise { + const { playlist } = removal; + // A desktop Xtream delete works through the catalog database and + // reports progress: its row returns at once as "Removing…". + if (playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource) { + this.progress.track(playlist._id, { + operation: 'delete-playlist', + status: 'started', + }); + this.setPending(playlist._id, false); + } + try { + const deleted = await this.deleteAction.deletePlaylist(playlist, { + onEvent: (event) => { + this.progress.track(playlist._id, event); + if (this.progress.isDeleting(playlist._id)) { + this.setPending(playlist._id, false); + } + }, + }); + if (deleted) { + const commit = () => + this.store.dispatch( + PlaylistActions.playlistRemovalCommitted({ + playlistId: playlist._id, + }) + ); + // The performance journeys time an Xtream row leaving the store. + if (playlist.serverUrl) { + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW, + commit, + () => ({ items: 1 }) + ); + } else { + commit(); + } + } else if (!this.progress.takeCancelled(playlist._id)) { + this.snackBar.open( + this.translate.instant('WORKSPACE.SOURCES.REMOVE_FAILED', { + name: this.nameOf(playlist), + }), + this.translate.instant('CLOSE'), + { duration: 5000, panelClass: 'app-toast' } + ); + } + } finally { + this.progress.clear(playlist._id); + this.setPending(playlist._id, false); + } + } + + private nameOf(playlist: PlaylistMeta): string { + return ( + sourceDisplayName(playlist) ?? + this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST') + ); + } + + private setPending(id: string, pending: boolean): void { + this.pendingState.update((current) => { + const next = new Set(current); + if (pending) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html index 9014bd32b..ac86d9cd3 100644 --- a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html @@ -102,7 +102,7 @@ -
- -
-
+ + + @if (showStatus) { +
+

+ {{ 'WORKSPACE.SOURCES.STATUS' | translate }} +

+ @for (option of statusOptions; track option.id) { + + } +
+ } + diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.scss index 8c0df9dec..e71903494 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.scss @@ -1,4 +1,5 @@ -@use '../../nav-list.scss'; +@use '../../../../../../ui/styles/app-controls' as controls; +@use '../../../../../../ui/styles/side-list'; :host { display: flex; @@ -7,7 +8,7 @@ height: 100%; } -.context-column { +.side-list { display: flex; flex-direction: column; min-height: 0; @@ -15,88 +16,73 @@ overflow: hidden; } -// ─── Header ─────────────────────────────────────────────────────────────────── -.context-header { - padding: 4px 6px 10px; +.side-list__header { + @include side-list.header; flex-shrink: 0; } -.context-header__top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - - h2 { - margin: 0; - font-size: 0.94rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--mat-sys-on-surface); - } +.side-list__heading { + flex: 1; + min-width: 0; } -.context-header p { - margin: 4px 0 0; - font-size: 0.78rem; - color: var(--mat-sys-on-surface-variant); +.side-list__title { + @include side-list.title; } -// ─── Divider ────────────────────────────────────────────────────────────────── -.context-divider { - height: 1px; - background: var(--mat-sys-outline-variant); - margin: 0 0 6px; - opacity: 0.6; - flex-shrink: 0; +.side-list__subtitle { + @include side-list.subtitle; } -.panel-section { +.side-list__action { + @include controls.ghost-icon-button(32px, 18px); + flex: none; +} + +.side-list__groups { display: flex; flex-direction: column; + gap: 14px; min-height: 0; -} - -.option-list { overflow-y: auto; - min-height: 0; + scrollbar-width: thin; } -.option-row { - border-radius: 12px; - transition: - box-shadow 0.18s ease, - transform 0.18s ease; +.side-list__group { + display: flex; + flex-direction: column; + gap: 1px; +} - .option-label, - .option-meta, - mat-icon { - transition: color 0.18s ease, opacity 0.18s ease; +.side-list__label { + @include side-list.label; +} + +.side-list__row { + @include side-list.row; +} + +.side-list__row-label { + @include side-list.row-label; +} + +.side-list__row-count { + @include side-list.row-count; +} + +// The status rows lead with a dot in the colour the source rows use. +.side-list__dot { + width: 8px; + height: 8px; + margin: 0 4px; + border-radius: 50%; + background: var(--app-muted-color); + + &[data-state='ok'] { + background: var(--app-success-color); } - &.selected { - box-shadow: 0 10px 20px -18px var(--app-selection-glow); - transform: translateY(-1px); - - .option-label, - .option-meta, - mat-icon { - color: var(--app-selection-color) !important; - } - - .option-meta { - opacity: 0.95; - } + &[data-state='bad'] { + background: var(--app-live-color); } } - -.panel-section-title { - margin: 0; - padding: 10px 10px 6px; - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--mat-sys-on-surface-variant); - opacity: 0.85; -} diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.spec.ts new file mode 100644 index 000000000..38ac80ad2 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.spec.ts @@ -0,0 +1,149 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { SourceRemovalService } from '@iptvnator/playlist/shared/ui'; +import { SourcesViewStateService } from '@iptvnator/playlist/shared/util'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; +import { WorkspaceSourcesFiltersPanelComponent } from './workspace-sources-filters-panel.component'; + +const PLAYLISTS = [ + { _id: 'm3u', title: 'a', url: 'https://a.example/list.m3u' }, + { _id: 'file', title: 'b' }, + { _id: 'xtream', title: 'c', serverUrl: 'http://x.example' }, + { _id: 'stalker', title: 'd', macAddress: 'm', portalUrl: 'http://p' }, +] as PlaylistMeta[]; +const BUCKETS: Record = { + m3u: 'ok', + xtream: 'bad', + stalker: 'ok', +}; + +describe('WorkspaceSourcesFiltersPanelComponent', () => { + let fixture: ComponentFixture; + let supportsStatusFilter: boolean; + let openAddPlaylistDialog: jest.Mock; + + const render = () => { + fixture = TestBed.createComponent( + WorkspaceSourcesFiltersPanelComponent + ); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + }; + const rows = (element: HTMLElement) => + Array.from( + element.querySelectorAll('.side-list__row') + ); + const counts = (element: HTMLElement) => + rows(element).map( + (row) => + `${row.dataset['filter']}:${row.querySelector('.side-list__row-count')?.textContent?.trim()}` + ); + + beforeEach(() => { + localStorage.clear(); + supportsStatusFilter = true; + openAddPlaylistDialog = jest.fn(); + TestBed.configureTestingModule({ + imports: [ + WorkspaceSourcesFiltersPanelComponent, + TranslateModule.forRoot(), + ], + providers: [ + provideMockStore({ + selectors: [ + { selector: selectAllPlaylistsMeta, value: PLAYLISTS }, + ], + }), + { + provide: SourceStatusService, + useValue: { + get supportsStatusFilter() { + return supportsStatusFilter; + }, + statusOf: (p: PlaylistMeta) => + BUCKETS[p._id] + ? { + reachability: + BUCKETS[p._id] === 'ok' + ? 'ok' + : 'bad', + } + : null, + }, + }, + { + provide: SourceRemovalService, + useValue: { pendingIds: signal(new Set()) }, + }, + { + provide: WORKSPACE_SHELL_ACTIONS, + useValue: { openAddPlaylistDialog }, + }, + ], + }); + }); + + it('counts each group within the other group’s choice', () => { + const element = render(); + expect(counts(element)).toEqual([ + 'all:4', + 'm3u:2', + 'xtream:1', + 'stalker:1', + 'status-all:4', + 'status-ok:2', + 'status-bad:1', + ]); + + rows(element)[6].click(); + fixture.detectChanges(); + expect(counts(element).slice(0, 4)).toEqual([ + 'all:1', + 'm3u:0', + 'xtream:1', + 'stalker:0', + ]); + expect(TestBed.inject(SourcesViewStateService).filter()).toEqual({ + type: 'all', + status: 'bad', + }); + }); + + it('marks the chosen row of each group as pressed', () => { + const element = render(); + rows(element)[2].click(); + fixture.detectChanges(); + expect( + rows(element).map((row) => row.getAttribute('aria-pressed')) + ).toEqual([ + 'false', + 'false', + 'true', + 'false', + 'true', + 'false', + 'false', + ]); + }); + + it('has no status group where source health is unknown', () => { + supportsStatusFilter = false; + const element = render(); + expect(rows(element)).toHaveLength(4); + }); + + it('adds a playlist from the header', () => { + const element = render(); + element + .querySelector( + '[data-test-id="sources-add-playlist"]' + ) + ?.click(); + expect(openAddPlaylistDialog).toHaveBeenCalled(); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts index b11842096..ae4633726 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts @@ -4,112 +4,139 @@ import { computed, inject, } from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; +import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; -import { - FilterActions, - selectActiveTypeFilters, - selectAllPlaylistsMeta, -} from '@iptvnator/m3u-state'; import { TranslatePipe } from '@ngx-translate/core'; -import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; -import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; +import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { SourceRemovalService } from '@iptvnator/playlist/shared/ui'; +import { + countSourcesByStatus, + countSourcesByType, + SourceBucketOf, + SourcesStatusFilter, + SourcesTypeFilter, + SourcesViewStateService, +} from '@iptvnator/playlist/shared/util'; +import { + SOURCE_TYPE_ICONS, + sourceStatusBucket, +} from '@iptvnator/shared/interfaces'; +import { + WORKSPACE_SHELL_ACTIONS, + WorkspaceShellContextDrawerService, +} from '@iptvnator/workspace/shell/util'; -type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker'; - -interface PlaylistFilterOption { - id: PlaylistFilterId; +interface TypeOption { + id: SourcesTypeFilter; icon: string; - label?: string; - translationKey?: string; + labelKey: string; } -const ALL_FILTERS = ['m3u', 'xtream', 'stalker']; +interface StatusOption { + id: SourcesStatusFilter; + labelKey: string; +} +/** + * The Sources side list: Type and, on desktop where source health is known, + * Status. Both are single-choice and combine; each group counts within the + * other group's choice. + */ @Component({ selector: 'app-workspace-sources-filters-panel', - imports: [MatIcon, MatListModule, TranslatePipe], + imports: [MatIcon, MatIconButton, MatTooltip, TranslatePipe], templateUrl: './workspace-sources-filters-panel.component.html', styleUrl: './workspace-sources-filters-panel.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class WorkspaceSourcesFiltersPanelComponent { private readonly store = inject(Store); - // Root-provided; optional keeps standalone unit tests light. Picking a type - // filter is a single-select choice that never navigates, so the phone - // drawer closes here to reveal the filtered list. - private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { + private readonly statusService = inject(SourceStatusService); + private readonly removal = inject(SourceRemovalService); + private readonly viewState = inject(SourcesViewStateService); + private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS, { optional: true, }); - - private readonly activeTypeFilters = this.store.selectSignal( - selectActiveTypeFilters - ); - private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); - - readonly typeOptions: PlaylistFilterOption[] = [ + // Root-provided; optional keeps standalone unit tests light. Picking a + // filter never navigates, so the phone drawer closes to reveal the list. + private readonly contextDrawer = inject( + WorkspaceShellContextDrawerService, { - id: 'all', - icon: 'layers', - translationKey: 'WORKSPACE.SOURCES.ALL', - }, + optional: true, + } + ); + + readonly typeOptions: readonly TypeOption[] = [ + { id: 'all', icon: 'layers', labelKey: 'WORKSPACE.SOURCES.ALL' }, { id: 'm3u', icon: SOURCE_TYPE_ICONS.m3u, - translationKey: 'HOME.PLAYLIST_TYPES.M3U', + labelKey: 'HOME.PLAYLIST_TYPES.M3U', }, { id: 'xtream', icon: SOURCE_TYPE_ICONS.xtream, - translationKey: 'HOME.PLAYLIST_TYPES.XTREAM', + labelKey: 'HOME.PLAYLIST_TYPES.XTREAM', }, { id: 'stalker', icon: SOURCE_TYPE_ICONS.stalker, - translationKey: 'HOME.PLAYLIST_TYPES.STALKER', + labelKey: 'HOME.PLAYLIST_TYPES.STALKER', }, ]; + readonly statusOptions: readonly StatusOption[] = [ + { id: 'all', labelKey: 'WORKSPACE.SOURCES.STATUS_ANY' }, + { id: 'ok', labelKey: 'WORKSPACE.SOURCES.STATUS_OK' }, + { id: 'bad', labelKey: 'WORKSPACE.SOURCES.STATUS_BAD' }, + ]; - readonly typeCounts = computed(() => { - const items = this.playlists(); - return { - all: items.length, - m3u: items.filter((item) => !item.serverUrl && !item.macAddress) - .length, - xtream: items.filter((item) => !!item.serverUrl).length, - stalker: items.filter((item) => !!item.macAddress).length, - }; - }); + readonly showStatus = this.statusService.supportsStatusFilter; + readonly filter = this.viewState.filter; - isTypeActive(filterId: PlaylistFilterId): boolean { - const selected = this.activeTypeFilters(); - if (filterId === 'all') { - return ( - selected.length === ALL_FILTERS.length && - ALL_FILTERS.every((id) => selected.includes(id)) - ); - } - - return selected.length === 1 && selected[0] === filterId; - } - - selectType(filterId: PlaylistFilterId): void { - const selectedFilters = - filterId === 'all' ? ALL_FILTERS : [filterId]; - this.store.dispatch( - FilterActions.setSelectedFilters({ - selectedFilters, - }) + private readonly allPlaylists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + private readonly playlists = computed(() => { + const pending = this.removal.pendingIds(); + return this.allPlaylists().filter( + (playlist) => !pending.has(playlist._id) ); + }); + private readonly bucketOf: SourceBucketOf = (playlist) => { + const status = this.statusService.statusOf(playlist); + return status ? sourceStatusBucket(status.reachability) : null; + }; + + readonly total = computed(() => this.playlists().length); + readonly typeCounts = computed(() => + countSourcesByType( + this.playlists(), + this.showStatus ? this.filter().status : 'all', + this.bucketOf + ) + ); + readonly statusCounts = computed(() => + countSourcesByStatus( + this.playlists(), + this.filter().type, + this.bucketOf + ) + ); + + selectType(type: SourcesTypeFilter): void { + this.viewState.setType(type); this.contextDrawer?.close(); } - getTypeCount(filterId: PlaylistFilterId): number { - const counts = this.typeCounts(); - if (filterId === 'all') { - return counts.all; - } - return counts[filterId]; + selectStatus(status: SourcesStatusFilter): void { + this.viewState.setStatus(status); + this.contextDrawer?.close(); + } + + addPlaylist(): void { + this.workspaceActions?.openAddPlaylistDialog(); } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources-sort-options.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources-sort-options.ts new file mode 100644 index 000000000..50e6983fb --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources-sort-options.ts @@ -0,0 +1,53 @@ +import { SortBy, SortOptions, SortOrder } from '@iptvnator/services'; + +export interface SourceSortOption { + by: SortBy; + order: SortOrder; + translationKey: string; +} + +/** The Sources sort menu, in menu order. Custom order allows dragging. */ +export const SOURCE_SORT_OPTIONS: readonly SourceSortOption[] = [ + { + by: SortBy.DATE_ADDED, + order: SortOrder.DESC, + translationKey: 'HOME.SORT_OPTIONS.NEWEST', + }, + { + by: SortBy.DATE_ADDED, + order: SortOrder.ASC, + translationKey: 'HOME.SORT_OPTIONS.OLDEST', + }, + { + by: SortBy.UPDATED, + order: SortOrder.DESC, + translationKey: 'HOME.SORT_OPTIONS.UPDATED', + }, + { + by: SortBy.NAME, + order: SortOrder.ASC, + translationKey: 'HOME.SORT_OPTIONS.NAME_ASC', + }, + { + by: SortBy.NAME, + order: SortOrder.DESC, + translationKey: 'HOME.SORT_OPTIONS.NAME_DESC', + }, + { + by: SortBy.CHANNELS, + order: SortOrder.DESC, + translationKey: 'HOME.SORT_OPTIONS.CHANNELS', + }, + { + by: SortBy.CUSTOM, + order: SortOrder.ASC, + translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER', + }, +]; + +export function isSortOptionActive( + option: SourceSortOption, + current: SortOptions +): boolean { + return option.by === current.by && option.order === current.order; +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html index a562fccae..32765174b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html @@ -1,55 +1,98 @@
- @if (visibleSourcesCount() > 0 || canCleanSources()) { + @if (hasPlaylists()) {
-
+

{{ title() }}

- {{ subtitle() }} + {{ + visibleCount() + }}
- @if (canCleanSources()) { - - } - - - @for (option of sortOptions; track option.translationKey) { +
+ @if (canCleanSources()) { } - + + +
+ @for (option of sortOptions; track $index) { + + } +
+ @if (showFailingFirst) { + + + } +
+
} -
+
(); } +const PLAYLISTS = [ + { _id: 'playlist-1', title: 'Playlist 1', url: 'https://a.example/1.m3u' }, + { + _id: 'playlist-2', + title: 'Playlist 2', + serverUrl: 'http://x.example', + username: 'u', + password: 'p', + }, +] as PlaylistMeta[]; + describe('WorkspaceSourcesComponent', () => { let fixture: ComponentFixture; + let setSortOptions: jest.Mock; beforeEach(async () => { + localStorage.clear(); + setSortOptions = jest.fn(); await TestBed.configureTestingModule({ imports: [WorkspaceSourcesComponent, NoopAnimationsModule], providers: [ @@ -62,21 +83,29 @@ describe('WorkspaceSourcesComponent', () => { }, provideMockStore({ selectors: [ - { - selector: selectActiveTypeFilters, - value: ['m3u', 'xtream', 'stalker'], - }, - { - selector: selectAllPlaylistsMeta, - value: [ - { - _id: 'playlist-1', - title: 'Playlist 1', - }, - ], - }, + { selector: selectAllPlaylistsMeta, value: PLAYLISTS }, ], }), + { + provide: RuntimeCapabilitiesService, + useValue: { supportsSourceHealth: true }, + }, + { + provide: SourceStatusService, + useValue: { + supportsStatusFilter: true, + statusOf: (p: PlaylistMeta) => ({ + reachability: p._id === 'playlist-2' ? 'bad' : 'ok', + }), + }, + }, + { + provide: SourceRemovalService, + useValue: { + pendingIds: signal(new Set()), + isPending: () => false, + }, + }, { provide: ActivatedRoute, useValue: { @@ -91,7 +120,8 @@ describe('WorkspaceSourcesComponent', () => { by: SortBy.DATE_ADDED, order: SortOrder.DESC, }), - setSortOptions: jest.fn(), + setSortOptions: (options: unknown) => + setSortOptions(options), }, }, { @@ -140,6 +170,7 @@ describe('WorkspaceSourcesComponent', () => { set: { imports: [ MatButtonModule, + MatDividerModule, MatIconModule, MatMenuModule, MenuItemRadioCheckDirective, @@ -199,35 +230,45 @@ describe('WorkspaceSourcesComponent', () => { expect(context.protected('other')).toBe(false); }); - it('renders the shared panel header structure without paragraph subtitle margins', async () => { + it('puts a 28px title and the visible count on the page', async () => { fixture.detectChanges(); await fixture.whenStable(); - const header: HTMLElement = - fixture.nativeElement.querySelector('.sources-header'); - const meta: HTMLElement = fixture.nativeElement.querySelector( - '.sources-header__meta' + const element = fixture.nativeElement as HTMLElement; + expect( + element.querySelector('.sources-title')?.textContent?.trim() + ).toBe('All Playlists'); + expect( + element.querySelector('.sources-count')?.textContent?.trim() + ).toBe('2'); + // The page scrolls as one; the header is not a separate bar. + expect(element.querySelector('.sources-page')?.classList).toContain( + 'app-scrollbar' ); - const title: HTMLElement = - fixture.nativeElement.querySelector('.sources-title'); - const subtitle: HTMLElement = - fixture.nativeElement.querySelector('.sources-subtitle'); - - expect(header).not.toBeNull(); - expect(meta).not.toBeNull(); - expect(title.textContent?.trim()).toBe('All Playlists'); - expect(subtitle.textContent?.trim()).toBe('1 playlist'); - expect(subtitle.tagName).toBe('SPAN'); }); - it('uses the shared always-visible scrollbar utility for the playlist list', async () => { + it('titles the page by the status filter and counts what it shows', async () => { + TestBed.inject(SourcesViewStateService).setStatus('bad'); fixture.detectChanges(); await fixture.whenStable(); - const content: HTMLElement = - fixture.nativeElement.querySelector('.sources-content'); + const element = fixture.nativeElement as HTMLElement; + expect( + element.querySelector('.sources-title')?.textContent?.trim() + ).toBe('WORKSPACE.SOURCES.STATUS_BAD'); + expect( + element.querySelector('.sources-count')?.textContent?.trim() + ).toBe('1'); + }); - expect(content.classList.contains('app-scrollbar')).toBe(true); + it('counts sources that do not respond on Clean up', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const pill = (fixture.nativeElement as HTMLElement).querySelector( + '.cleanup-trigger .sources-action__count' + ); + expect(pill?.textContent?.trim()).toBe('1'); }); it('offers the source sorts as radio rows with the active one checked', async () => { @@ -246,20 +287,25 @@ describe('WorkspaceSourcesComponent', () => { '.cdk-overlay-container [mat-menu-item]' ) ); - expect(rows.map((row) => row.getAttribute('role'))).toEqual( - Array(5).fill('menuitemradio') - ); + expect(rows.map((row) => row.getAttribute('role'))).toEqual([ + ...Array(7).fill('menuitemradio'), + 'menuitemcheckbox', + ]); expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([ 'true', - 'false', - 'false', - 'false', - 'false', + ...Array(7).fill('false'), ]); - for (const row of rows) { + for (const row of rows.slice(0, 7)) { expect(row.firstElementChild?.classList).toContain( 'app-menu-item-radio-check' ); } + + rows[7].click(); + expect(setSortOptions).toHaveBeenCalledWith({ + by: SortBy.DATE_ADDED, + order: SortOrder.DESC, + failingFirst: true, + }); }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 60272c215..baf1f6fcd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -1,65 +1,81 @@ import { WorkspaceShellXtreamImportService } from '../workspace-shell/services/workspace-shell-xtream-import.service'; -import { Injector, viewChild } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { - SourceCleanupDialogComponent, + ChangeDetectionStrategy, + Component, + computed, + inject, + Injector, + viewChild, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { map } from 'rxjs'; +import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + isLiveExternalPlayerSession, + PORTAL_EXTERNAL_PLAYBACK, +} from '@iptvnator/portal/shared/util'; +import { PlaylistRefreshActionService, + RecentPlaylistsComponent, + SourceCleanupDialogComponent, + SourceRemovalService, } from '@iptvnator/playlist/shared/ui'; import { - RuntimeCapabilitiesService, - SourceActivityService, + filterSources, + SourceBucketOf, + SourcesViewStateService, +} from '@iptvnator/playlist/shared/util'; +import { injectTranslationTick, + RuntimeCapabilitiesService, + SortService, + SourceActivityService, } from '@iptvnator/services'; import { - PORTAL_EXTERNAL_PLAYBACK, - isLiveExternalPlayerSession, -} from '@iptvnator/portal/shared/util'; -import { PlaylistActions } from '@iptvnator/m3u-state'; + PlaylistUpdateState, + sourceHealthType, + sourceStatusBucket, +} from '@iptvnator/shared/interfaces'; import { MenuItemRadioCheckDirective, MenuItemRadioDirective, } from '@iptvnator/ui/components'; -import { - foldSearchText, - sourceHealthType, - PlaylistUpdateState, -} from '@iptvnator/shared/interfaces'; -import { - Component, - computed, - inject, - ChangeDetectionStrategy, -} from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { ActivatedRoute } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { - selectActiveTypeFilters, - selectAllPlaylistsMeta, -} from '@iptvnator/m3u-state'; -import { map } from 'rxjs'; -import { SortBy, SortOrder, SortService } from '@iptvnator/services'; import { WORKSPACE_SHELL_ACTIONS, WorkspacePlaylistType, } from '@iptvnator/workspace/shell/util'; +import { + isSortOptionActive, + SOURCE_SORT_OPTIONS, + SourceSortOption, +} from './workspace-sources-sort-options'; -interface SortOption { - by: SortBy; - order: SortOrder; - icon: string; - translationKey: string; -} +const TYPE_TITLE_KEYS = { + all: 'WORKSPACE.SOURCES.ALL_PLAYLISTS', + m3u: 'WORKSPACE.SOURCES.M3U_PLAYLISTS', + xtream: 'WORKSPACE.SOURCES.XTREAM_PLAYLISTS', + stalker: 'WORKSPACE.SOURCES.STALKER_PLAYLISTS', +} as const; +/** + * The Sources page: a 28px title with the visible count, Clean up (desktop, + * with a count of sources that do not respond) and the sort menu, above the + * list. The side list's filters live in `SourcesViewStateService`. + */ @Component({ selector: 'app-workspace-sources', imports: [ MatButtonModule, + MatDividerModule, MatIconModule, MatMenuModule, MenuItemRadioCheckDirective, @@ -68,12 +84,20 @@ interface SortOption { TranslatePipe, ], templateUrl: './workspace-sources.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-sources.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class WorkspaceSourcesComponent { private readonly injector = inject(Injector); + private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly sortService = inject(SortService); + private readonly statusService = inject(SourceStatusService); + private readonly removal = inject(SourceRemovalService); + private readonly viewState = inject(SourcesViewStateService); + private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS); private readonly playback = inject(PORTAL_EXTERNAL_PLAYBACK, { optional: true, }); @@ -81,11 +105,95 @@ export class WorkspaceSourcesComponent { optional: true, }); private readonly sourceList = viewChild(RecentPlaylistsComponent); + private readonly languageTick = injectTranslationTick(); + + readonly sortOptions = SOURCE_SORT_OPTIONS; + readonly showFailingFirst = this.statusService.supportsStatusFilter; + private readonly currentSort = toSignal(this.sortService.getSortOptions(), { + requireSync: true, + }); + readonly searchQuery = toSignal( + this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')), + { initialValue: '' } + ); + + private readonly allPlaylists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + private readonly playlists = computed(() => { + const pending = this.removal.pendingIds(); + return this.allPlaylists().filter((p) => !pending.has(p._id)); + }); + private readonly bucketOf: SourceBucketOf = (playlist) => { + const status = this.statusService.statusOf(playlist); + return status ? sourceStatusBucket(status.reachability) : null; + }; + + readonly hasPlaylists = computed(() => this.playlists().length > 0); + readonly visibleCount = computed(() => { + const filter = this.viewState.filter(); + return filterSources( + this.playlists(), + this.showFailingFirst ? filter : { ...filter, status: 'all' }, + this.searchQuery(), + this.bucketOf + ).length; + }); + readonly title = computed(() => { + this.languageTick(); + const filter = this.viewState.filter(); + const key = + this.showFailingFirst && filter.status === 'bad' + ? 'WORKSPACE.SOURCES.STATUS_BAD' + : TYPE_TITLE_KEYS[filter.type]; + return this.translate.instant(key); + }); + readonly canCleanSources = computed( () => this.runtime.supportsSourceHealth && this.playlists().some((p) => sourceHealthType(p)) ); + /** Sources last seen not responding, across every filter. */ + readonly failingCount = computed( + () => this.playlists().filter((p) => this.bucketOf(p) === 'bad').length + ); + + readonly activeSortLabel = computed(() => { + this.languageTick(); + const current = this.currentSort(); + const match = this.sortOptions.find((option) => + isSortOptionActive(option, current) + ); + return this.translate.instant( + match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST' + ); + }); + readonly failingFirst = computed(() => !!this.currentSort().failingFirst); + + isSortActive(option: SourceSortOption): boolean { + return isSortOptionActive(option, this.currentSort()); + } + + setSortOption(option: SourceSortOption): void { + this.sortService.setSortOptions({ + ...this.currentSort(), + by: option.by, + order: option.order, + }); + } + + toggleFailingFirst(): void { + this.sortService.setSortOptions({ + ...this.currentSort(), + failingFirst: !this.failingFirst(), + }); + } + + onAddPlaylist(type?: WorkspacePlaylistType): void { + this.workspaceActions.openAddPlaylistDialog(type); + } + openCleanup(): void { const dialogs = this.injector.get(MatDialog); const refresh = this.injector.get(PlaylistRefreshActionService); @@ -108,7 +216,7 @@ export class WorkspaceSourcesComponent { activity.isBusy(id) || refresh.isSourceBusy(id) || !!this.imports?.isSourceBusy(id) || - !!this.sourceList()?.isDeletePending(id) || + this.removal.isPending(id) || !!this.sourceList()?.isRefreshPending(id) || this.playlists().find((p) => p._id === id)?.updateState === PlaylistUpdateState.IN_PROGRESS, @@ -121,156 +229,4 @@ export class WorkspaceSourcesComponent { }, }); } - private readonly route = inject(ActivatedRoute); - private readonly store = inject(Store); - private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS); - private readonly translate = inject(TranslateService); - private readonly sortService = inject(SortService); - - private readonly activeTypeFilters = this.store.selectSignal( - selectActiveTypeFilters - ); - private readonly playlists = this.store.selectSignal( - selectAllPlaylistsMeta - ); - private readonly languageTick = injectTranslationTick(); - - readonly sortOptions: SortOption[] = [ - { - by: SortBy.DATE_ADDED, - order: SortOrder.DESC, - icon: 'schedule', - translationKey: 'HOME.SORT_OPTIONS.NEWEST', - }, - { - by: SortBy.DATE_ADDED, - order: SortOrder.ASC, - icon: 'history', - translationKey: 'HOME.SORT_OPTIONS.OLDEST', - }, - { - by: SortBy.NAME, - order: SortOrder.ASC, - icon: 'sort_by_alpha', - translationKey: 'HOME.SORT_OPTIONS.NAME_ASC', - }, - { - by: SortBy.NAME, - order: SortOrder.DESC, - icon: 'sort_by_alpha', - translationKey: 'HOME.SORT_OPTIONS.NAME_DESC', - }, - { - by: SortBy.CUSTOM, - order: SortOrder.ASC, - icon: 'drag_indicator', - translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER', - }, - ]; - - private readonly currentSortOptions = toSignal( - this.sortService.getSortOptions(), - { requireSync: true } - ); - - readonly searchQuery = toSignal( - this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')), - { initialValue: '' } - ); - readonly title = computed(() => { - this.languageTick(); - - const filters = this.activeTypeFilters(); - - if (filters.length === 1) { - if (filters[0] === 'm3u') { - return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS'); - } - if (filters[0] === 'xtream') { - return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS'); - } - if (filters[0] === 'stalker') { - return this.translateText( - 'WORKSPACE.SOURCES.STALKER_PLAYLISTS' - ); - } - } - - return this.translateText('WORKSPACE.SOURCES.ALL_PLAYLISTS'); - }); - - readonly visibleSourcesCount = computed(() => { - const query = foldSearchText(this.searchQuery().trim()); - const filters = this.activeTypeFilters(); - const allPlaylists = this.playlists(); - - return allPlaylists - .filter((item) => { - const isStalkerFilter = - !!item.macAddress && filters.includes('stalker'); - const isXtreamFilter = - !!item.username && - !!item.password && - !!item.serverUrl && - filters.includes('xtream'); - const isM3uFilter = - !item.username && - !item.password && - !item.serverUrl && - !item.macAddress && - filters.includes('m3u'); - - return isStalkerFilter || isXtreamFilter || isM3uFilter; - }) - .filter((item) => foldSearchText(item.title || '').includes(query)) - .length; - }); - - readonly subtitle = computed(() => { - this.languageTick(); - - const count = this.visibleSourcesCount(); - if (count === 1) { - return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE'); - } - - return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', { - count, - }); - }); - - readonly activeSortLabel = computed(() => { - this.languageTick(); - const current = this.currentSortOptions(); - const match = this.sortOptions.find( - (option) => - option.by === current.by && option.order === current.order - ); - return this.translateText( - match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST' - ); - }); - - onAddPlaylist(type?: WorkspacePlaylistType): void { - this.workspaceActions.openAddPlaylistDialog(type); - } - - isSortActive(option: SortOption): boolean { - const current = this.currentSortOptions(); - return current.by === option.by && current.order === option.order; - } - - setSortOption(option: SortOption): void { - this.sortService.setSortOptions({ - by: option.by, - order: option.order, - }); - } - - private translateText( - key: string, - params?: Record - ): string { - return this.translate.instant(key, params); - } }