fix(portals): third review round on the cinematic details

- A forced MPV/VLC episode launch closes an episode of the same series
  that is still running externally before starting, as the movie flow
  does; a failed close keeps the running player.
- A Stalker external launch resolved after the user moved to another
  movie is dropped instead of launching the old stream or closing the
  new item's inline playback.
- The trailer's sound toggle is rendered by the hero above its content
  layer, so it can actually be clicked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 10:50:52 +02:00
1 parent efd70b75e6
commit d79e6504a5
13 files changed
+327 -78

No files matched your search

@@ -20,6 +20,70 @@ const POSITION = {
durationSeconds: 5400,
} as PlaybackPositionData;
describe('createStalkerVodDetailActions openExternal', () => {
function setup(selectedVodId: () => number | null) {
let resolveLink: (playback: unknown) => void = () => undefined;
const resolvePlayback = jest.fn(
() => new Promise((resolve) => (resolveLink = resolve))
);
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const beforeExternalLaunch = jest.fn();
const actions = createStalkerVodDetailActions({
resolvePlayback: resolvePlayback as never,
portalPlayer: { openExternalPlayback },
playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() },
playlistId: () => 'portal-1',
selectedVodId,
selectedVodPosition: signal(null),
beforeExternalLaunch,
snackBar: { open: jest.fn() },
translate: { instant: (key: string) => key },
logError: jest.fn(),
});
return {
actions,
openExternalPlayback,
beforeExternalLaunch,
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
};
}
it('launches the resolved stream while the movie is still selected', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await launch;
expect(t.beforeExternalLaunch).toHaveBeenCalledTimes(1);
expect(t.openExternalPlayback).toHaveBeenCalledWith(
{ streamUrl: 'http://cdn/42.mp4' },
'mpv'
);
});
it('drops the stream once another movie was selected meanwhile', async () => {
let selected = 42;
const t = setup(() => selected);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'vlc',
positionSeconds: null,
});
selected = 7;
t.resolveLink();
await launch;
// Neither the old movie's player nor the new movie's inline playback
// is touched.
expect(t.beforeExternalLaunch).not.toHaveBeenCalled();
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
});
describe('createStalkerVodDetailActions resetProgress', () => {
function setup(selectedVodId: () => number | null) {
const selectedVodPosition = signal<PlaybackPositionData | null>(
@@ -83,6 +83,14 @@ export function createStalkerVodDetailActions(
if (event.item.type !== 'stalker') {
return;
}
// The `create_link` round trip may outlive the selection: a
// stream resolved for a movie the user left is dropped, and its
// failure is not reported over the new one.
const playlistId = deps.playlistId();
const vodId = Number(event.item.data.id);
const stillSelected = () =>
deps.selectedVodId() === vodId &&
deps.playlistId() === playlistId;
try {
const playback = await deps.resolvePlayback(
event.item.cmd,
@@ -90,6 +98,9 @@ export function createStalkerVodDetailActions(
event.item.data.info?.movie_image,
event.positionSeconds ?? undefined
);
if (!stillSelected()) {
return;
}
deps.beforeExternalLaunch?.();
await deps.portalPlayer.openExternalPlayback(
playback,
@@ -97,7 +108,9 @@ export function createStalkerVodDetailActions(
);
} catch (error) {
deps.logError('External VOD playback failed', error);
notify('PORTALS.PLAYBACK_ERROR');
if (stillSelected()) {
notify('PORTALS.PLAYBACK_ERROR');
}
}
},
@@ -0,0 +1,93 @@
import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { openEpisodeExternally } from './serial-details-external-launch';
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'http://xtream.example/series/1002.mp4',
title: 'Episode 2',
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 1002,
contentType: 'episode',
seriesXtreamId: 103,
},
};
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'mpv-1',
player: 'mpv',
status: 'opened',
canClose: true,
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
},
...overrides,
} as ExternalPlayerSession;
}
describe('openEpisodeExternally', () => {
function host(active: ExternalPlayerSession | null) {
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const closeSession = jest.fn().mockResolvedValue(undefined);
return {
openExternalPlayback,
closeSession,
host: {
portalPlayer: { openExternalPlayback },
externalPlayback: {
activeSession: () => active,
closeSession,
},
currentPlaylistId: () => 'xtream-1',
selectedItem: () => ({ series_id: '103' }),
},
};
}
it('closes the running episode of this series before launching', async () => {
const running = session();
const t = host(running);
await openEpisodeExternally(t.host, PLAYBACK, 'vlc');
expect(t.closeSession).toHaveBeenCalledWith(running);
expect(t.closeSession.mock.invocationCallOrder[0]).toBeLessThan(
t.openExternalPlayback.mock.invocationCallOrder[0]
);
expect(t.openExternalPlayback).toHaveBeenCalledWith(PLAYBACK, 'vlc');
});
it('leaves another title alone and launches beside it', async () => {
const other = session({
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 777,
contentType: 'vod',
},
});
const t = host(other);
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
expect(t.closeSession).not.toHaveBeenCalled();
expect(t.openExternalPlayback).toHaveBeenCalledWith(PLAYBACK, 'mpv');
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(
openEpisodeExternally(t.host, PLAYBACK, 'mpv')
).resolves.toBeUndefined();
expect(t.openExternalPlayback).not.toHaveBeenCalled();
warn.mockRestore();
});
});
@@ -0,0 +1,55 @@
import type {
PortalExternalPlayback,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type {
ExternalPlayerName,
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { closeRunningExternalSession } from '../vod-details/vod-details-external-session';
/** What a forced MPV/VLC episode launch reads from the series page. */
export interface SeriesExternalLaunchHost {
readonly portalPlayer: Pick<PortalPlayer, 'openExternalPlayback'>;
readonly externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
currentPlaylistId(): string;
selectedItem(): { series_id?: string | number } | null;
}
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
* second detached player would start beside it. When that close fails the
* running player stays and nothing new launches.
*/
export async function openEpisodeExternally(
host: SeriesExternalLaunchHost,
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const session = host.externalPlayback.activeSession();
const info = session?.contentInfo;
const ownSession =
session &&
info &&
session.status !== 'closed' &&
info.contentType === 'episode' &&
info.playlistId === host.currentPlaylistId() &&
info.seriesXtreamId === Number(host.selectedItem()?.series_id ?? 0)
? session
: null;
const replaced = await closeRunningExternalSession(
ownSession,
(running) => host.externalPlayback.closeSession(running),
(message, error) =>
console.warn(`[SerialDetailsPlayback] ${message}`, error)
);
if (!replaced) {
return;
}
return host.portalPlayer.openExternalPlayback(playback, player);
}
@@ -39,6 +39,7 @@ import {
} from '@iptvnator/ui/playback';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { openEpisodeExternally } from './serial-details-external-launch';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import {
SerialDetailsSeasonWatchService,
@@ -66,8 +67,8 @@ export class SerialDetailsPlaybackService {
private readonly playbackPositionBridge = inject(
PlaybackPositionRuntimeBridgeService
);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly recordRecentItem = injectXtreamRecentHistory();
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
@@ -75,7 +76,7 @@ export class SerialDetailsPlaybackService {
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
null
);
private readonly currentPlaylistId = computed(
readonly currentPlaylistId = computed(
() => this.xtreamStore.currentPlaylist()?.id ?? ''
);
private readonly playbackPositionState =
@@ -254,11 +255,9 @@ export class SerialDetailsPlaybackService {
playQuickStartEpisode(): void {
const action = this.quickStartAction();
if (!action || action.disabled) {
return;
if (action && !action.disabled) {
this.playEpisode(action.episode);
}
this.playEpisode(action.episode);
}
playPreviousEpisode(): void {
@@ -410,7 +409,7 @@ export class SerialDetailsPlaybackService {
);
}
private selectedItem(): XtreamSerieDetailsView | null {
selectedItem(): XtreamSerieDetailsView | null {
return this.bindings()?.selectedItem() ?? null;
}
@@ -430,7 +429,7 @@ export class SerialDetailsPlaybackService {
void this.playbackPositionState.recordExternalLaunch(
playback,
player
? this.portalPlayer.openExternalPlayback(playback, player)
? openEpisodeExternally(this, playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true),
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
@@ -106,10 +106,37 @@
@if (trailerBackdropUrl(); as trailerUrl) {
<app-hero-trailer-backdrop
#trailer
class="hero__trailer"
[embedUrl]="trailerUrl"
[title]="formattedTitle()"
/>
<!-- Above the content layer, or the content box would take the clicks -->
@if (trailer.playing()) {
<button
type="button"
class="hero__trailer-mute"
[matTooltip]="
(trailer.muted()
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
: 'PORTALS.DETAIL.TRAILER_MUTE'
) | translate
"
[attr.aria-label]="
(trailer.muted()
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
: 'PORTALS.DETAIL.TRAILER_MUTE'
) | translate
"
[attr.aria-pressed]="!trailer.muted()"
data-test-id="trailer-backdrop-mute"
(click)="trailer.toggleMute()"
>
<mat-icon aria-hidden="true">{{
trailer.muted() ? 'volume_off' : 'volume_up'
}}</mat-icon>
</button>
}
}
<!-- Scrim: strong on the text side, fading into the page below -->
@@ -91,6 +91,38 @@
z-index: 0;
}
// Its sound toggle lives above the content box (z-index 2), which would
// otherwise take the clicks; the top band is clear of the content.
&__trailer-mute {
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--surface-bg) 70%, transparent);
color: var(--app-heading-color);
cursor: pointer;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
// Two scrim layers: a horizontal one for the text column, a vertical one
// that dissolves the artwork into the page.
&__vignette {
@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component';
import { ContentHeroComponent } from './content-hero.component';
describe('ContentHeroComponent', () => {
@@ -205,6 +207,30 @@ describe('ContentHeroComponent cinematic layout', () => {
).toBeTruthy();
});
it('renders the trailer sound toggle above the content once the trailer plays', () => {
fixture.componentRef.setInput('title', 'Black Harbor');
fixture.componentRef.setInput(
'trailerBackdropUrl',
'https://www.youtube-nocookie.com/embed/abc123'
);
fixture.detectChanges();
expect(host().querySelector('.hero__trailer-mute')).toBeNull();
const trailer = fixture.debugElement.query(
By.directive(HeroTrailerBackdropComponent)
).componentInstance as HeroTrailerBackdropComponent;
trailer.playing.set(true);
fixture.detectChanges();
const mute = host().querySelector<HTMLButtonElement>(
'.hero__trailer-mute'
);
expect(mute?.getAttribute('aria-pressed')).toBe('false');
mute?.click();
fixture.detectChanges();
expect(trailer.muted()).toBe(false);
expect(mute?.getAttribute('aria-pressed')).toBe('true');
});
it('places the actions before the credits slot', () => {
fixture.componentRef.setInput('title', 'Black Harbor');
fixture.detectChanges();
@@ -14,6 +14,7 @@ import {
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component';
@@ -28,6 +29,7 @@ export type ContentHeroLayout = 'stage' | 'compact';
HeroTrailerBackdropComponent,
MatIconModule,
MatButtonModule,
MatTooltipModule,
NgxSkeletonLoaderComponent,
TranslateModule,
],
@@ -9,27 +9,4 @@
tabindex="-1"
aria-hidden="true"
></iframe>
<button
type="button"
class="trailer-backdrop__mute"
[matTooltip]="
(muted()
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
: 'PORTALS.DETAIL.TRAILER_MUTE'
) | translate
"
[attr.aria-label]="
(muted()
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
: 'PORTALS.DETAIL.TRAILER_MUTE'
) | translate
"
[attr.aria-pressed]="!muted()"
data-test-id="trailer-backdrop-mute"
(click)="toggleMute()"
>
<mat-icon aria-hidden="true">{{
muted() ? 'volume_off' : 'volume_up'
}}</mat-icon>
</button>
}
@@ -23,35 +23,6 @@
border: 0;
}
.trailer-backdrop__mute {
position: absolute;
right: 16px;
bottom: 16px;
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--app-content-bg, #161a22) 70%, transparent);
color: var(--app-heading-color, #e7ecf3);
cursor: pointer;
pointer-events: auto;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
:host {
transition: none;
@@ -63,16 +63,13 @@ describe('HeroTrailerBackdropComponent', () => {
value: { postMessage },
configurable: true,
});
const mute = host().querySelector<HTMLButtonElement>(
'[data-test-id="trailer-backdrop-mute"]'
);
mute?.click();
fixture.componentInstance.toggleMute();
fixture.detectChanges();
expect(postMessage).toHaveBeenCalledWith(
expect.stringContaining('"unMute"'),
'*'
);
expect(mute?.getAttribute('aria-pressed')).toBe('true');
expect(fixture.componentInstance.muted()).toBe(false);
});
it('never starts under reduced motion', () => {
@@ -87,11 +84,7 @@ describe('HeroTrailerBackdropComponent', () => {
renderFocused();
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
fixture.detectChanges();
host()
.querySelector<HTMLButtonElement>(
'[data-test-id="trailer-backdrop-mute"]'
)
?.click();
fixture.componentInstance.toggleMute();
fixture.detectChanges();
expect(fixture.componentInstance.muted()).toBe(false);
@@ -13,9 +13,6 @@ import {
viewChild,
} from '@angular/core';
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
/** Idle time on the page before the trailer takes over the backdrop. */
export const TRAILER_BACKDROP_IDLE_MS = 3000;
@@ -25,12 +22,12 @@ export const TRAILER_BACKDROP_IDLE_MS = 3000;
* few idle seconds (`Settings → Playback → Play trailers in details
* background`). It stops when the hero scrolls out of view, the window
* loses focus or the document is hidden, and never starts under
* `prefers-reduced-motion` or on a metered connection. The 32px button in the corner toggles the sound
* through the YouTube IFrame API (`enablejsapi`).
* `prefers-reduced-motion` or on a metered connection. `toggleMute()`
* drives the sound through the YouTube IFrame API (`enablejsapi`); the hero
* renders that control itself, above its content layer.
*/
@Component({
selector: 'app-hero-trailer-backdrop',
imports: [MatIcon, MatTooltip, TranslatePipe],
templateUrl: './hero-trailer-backdrop.component.html',
styleUrl: './hero-trailer-backdrop.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,