diff --git a/.changes/workspace-back-parent-fallback.md b/.changes/workspace-back-parent-fallback.md new file mode 100644 index 000000000..b6536e7f9 --- /dev/null +++ b/.changes/workspace-back-parent-fallback.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +Back on Settings, search, Discover and actor pages now works when that page is the first one you opened, for example after a reload or from a link: it takes you to the dashboard or to the playlist's movies or series instead of doing nothing or leaving the app. diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts index 7be2f2e85..f45655f48 100644 --- a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -13,6 +13,10 @@ import { test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; // --------------------------------------------------------------------------- // A detail page's Back lives in the workspace header's leading slot, not in @@ -29,6 +33,10 @@ import { // header's history fallback while an in-app previous page exists; on a // phone it yields to the drawer toggle, and with nowhere to go the slot is // empty rather than a disabled arrow. +// +// A page with a parent route (settings here) that opened the session, as +// after a reload, has no in-app entry for history Back: its Back leads to +// the parent instead and replaces the page's entry. // --------------------------------------------------------------------------- const widths = [1280, 780, 375]; @@ -346,4 +354,33 @@ test.describe('Portal detail header Back', () => { await closeElectronApp(app); } }); + + test('@electron @settings settings Back after a reload leads to the dashboard', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await page.waitForURL(/\/workspace\/dashboard$/); + await openSettings(page); + + // The reloaded document re-boots on the settings route; the + // dashboard entry before it belongs to the old document. + await reloadFromMainProcess(app); + await expectRendererReloadedOnRoute( + page, + /\/workspace\/settings\/general$/ + ); + await expect(page.getByTestId('settings-container')).toBeVisible(); + + await headerBack(page).click(); + await expect(page).toHaveURL(/\/workspace\/dashboard$/); + // The dashboard replaced the settings entry: nothing in this + // document precedes it, so the header offers no Back. + await expect(headerBack(page)).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index b40e6fb61..e440d0cd9 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -49,6 +49,24 @@ test.describe('Settings', () => { await page.waitForURL(/\/workspace\/dashboard$/); }); + test('@settings @web settings opening the session lead to the dashboard', async ({ + page, + }) => { + // The only entry of a new tab's history, as in Electron after a + // deep link: browser Back has nowhere to go. + const firstEntryPage = await page.context().newPage(); + await firstEntryPage.goto('/workspace/settings/general'); + // A cold start in a new tab passes the startup splash first. + await expect(firstEntryPage.locator('.settings-container')).toBeVisible( + { timeout: 15_000 } + ); + + await settingsBack(firstEntryPage).click(); + await firstEntryPage.waitForURL(/\/workspace\/dashboard$/); + // The dashboard replaced the settings entry: no Back leads to it. + await expect(settingsBack(firstEntryPage)).toHaveCount(0); + }); + test('@settings @web Change video player', async ({ page }) => { await openSettings(page); await openSettingsSection(page, 'playback'); diff --git a/apps/web-e2e/src/workspace-header-back.e2e.ts b/apps/web-e2e/src/workspace-header-back.e2e.ts index 6bdb5f175..a763ff4eb 100644 --- a/apps/web-e2e/src/workspace-header-back.e2e.ts +++ b/apps/web-e2e/src/workspace-header-back.e2e.ts @@ -8,8 +8,10 @@ import { /** * Pages reached from a detail or the header search draw no Back arrow of - * their own: they register the workspace header's leading Back, which keeps - * their previous return behaviour (history Back). + * their own: they register the workspace header's leading Back. It goes back + * in history when the page was reached in the app; a page that opened the + * session (a deep link, reload or restored view) leads to its parent + * instead, replacing its own history entry. * Contract: docs/architecture/workspace-shell.md, "Header Back". */ @@ -29,6 +31,24 @@ async function expectOnlyHeaderBack(page: Page): Promise { await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts'); } +/** The portal's own URL, from the VOD list that adding it opens. */ +function portalUrlOf(page: Page): string { + return page.url().replace(/\/vod.*$/, ''); +} + +/** + * Opens `url` as the only entry of a new tab's history, where browser Back + * has nowhere to go, as in Electron after a deep link or restored view. A + * `page.goto` in the same tab would leave the previous document behind, + * often at the very URL of the parent. + */ +async function openAsFirstEntry(page: Page, url: string): Promise { + const firstEntryPage = await page.context().newPage(); + await interceptXtreamRequests(firstEntryPage); + await firstEntryPage.goto(url); + return firstEntryPage; +} + test.beforeEach(async ({ page, request }) => { await request.post(`${MOCK_SERVER}/reset`); await page.goto('/'); @@ -36,7 +56,7 @@ test.beforeEach(async ({ page, request }) => { await addXtreamPortal(page); }); -test('@web @xtream the in-portal search page returns through the header Back', async ({ +test('@web @xtream the in-portal search page reached in the app goes back in history', async ({ page, }) => { await page @@ -62,25 +82,51 @@ test('@web @xtream the in-portal search page returns through the header Back', a await page.waitForURL(/\/workspace\/dashboard$/); }); -for (const { name, path, selector } of [ +for (const { name, path, selector, parent } of [ { - name: 'Discover', + name: 'movie Discover', path: 'discover?type=movie&genre=18&genreLabel=Drama', selector: 'app-discover-view', + parent: 'vod', + }, + { + name: 'series Discover', + path: 'discover?type=tv&genre=18&genreLabel=Drama', + selector: 'app-discover-view', + parent: 'series', + }, + // The portal root: its route config redirects to the default section. + { + name: 'actor', + path: 'actor/287', + selector: 'app-actor-view', + parent: 'vod', + }, + { + name: 'search', + path: 'search?q=Movie', + selector: 'app-search-layout', + parent: 'vod', }, - { name: 'actor', path: 'actor/287', selector: 'app-actor-view' }, ]) { - test(`@web @xtream the ${name} page returns through the header Back`, async ({ + test(`@web @xtream the ${name} page opening the session leads to its parent`, async ({ page, }) => { - const listUrl = page.url(); - const portalUrl = listUrl.replace(/\/vod.*$/, ''); + const portalUrl = portalUrlOf(page); + const firstEntryPage = await openAsFirstEntry( + page, + `${portalUrl}/${path}` + ); + // A cold start in a new tab passes the startup splash first. + await expect(firstEntryPage.locator(selector)).toBeAttached({ + timeout: 15_000, + }); - await page.goto(`${portalUrl}/${path}`); - await expect(page.locator(selector)).toBeAttached(); - - await expectOnlyHeaderBack(page); - await headerBack(page).click(); - await page.waitForURL(listUrl); + await expectOnlyHeaderBack(firstEntryPage); + await headerBack(firstEntryPage).click(); + await firstEntryPage.waitForURL(`${portalUrl}/${parent}`); + // The parent replaced the page's entry, so nothing precedes it and + // the list shows no Back (the history fallback would otherwise). + await expect(headerBack(firstEntryPage)).toHaveCount(0); }); } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 53e89559c..d6e6c79d4 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -195,7 +195,10 @@ Page-level Back lives only in the workspace header's leading slot (see [Header Back](./workspace-shell.md#header-back)). A routed page, or the shell it renders in, registers it with `registerWorkspaceBack()` instead of drawing an arrow, so Back keeps one position and one look on every page and never -floats over a scroll owner. Without a registration the header falls back to +floats over a scroll owner. A page whose Back is history Back calls +`WorkspaceBackNavigationService.back()` with its parent route rather than +`Location.back()`, so Back still leads somewhere when the page opened the +session. Without a registration the header falls back to browser history while an in-app previous page exists, and shows nothing otherwise. An arrow that returns within a menu, dialog or player panel is not page navigation and stays in that surface; an error state may repeat the diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 44f4d2771..138238919 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -146,9 +146,9 @@ label (else the translated "Back"), whether Escape on the page runs it, and | Page | Registered by | Back runs | ≤640 px | | --- | --- | --- | --- | | Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle | -| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) | -| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) | -| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle | +| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's history Back; parent: the catalog section Discover lists (`vod` for movies, `series` for TV), the portal's default section for actor | (no drawer) | +| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | history Back; parent: the portal's default section | (no drawer) | +| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | history Back; parent: the first workspace view (`WorkspaceStartupPreferencesService.resolveDashboardPath()`: the dashboard, or sources when it is hidden) | beside the drawer toggle | Detail-page semantics (Escape, browse and watch) are in [Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus). @@ -167,6 +167,24 @@ advertises no Escape, because no page handles one for it. Pages that set `backAvailable=false`, such as M3U details, therefore show it too when they were reached by navigation. +**Parent fallback.** "Parent" in the table above: a registered page whose +Back is history Back calls `WorkspaceBackNavigationService.back(resolveParent)` +instead of `Location.back()`. It runs `Location.back()` while the previous +entry is an in-app one, by the same Navigation API test as the history +fallback, and also when that is unknown because the API is missing: there a +page reached in the app must not jump to its parent. Otherwise the page opened +the session (a deep link, a reload or a restored view), where +`Location.back()` does nothing in Electron and leaves the app in a browser. +The service then navigates to the page's parent with `replaceUrl`, so history +Back cannot return to the page just left; with nothing in-app before it, the +parent shows no history fallback. The resolver returns a URL or router commands, may be asynchronous, and +returns null when the page knows no parent, which keeps `Location.back()`. +Portal pages build their parent with `workspacePortalCommands()` +(`@iptvnator/portal/shared/util`) from the route's `:id`; without a section, +the portal route's `redirectTo` picks the default section within the same +navigation, so the replacement still applies. Detail pages keep their own +return logic (`backClicked`). + When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live TV), where a disabled arrow would stay for the whole session. The cost is one diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts index 168642daf..acd017d8e 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts @@ -1,6 +1,7 @@ import { Location } from '@angular/common'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; import { WORKSPACE_HISTORY_NAVIGATION, @@ -42,15 +43,20 @@ class FakeHistory extends EventTarget { describe('WorkspaceBackNavigationService', () => { const back = jest.fn(); + const navigate = jest.fn().mockResolvedValue(true); + const navigateByUrl = jest.fn().mockResolvedValue(true); function createService( history: FakeHistory | null = null ): WorkspaceBackNavigationService { back.mockReset(); + navigate.mockClear(); + navigateByUrl.mockClear(); TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ { provide: Location, useValue: { back } }, + { provide: Router, useValue: { navigate, navigateByUrl } }, { provide: WORKSPACE_HISTORY_NAVIGATION, useValue: history as unknown as WorkspaceHistoryNavigation, @@ -202,4 +208,80 @@ describe('WorkspaceBackNavigationService', () => { ); }); }); + + describe('back to a parent', () => { + it('goes back in history while the previous entry is in-app', () => { + const history = new FakeHistory(); + const service = createService(history); + history.push(); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + expect(navigateByUrl).not.toHaveBeenCalled(); + }); + + it('opens the parent in place of a page that opened the session', async () => { + const service = createService(new FakeHistory()); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + // Replacing keeps history Back from returning to the page. + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/dashboard', { + replaceUrl: true, + }); + expect(back).not.toHaveBeenCalled(); + }); + + it('opens the parent after a reload, whose old entries do not count', async () => { + const service = createService(new FakeHistory([false, true])); + + service.back(() => ['/workspace', 'xtreams', 'pl/1', 'vod']); + await Promise.resolve(); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'pl/1', 'vod'], + { replaceUrl: true } + ); + expect(back).not.toHaveBeenCalled(); + }); + + it('waits for a parent that resolves asynchronously', async () => { + const service = createService(new FakeHistory()); + + service.back(() => Promise.resolve('/workspace/sources')); + await Promise.resolve(); + await Promise.resolve(); + + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { + replaceUrl: true, + }); + }); + + it('keeps browser history when the page knows no parent', async () => { + const service = createService(new FakeHistory()); + + service.back(() => null); + await Promise.resolve(); + + expect(back).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + expect(navigateByUrl).not.toHaveBeenCalled(); + }); + + it('keeps browser history without the Navigation API', () => { + // The history is unknown there, so a reached page must not + // jump to its parent. + const service = createService(null); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts index fd79fd8da..db2295e6b 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts @@ -7,6 +7,7 @@ import { InjectionToken, signal, } from '@angular/core'; +import { Router } from '@angular/router'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; /** The parts of the browser's Navigation API the history fallback reads. */ @@ -28,6 +29,12 @@ export const WORKSPACE_HISTORY_NAVIGATION = } ); +/** + * Where a page's Back leads when there is no in-app history: a URL or router + * commands. Null keeps browser history. + */ +export type WorkspaceBackParent = string | readonly string[] | null; + /** * True when the previous history entry belongs to this document, i.e. the * router pushed it in this app session. Entries from before a reload or from @@ -49,6 +56,8 @@ function hasInAppPreviousEntry(history: WorkspaceHistoryNavigation): boolean { @Injectable({ providedIn: 'root' }) export class WorkspaceBackNavigationService { private readonly location = inject(Location); + private readonly router = inject(Router); + private readonly history = inject(WORKSPACE_HISTORY_NAVIGATION); private readonly targets = signal([]); private readonly canGoBackInApp = signal(false); @@ -73,7 +82,7 @@ export class WorkspaceBackNavigationService { }); constructor() { - const history = inject(WORKSPACE_HISTORY_NAVIGATION); + const history = this.history; if (!history) return; // Fires for router pushes and replacements and for traversals, // including a guard-cancelled Back that the router rewrites. @@ -86,6 +95,24 @@ export class WorkspaceBackNavigationService { ); } + /** + * Back for a page with a parent route. Browser history while the previous + * entry is an in-app one, and while that is unknown (no Navigation API). + * Otherwise the page opened the session (deep link, reload, restored + * view), where `Location.back()` would do nothing in Electron or leave + * the app in a browser: the parent replaces the current entry, so + * history Back cannot return to the page just left. + */ + back( + resolveParent: () => WorkspaceBackParent | Promise + ): void { + if (!this.history || this.canGoBackInApp()) { + this.location.back(); + return; + } + void this.openParent(resolveParent); + } + /** * Returns the release function. It removes only this target: when one * page replaces another (a loading shell by the loaded one), creation and @@ -109,4 +136,17 @@ export class WorkspaceBackNavigationService { target.run(); return true; } + + private async openParent( + resolveParent: () => WorkspaceBackParent | Promise + ): Promise { + const parent = await resolveParent(); + if (parent === null) { + this.location.back(); + } else if (typeof parent === 'string') { + await this.router.navigateByUrl(parent, { replaceUrl: true }); + } else { + await this.router.navigate([...parent], { replaceUrl: true }); + } + } } diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index 52059e9aa..4ff19f141 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -22,6 +22,7 @@ import { routeParamSignal, resolveCurrentPortalPlaylistId, resolveCurrentPortalSection, + workspacePortalCommands, } from './portal-route.utils'; describe('portal-route.utils', () => { @@ -189,6 +190,41 @@ describe('portal-route.utils', () => { ).toBe('playlist-2'); }); + it('builds commands for the portal a nested route belongs to', () => { + const actorRoute = { + pathFromRoot: [ + { snapshot: { params: {}, paramMap: convertToParamMap({}) } }, + { + snapshot: { + params: { id: 'playlist-1' }, + paramMap: convertToParamMap({ id: 'playlist-1' }), + }, + }, + { + snapshot: { + params: { personId: '287' }, + paramMap: convertToParamMap({ personId: '287' }), + }, + }, + ], + } as unknown as ActivatedRoute; + const outsidePortal = { + pathFromRoot: [ + { snapshot: { params: {}, paramMap: convertToParamMap({}) } }, + ], + } as unknown as ActivatedRoute; + + expect(workspacePortalCommands(actorRoute, 'xtreams')).toEqual([ + '/workspace', + 'xtreams', + 'playlist-1', + ]); + expect( + workspacePortalCommands(actorRoute, 'stalker', 'series') + ).toEqual(['/workspace', 'stalker', 'playlist-1', 'series']); + expect(workspacePortalCommands(outsidePortal, 'xtreams')).toBeNull(); + }); + it('creates normalized query-param signals', () => { TestBed.configureTestingModule({}); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts index 9a2c81235..050baee42 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts @@ -117,6 +117,27 @@ export function resolveCurrentPortalPlaylistId( return extractPortalPlaylistId(routerUrl, provider); } +/** + * Router commands for the workspace portal that `route` belongs to (the `:id` + * param of an ancestor), or for one of its sections. Without a section the + * portal's route config redirects to its default section. Null outside a + * portal route. + */ +export function workspacePortalCommands( + route: ActivatedRoute, + provider: PortalProvider, + section?: PortalRailSection +): string[] | null { + const playlistId = resolveCurrentRouteParam(route, 'id'); + if (!playlistId) { + return null; + } + + return section + ? ['/workspace', provider, playlistId, section] + : ['/workspace', provider, playlistId]; +} + function resolveCurrentRouteParam( route: ActivatedRoute, key: string diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts index c3c2b69a2..ea3bbd477 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,7 +19,11 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -53,7 +56,7 @@ import { export class StalkerActorRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -139,8 +142,11 @@ export class StalkerActorRouteComponent { ); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.route, 'stalker') + ); } /** The portal ':id' param lives on an ancestor route */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts index b1af8a07f..e2d8ad1a7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -17,10 +16,15 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { DiscoverViewComponent, TitleResultsScope, + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -58,7 +62,7 @@ interface DiscoverItem extends DiscoverTitle { export class StalkerDiscoverRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -143,8 +147,18 @@ export class StalkerDiscoverRouteComponent { ); } + /** + * History Back; the catalog section this page lists when it opened the + * session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands( + this.route, + 'stalker', + discoverCatalogSection(this.facets()) + ) + ); } /** The portal ':id' param lives on an ancestor route */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 0c2a71fd6..ef4c207a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -8,7 +8,6 @@ import { untracked, viewChild, } from '@angular/core'; -import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -36,12 +35,14 @@ import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { isWorkspaceLayoutRoute, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, queryParamSignal, + workspacePortalCommands, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { @@ -89,7 +90,7 @@ interface StalkerFilter { }) export class StalkerSearchComponent { private readonly activatedRoute = inject(ActivatedRoute); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly dataService = inject(DataService); private readonly parentalLock = inject(ParentalLockService); private readonly playlistContext = inject(PlaylistContextFacade); @@ -418,9 +419,14 @@ export class StalkerSearchComponent { this.stalkerStore.setSelectedItem(null); } - /** Leave the search page (e.g. back to the actor page that opened it) */ + /** + * Leave the search page (e.g. back to the actor page that opened it); the + * portal's default section when the page opened the session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.activatedRoute, 'stalker') + ); } onVodBack(): void { diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 2d448734c..f6cdc3b33 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -21,7 +20,11 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -59,7 +62,7 @@ import { export class XtreamActorRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly xtreamStore = inject(XtreamStore); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -161,8 +164,11 @@ export class XtreamActorRouteComponent { this.openPortalSearch(item.title); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.route, 'xtreams') + ); } private openPortalSearch(title: string): void { diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts index 6fcced29c..f1a1c18b2 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts @@ -2,6 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { of } from 'rxjs'; +import { + WorkspaceBackNavigationService, + WorkspaceBackParent, +} from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { CatalogTitleMatchService, @@ -25,6 +29,8 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { let resolveDiscover: (titles: DiscoverTitle[] | null) => void; let discoverTitles: jest.Mock; + const back = jest.fn(); + let facetParams: Record; /** Creates the component and flushes the effect that starts the load */ function createComponent(): XtreamDiscoverRouteComponent { @@ -49,6 +55,8 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { vodStreams.set([]); serialStreams.set([]); + back.mockReset(); + facetParams = { type: 'movie', year: '1990' }; discoverTitles = jest.fn().mockImplementation( () => new Promise((resolve) => { @@ -61,13 +69,18 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { provideRouter([]), { provide: ActivatedRoute, - useValue: { - queryParams: of({ type: 'movie', year: '1990' }), + useFactory: () => ({ + queryParams: of(facetParams), snapshot: { - queryParams: { type: 'movie', year: '1990' }, + queryParams: facetParams, + params: { id: 'pl-1' }, pathFromRoot: [], }, - }, + }), + }, + { + provide: WorkspaceBackNavigationService, + useValue: { back }, }, { provide: XtreamStore, @@ -205,4 +218,26 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { expect(component.isLoading()).toBe(true); }); + + it.each([ + ['movie', 'vod'], + ['tv', 'series'], + ])( + 'leads a %s Discover page that opened the session to the %s list', + async (type, section) => { + facetParams = { type, year: '1990' }; + const component = createComponent(); + + component.goBack(); + const resolveParent = back.mock.calls[0][0] as () => + WorkspaceBackParent | Promise; + + expect(await resolveParent()).toEqual([ + '/workspace', + 'xtreams', + 'pl-1', + section, + ]); + } + ); }); diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts index 0399dd2bf..0e9b4d4c6 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -18,10 +17,15 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { DiscoverViewComponent, TitleResultsScope, + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -65,7 +69,7 @@ interface DiscoverItem extends DiscoverTitle { export class XtreamDiscoverRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly xtreamStore = inject(XtreamStore); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -185,8 +189,18 @@ export class XtreamDiscoverRouteComponent { this.openPortalSearch(item.title); } + /** + * History Back; the catalog section this page lists when it opened the + * session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands( + this.route, + 'xtreams', + discoverCatalogSection(this.facets()) + ) + ); } private openPortalSearch(title: string): void { diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index cfa8c7e44..737eee026 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -11,7 +11,6 @@ import { signal, viewChild, } from '@angular/core'; -import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -27,10 +26,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService } from '@iptvnator/services'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { buildXtreamNavigationTarget, isWorkspaceLayoutRoute, queryParamSignal, + workspacePortalCommands, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { SearchFilters } from '@iptvnator/portal/xtream/data-access'; @@ -108,7 +109,7 @@ export class SearchResultsComponent implements AfterViewInit { readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); readonly databaseService = inject(DatabaseService); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly logger = createLogger('XtreamSearchResults'); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); readonly routeSearchTerm = queryParamSignal( @@ -538,8 +539,11 @@ export class SearchResultsComponent implements AfterViewInit { this.dialogRef?.close(); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.activatedRoute, 'xtreams') + ); } toggleGroupByPlaylist(value: boolean) { diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts index a47693c57..ace2409a3 100644 --- a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts @@ -1,4 +1,5 @@ import { + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -94,6 +95,17 @@ describe('hasDiscoverFacet', () => { }); }); +describe('discoverCatalogSection', () => { + it('maps movies to the VOD section and TV to series', () => { + expect( + discoverCatalogSection(parseDiscoverParams({ type: 'movie' })) + ).toBe('vod'); + expect( + discoverCatalogSection(parseDiscoverParams({ type: 'tv' })) + ).toBe('series'); + }); +}); + describe('discoverFacetKey', () => { it('produces distinct keys for distinct facet sets', () => { const keys = new Set( diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts index 47ea52486..841b959c8 100644 --- a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts @@ -67,6 +67,16 @@ export function hasDiscoverFacet(facets: DiscoverRouteParams): boolean { ); } +/** + * The catalog section whose titles a Discover page lists. Its Back leads + * there when the page opened the session. + */ +export function discoverCatalogSection( + facets: DiscoverRouteParams +): 'vod' | 'series' { + return facets.type === 'tv' ? 'series' : 'vod'; +} + /** * Stable identity of one facet set. Used as the staleness-guard token by * the route containers (facets change via query params on the same route diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts index 5f57e5cee..a92669eb5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts @@ -1,18 +1,43 @@ import { Location } from '@angular/common'; import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + WORKSPACE_HISTORY_NAVIGATION, + WorkspaceBackNavigationService, + WorkspaceHistoryNavigation, +} from '@iptvnator/portal/shared/data-access'; +import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component'; +/** Navigation API state of a session that opened on the settings page. */ +const firstPageHistory = { + currentEntry: { index: 0 }, + entries: () => [{ index: 0, sameDocument: true }], + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +} as unknown as WorkspaceHistoryNavigation; + describe('WorkspaceSettingsContextPanelComponent', () => { - function setup() { + const resolveDashboardPath = jest.fn(); + + // Without a Navigation API (jsdom's default) history is unknown, so + // Back stays the browser's. + function setup(history: WorkspaceHistoryNavigation | null = null) { + resolveDashboardPath + .mockReset() + .mockResolvedValue('/workspace/dashboard'); TestBed.configureTestingModule({ imports: [WorkspaceSettingsContextPanelComponent], providers: [ provideRouter([]), + { provide: WORKSPACE_HISTORY_NAVIGATION, useValue: history }, + { + provide: WorkspaceStartupPreferencesService, + useValue: { resolveDashboardPath }, + }, { provide: TranslateService, useValue: { @@ -66,11 +91,33 @@ describe('WorkspaceSettingsContextPanelComponent', () => { backNavigation.goBack(); expect(back).toHaveBeenCalledTimes(1); + expect(resolveDashboardPath).not.toHaveBeenCalled(); fixture.destroy(); expect(backNavigation.target()).toBeNull(); }); + it('leads to the first workspace view when settings opened the session', async () => { + setup(firstPageHistory); + const back = jest + .spyOn(TestBed.inject(Location), 'back') + .mockImplementation(() => undefined); + const navigateByUrl = jest + .spyOn(TestBed.inject(Router), 'navigateByUrl') + .mockResolvedValue(true); + resolveDashboardPath.mockResolvedValue('/workspace/sources'); + + TestBed.inject(WorkspaceBackNavigationService).goBack(); + await Promise.resolve(); + await Promise.resolve(); + + // Location.back() would do nothing in Electron or leave the PWA. + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { + replaceUrl: true, + }); + }); + it('shows no counts while settings search is idle', () => { const { link } = setup(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 5a93f9125..cb6daa415 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -1,10 +1,15 @@ -import { Location } from '@angular/common'; import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access'; -import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; +import { + registerWorkspaceBack, + WorkspaceBackNavigationService, +} from '@iptvnator/portal/shared/data-access'; +import { + WorkspaceShellContextDrawerService, + WorkspaceStartupPreferencesService, +} from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; @Component({ @@ -56,7 +61,10 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings }) export class WorkspaceSettingsContextPanelComponent { readonly ctx = inject(SettingsContextService); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); + private readonly startupPreferences = inject( + WorkspaceStartupPreferencesService + ); // Root-provided; optional keeps standalone unit tests light. Section // links are real navigations now, so the phone drawer's NavigationEnd // auto-close fires too — the explicit close just makes the drawer react @@ -72,9 +80,14 @@ export class WorkspaceSettingsContextPanelComponent { // The panel exists exactly while the settings route shows, so it // offers the page's Back in the header. On a phone the toggle for // this drawer stays beside it: the drawer holds the sections. + // Opened as the session's first page, Back leads to the first + // workspace view (the dashboard, or sources when it is hidden). registerWorkspaceBack({ phoneDrawerToggle: 'beside', - run: () => this.location.back(), + run: () => + this.backNavigation.back(() => + this.startupPreferences.resolveDashboardPath() + ), }); }