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:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-19 10:44:17 +02:00
1 parent 5273e5fb55
commit c76a901e8d
22 files changed
+127 -107

No files matched your search

+6
View File
@@ -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.
+4 -3
View File
@@ -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
+5 -4
View File
@@ -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()
+2 -2
View File
@@ -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()
+2 -2
View File
@@ -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()
+4 -2
View File
@@ -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);
+14 -12
View File
@@ -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.
+17 -11
View File
@@ -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.
@@ -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>
@@ -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;
@@ -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)