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); - } }