From 25cc6b1ced631c549d18bd17198d8103c7d5e3e7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 10:14:28 +0200 Subject: [PATCH] fix(portals): address the review on the cinematic details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web-e2e/src/xtream.e2e.ts | 29 ++++-- .../src/lib/detail/country-short-name.spec.ts | 11 +++ .../util/src/lib/detail/country-short-name.ts | 33 ++++++- .../stalker-catalog-detail.component.ts | 6 +- .../stalker-search.component.ts | 3 + .../lib/stalker-vod-detail-actions.spec.ts | 91 +++++++++++++++++++ .../src/lib/stalker-vod-detail-actions.ts | 18 +++- .../serial-details-menu.service.ts | 46 +++++----- .../serial-details-playback.service.ts | 18 +++- .../serial-details.component.spec.ts | 46 +++++++++- .../serial-details.component.ts | 9 +- .../vod-details/vod-details-menu.service.ts | 23 ++--- .../vod-details-playback.service.spec.ts | 31 +++++++ .../vod-details-playback.service.ts | 26 +++++- .../hero-trailer-backdrop.component.spec.ts | 43 +++++++++ .../hero-trailer-backdrop.component.ts | 28 ++++-- .../detail-ui/vod-more-menu.component.spec.ts | 15 ++- .../lib/detail-ui/vod-more-menu.component.ts | 17 +++- .../dashboard-hero-slides.presenter.spec.ts | 5 +- .../rails/dashboard-hero-slides.presenter.ts | 19 ++-- 20 files changed, 432 insertions(+), 85 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 40cc16adb..9686a9939 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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 { - 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 }) diff --git a/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts b/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts index dfe616493..0e771ce6c 100644 --- a/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts +++ b/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts @@ -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', diff --git a/libs/portal/shared/util/src/lib/detail/country-short-name.ts b/libs/portal/shared/util/src/lib/detail/country-short-name.ts index 0383326fb..ccfab04f6 100644 --- a/libs/portal/shared/util/src/lib/detail/country-short-name.ts +++ b/libs/portal/shared/util/src/lib/detail/country-short-name.ts @@ -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; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index e2726089d..fbc704536 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -145,7 +145,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy { readonly selectedVodPlaybackDuration = computed( () => this.selectedVodPosition()?.durationSeconds ?? null ); - readonly sourceLabel = computed(() => this.catalog.playlist()?.title ?? null); + readonly sourceLabel = computed( + () => this.catalog.playlist()?.title ?? null + ); readonly selectedVodPlaybackPosition = computed( () => 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), diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 6272b214f..34b038909 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts new file mode 100644 index 000000000..937240d72 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts @@ -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( + 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'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts index 08ce8f943..1e146f8e7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts @@ -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; + /** 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; @@ -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'); }, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts index ee4ee42d3..fd7fe007b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -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; readonly episodePositions: Signal>; readonly resetProgress: () => Promise; + /** 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 { - 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 { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 5d8bd41f8..0b61c78c8 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -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 | null + episodeState: SeriesPlaybackEpisodeState | 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, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 87f56a901..956994ebb 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -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', diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 846c1895a..a93cb1504 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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 { - const request = this.seasonContainer()?.watchPresenter.buildResetRequest(); + const request = + this.seasonContainer()?.watchPresenter.buildResetRequest(); return request ? this.playback.handleWatchToggleRequested(request, 'series') : Promise.resolve(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts index 7c6d17e3d..2d10ae399 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts @@ -129,20 +129,17 @@ export class VodDetailsMenuService { async run(actionId: string): Promise { 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; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 0ff0d85a3..1a6ed5bd1 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -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', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 308886193..98fb7d17a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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 | 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, diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts index fe9263089..27c11ad79 100644 --- a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts @@ -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( + '[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); diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts index d96128a5b..11e7a3cc2 100644 --- a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts @@ -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>('frame'); private readonly inView = signal(true); private readonly windowFocused = signal(true); + private readonly documentVisible = signal(true); private idleTimer: ReturnType | null = null; readonly frameUrl = computed(() => @@ -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); }); } diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts index 28b1620c0..5c93c2d6c 100644 --- a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts @@ -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('[data-test-id="menu-copy"]')!.click(); + overlay + .querySelector('[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('.more-menu__back')!.click(); fixture.detectChanges(); expect(fixture.componentInstance.view()).toBe('menu'); diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts index 495e52f93..4f59d1c97 100644 --- a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts @@ -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( + 'button:not([disabled])' + ) + ?.focus() + ); + } } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index 40e4da792..155ecbacc 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -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'); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index 2eb353128..a6f4b889c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -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,