diff --git a/.changes/portals-view-in-portal-back.md b/.changes/portals-view-in-portal-back.md new file mode 100644 index 000000000..e44ee31d0 --- /dev/null +++ b/.changes/portals-view-in-portal-back.md @@ -0,0 +1,8 @@ +--- +type: fix +area: portals +--- + +Going back from a Stalker title you reached with "View in portal" now returns +you to exactly where you were — the same favorites or history tab, with the +title still open — instead of dropping you on the Live TV tab. diff --git a/CLAUDE.md b/CLAUDE.md index 43788a913..e55ed6af3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1257,7 +1257,33 @@ engine` (restart required) or a `/series` route leaves the detail unable to load episodes. The virtual `series` category is normalized to `vod` the same way `resolveStalkerCollectionSelectedCategory()` does. Stalker also carries - `stalkerReturnTo`. + `stalkerReturnTo` plus + `stalkerReturnByHistory`, and the portal detail's back affordance + (`StalkerCatalogDetailComponent.onVodBack()`, + `StalkerSeriesViewComponent.goBack()`) honours the latter by stepping back + one history entry instead of calling `navigateByUrl()`. The collection's + active tab, scope and open inline detail live only in `window.history.state` + (`collectionViewState` / `openCollectionDetailItem`), so re-navigating would + reopen it on the default `live` tab and leave the portal page one browser + Back away. The marker carries the handed-off item's identity, not a bare + `true`: `openStalkerItem` is consumed on arrival while the return keys stay + on the entry, and a Stalker detail opens in place without pushing one — so + after Back + browser Forward the same entry can host a different title, whose + back affordance must just close it. A stale marker suppresses the whole + return contract, and honouring it retires both keys from the entry so a + browser Forward cannot replay them for a reopened title. Leaving with the + browser's own Back runs no affordance, so `CategoryContentViewComponent` + also retires the contract whenever it lands on the entry with no handoff + item and no open detail. That retirement is gated on the marker, so a plain + `stalkerReturnTo` caller such as the dashboard handoff is unaffected. The identity is + restricted to what `buildStalkerSelectedVodItem()` preserves (`id ?? +stream_id`); it drops `series_id`/`movie_id`, so the builder pins the + resolved id onto the handoff state item when the raw row carries neither — + those rows then get the same history return instead of degrading to a + re-navigation that resets the collection's tab. + Only this builder sets the marker, so the + dashboard handoff and any other `stalkerReturnTo` caller keeps + re-navigating. - Unlike the download handoff this bridge does NOT pass `detailPresentation: 'provider-only'` — the item exists in the provider catalog, so the full normal detail (downloads included) is wanted. diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 894e66989..e45fbcb93 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -53,7 +53,36 @@ Related: download handoff it does NOT pass `detailPresentation: 'provider-only'` — the item exists in the provider catalog and the full normal detail is desired. The Stalker handoff carries `stalkerReturnTo` so the portal detail's back - affordance returns to the originating collection. + affordance returns to the originating collection, plus + `stalkerReturnByHistory` so that return is a single history step rather than + a fresh `navigateByUrl()`. The collection's active tab, scope and open inline + detail live only in `window.history.state` (`collectionViewState` / + `openCollectionDetailItem`); re-navigating starts a stateless entry, which + reopened the collection on its default `live` tab and left the portal page + one browser Back away. The marker carries the handed-off item's identity + rather than a bare `true`, because `openStalkerItem` is consumed on arrival + while the return keys stay on the entry and a Stalker detail opens in place + without pushing one: after Back + browser Forward the same entry can host a + different title, and that title's back affordance must close it rather than + exit to the collection. A marker that does not match the open item is stale + and suppresses the whole return contract. Honouring it retires both return + keys from the entry, so the handoff is genuinely one-shot: a browser Forward + onto the same entry cannot replay it for a title reopened from the catalog. + Leaving via the browser's own Back runs no affordance at all, so + `CategoryContentViewComponent` retires the contract as well whenever it + lands on the entry with no handoff item and no detail open — the handoff is + over, and anything opened from the list afterwards is a fresh selection. It + is gated on the marker's presence, so a plain `stalkerReturnTo` caller such + as the dashboard handoff keeps its existing behaviour untouched. + The identity is restricted to the fields `buildStalkerSelectedVodItem()` + preserves (`id ?? stream_id`) — it drops `series_id`/`movie_id`, so binding + to the wider `extractStalkerItemId()` set would compare against an identity + the opened detail can no longer report and silently strand the affordance. + A row identified only by those alternate fields would open with an empty + identity, so the builder pins the resolved id onto the handoff state item + and those rows keep the history return as well. The marker is set only by this + builder and only alongside `returnTo`, so the dashboard handoff and every + other `stalkerReturnTo` caller keeps its re-navigating behaviour. - Do not force both portals into the same browse/detail behavior unless the full portal detail architecture is being changed. diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 026c96fad..a2e4e4918 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -373,6 +373,72 @@ describe('CategoryContentViewComponent', () => { expect(window.history.state).toEqual({ preserved: 'value' }); }); + it('retires a return marker that outlived its handoff item', async () => { + // Leaving the entry with the browser's own Back never runs a back + // affordance, so nothing retired the contract; a Forward replay lands + // here with the marker but no handoff item and no open detail. + catalog.provider = 'stalker'; + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + preserved: 'value', + }, + '', + window.location.href + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(window.history.state).toEqual({ preserved: 'value' }); + }); + + it('leaves a plain stalkerReturnTo handoff alone', async () => { + // The dashboard handoff sets no history-back marker and keeps its + // pre-existing re-navigating behaviour. + catalog.provider = 'stalker'; + window.history.replaceState( + { stalkerReturnTo: '/workspace/dashboard' }, + '', + window.location.href + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(window.history.state).toEqual({ + stalkerReturnTo: '/workspace/dashboard', + }); + }); + + it('keeps the return marker while the handoff detail is being opened', async () => { + const item = { id: '42', category_id: 'vod' }; + catalog.provider = 'stalker'; + catalog.selectItem.mockImplementation((selected) => { + selectedItem.set(selected); + return null; + }); + window.history.replaceState( + { + openStalkerItem: item, + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '', + window.location.href + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + // The contract must survive arrival — the back affordance consumes it. + expect(window.history.state).toEqual({ + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }); + }); + it('does not retain the consumed provider-only presentation across identity, regular-open, or route changes', async () => { const item = { id: '42', category_id: 'vod' }; catalog.provider = 'stalker'; @@ -500,9 +566,7 @@ describe('CategoryContentViewComponent', () => { infiniteFixture.detectChanges(); - expect(catalog.consumeSavedScrollPosition).toHaveBeenCalledTimes( - 1 - ); + expect(catalog.consumeSavedScrollPosition).toHaveBeenCalledTimes(1); const grid = infiniteFixture.nativeElement.querySelector( 'app-grid-list' diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 5a5cf175f..33f4e311b 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -26,8 +26,11 @@ import { } from '@iptvnator/portal/shared/ui'; import { clearNavigationStateKeys, + consumeStalkerReturnMarker, getOpenStalkerItemState, + getStalkerReturnByHistoryState, isProviderOnlyDetailState, + normalizeStalkerHandoffIdentity, PortalCatalogFacade, OPEN_STALKER_ITEM_STATE_KEY, PORTAL_CATALOG_DETAIL_COMPONENT, @@ -333,6 +336,21 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { const item = getOpenStalkerItemState(window.history.state); if (!item) { + // The handoff item is gone, but its return contract can still sit + // on this entry: leaving with the browser's own Back never runs a + // back affordance, so nothing retired it. Landing here with no + // detail open means the handoff is over — any title opened from + // the list now is a fresh selection whose Back must close it + // rather than exit to the collection. + // Scoped to collection handoffs, which are the only ones that + // set the history-back marker: a plain `stalkerReturnTo` caller + // (the dashboard) keeps its existing re-navigating behaviour. + if ( + !this.selectedItem() && + getStalkerReturnByHistoryState(window.history.state) + ) { + consumeStalkerReturnMarker(); + } return; } @@ -359,10 +377,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { ): string | null { const rawId = item?.id ?? item?.series_id ?? item?.movie_id ?? item?.stream_id; - const normalized = String(rawId ?? '') - .trim() - .split(':')[0] - ?.trim(); - return normalized || null; + // Shared with the return-marker binding in + // `resolveStalkerBackNavigation()` so the two cannot drift apart. + return normalizeStalkerHandoffIdentity(rawId) || null; } } diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts index c46dc19a4..65b5f07df 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts @@ -1,5 +1,10 @@ import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface'; -import { getUnifiedCollectionDetailNavigation } from './collection-detail-portal-navigation'; +import { + getStalkerReturnByHistoryState, + getUnifiedCollectionDetailNavigation, + isStalkerReturnByHistoryFor, + resolveStalkerBackNavigation, +} from './collection-detail-portal-navigation'; describe('getUnifiedCollectionDetailNavigation', () => { const xtreamMovie: UnifiedCollectionItem = { @@ -217,6 +222,127 @@ describe('getUnifiedCollectionDetailNavigation', () => { ); }); + it('binds the history-return marker to the handed-off item', () => { + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(getStalkerReturnByHistoryState(navigation?.state)).toBe( + 'movie-5' + ); + expect( + isStalkerReturnByHistoryFor(navigation?.state, { id: 'movie-5' }) + ).toBe(true); + // A lazy episode id keeps its parent identity. + expect( + isStalkerReturnByHistoryFor(navigation?.state, { id: 'movie-5:12' }) + ).toBe(true); + // Any other title on the same history entry is a stale match. + expect( + isStalkerReturnByHistoryFor(navigation?.state, { id: 'movie-9' }) + ).toBe(false); + expect(isStalkerReturnByHistoryFor(navigation?.state, undefined)).toBe( + false + ); + }); + + it('pins a usable id so an alternate-id row still gets history return', () => { + // buildStalkerSelectedVodItem() derives `id` from `id ?? stream_id`, + // so a movie_id-only row would open with an empty identity and the + // marker would have nothing to bind to. Pinning the resolved id keeps + // these rows on the history return instead of degrading to a + // re-navigation that resets the collection's tab. + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + stalkerItem: { + movie_id: 'movie-5', + title: 'Movie Five', + } as never, + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(getStalkerReturnByHistoryState(navigation?.state)).toBe( + 'movie-5' + ); + expect(navigation?.state?.['openStalkerItem']).toEqual( + expect.objectContaining({ id: 'movie-5', movie_id: 'movie-5' }) + ); + }); + + it('leaves an existing id on the state item untouched', () => { + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + stalkerItem: { + id: 'raw-id', + title: 'Movie Five', + } as never, + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(navigation?.state?.['openStalkerItem']).toEqual( + expect.objectContaining({ id: 'raw-id' }) + ); + expect(getStalkerReturnByHistoryState(navigation?.state)).toBe( + 'raw-id' + ); + }); + + it('does not mark the handoff when no returnTo is supplied', () => { + const navigation = getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + }); + + expect(getStalkerReturnByHistoryState(navigation?.state)).toBeNull(); + }); + + it('does not treat other navigation state as history-returnable', () => { + expect(getStalkerReturnByHistoryState(null)).toBeNull(); + expect(getStalkerReturnByHistoryState(undefined)).toBeNull(); + expect( + getStalkerReturnByHistoryState({ + stalkerReturnTo: '/workspace/dashboard', + }) + ).toBeNull(); + // A non-string or blank marker carries no identity to bind to. + expect( + getStalkerReturnByHistoryState({ stalkerReturnByHistory: true }) + ).toBeNull(); + expect( + getStalkerReturnByHistoryState({ stalkerReturnByHistory: ' ' }) + ).toBeNull(); + }); + it('returns null for live and m3u items', () => { expect( getUnifiedCollectionDetailNavigation({ @@ -236,3 +362,62 @@ describe('getUnifiedCollectionDetailNavigation', () => { ).toBeNull(); }); }); + +describe('resolveStalkerBackNavigation', () => { + const handoff = { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: 'movie-5', + }; + + it('steps back for the title its marker was bound to', () => { + expect( + resolveStalkerBackNavigation(handoff, { id: 'movie-5' }) + ).toEqual({ kind: 'history-back' }); + expect( + resolveStalkerBackNavigation(handoff, { id: 'movie-5:3' }) + ).toEqual({ kind: 'history-back' }); + }); + + it('matches the id shape buildStalkerSelectedVodItem() produces', () => { + // That normalizer derives `id` from `id ?? stream_id` only, so those + // are the sole fields the opened detail can still report. + expect( + resolveStalkerBackNavigation(handoff, { stream_id: 'movie-5' }) + ).toEqual({ kind: 'history-back' }); + // series_id/movie_id do not survive normalization, so the builder + // pins a usable `id` instead of binding to them; a selection that + // still reports only movie_id cannot match. + expect( + resolveStalkerBackNavigation(handoff, { movie_id: 'movie-5' }) + ).toEqual({ kind: 'none' }); + }); + + it('suppresses the whole contract for a stale marker', () => { + // Gating only the history step would let the equally stale + // `stalkerReturnTo` re-navigate and produce the same unexpected exit. + expect( + resolveStalkerBackNavigation(handoff, { id: 'movie-9' }) + ).toEqual({ kind: 'none' }); + expect(resolveStalkerBackNavigation(handoff, undefined)).toEqual({ + kind: 'none', + }); + }); + + it('re-navigates for a plain returnTo handoff', () => { + expect( + resolveStalkerBackNavigation( + { stalkerReturnTo: '/workspace/dashboard' }, + { id: 'movie-5' } + ) + ).toEqual({ kind: 'navigate', url: '/workspace/dashboard' }); + }); + + it('does nothing without any return target', () => { + expect(resolveStalkerBackNavigation({}, { id: 'movie-5' })).toEqual({ + kind: 'none', + }); + expect(resolveStalkerBackNavigation(null, { id: 'movie-5' })).toEqual({ + kind: 'none', + }); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts index 040bd8178..cc5545366 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts @@ -3,6 +3,9 @@ import { buildStalkerDetailNavigationTarget, buildStalkerStateItem, buildXtreamNavigationTarget, + clearNavigationStateKeys, + getStalkerReturnToState, + STALKER_RETURN_TO_STATE_KEY, WorkspaceNavigationTarget, } from './workspace-portal-navigation'; import { @@ -10,6 +13,128 @@ import { StalkerPortalItem, } from '@iptvnator/shared/interfaces'; +/** + * Marks a Stalker handoff whose origin is exactly one history entry back, so + * the portal detail's back affordance can step through history instead of + * re-navigating to `stalkerReturnTo`. + * + * The collection page keeps its active tab, scope and open inline detail only + * in `window.history.state` (`collectionViewState` / `openCollectionDetailItem`). + * `navigateByUrl()` starts a fresh entry without them, so the collection would + * come back on its default `live` tab and leave the portal page one browser + * Back away. Only this builder sets the marker, and only when it also supplies + * `returnTo`, so every other `stalkerReturnTo` caller keeps re-navigating. + * + * The value is the handed-off item's identity rather than a bare `true`: + * `openStalkerItem` is consumed on arrival, but the return keys stay on the + * history entry, and a Stalker detail opens in place without pushing one. So + * after a Back + browser Forward the same entry can host a *different* title, + * and an unbound marker would send that title's back affordance out to the + * collection instead of closing it. Binding scopes the whole return contract + * to the one title the handoff opened. + */ +export const STALKER_RETURN_BY_HISTORY_STATE_KEY = 'stalkerReturnByHistory'; + +/** + * Reads the marker above off a history/navigation state record, returning the + * item identity it is bound to, or `null` when absent/malformed. + */ +export function getStalkerReturnByHistoryState(state: unknown): string | null { + if (!state || typeof state !== 'object') { + return null; + } + + const raw = (state as Record)[ + STALKER_RETURN_BY_HISTORY_STATE_KEY + ]; + + return typeof raw === 'string' && raw.trim() ? raw.trim() : null; +} + +/** + * Normalizes a Stalker item id for marker comparison. Mirrors the catalog + * view's own `stalkerItemIdentity()`: a lazy episode id carries a `parent:child` + * suffix, and only the parent identifies the opened title. + */ +export function normalizeStalkerHandoffIdentity(value: unknown): string { + // `split` always yields at least one element, so the first is a string. + return String(value ?? '') + .trim() + .split(':')[0] + .trim(); +} + +/** + * Identity of a Stalker item for marker comparison, restricted to the fields + * that survive `buildStalkerSelectedVodItem()` — it derives `id` from + * `id ?? stream_id` and drops `series_id`/`movie_id`. Comparing against the + * wider `extractStalkerItemId()` set would read an identity the opened detail + * can no longer produce, so the marker would never match and the back + * affordance would silently do nothing. + */ +export function stalkerHandoffIdentityOf(item: unknown): string { + const raw = (item ?? {}) as Record; + + return normalizeStalkerHandoffIdentity(raw['id'] ?? raw['stream_id']); +} + +/** + * True when the history entry's return marker belongs to the currently opened + * item. A marker left over from an earlier handoff on the same entry is stale + * and must not drive the back affordance. + */ +export function isStalkerReturnByHistoryFor( + state: unknown, + selectedItem: unknown +): boolean { + const marker = getStalkerReturnByHistoryState(state); + const identity = stalkerHandoffIdentityOf(selectedItem); + + return Boolean(marker && identity && marker === identity); +} + +/** + * Retires the return contract from the current history entry. The handoff is + * one-shot: without this, browser Forward reopens the portal entry with the + * marker intact, and reopening the same title from the catalog would send its + * back affordance out to the collection instead of just closing it. + */ +export function consumeStalkerReturnMarker(): void { + clearNavigationStateKeys([ + STALKER_RETURN_BY_HISTORY_STATE_KEY, + STALKER_RETURN_TO_STATE_KEY, + ]); +} + +/** + * What a Stalker detail's back affordance should do, given the current history + * entry and the title it currently shows. Both back handlers share this so the + * marker/`returnTo` precedence cannot drift between them. + */ +export type StalkerBackNavigation = + | { kind: 'history-back' } + | { kind: 'navigate'; url: string } + | { kind: 'none' }; + +export function resolveStalkerBackNavigation( + state: unknown, + selectedItem: unknown +): StalkerBackNavigation { + // A collection handoff is exactly one entry back, and the collection's + // tab/scope/inline-detail live only on that entry — re-navigating would + // drop them and leave the portal page one browser Back away. The keys + // outlive the handoff though, so a marker bound to another title is stale + // and must suppress the whole contract: back then just closes the detail. + if (getStalkerReturnByHistoryState(state)) { + return isStalkerReturnByHistoryFor(state, selectedItem) + ? { kind: 'history-back' } + : { kind: 'none' }; + } + + const returnTo = getStalkerReturnToState(state); + return returnTo ? { kind: 'navigate', url: returnTo } : { kind: 'none' }; +} + /** * Builds the portal-detail target for a collection item, or `null` when no * exact detail route can be formed. Unlike `getUnifiedCollectionNavigation` @@ -52,19 +177,49 @@ export function getUnifiedCollectionDetailNavigation( type ); - return buildStalkerDetailNavigationTarget({ + const returnTo = options?.returnTo ?? null; + const stalkerId = item.stalkerId ?? lastUidSegment(item.uid) ?? ''; + const stateItem = buildStalkerStateItem(stalkerItem, { + id: stalkerId, + title: item.name, + type, + category_id: categoryId, + poster_url: item.posterUrl ?? item.logo ?? undefined, + }); + + // `buildStalkerStateItem()` keeps the raw row as-is, and + // `buildStalkerSelectedVodItem()` later derives `id` from + // `id ?? stream_id` only. A row carrying just `movie_id`/`series_id` + // would therefore open with an empty identity — nothing the marker + // could bind to. Pin the resolved id so those rows get the same + // history return as every other one instead of degrading to a + // re-navigation that resets the collection's tab. + if (!stalkerHandoffIdentityOf(stateItem)) { + const resolvedId = normalizeStalkerHandoffIdentity(stalkerId); + if (resolvedId) { + stateItem['id'] = resolvedId; + } + } + + const target = buildStalkerDetailNavigationTarget({ playlistId: item.playlistId, type, categoryId, - item: buildStalkerStateItem(stalkerItem, { - id: item.stalkerId ?? lastUidSegment(item.uid) ?? '', - title: item.name, - type, - category_id: categoryId, - poster_url: item.posterUrl ?? item.logo ?? undefined, - }), - returnTo: options?.returnTo ?? null, + item: stateItem, + returnTo, }); + + const handoffIdentity = stalkerHandoffIdentityOf(stateItem); + + return returnTo && handoffIdentity + ? { + ...target, + state: { + ...(target.state ?? {}), + [STALKER_RETURN_BY_HISTORY_STATE_KEY]: handoffIdentity, + }, + } + : target; } return null; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts index d0f4ac49a..041d23ab1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts @@ -1,3 +1,4 @@ +import { Location } from '@angular/common'; import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -16,6 +17,7 @@ import { } from '@iptvnator/services'; import { VodDetailsComponent } from '@iptvnator/ui/playback'; import { EMPTY, of } from 'rxjs'; +import { buildStalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access'; import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component'; @@ -68,6 +70,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { const contentType = signal<'vod' | 'series'>('vod'); const catalogPlaylist = signal({ id: 'stalker-1' }); const snackBar = { open: jest.fn() }; + const routerMock = { navigateByUrl: jest.fn() }; + const locationMock = { back: jest.fn() }; + const originalHistoryState = window.history.state; const selectedItem = signal({ id: '42', cmd: '/media/42', @@ -85,6 +90,8 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(true); catalogPlaylist.set({ id: 'stalker-1' }); snackBar.open.mockReset(); + routerMock.navigateByUrl.mockReset(); + locationMock.back.mockReset(); await TestBed.configureTestingModule({ imports: [StalkerCatalogDetailComponent], @@ -123,7 +130,8 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { useValue: { getPortalFavorites: jest.fn(() => of([])) }, }, { provide: DownloadsService, useValue: {} }, - { provide: Router, useValue: { navigateByUrl: jest.fn() } }, + { provide: Router, useValue: routerMock }, + { provide: Location, useValue: locationMock }, { provide: MatSnackBar, useValue: snackBar }, { provide: TranslateService, @@ -153,6 +161,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { afterEach(() => { fixture.destroy(); + window.history.replaceState(originalHistoryState, ''); }); it('passes provider-only mode to the matching regular VOD', async () => { @@ -319,4 +328,110 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { expect(fixture.componentInstance.inlinePlayback()).toBe(playback); }); + + it('steps back through history for a collection handoff', () => { + // The collection's tab, scope and open inline detail live only on the + // previous history entry; re-navigating would drop them. + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('still re-navigates for a plain stalkerReturnTo handoff', () => { + window.history.replaceState( + { stalkerReturnTo: '/workspace/dashboard' }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith( + '/workspace/dashboard' + ); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('does not navigate when no return target is present', () => { + window.history.replaceState({}, ''); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('retires the return contract so a forward-replay cannot fire it again', () => { + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(window.history.state?.stalkerReturnByHistory).toBeUndefined(); + expect(window.history.state?.stalkerReturnTo).toBeUndefined(); + }); + + it('matches a selection whose id came from stream_id', () => { + // buildStalkerSelectedVodItem() derives `id` from `id ?? stream_id`, + // so that is the only shape the opened detail can report here. + selectedItem.set( + buildStalkerSelectedVodItem({ + stream_id: '42', + cmd: '/media/42', + } as never) + ); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + }); + + it('ignores a marker left over from an earlier handoff on this entry', () => { + // The return keys outlive the handoff, and a Stalker detail opens in + // place — so a later title on the same entry must just close. + selectedItem.set({ + id: '77', + cmd: '/media/77', + info: { name: 'A later title' }, + }); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 1d34094a9..98fa25f03 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -7,11 +7,13 @@ import { input, signal, } from '@angular/core'; +import { Location } from '@angular/common'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { - getStalkerReturnToState, + consumeStalkerReturnMarker, + resolveStalkerBackNavigation, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -71,6 +73,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { PlaybackPositionRuntimeBridgeService ); private readonly router = inject(Router); + private readonly location = inject(Location); readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); @@ -224,12 +227,23 @@ export class StalkerCatalogDetailComponent implements OnDestroy { } onVodBack(): void { - const returnTo = getStalkerReturnToState(window.history.state); + const back = resolveStalkerBackNavigation( + window.history.state, + this.selectedItem() + ); + // Closing the detail is unconditional: a `none` decision (no return + // target, or a marker left by an earlier handoff) still returns the + // user to the category list — it only suppresses the navigation. this.closeInlinePlayer(); this.catalog.clearSelectedItem(); - if (returnTo) { - void this.router.navigateByUrl(returnTo); + if (back.kind === 'history-back') { + // One-shot: retire the contract so a browser Forward onto this + // entry cannot replay it for a freshly opened title. + consumeStalkerReturnMarker(); + this.location.back(); + } else if (back.kind === 'navigate') { + void this.router.navigateByUrl(back.url); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index d5d0788df..dc50a34ca 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -586,9 +586,9 @@ describe('StalkerCollectionDetailComponent', () => { ); fixture.detectChanges(); - expect( - fixture.debugElement.injector.get(VIEW_IN_PORTAL_HANDOFF) - ).toBe(fixture.componentInstance); + expect(fixture.debugElement.injector.get(VIEW_IN_PORTAL_HANDOFF)).toBe( + fixture.componentInstance + ); expect(fixture.componentInstance.viewInPortalAvailable()).toBe(true); expect(fixture.componentInstance.viewInPortalPlaylistName()).toBe( 'Stalker Portal' @@ -619,6 +619,11 @@ describe('StalkerCollectionDetailComponent', () => { title: 'Series Nine', }), stalkerReturnTo: '/workspace/global-favorites', + // The portal detail's back affordance steps back through + // history so the collection keeps its tab and open detail. + // Bound to the handed-off item so a later title on the + // same history entry does not inherit it. + stalkerReturnByHistory: 'series-9', }, } ); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts new file mode 100644 index 000000000..db201e0b5 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts @@ -0,0 +1,212 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + buildStalkerSelectedVodItem, + StalkerStore, +} from '@iptvnator/portal/stalker/data-access'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { EMPTY, of } from 'rxjs'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; + +describe('StalkerSeriesViewComponent back navigation', () => { + let fixture: ComponentFixture; + const routerMock = { navigateByUrl: jest.fn() }; + const locationMock = { back: jest.fn() }; + const clearSelectedItem = jest.fn(); + const originalHistoryState = window.history.state; + const selectedItem = signal | null>({ + id: '42', + }); + + beforeEach(async () => { + routerMock.navigateByUrl.mockReset(); + locationMock.back.mockReset(); + clearSelectedItem.mockReset(); + selectedItem.set({ id: '42' }); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType: signal('series'), + currentPlaylist: signal({ + _id: 'stalker|playlist', + title: 'Portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }), + getSerialSeasonsResource: () => [], + getVodSeriesSeasonsResource: () => [], + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes: jest.fn(), + resolveVodPlayback: jest.fn(), + fetchLinkToPlay: jest.fn(), + clearSelectedItem, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions: jest + .fn() + .mockResolvedValue([]), + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + }, + }, + { provide: Router, useValue: routerMock }, + { provide: Location, useValue: locationMock }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn(), + getSeasonEpisodes: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + window.history.replaceState(originalHistoryState, ''); + }); + + it('steps back through history for a collection handoff', () => { + // The originating collection keeps its tab, scope and open inline + // detail only on the previous history entry. + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(clearSelectedItem).toHaveBeenCalled(); + }); + + it('still re-navigates for a plain stalkerReturnTo handoff', () => { + window.history.replaceState( + { stalkerReturnTo: '/workspace/dashboard' }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith( + '/workspace/dashboard' + ); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('does not navigate when no return target is present', () => { + window.history.replaceState({}, ''); + + fixture.componentInstance.goBack(); + + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('retires the return contract so a forward-replay cannot fire it again', () => { + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(window.history.state?.stalkerReturnByHistory).toBeUndefined(); + expect(window.history.state?.stalkerReturnTo).toBeUndefined(); + }); + + it('matches a selection whose id came from stream_id', () => { + selectedItem.set( + buildStalkerSelectedVodItem({ stream_id: '42' } as never) as never + ); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + }); + + it('ignores a marker left over from an earlier handoff on this entry', () => { + // The return keys outlive the handoff, and a Stalker detail opens in + // place — so a later title on the same entry must just close. + selectedItem.set({ id: '77' }); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(clearSelectedItem).toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 55d480ac5..807d2be0c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -9,6 +9,7 @@ import { signal, untracked, } from '@angular/core'; +import { Location } from '@angular/common'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; @@ -39,7 +40,8 @@ import { PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, createLogger, - getStalkerReturnToState, + consumeStalkerReturnMarker, + resolveStalkerBackNavigation, } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, @@ -160,6 +162,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { }; private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly router = inject(Router); + private readonly location = inject(Location); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly playbackPositionBridge = inject( PlaybackPositionRuntimeBridgeService @@ -839,13 +842,24 @@ export class StalkerSeriesViewComponent implements OnDestroy { } goBack() { - const returnTo = getStalkerReturnToState(window.history.state); + const back = resolveStalkerBackNavigation( + window.history.state, + this.stalkerStore.selectedItem() + ); + // Closing the detail is unconditional: a `none` decision (no return + // target, or a marker left by an earlier handoff) still returns the + // user to the category list — it only suppresses the navigation. this.closeInlinePlayer(); this.backClicked.emit(); this.stalkerStore.clearSelectedItem(); - if (returnTo) { - void this.router.navigateByUrl(returnTo); + if (back.kind === 'history-back') { + // One-shot: retire the contract so a browser Forward onto this + // entry cannot replay it for a freshly opened title. + consumeStalkerReturnMarker(); + this.location.back(); + } else if (back.kind === 'navigate') { + void this.router.navigateByUrl(back.url); } }