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 index 937240d72..75aba49ec 100644 --- 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 @@ -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( 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 1e146f8e7..68190ae90 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 @@ -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'); + } } }, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts new file mode 100644 index 000000000..a15d20836 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts new file mode 100644 index 000000000..1ac9d9494 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts @@ -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; + 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 { + 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); +} 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 0b61c78c8..42c08bab6 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 @@ -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( 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( diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 2c0c27a9c..e73c4edcc 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -106,10 +106,37 @@ @if (trailerBackdropUrl(); as trailerUrl) { + + @if (trailer.playing()) { + + } } diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index ec7392d26..553cbb452 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts index 01f818f81..03263496c 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts @@ -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( + '.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(); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 86c5126d3..908884cdd 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -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, ], diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html index 830ebd50d..3e2ba6ee5 100644 --- a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html @@ -9,27 +9,4 @@ tabindex="-1" aria-hidden="true" > - } diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss index fccacc654..e6bb2c38f 100644 --- a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss @@ -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; 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 27c11ad79..71888df66 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 @@ -63,16 +63,13 @@ describe('HeroTrailerBackdropComponent', () => { value: { postMessage }, configurable: true, }); - const mute = host().querySelector( - '[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( - '[data-test-id="trailer-backdrop-mute"]' - ) - ?.click(); + fixture.componentInstance.toggleMute(); fixture.detectChanges(); expect(fixture.componentInstance.muted()).toBe(false); 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 11e7a3cc2..ea8735f43 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 @@ -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,