diff --git a/.changes/ui-single-detail-back.md b/.changes/ui-single-detail-back.md new file mode 100644 index 000000000..80dfe35ba --- /dev/null +++ b/.changes/ui-single-detail-back.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +Movie and series detail pages now show one Back arrow instead of two. The floating arrow always returns to the list, while watching too, and the "Close player" button beside the player is the only control that returns to the description. Escape still closes the player first and then goes back. diff --git a/AGENTS.md b/AGENTS.md index b757031cb..82a8400e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -338,9 +338,10 @@ 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. 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 +scrollbar and guarded initial page focus. Its one sticky arrow is the host's +Back action in browse and watch alike; only Escape unwinds one level (close +inline playback to browse, then Back), and the now-playing bar's Close button +is the pointer way back to browse. 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 diff --git a/CLAUDE.md b/CLAUDE.md index dd9b76ee1..8900c0793 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1558,7 +1558,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) - The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style) - For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill. In fullscreen the same host offers the whole series as the slide-in episode panel (see the fullscreen channel panel paragraph in Video Players) -- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) +- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and the now-playing bar's "Close player" exit to browse without navigation; the shell's sticky back arrow is route-level back in both states (straight to the list via the host's `goBack()`), so the bar carries no second arrow - Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields. - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. - Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Single-season title markers correct both displayed and playback season coordinates; lazy VOD retains the original provider season key/number for stable IDs and old progress. Lazy VOD episode tracking IDs scope the parent series, provider episode, original season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match the episode and either its resolved or retained original provider season. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. Season resources ignore metadata-only selection patches, and episode responses belong to the exact loading VM so navigation cannot mix episode lists. @@ -1922,9 +1922,10 @@ 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. 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 +scrollbar and guarded initial page focus. Its one sticky arrow is the host's +Back action in browse and watch alike; only Escape unwinds one level (close +inline playback to browse, then Back), and the now-playing bar's Close button +is the pointer way back to browse. 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 diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index 9e2952863..c6029fc95 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -590,8 +590,8 @@ 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). + // Return to the list: the shell's sticky Back is route-level in browse + // and watch alike (closing the player is the bar's own Close button). const backButton = page .locator('app-portal-detail-shell') .first() diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index cb0e3ce5c..e78ad2d26 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -249,8 +249,8 @@ function dashboardRailCardByTitle( } 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). + // Return to the list: the shell's sticky Back is route-level in browse + // and watch alike (closing the player is the bar's own Close button). const backButton = page .locator('app-portal-detail-shell') .first() diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 99a528d12..a1b82b105 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -606,8 +606,8 @@ 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). + // Return to the list: the shell's sticky Back is route-level in browse + // and watch alike (closing the player is the bar's own Close button). const backButton = page .locator('app-portal-detail-shell') .first() diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 9221fdc27..d419d180e 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -653,8 +653,8 @@ 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). + // Return to the list: the shell's sticky Back is route-level in browse + // and watch alike (closing the player is the bar's own Close button). const backButton = page .locator('app-portal-detail-shell') .first() diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index a4cd8a6b9..b9bdee229 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1540,8 +1540,8 @@ 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). + // Return to the list: the shell's sticky Back is route-level in browse + // and watch alike (closing the player is the bar's own Close button). const backButton = page .locator('app-portal-detail-shell') .first() diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 9ce0cb863..469a220ee 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -406,11 +406,13 @@ 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. + // M3U has no browse Back target, so the shell shows no arrow in either + // state; the now-playing bar's own Close button returns to browse. const shell = detail(page).locator('app-portal-detail-shell'); + await expect(shell.locator('.shell__back-button')).toHaveCount(0); await shell + .locator('app-portal-inline-player') .getByRole('button', { name: 'Close player', exact: true }) - .first() .click(); await expect(inlineVideo(page)).toHaveCount(0); await expect(shell.locator('.shell__back-button')).toHaveCount(0); diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index b5a965353..70b05f160 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -162,18 +162,20 @@ Contracts: player subtree, so shell state changes cannot recreate the `