From fff022afe4c65b0f8fef5d74af975ed8ba30e460 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:34:10 +0200 Subject: [PATCH] fix(ui): keep detail back navigation available while scrolling (#1576) --- .changes/ui-sticky-detail-back.md | 7 + AGENTS.md | 8 +- CLAUDE.md | 8 +- .../src/catalog-sorting.e2e.ts | 7 +- .../src/dashboard-activation.e2e.ts | 28 +- .../electron-backend-e2e/src/favorites.e2e.ts | 29 +- apps/electron-backend-e2e/src/recent.e2e.ts | 18 +- apps/electron-backend-e2e/src/search.e2e.ts | 7 +- .../src/xtream-vod-details.e2e.ts | 44 ++- apps/web-e2e/src/m3u-movie-details.e2e.ts | 11 + apps/web-e2e/src/stalker.e2e.ts | 9 + apps/web-e2e/src/xtream.e2e.ts | 61 ++++ docs/architecture/embedded-inline-playback.md | 9 +- docs/architecture/portal-detail-navigation.md | 29 +- .../m3u-vod-detail.component.html | 1 + .../m3u-vod-detail.component.scss | 9 - .../m3u-vod-detail.component.spec.ts | 1 + .../download-offline-detail.component.spec.ts | 4 +- .../stalker-collection-detail.component.ts | 5 +- .../lib/xtream-collection-detail.component.ts | 5 +- .../content-hero/content-hero.component.html | 9 - .../content-hero/content-hero.component.scss | 33 --- .../content-hero.component.spec.ts | 20 -- .../content-hero/content-hero.component.ts | 7 - .../portal-detail-shell.component.html | 22 +- .../portal-detail-shell.component.scss | 42 +++ .../portal-detail-shell.component.spec.ts | 278 +++++++++++++++++- .../portal-detail-shell.component.ts | 106 ++++++- 28 files changed, 679 insertions(+), 138 deletions(-) create mode 100644 .changes/ui-sticky-detail-back.md diff --git a/.changes/ui-sticky-detail-back.md b/.changes/ui-sticky-detail-back.md new file mode 100644 index 000000000..e943bed2d --- /dev/null +++ b/.changes/ui-sticky-detail-back.md @@ -0,0 +1,7 @@ +--- +type: fix +area: ui +issues: [1570] +--- + +Movie and series detail pages keep their Back button visible while scrolling. Escape closes the inline player to the description, then returns to the previous view. Open menus, dialogs and fullscreen retain priority. diff --git a/AGENTS.md b/AGENTS.md index 98999d336..e5c67996c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,7 +315,13 @@ row Enter/Space activation stays separate from focus movement. Portal Live TV uses ArrowRight from the selected category and ArrowLeft from the channels pane to move between columns. Shared live sidebars reserve scrollbar space beside the resize handle. `PortalDetailShellComponent` owns a visible native -scrollbar and guarded initial page focus. Contracts: +scrollbar and guarded initial page focus. Its sticky control and Escape close +inline playback to browse, then invoke the host's existing Back action; the +now-playing bar retains its separate direct route Back. Browse Escape requires +focus inside the shell; watch preserves the global close shortcut. Menus, +dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain +their keys. M3U and collection bootstrap shells set `backAvailable=false` when +there is no browse return action. Contracts: `docs/architecture/iptvnator-ui-guidelines.md` and `docs/architecture/portal-detail-navigation.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 96ca8c52f..ec455f414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1860,7 +1860,13 @@ row Enter/Space activation stays separate from focus movement. Portal Live TV uses ArrowRight from the selected category and ArrowLeft from the channels pane to move between columns. Shared live sidebars reserve scrollbar space beside the resize handle. `PortalDetailShellComponent` owns a visible native -scrollbar and guarded initial page focus. Contracts: +scrollbar and guarded initial page focus. Its sticky control and Escape close +inline playback to browse, then invoke the host's existing Back action; the +now-playing bar retains its separate direct route Back. Browse Escape requires +focus inside the shell; watch preserves the global close shortcut. Menus, +dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain +their keys. M3U and collection bootstrap shells set `backAvailable=false` when +there is no browse return action. Contracts: `docs/architecture/iptvnator-ui-guidelines.md` and `docs/architecture/portal-detail-navigation.md`. diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index f8fe41f85..9e2952863 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -590,9 +590,12 @@ async function firstVisibleGridTitle(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { + // Return to the list: browse uses the sticky Back, watch uses the + // now-playing bar's direct Back (the sticky watch action is Close player). const backButton = page - .locator('app-content-hero .hero__back-button') - .first(); + .locator('app-portal-detail-shell') + .first() + .getByRole('button', { name: 'Back', exact: true }); await expect(backButton).toBeVisible({ timeout: 20000 }); try { diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index ed9ded21b..cb0e3ce5c 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -97,7 +97,10 @@ test.describe('Dashboard Activation', () => { await app.mainWindow .getByTestId('dashboard-favorite-vod-rail-manage-all') .click(); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await app.mainWindow.goBack(); @@ -223,7 +226,10 @@ const xtreamCredentials = { password: defaultXtreamPassword, }; -async function expectDashboardRail(page: Page, railTestId: string): Promise { +async function expectDashboardRail( + page: Page, + railTestId: string +): Promise { await expect(page.locator(`[data-test-id="${railTestId}"]`)).toBeVisible({ timeout: 20000, }); @@ -234,15 +240,21 @@ function dashboardRailCardByTitle( railTestId: string, title: string ) { - return page.locator(`[data-test-id="${railTestId}-card"]`).filter({ - hasText: title, - }).first(); + return page + .locator(`[data-test-id="${railTestId}-card"]`) + .filter({ + hasText: title, + }) + .first(); } async function goBackFromDetail(page: Page): Promise { + // Return to the list: browse uses the sticky Back, watch uses the + // now-playing bar's direct Back (the sticky watch action is Close player). const backButton = page - .locator('app-content-hero .hero__back-button') - .first(); + .locator('app-portal-detail-shell') + .first() + .getByRole('button', { name: 'Back', exact: true }); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -282,7 +294,7 @@ async function expectInlineCollectionDetail( await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); await expect( - page.locator('app-content-hero .hero__back-button').first() + page.locator('app-portal-detail-shell .shell__back-button').first() ).toBeVisible({ timeout: 20000 }); } diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index dcea53471..99a528d12 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -351,7 +351,10 @@ test.describe('Electron Favorites', () => { }); await goBackFromDetail(app.mainWindow); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); @@ -366,7 +369,10 @@ test.describe('Electron Favorites', () => { }); await goBackFromDetail(app.mainWindow); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); } finally { @@ -545,7 +551,10 @@ test.describe('Electron Favorites', () => { await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); @@ -562,7 +571,10 @@ test.describe('Electron Favorites', () => { await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); } finally { @@ -594,9 +606,12 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { + // Return to the list: browse uses the sticky Back, watch uses the + // now-playing bar's direct Back (the sticky watch action is Close player). const backButton = page - .locator('app-content-hero .hero__back-button') - .first(); + .locator('app-portal-detail-shell') + .first() + .getByRole('button', { name: 'Back', exact: true }); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -623,7 +638,7 @@ async function expectInlineCollectionDetail( await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); await expect( - page.locator('app-content-hero .hero__back-button').first() + page.locator('app-portal-detail-shell .shell__back-button').first() ).toBeVisible({ timeout: 20000 }); } diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 66d9e6322..9221fdc27 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -74,7 +74,9 @@ test.describe('Electron Recently Viewed', () => { 'Stable Recent Channel' ).first(); const livePlayer = app.mainWindow - .locator('app-unified-live-tab .content-container .video-player') + .locator( + 'app-unified-live-tab .content-container .video-player' + ) .first(); await item.click(); @@ -129,7 +131,10 @@ test.describe('Electron Recently Viewed', () => { .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); - await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two'); + await toggleFavoriteForChannel( + app.mainWindow, + 'Recent Channel Two' + ); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); @@ -648,9 +653,12 @@ async function expectUnifiedLiveDetailOpen( } async function goBackFromDetail(page: Page): Promise { + // Return to the list: browse uses the sticky Back, watch uses the + // now-playing bar's direct Back (the sticky watch action is Close player). const backButton = page - .locator('app-content-hero .hero__back-button') - .first(); + .locator('app-portal-detail-shell') + .first() + .getByRole('button', { name: 'Back', exact: true }); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -677,7 +685,7 @@ async function expectInlineCollectionDetail( await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); await expect( - page.locator('app-content-hero .hero__back-button').first() + page.locator('app-portal-detail-shell .shell__back-button').first() ).toBeVisible({ timeout: 20000 }); } diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 974e7c17c..a4cd8a6b9 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1540,9 +1540,12 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { + // Return to the list: browse uses the sticky Back, watch uses the + // now-playing bar's direct Back (the sticky watch action is Close player). const backButton = page - .locator('app-content-hero .hero__back-button') - .first(); + .locator('app-portal-detail-shell') + .first() + .getByRole('button', { name: 'Back', exact: true }); await expect(backButton).toBeVisible({ timeout: 20000 }); await backButton.click(); diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index da53c5848..f87ed9687 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -135,6 +135,46 @@ test.describe('Xtream VOD Details', () => { }); for (const theme of ['light', 'dark']) { + test(`detail action tooltips preserve one-press Escape (${theme})`, async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await page + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await page.evaluate( + (dark) => document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); + for (const action of [ + 'vod-favorite-toggle', + 'vod-download-start', + ]) { + await page.locator('app-grid-list mat-card').first().click(); + const shell = page.locator('app-portal-detail-shell'); + await expect( + shell.getByRole('heading', { level: 1 }) + ).toBeVisible(); + const button = shell.locator(`[data-testid="${action}"]`); + await button.focus(); + await button.hover(); + await expect( + page.locator('.mat-mdc-tooltip-show') + ).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(shell).toHaveCount(0); + } + } finally { + await closeElectronApp(app); + } + }); + test(`supports keyboard and mouse scrolling in portal details (${theme})`, async ({ dataDir, request, @@ -188,7 +228,9 @@ for (const theme of ['light', 'dark']) { .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); await page.keyboard.press('Tab'); - await expect(shell.locator('.hero__back-button')).toBeFocused(); + await expect( + shell.locator('.shell__back-button') + ).toBeFocused(); await page.keyboard.press('Enter'); await expect(shell).toHaveCount(0); } diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index a0d31efaa..4859d22c4 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -250,6 +250,17 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ ) ) .toBe(0.25); + // M3U has no browse Back target, but the sticky watch control can close it. + const shell = detail(page).locator('app-portal-detail-shell'); + await shell + .getByRole('button', { name: 'Close player', exact: true }) + .first() + .click(); + await expect(inlineVideo(page)).toHaveCount(0); + await expect(shell.locator('.shell__back-button')).toHaveCount(0); + await shell.focus(); + await page.keyboard.press('Escape'); + await expect(playButton).toBeVisible(); }); for (const theme of ['light', 'dark']) { diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index e3888c843..be251b322 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1218,6 +1218,15 @@ test('@stalker series watched toggle — embedded series marks and clears from t await expect(seriesToggle).toContainText( `Mark series as watched (${episodeCount})` ); + // The menu owns the first Escape; inline Stalker detail owns the next. + const detailUrl = page.url(); + await page.keyboard.press('Escape'); + await expect(seriesToggle).toBeHidden(); + await menuTrigger.focus(); + await page.keyboard.press('Escape'); + await expect(page.locator('app-portal-detail-shell')).toHaveCount(0); + await expect(page).toHaveURL(detailUrl); + await expect(card).toBeVisible(); }); test('@stalker series — seasons load for a series item', async ({ diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 0b938a887..bf6adec9e 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1400,5 +1400,66 @@ for (const theme of ['light', 'dark']) { .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); }); + + test(`@xtream sticky detail Back ${section} (${theme})`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1200, height: 540 }); + await addXtreamPortal(page); + await page + .getByRole('link', { name: section, exact: true }) + .click(); + await page.locator('app-grid-list mat-card').first().click(); + const shell = page.locator('app-portal-detail-shell'); + await expect( + shell.getByRole('heading', { level: 1 }) + ).toBeVisible(); + await shell.focus(); + await page.evaluate( + (dark) => document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); + const back = shell.getByRole('button', { + name: 'Back', + exact: true, + }); + await expect(back).toBeVisible(); + const backOffset = () => + back.evaluate((el) => { + const owner = el.closest('app-portal-detail-shell'); + return owner + ? el.getBoundingClientRect().top - + owner.getBoundingClientRect().top + : NaN; + }); + await expect.poll(backOffset).toBeCloseTo(16, 0); + await page.keyboard.press('End'); + await expect + .poll(() => shell.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await waitForScrollIdle(shell); + await expect.poll(backOffset).toBeCloseTo(16, 0); + await expect(back).toBeInViewport(); + await shell.screenshot({ + path: testInfo.outputPath( + `sticky-back-${section}-${theme}.png` + ), + }); + await back.click(); + await expect(shell).toHaveCount(0); + await page.locator('app-grid-list mat-card').first().click(); + await expect( + shell.getByRole('heading', { level: 1 }) + ).toBeVisible(); + await shell.focus(); + await page.keyboard.press('End'); + // Hover/focus must not make a tooltip consume the advertised Esc. + await page.clock.install(); + await back.focus(); + await back.hover(); + await page.clock.runFor(500); + await page.keyboard.press('Escape'); + await expect(shell).toHaveCount(0); + }); } } diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 8eb6f610f..38f8aa7e7 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -162,9 +162,12 @@ Contracts: player subtree, so shell state changes cannot recreate the `