fix(portals): address the review on the cinematic details

- The "…" menu's MPV/VLC launch goes through the regular episode and
  movie starts, so recent history and the launch position are recorded
  as for the Play button.
- Resetting a Stalker movie's progress only clears the shown position
  while that movie is still selected, and retires a position read in
  flight so it cannot restore the reset row.
- The sources panel takes keyboard focus when it replaces the menu rows.
- The trailer backdrop drops its frame and waits again (muted) when the
  trailer changes, and stops while the document is hidden.
- The dashboard hero strips the season marker from series only.
- Provider country lists keep "Korea, Republic of" whole.
- The web season-watched E2E reacquires the menu row after every close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 10:14:28 +02:00
1 parent 78314c7fbc
commit 25cc6b1ced
20 files changed
+432 -85

No files matched your search

+19 -10
View File
@@ -706,19 +706,23 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself still holds the 8 persisted rows.
// Season 1 itself still holds the 8 persisted rows. The row lives in
// the overlay, so it is reacquired after every close.
await seasonTabs.first().click();
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
// Second click clears every episode's position again.
await seasonToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
timeout: 15_000,
});
await expect(watchedCards).toHaveCount(0);
await closeSeriesMenu(page);
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
@@ -798,7 +802,6 @@ test('@xtream season cover — shows the provider season cover and follows the s
// unwatch-all once the whole series is watched, and survives a reload.
// ---------------------------------------------------------------------------
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
async function seriesMenuRow(page: Page, rowTestId: string) {
const row = page.locator(`[data-test-id="${rowTestId}"]`);
@@ -810,9 +813,13 @@ async function seriesMenuRow(page: Page, rowTestId: string) {
}
async function closeSeriesMenu(page: Page): Promise<void> {
if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) {
// The panel, not the backdrop: a backdrop locator can match a second
// overlay and then fail the strict check, leaving the menu open.
const panel = page.locator('[data-test-id="vod-more-menu"]');
if (await panel.isVisible().catch(() => false)) {
await page.keyboard.press('Escape');
}
await expect(panel).toBeHidden({ timeout: 10_000 });
}
test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({
@@ -875,9 +882,9 @@ test('@xtream series watched toggle — marks every season from the header menu,
// PWA persistence: positions live in localStorage, so a reload must come
// back fully watched across all seasons.
await page.reload();
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible(
{ timeout: 20_000 }
);
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 10_000,
});
@@ -1011,6 +1018,8 @@ for (const theme of ['light', 'dark']) {
.locator('[data-testid="series-favorite-toggle"]')
.first();
const card = shell.locator('.episode-card').first();
// The card is flat; its visible edge is the artwork's hairline.
const artwork = card.locator('.episode-card__thumbnail');
const toggle = shell.locator('mat-button-toggle-group');
await expect(card).toBeVisible();
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
@@ -1020,7 +1029,7 @@ for (const theme of ['light', 'dark']) {
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [card, toggle]) {
for (const surface of [artwork, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
@@ -1035,7 +1044,7 @@ for (const theme of ['light', 'dark']) {
});
await card.hover();
await expect
.poll(async () => (await surfaceContrast(card)).border)
.poll(async () => (await surfaceContrast(artwork)).border)
.toBeGreaterThan(1.15);
await page
.getByRole('radio', { name: 'List view', exact: true })
@@ -17,6 +17,17 @@ describe('shortCountryName', () => {
expect(shortCountryName('', 'xx')).toBe('XX');
});
it('keeps a comma-bearing country name whole inside a list', () => {
expect(shortCountryList('Korea, Republic of, Japan')).toEqual([
'South Korea',
'Japan',
]);
expect(shortCountryList('France, Taiwan, Province of China')).toEqual([
'France',
'Taiwan',
]);
});
it('splits provider lists', () => {
expect(shortCountryList('United States, Canada / France')).toEqual([
'USA',
@@ -70,10 +70,37 @@ export function shortCountryName(
return trimmed || (code ?? '').trim().toUpperCase();
}
/** First country of a comma-separated provider list, shortened. */
/** Canonical names that carry a comma themselves ("Korea, Republic of"). */
const COMMA_NAMES = new Set(
Object.keys(SHORT_BY_NAME).filter((name) => name.includes(','))
);
/** Every country of a provider list, shortened. */
export function shortCountryList(value: string | null | undefined): string[] {
return (value ?? '')
.split(/[,;/]/)
const parts: string[] = [];
for (const group of (value ?? '').split(/[;/]/)) {
parts.push(...splitCommaList(group));
}
return parts
.map((part) => shortCountryName(part))
.filter((part) => part.length > 0);
}
/** Splits on commas but keeps a known comma-bearing country name whole. */
function splitCommaList(value: string): string[] {
const segments = value
.split(',')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
const parts: string[] = [];
for (let index = 0; index < segments.length; index++) {
const joined = `${segments[index]}, ${segments[index + 1] ?? ''}`;
if (COMMA_NAMES.has(joined.toLowerCase())) {
parts.push(joined);
index++;
} else {
parts.push(segments[index]);
}
}
return parts;
}
@@ -145,7 +145,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
readonly selectedVodPlaybackDuration = computed<number | null>(
() => this.selectedVodPosition()?.durationSeconds ?? null
);
readonly sourceLabel = computed(() => this.catalog.playlist()?.title ?? null);
readonly sourceLabel = computed(
() => this.catalog.playlist()?.title ?? null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
@@ -281,7 +283,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
portalPlayer: this.portalPlayer,
playbackPositions: this.playbackPositions,
playlistId: () => this.catalog.playlist()?.id,
selectedVodId: () => Number(this.selectedItem()?.id) || null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
beforeExternalLaunch: () => this.closeInlinePlayer(),
afterProgressReset: (playlistId) =>
void this.catalog.refreshPositions(playlistId),
@@ -912,7 +912,10 @@ export class StalkerSearchComponent {
portalPlayer: this.portalPlayer,
playbackPositions: this.playbackPositions,
playlistId: () => this.stalkerStore.currentPlaylist()?._id,
selectedVodId: () => Number(this.itemDetails()?.id) || null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () =>
this.vodPlayback.discardPendingPositionLoad(),
snackBar: this.snackBar,
translate: this.translateService,
logError: () => undefined,
@@ -0,0 +1,91 @@
import { signal } from '@angular/core';
import type {
PlaybackPositionData,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { createStalkerVodDetailActions } from './stalker-vod-detail-actions';
const MOVIE = {
type: 'stalker',
data: { id: '42' },
playlistId: 'portal-1',
cmd: 'ffmpeg http://portal/42',
} as unknown as VodDetailsItem;
const POSITION = {
playlistId: 'portal-1',
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 600,
durationSeconds: 5400,
} as PlaybackPositionData;
describe('createStalkerVodDetailActions resetProgress', () => {
function setup(selectedVodId: () => number | null) {
const selectedVodPosition = signal<PlaybackPositionData | null>(
POSITION
);
const clearPlaybackPositionOrThrow = jest
.fn()
.mockResolvedValue(undefined);
const discardPendingPositionLoad = jest.fn();
const afterProgressReset = jest.fn();
const open = jest.fn();
const actions = createStalkerVodDetailActions({
resolvePlayback: jest.fn(),
portalPlayer: { openExternalPlayback: jest.fn() },
playbackPositions: { clearPlaybackPositionOrThrow },
playlistId: () => 'portal-1',
selectedVodId,
selectedVodPosition,
discardPendingPositionLoad,
afterProgressReset,
snackBar: { open },
translate: { instant: (key: string) => key },
logError: jest.fn(),
});
return {
actions,
selectedVodPosition,
clearPlaybackPositionOrThrow,
discardPendingPositionLoad,
afterProgressReset,
open,
};
}
it('clears the shown position of the movie that is still selected', async () => {
const t = setup(() => 42);
await t.actions.resetProgress(MOVIE);
expect(t.clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
'portal-1',
42,
'vod'
);
// No read still in flight may put the row back.
expect(t.discardPendingPositionLoad).toHaveBeenCalledTimes(1);
expect(t.selectedVodPosition()).toBeNull();
expect(t.afterProgressReset).toHaveBeenCalledWith('portal-1');
expect(t.open).toHaveBeenCalledWith(
'PORTALS.DETAIL.PROGRESS_RESET',
undefined,
expect.anything()
);
});
it('leaves the position of a movie selected meanwhile untouched', async () => {
// The user moved on to movie 7 while the clear for 42 was pending.
const t = setup(() => 7);
await t.actions.resetProgress(MOVIE);
expect(t.clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
'portal-1',
42,
'vod'
);
expect(t.discardPendingPositionLoad).not.toHaveBeenCalled();
expect(t.selectedVodPosition()).toEqual(POSITION);
expect(t.afterProgressReset).toHaveBeenCalledWith('portal-1');
});
});
@@ -34,7 +34,11 @@ interface StalkerVodDetailActionsDeps {
'clearPlaybackPositionOrThrow'
>;
readonly playlistId: () => string | undefined;
/** The movie on screen now; a reset finished for another one leaves it alone. */
readonly selectedVodId: () => number | null;
readonly selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
/** Retires a stored-position read in flight, which would restore the row. */
readonly discardPendingPositionLoad?: () => void;
readonly beforeExternalLaunch?: () => void;
readonly afterProgressReset?: (playlistId: string) => void;
readonly snackBar: Pick<MatSnackBar, 'open'>;
@@ -47,7 +51,9 @@ interface StalkerVodDetailActionsDeps {
* external player" resolves the stream (a `create_link` round trip) and
* hands it to MPV/VLC; "Reset progress" clears the saved position.
*/
export function createStalkerVodDetailActions(deps: StalkerVodDetailActionsDeps) {
export function createStalkerVodDetailActions(
deps: StalkerVodDetailActionsDeps
) {
const notify = (key: string) =>
deps.snackBar.open(deps.translate.instant(key), undefined, {
duration: 3000,
@@ -111,7 +117,15 @@ export function createStalkerVodDetailActions(deps: StalkerVodDetailActionsDeps)
deps.logError('Resetting the VOD position failed', error);
return;
}
deps.selectedVodPosition.set(null);
// The clear was async: only the movie still on screen loses its
// shown progress, and no older read may put it back.
if (
deps.selectedVodId() === vodId &&
deps.playlistId() === playlistId
) {
deps.discardPendingPositionLoad?.();
deps.selectedVodPosition.set(null);
}
deps.afterProgressReset?.(playlistId);
notify('PORTALS.DETAIL.PROGRESS_RESET');
},
@@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core';
import {
createLogger,
formatSeriesEpisodeCode,
PORTAL_PLAYER,
type SeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import {
@@ -19,6 +18,7 @@ import {
type PlayerContentInfo,
type ResolvedPortalPlayback,
type XtreamCategory,
type XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import {
buildSeriesMenuSections,
@@ -36,6 +36,11 @@ interface SerialDetailsMenuBindings {
readonly categoryId: Signal<string>;
readonly episodePositions: Signal<ReadonlyMap<number, unknown>>;
readonly resetProgress: () => Promise<void>;
/** The regular episode start forced to MPV/VLC, so history and the launch position are recorded. */
readonly openEpisodeExternally: (
episode: XtreamSerieEpisode,
player: ExternalPlayerName
) => void;
}
/**
@@ -48,8 +53,9 @@ interface SerialDetailsMenuBindings {
export class SerialDetailsMenuService {
private readonly xtreamStore = inject(XtreamStore);
/** Optional: hosts without the data source simply get no history row. */
private readonly dataSource = inject(XTREAM_DATA_SOURCE, { optional: true });
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly dataSource = inject(XTREAM_DATA_SOURCE, {
optional: true,
});
private readonly downloadsService = inject(DownloadsService);
private readonly settingsStore = inject(SettingsStore);
private readonly router = inject(Router);
@@ -88,10 +94,11 @@ export class SerialDetailsMenuService {
if (!categoryId) {
return null;
}
const categories = this.xtreamStore.serialCategories() as ReadonlyArray<{
category_id?: string | number;
category_name?: string;
}>;
const categories =
this.xtreamStore.serialCategories() as ReadonlyArray<{
category_id?: string | number;
category_name?: string;
}>;
const found = categories.find(
(candidate) => String(candidate.category_id) === categoryId
);
@@ -104,7 +111,11 @@ export class SerialDetailsMenuService {
/** Whether the series row sits in this playlist's recently viewed list. */
private readonly inContinueWatching = computed(() => {
const seriesId = this.seriesId();
if (!seriesId || !this.dataSource || this.hiddenSeriesIds().has(seriesId)) {
if (
!seriesId ||
!this.dataSource ||
this.hiddenSeriesIds().has(seriesId)
) {
return false;
}
return (
@@ -174,7 +185,7 @@ export class SerialDetailsMenuService {
await container?.downloadPresenter.enqueueSeason();
return;
case SERIES_MENU_ACTION.ExternalPlayer:
await this.openExternal();
this.openExternal();
return;
case SERIES_MENU_ACTION.CopyUrl:
await this.copyStreamUrl();
@@ -217,20 +228,13 @@ export class SerialDetailsMenuService {
};
}
private async openExternal(): Promise<void> {
const playback = this.buildEpisodePlayback();
if (!playback) {
private openExternal(): void {
const bindings = this.bindings();
const episode = bindings?.quickStart()?.episode;
if (!bindings || !episode) {
return;
}
try {
await this.portalPlayer.openExternalPlayback(
playback,
this.externalPlayer()
);
} catch (error) {
this.logger.warn('External episode launch failed', error);
this.notify('PORTALS.PLAYBACK_ERROR');
}
bindings.openEpisodeExternally(episode, this.externalPlayer());
}
private async copyStreamUrl(): Promise<void> {
@@ -19,6 +19,7 @@ import {
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
ExternalPlayerName,
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
@@ -204,7 +205,11 @@ export class SerialDetailsPlaybackService {
this.activeEpisodeId.set(null);
}
playEpisode(episode: XtreamSerieEpisode): void {
/** `player` forces MPV/VLC (the "…" menu); history and the launch position are recorded either way. */
playEpisode(
episode: XtreamSerieEpisode,
player?: ExternalPlayerName
): void {
const playlist = this.xtreamStore.currentPlaylist();
const selectedItem = this.selectedItem();
if (!playlist || !selectedItem) {
@@ -244,7 +249,7 @@ export class SerialDetailsPlaybackService {
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
this.startPlayback(playback, episodeState);
this.startPlayback(playback, episodeState, player);
}
playQuickStartEpisode(): void {
@@ -411,10 +416,11 @@ export class SerialDetailsPlaybackService {
private startPlayback(
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null,
player?: ExternalPlayerName
): void {
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlaybackSessionEpisodeState.set(episodeState);
this.inlinePlayback.set(playback);
return;
@@ -423,7 +429,9 @@ export class SerialDetailsPlaybackService {
this.closeInlinePlayer();
void this.playbackPositionState.recordExternalLaunch(
playback,
this.portalPlayer.openResolvedPlayback(playback, true),
player
? this.portalPlayer.openExternalPlayback(playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true),
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
@@ -31,10 +31,14 @@ import {
SettingsStore,
} from '@iptvnator/services';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import { PlaybackPositionData, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
PlaybackPositionData,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsMenuService } from './serial-details-menu.service';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@@ -815,6 +819,46 @@ describe('SerialDetailsComponent', () => {
expect(quickStartButton()?.textContent).toContain('S01E01');
});
it('records history and the launch position when the menu opens the next episode in MPV', async () => {
openExternalPlayback.mockResolvedValue({
id: 'mpv-session-2',
player: 'mpv',
status: 'opened',
});
fixture.detectChanges();
await fixture.whenStable();
const menu = fixture.debugElement.injector.get(
SerialDetailsMenuService
);
await menu.run('external-player');
await fixture.whenStable();
expect(openResolvedPlayback).not.toHaveBeenCalled();
expect(openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1001.mp4',
}),
'mpv'
);
// Same bookkeeping as the Play button: the view lands once the
// stream plays, the launch position is written right away.
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['http://xtream.example/series/1001.mp4'],
});
expect(addRecentItem).toHaveBeenCalledWith(
expect.objectContaining({ xtreamId: '103', contentType: 'series' })
);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
contentXtreamId: 1001,
contentType: 'episode',
positionSeconds: 0,
})
);
});
it('persists the launched episode after an external fallback succeeds', async () => {
openExternalPlayback.mockResolvedValue({
id: 'mpv-session-1',
@@ -217,9 +217,13 @@ export class SerialDetailsComponent implements OnDestroy {
selectedItem: this.selectedItem,
quickStart: this.quickStartAction,
seasonContainer: this.seasonContainer,
categoryId: computed(() => String(this.routeParams()['categoryId'] ?? '')),
categoryId: computed(() =>
String(this.routeParams()['categoryId'] ?? '')
),
episodePositions: this.episodePlaybackPositions,
resetProgress: () => this.resetProgress(),
openEpisodeExternally: (episode, player) =>
this.playback.playEpisode(episode, player),
});
// TMDB season enrichment, keyed on (tmdb_id, selected season). With
@@ -299,7 +303,8 @@ export class SerialDetailsComponent implements OnDestroy {
/** Clears every saved episode position of the series. */
resetProgress(): Promise<void> {
const request = this.seasonContainer()?.watchPresenter.buildResetRequest();
const request =
this.seasonContainer()?.watchPresenter.buildResetRequest();
return request
? this.playback.handleWatchToggleRequested(request, 'series')
: Promise.resolve();
@@ -129,20 +129,17 @@ export class VodDetailsMenuService {
async run(actionId: string): Promise<void> {
const item = this.bindings()?.item() ?? null;
switch (actionId) {
case VOD_MENU_ACTION.ExternalPlayer: {
const playback = this.playback.buildVodPlayback(
item,
this.msUi.hasPlaybackPosition()
);
if (playback) {
await this.playback
.launchExternal(playback, this.externalPlayer())
.catch((error) =>
this.logger.warn('External launch failed', error)
);
}
case VOD_MENU_ACTION.ExternalPlayer:
await this.playback
.openInExternalPlayer(
item,
this.msUi.hasPlaybackPosition(),
this.externalPlayer()
)
?.catch((error) =>
this.logger.warn('External launch failed', error)
);
return;
}
case VOD_MENU_ACTION.CopyUrl:
await this.copyStreamUrl(item);
return;
@@ -163,6 +163,37 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
});
});
it('records the view when the menu opens the movie in MPV or VLC', async () => {
const movie = {
info: {},
movie_data: {
stream_id: ROUTE_VOD_ID,
name: 'Route movie',
container_extension: 'mkv',
},
} as never;
const launch = service.openInExternalPlayer(movie, false, 'vlc');
expect(launch).not.toBeNull();
await launch;
expect(openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/route.mkv',
}),
'vlc'
);
expect(addRecentItem).not.toHaveBeenCalled();
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['https://example.com/route.mkv'],
});
expect(addRecentItem).toHaveBeenCalledWith(
expect.objectContaining({
xtreamId: ROUTE_VOD_ID,
contentType: 'movie',
})
);
});
it('records the movie as recently viewed only once its stream played', async () => {
await service.startResolvedPlayback({
streamUrl: 'https://example.com/broken.mkv',
@@ -306,10 +306,32 @@ export class VodDetailsPlaybackService {
);
}
/**
* The "…" menu's explicit MPV/VLC launch. It counts as a view like any
* start; `launchExternal` alone serves the inline player's fallback,
* whose start already recorded the item.
*/
openInExternalPlayer(
vodItem: XtreamVodDetails | null,
resume: boolean,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> | null {
const playback = this.buildVodPlayback(vodItem, resume);
if (!playback) {
return null;
}
this.recordRecentItem(playback.streamUrl, {
xtreamId: this.bindings()?.vodId() ?? NaN,
contentType: 'movie',
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
});
return this.launchExternal(playback, player);
}
/**
* Hands the playback to MPV or VLC regardless of the configured player
* (the "Open in external player" action and the inline player's
* fallback), owned and settled like a regular external start.
* (the inline player's fallback), owned and settled like a regular
* external start.
*/
launchExternal(
playback: ResolvedPortalPlayback,
@@ -83,6 +83,49 @@ describe('HeroTrailerBackdropComponent', () => {
expect(host().querySelector('iframe')).toBeNull();
});
it('drops the frame and waits again, muted, when the trailer changes', () => {
renderFocused();
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
fixture.detectChanges();
host()
.querySelector<HTMLButtonElement>(
'[data-test-id="trailer-backdrop-mute"]'
)
?.click();
fixture.detectChanges();
expect(fixture.componentInstance.muted()).toBe(false);
fixture.componentRef.setInput(
'embedUrl',
'https://www.youtube-nocookie.com/embed/next456'
);
fixture.detectChanges();
// No mounted frame may autoplay the next URL at once.
expect(host().querySelector('iframe')).toBeNull();
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
fixture.detectChanges();
expect(host().querySelector('iframe')?.getAttribute('src')).toContain(
'playlist=next456'
);
expect(fixture.componentInstance.muted()).toBe(true);
});
it('stops while the document is hidden', () => {
renderFocused();
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
fixture.detectChanges();
expect(host().querySelector('iframe')).not.toBeNull();
const visibility = jest
.spyOn(document, 'visibilityState', 'get')
.mockReturnValue('hidden');
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(host().querySelector('iframe')).toBeNull();
visibility.mockRestore();
});
it('stops when the window loses focus', () => {
renderFocused();
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
@@ -23,9 +23,9 @@ export const TRAILER_BACKDROP_IDLE_MS = 3000;
/**
* Plays a title's trailer, muted and looping, as the hero backdrop after a
* few idle seconds (`Settings → Playback → Play trailers in details
* background`). It stops when the hero scrolls out of view or the window
* loses focus, and never starts under `prefers-reduced-motion` or on a
* metered connection. The 32px button in the corner toggles the sound
* 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`).
*/
@Component({
@@ -50,6 +50,7 @@ export class HeroTrailerBackdropComponent {
private readonly frame = viewChild<ElementRef<HTMLIFrameElement>>('frame');
private readonly inView = signal(true);
private readonly windowFocused = signal(true);
private readonly documentVisible = signal(true);
private idleTimer: ReturnType<typeof setTimeout> | null = null;
readonly frameUrl = computed<SafeResourceUrl>(() =>
@@ -60,17 +61,19 @@ export class HeroTrailerBackdropComponent {
constructor() {
effect(() => {
// A new trailer or a hero that left the screen resets the idle wait.
// A new trailer, a hero off screen or a hidden window: the frame
// goes away and the idle wait starts over (a mounted frame would
// autoplay the next URL at once).
this.embedUrl();
const visible = this.inView() && this.windowFocused();
const visible =
this.inView() && this.windowFocused() && this.documentVisible();
untracked(() => {
this.clearIdleTimer();
if (!visible) {
this.playing.set(false);
return;
}
if (canAutoplayTrailer()) {
this.playing.set(false);
if (visible && canAutoplayTrailer()) {
this.idleTimer = setTimeout(() => {
// Every frame mounts with `mute=1`.
this.muted.set(true);
this.playing.set(true);
this.idleTimer = null;
}, TRAILER_BACKDROP_IDLE_MS);
@@ -109,12 +112,17 @@ export class HeroTrailerBackdropComponent {
}
const onFocus = () => this.windowFocused.set(true);
const onBlur = () => this.windowFocused.set(false);
const onVisibility = () =>
this.documentVisible.set(document.visibilityState !== 'hidden');
this.windowFocused.set(document.hasFocus());
onVisibility();
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onVisibility);
this.destroyRef.onDestroy(() => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', onVisibility);
});
}
@@ -65,13 +65,17 @@ describe('VodMoreMenuComponent', () => {
expect(overlay.querySelector('[role="menu"]')).toBeTruthy();
expect(overlay.textContent).toContain('Watching');
expect(overlay.textContent).toContain('Other sources');
expect(overlay.querySelector('.more-menu__hint')?.textContent?.trim()).toBe('2');
expect(
overlay.querySelector('.more-menu__hint')?.textContent?.trim()
).toBe('2');
const reset = rows().find((row) => row.textContent?.includes('Reset'))!;
expect(reset.disabled).toBe(true);
const spy = jest.fn();
fixture.componentInstance.selected.subscribe(spy);
overlay.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!.click();
overlay
.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!
.click();
fixture.detectChanges();
expect(spy).toHaveBeenCalledWith('copy');
expect(overlay.querySelector('[role="menu"]')).toBeNull();
@@ -99,7 +103,7 @@ describe('VodMoreMenuComponent', () => {
expect(fixture.componentInstance.isOpen()).toBe(false);
});
it('switches to the sources panel and back', () => {
it('switches to the sources panel, moves focus into it, and back', async () => {
fixture.componentRef.setInput('sources', []);
trigger().click();
fixture.detectChanges();
@@ -109,6 +113,11 @@ describe('VodMoreMenuComponent', () => {
fixture.detectChanges();
expect(fixture.componentInstance.view()).toBe('sources');
expect(overlay.querySelector('app-vod-sources-menu')).toBeTruthy();
// The focused row is gone; keyboard focus lands on the panel's first control.
await new Promise((resolve) => setTimeout(resolve));
expect(document.activeElement).toBe(
overlay.querySelector('.more-menu__back')
);
overlay.querySelector<HTMLButtonElement>('.more-menu__back')!.click();
fixture.detectChanges();
expect(fixture.componentInstance.view()).toBe('menu');
@@ -21,7 +21,10 @@ import type {
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { VodSourcesMenuComponent } from '../vod-sources/vod-sources-menu.component';
import type { VodMoreMenuItem, VodMoreMenuSection } from './vod-more-menu.model';
import type {
VodMoreMenuItem,
VodMoreMenuSection,
} from './vod-more-menu.model';
type MenuView = 'menu' | 'sources';
@@ -129,6 +132,7 @@ export class VodMoreMenuComponent {
}
if (item.kind === 'sources') {
this.view.set('sources');
this.focusFirstControl();
return;
}
this.close(true);
@@ -207,4 +211,15 @@ export class VodMoreMenuComponent {
// The overlay attaches after this change detection pass.
setTimeout(() => this.rows()[index]?.focus());
}
/** The sources panel replaces the focused row; focus follows into it. */
private focusFirstControl(): void {
setTimeout(() =>
this.panel()
?.nativeElement.querySelector<HTMLElement>(
'button:not([disabled])'
)
?.focus()
);
}
}
@@ -230,10 +230,10 @@ describe('DashboardHeroSlidesPresenter', () => {
);
});
it('drops a season marker from a series title and leaves live titles alone', () => {
it('drops a season marker from a series title and leaves movies and live titles alone', () => {
recentItems.set([
{ ...series, title: 'Big Pharma (1 сезон)' },
watchedMovie,
{ ...watchedMovie, title: 'Film Season 2' },
]);
candidates.set([
{ origin: 'favorite', item: { ...channel, title: 'Sport S01' } },
@@ -242,6 +242,7 @@ describe('DashboardHeroSlidesPresenter', () => {
const titles = presenter.slides().map((slide) => slide.title);
expect(titles).toContain('Big Pharma');
expect(titles).not.toContain('Big Pharma (1 сезон)');
expect(titles).toContain('Film Season 2');
expect(titles).toContain('Sport S01');
});
@@ -226,21 +226,22 @@ export class DashboardHeroSlidesPresenter {
this.failedImages()
);
// Watch kind: a Stalker embedded-VOD show routes as a movie
const watchKind = isLive
? 'live'
: (resolvePortalActivityWatchKind(item) ?? item.type);
return {
...artwork,
id: `${source.kind}:${dashboardHeroItemKey(item)}`,
kind: source.kind,
contentType: item.type,
// "Большая фарма (1 сезон)" → "Большая фарма": the episode badge
// already names the season.
title: isLive ? item.title : splitSeasonSuffix(item.title).title,
// Watch kind: a Stalker embedded-VOD show routes as a movie
typeLabelKey:
TYPE_LABEL_KEYS[
isLive
? 'live'
: (resolvePortalActivityWatchKind(item) ?? item.type)
],
// already names the season. A movie keeps its name as is.
title:
watchKind === 'series'
? splitSeasonSuffix(item.title).title
: item.title,
typeLabelKey: TYPE_LABEL_KEYS[watchKind],
reasonLabelKey: this.reasonLabelKey(source),
episodeBadge: buildDashboardEpisodeBadge(
item,