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