mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(ui): keep one Back arrow on detail pages (#1627)
Movie and series detail pages showed two arrow_back controls while the inline player was open: the shell's sticky arrow (added in #1576 so Back survives scrolling) meant "Close player" in watch state, while the now-playing bar carried a second arrow that meant route-level Back — the same icon with two meanings, next to a "Close player" button that duplicated the first. The shell's sticky arrow is now route-level Back in browse and watch alike, and the bar carries no arrow of its own. Closing the player is the bar's "Close player" button and Escape, which still unwinds one level (close, then back). Hosts without a browse Back target (M3U, downloads) render no arrow in either state. Unit specs for the shell and the inline player cover the new contract; the Electron and web E2E helpers that pressed Back from watch are updated, and the M3U flow closes the player through the bar's button. Docs, the mirrored CLAUDE.md/AGENTS.md paragraph and a release note follow the change. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5273e5fb55
commit
c76a901e8d
22 files changed
+127
-107
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -590,8 +590,8 @@ async function firstVisibleGridTitle(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// 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()
|
||||
|
||||
@@ -249,8 +249,8 @@ function dashboardRailCardByTitle(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// 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()
|
||||
|
||||
@@ -606,8 +606,8 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// 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()
|
||||
|
||||
@@ -653,8 +653,8 @@ async function expectUnifiedLiveDetailOpen(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// 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()
|
||||
|
||||
@@ -1540,8 +1540,8 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// 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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -162,18 +162,20 @@ Contracts:
|
||||
player subtree, so shell state changes cannot recreate the `<video>`.
|
||||
- **External MPV/VLC sessions do not flip the layout to watch** — browse
|
||||
layout stays, and the primary CTA keeps its "Stop <player>" behavior.
|
||||
- The shell's sticky control and Escape close inline playback through
|
||||
`closePlayerRequested`; hosts wire it to `closeInlinePlayer()`. From browse,
|
||||
they emit the host-owned `backClicked` instead (unless `backAvailable=false`).
|
||||
Escape respects fullscreen, menus/dialogs, editable fields and hidden/inert
|
||||
surfaces, and consumes a handled key so one press performs only one action.
|
||||
See [Portal Detail Navigation](./portal-detail-navigation.md).
|
||||
- The now-playing bar separates two exits: the back arrow emits
|
||||
`backClicked`, which hosts wire to their route-level `goBack()` (straight
|
||||
back to the list — everything browse offers is also visible in watch, so
|
||||
a two-step unwind would be ceremony); the "Close player" button and
|
||||
Escape emit `closed`/`closePlayerRequested` and return to browse without
|
||||
navigating.
|
||||
- The shell's sticky arrow emits the host-owned `backClicked` in browse and
|
||||
watch alike (unless `backAvailable=false`, when it is not rendered at all):
|
||||
hosts wire it to their route-level `goBack()`, straight back to the list —
|
||||
everything browse offers is also visible in watch, so a two-step unwind
|
||||
would be ceremony. Escape alone unwinds one level: in watch it emits
|
||||
`closePlayerRequested`, which hosts wire to `closeInlinePlayer()`; in browse
|
||||
it emits `backClicked`. Escape respects fullscreen, menus/dialogs, editable
|
||||
fields and hidden/inert surfaces, and consumes a handled key so one press
|
||||
performs only one action. See
|
||||
[Portal Detail Navigation](./portal-detail-navigation.md).
|
||||
- The now-playing bar has one exit of its own: the "Close player" button
|
||||
emits `closed` and returns to browse without navigating. It carries no back
|
||||
arrow — a second arrow beside the sticky one, with a different meaning,
|
||||
was the duplicate this contract removes.
|
||||
- Entering watch scrolls the shell to the top; leaving keeps the scroll
|
||||
position.
|
||||
|
||||
|
||||
@@ -30,10 +30,14 @@ The button has an opaque app-themed surface, visible keyboard focus, an Escape
|
||||
shortcut hint via native `title` and Electron `no-drag` hit testing. The hint
|
||||
does not create an overlay that could consume the first Escape press.
|
||||
|
||||
The sticky control and Escape unwind one level: watch emits
|
||||
`closePlayerRequested`, browse emits `backClicked`. Hosts retain their existing
|
||||
route/inline/collection return behavior. The now-playing bar's separate route
|
||||
Back action still returns directly to the list. Browse Escape requires focus
|
||||
The sticky control is route-level Back in both states: it emits `backClicked`
|
||||
whether or not inline playback is active, so the arrow keeps one meaning and
|
||||
the list is one click away while watching. Only Escape unwinds one level: watch
|
||||
emits `closePlayerRequested`, browse emits `backClicked`. Hosts retain their
|
||||
existing route/inline/collection return behavior. The now-playing bar carries
|
||||
no second arrow; its "Close player" button is the pointer counterpart of the
|
||||
watch Escape (two arrows with different meanings, and two controls for one
|
||||
action, were the pre-#1576 confusion this replaces). Browse Escape requires focus
|
||||
inside this shell; watch keeps the existing global close shortcut, including
|
||||
M3U playback started from its sidebar. Handled events, key repeats/modifiers,
|
||||
editable fields, inert/hidden shells, fullscreen, dialogs and menus are ignored.
|
||||
@@ -41,13 +45,15 @@ Escape bubbles through the shell before Material's body-level tooltip dispatcher
|
||||
so focused detail actions return with one press even while their tooltip is open.
|
||||
The document listener remains the outside-shell watch fallback; `defaultPrevented`
|
||||
prevents duplicate actions and preserves descendant handlers' priority.
|
||||
After closing a player, lost focus moves to the sticky control (or the shell
|
||||
when there is no browse Back), without scrolling or stealing existing focus.
|
||||
After Escape closes a player, lost focus moves to the sticky control (or the
|
||||
shell when there is no browse Back), without scrolling or stealing existing
|
||||
focus.
|
||||
|
||||
Hosts without browse navigation set `backAvailable=false`: M3U uses its channel
|
||||
sidebar, and collection bootstrap placeholders have no return handler. They
|
||||
have no browse button or browse Escape action; M3U watch still offers Close
|
||||
player. Loading/error shells with a return handler keep Back available.
|
||||
render no sticky arrow in either state and have no browse Escape action; their
|
||||
watch exits are the bar's Close player button and Escape. Loading/error shells
|
||||
with a return handler keep Back available.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -55,9 +61,9 @@ player. Loading/error shells with a return handler keep Back available.
|
||||
- Stalker uses an inline/store-state detail model.
|
||||
- Detail pages themselves are two-state (browse ↔ watch) inside
|
||||
`PortalDetailShellComponent`; entering/leaving watch is a layout state,
|
||||
not a navigation. Route-level back semantics are unchanged; the
|
||||
sticky watch control closes the inline player, while the now-playing bar
|
||||
retains its separate direct return to the list. See
|
||||
not a navigation. Route-level back semantics are unchanged; the one
|
||||
sticky arrow returns to the list from either state, while Escape and the
|
||||
now-playing bar's Close button close the inline player. See
|
||||
[Embedded Inline Playback](./embedded-inline-playback.md).
|
||||
- Favorites and recently viewed collections now use collection-owned inline detail
|
||||
for non-live Xtream and Stalker items.
|
||||
|
||||
-1
@@ -216,7 +216,6 @@
|
||||
[seasonLoadStates]="vodSeasonLoadStates()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(playbackEnded)="handleInlinePlaybackEnded()"
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
|
||||
@@ -222,7 +222,6 @@
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(playbackEnded)="handleInlinePlaybackEnded()"
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
|
||||
@@ -290,7 +290,6 @@
|
||||
(playbackFailed)="onPlaybackFailed()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@if (backAvailable() || isWatch()) {
|
||||
@let label =
|
||||
isWatch()
|
||||
? ('PORTALS.CLOSE_PLAYER' | translate)
|
||||
: backLabel() || ('BACK' | translate);
|
||||
@if (backAvailable()) {
|
||||
@let label = backLabel() || ('BACK' | translate);
|
||||
<!-- Route-level Back in both states. Watch keeps the arrow's meaning;
|
||||
closing the player belongs to the player's own Close button and Escape. -->
|
||||
<div class="shell__navigation">
|
||||
<button
|
||||
#backButton
|
||||
class="shell__back-button"
|
||||
type="button"
|
||||
[attr.aria-label]="label"
|
||||
aria-keyshortcuts="Escape"
|
||||
[title]="label + ' (Esc)'"
|
||||
(click)="onBack()"
|
||||
[attr.aria-keyshortcuts]="isWatch() ? null : 'Escape'"
|
||||
[title]="isWatch() ? label : label + ' (Esc)'"
|
||||
(click)="backClicked.emit()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
+38
-7
@@ -277,23 +277,44 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps one labelled back control outside the collapsing hero', async () => {
|
||||
it('keeps one route-back control outside the collapsing hero in both states', () => {
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
expect(back.closest('app-content-hero')).toBeNull();
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
back.click();
|
||||
expect(host.backRequests).toBe(1);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(query('.shell__back-button')).toBe(back);
|
||||
expect(back.getAttribute('aria-label')).toBe('Close player');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
requiredQuery('.fake-player').tabIndex = 0;
|
||||
requiredQuery('.fake-player').focus();
|
||||
// Watch keeps the arrow's meaning: it leaves the page, it does not
|
||||
// close the player. Escape is the close shortcut, so the hint goes.
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBeNull();
|
||||
expect(back.getAttribute('title')).toBe('Return to downloads');
|
||||
back.click();
|
||||
expect(host.backRequests).toBe(2);
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('moves lost focus to the back control after Escape closes the player', async () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
const player = requiredQuery('.fake-player');
|
||||
player.tabIndex = 0;
|
||||
player.focus();
|
||||
player.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
expect(document.activeElement).toBe(back);
|
||||
});
|
||||
|
||||
@@ -311,8 +332,18 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.backRequests).toBe(0);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
requiredQuery('.shell__back-button').click();
|
||||
// No route to go back to, so no arrow in watch either; the player's
|
||||
// own Close button and Escape remain the exits.
|
||||
expect(query('.shell__back-button')).toBeNull();
|
||||
requiredQuery('app-portal-detail-shell').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
* About block below the episodes slot.
|
||||
*
|
||||
* The shell owns the page scroll, the browse↔watch animation, Escape
|
||||
* handling, and never conditionally wraps the `[detail-player]` slot — the
|
||||
* handling, the one sticky Back control (route-level in both states; closing
|
||||
* the player is the player's own Close button and Escape), and never
|
||||
* conditionally wraps the `[detail-player]` slot — the
|
||||
* host's own `@if (inlinePlayback())` is the only thing that creates or
|
||||
* destroys the player, so shell state changes cannot recreate it.
|
||||
*/
|
||||
@@ -80,8 +82,9 @@ export class PortalDetailShellComponent {
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
/** The sticky control in either state, or Escape in browse. */
|
||||
readonly backClicked = output<void>();
|
||||
/** Emitted by the sticky control or Escape during inline playback. */
|
||||
/** Emitted by Escape during inline playback. */
|
||||
readonly closePlayerRequested = output<void>();
|
||||
|
||||
protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective);
|
||||
@@ -198,10 +201,11 @@ export class PortalDetailShellComponent {
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
this.onBack();
|
||||
this.unwind();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
/** One level per Escape: watch closes the player, browse leaves the page. */
|
||||
private unwind(): void {
|
||||
if (!this.playbackActive()) {
|
||||
if (this.backAvailable()) this.backClicked.emit();
|
||||
return;
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
<section class="player-shell" aria-label="Inline player">
|
||||
<header class="player-shell__header">
|
||||
<div class="player-shell__title-row">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-shell__back"
|
||||
data-testid="inline-player-back"
|
||||
[attr.aria-label]="'BACK' | translate"
|
||||
[matTooltip]="'BACK' | translate"
|
||||
(click)="onBack()"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="player-shell__heading">
|
||||
<div class="player-shell__eyebrow">
|
||||
{{ 'PORTALS.NOW_PLAYING' | translate }}
|
||||
|
||||
@@ -48,10 +48,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-shell__back {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.player-shell__heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
+12
-21
@@ -252,33 +252,24 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits backClicked (not closed) from the back button in the now-playing bar', () => {
|
||||
let backCount = 0;
|
||||
let closedCount = 0;
|
||||
it('offers no back arrow in the now-playing bar: leaving the page belongs to the detail shell', () => {
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'https://example.test/vod/1.mp4',
|
||||
title: 'Movie',
|
||||
});
|
||||
(
|
||||
component as unknown as {
|
||||
backClicked: { subscribe: (fn: () => void) => void };
|
||||
}
|
||||
).backClicked.subscribe(() => backCount++);
|
||||
(
|
||||
component as unknown as {
|
||||
closed: { subscribe: (fn: () => void) => void };
|
||||
}
|
||||
).closed.subscribe(() => closedCount++);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const backButton = fixture.nativeElement.querySelector(
|
||||
'[data-testid="inline-player-back"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(backButton).toBeTruthy();
|
||||
backButton.click();
|
||||
expect(backCount).toBe(1);
|
||||
expect(closedCount).toBe(0);
|
||||
const header = fixture.nativeElement.querySelector(
|
||||
'.player-shell__header'
|
||||
) as HTMLElement;
|
||||
expect(header).toBeTruthy();
|
||||
expect(header.querySelector('.player-shell__back')).toBeNull();
|
||||
expect(
|
||||
Array.from(header.querySelectorAll('mat-icon')).map((icon) =>
|
||||
icon.textContent?.trim()
|
||||
)
|
||||
).not.toContain('arrow_back');
|
||||
expect('backClicked' in component).toBe(false);
|
||||
});
|
||||
|
||||
describe('with strip country prefix enabled', () => {
|
||||
|
||||
@@ -234,9 +234,9 @@ export class PortalInlinePlayerComponent {
|
||||
fallbackTitle: this.title,
|
||||
});
|
||||
|
||||
/** The now-playing bar's Close button: back to browse, no navigation.
|
||||
* Leaving the page is the detail shell's sticky Back, not this bar's. */
|
||||
readonly closed = output<void>();
|
||||
/** Back arrow in the now-playing bar: route-level back, not just close. */
|
||||
readonly backClicked = output<void>();
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -294,10 +294,6 @@ export class PortalInlinePlayerComponent {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
onTimeUpdate(event: { currentTime: number; duration: number }): void {
|
||||
this.timeUpdate.emit(event);
|
||||
}
|
||||
|
||||
@@ -320,7 +320,6 @@
|
||||
[playback]="playback"
|
||||
(timeUpdate)="onInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="onStreamUrlCopied()"
|
||||
(externalFallbackRequested)="
|
||||
onInlineExternalFallbackRequested($event)
|
||||
|
||||
Reference in new issue
Block a user