feat(playback): show media title overlay in fullscreen shared controls (#1228)

* feat(playback): show media title overlay in fullscreen shared controls

In fullscreen with the shared player-controls layer, a pointer-transparent
overlay at the top of the player now names the content while controls are
revealed: one line for movies and live channels, two lines for series
(series name + S01E03 label). The overlay follows the bar's auto-hide
transition and stays hidden outside fullscreen, where page chrome already
names the content.

Data flows top-down: the Xtream/Stalker series detail views pass the series
name via a new PortalInlinePlayerComponent.seriesTitle input (Xtream episode
playback titles carry the episode name, not the series), the inline player
builds the two-line form from the episode metadata label, and
WebPlayerViewComponent falls back to playback.title for movies/channels
while skipping raw stream-URL fallbacks. All four shared-controls hosts
(HTML5, Video.js, ArtPlayer, Embedded MPV frame-copy) forward the value.

To stay under the max-lines limit, scrub/timeline state is extracted into
ControlsTimeline and the playback-diagnostics display helpers into
web-player-view-diagnostics.utils.ts, with behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: mirror fullscreen media-title contract into AGENTS.md

Addresses Codex review: the Shared Player Controls section in AGENTS.md
must stay in sync with the CLAUDE.md description of the new mediaTitle
input, fullscreen overlay behavior, and series-title wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-24 18:41:52 +02:00
1 parent 188f5c4b56
commit 6dc8a10d52
30 files changed
+705 -170

No files matched your search

@@ -156,6 +156,7 @@
detail-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="serial.info.name"
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
@@ -53,6 +53,7 @@ class StubSeasonContainerComponent {
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
@@ -165,6 +165,7 @@
detail-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="item.info.name"
[seriesNavigation]="inlineSeriesNavigation()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
@@ -54,6 +54,7 @@ class StubSeasonContainerComponent {
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
@@ -10,6 +10,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -16,6 +16,7 @@ import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -52,6 +53,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -98,6 +98,7 @@
<app-player-controls
[controller]="sharedControls"
[playerSurface]="playerSurface()"
[mediaTitle]="mediaTitle()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
@@ -24,6 +24,7 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import type { PlayerMediaTitle } from '../player-controls/player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
@@ -77,6 +78,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly showControls = input(true);
readonly recordingFolder = input('');
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -10,6 +10,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -28,6 +28,7 @@ import {
} from '../playback-diagnostics/playback-diagnostics.util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -64,6 +65,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
@@ -0,0 +1,51 @@
import { Signal, computed, signal } from '@angular/core';
import type { PlayerControlsState } from './player-controls.model';
/**
* Owns the scrub state and timeline projections for the controls bar: the
* current slider value (scrub position while dragging, playback position
* otherwise), the bounded duration, and the progress percentage.
*/
export class ControlsTimeline {
readonly scrubPosition = signal<number | null>(null);
constructor(private readonly state: Signal<PlayerControlsState>) {}
readonly duration = computed(() => {
const duration = this.state().durationSeconds;
return typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: 0;
});
readonly value = computed(
() =>
this.normalize(
this.scrubPosition() ?? this.state().positionSeconds
) ?? 0
);
readonly progress = computed(() => {
const duration = this.duration();
return this.state().canSeek && duration > 0
? (this.value() / duration) * 100
: 0;
});
readEventValue(event: Event): number | null {
return this.normalize(Number((event.target as HTMLInputElement).value));
}
private normalize(value: number): number | null {
if (!Number.isFinite(value)) {
return null;
}
const duration = this.state().durationSeconds;
const upperBound =
typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: Number.POSITIVE_INFINITY;
return Math.min(Math.max(0, value), upperBound);
}
}
@@ -6,6 +6,7 @@ export * from './controls-feedback';
export * from './controls-fullscreen';
export * from './controls-shortcuts';
export * from './controls-surface';
export * from './controls-timeline';
export * from './controls-view-model';
export * from './controls-visibility';
export * from './controls-volume';
@@ -9,6 +9,23 @@
</div>
}
@if (fullscreenMediaTitle(); as mediaTitle) {
<div
class="player-controls__title"
[class.player-controls__title--visible]="controlsAreVisible()"
data-test-id="player-controls-media-title"
>
<div class="player-controls__title-primary">
{{ mediaTitle.primary }}
</div>
@if (mediaTitle.secondary) {
<div class="player-controls__title-secondary">
{{ mediaTitle.secondary }}
</div>
}
</div>
}
@if (showControls()) {
<div
#controlsBar
@@ -0,0 +1,191 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent fullscreen media title', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
let surface: HTMLElement;
let fullscreenElement: Element | null;
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const enterFullscreen = () => {
fullscreenElement = surface;
document.dispatchEvent(new Event('fullscreenchange'));
fixture.detectChanges();
};
const queryTitle = (): HTMLElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="player-controls-media-title"]'
);
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
surface = document.createElement('div');
document.body.appendChild(surface);
fullscreenElement = null;
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
surface.remove();
jest.useRealTimers();
});
it('stays hidden outside fullscreen even when a media title is set', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Some Movie',
secondary: null,
});
fixture.detectChanges();
expect(queryTitle()).toBeNull();
});
it('shows both title lines in fullscreen and hides them again on exit', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: 'S01E02',
});
fixture.detectChanges();
enterFullscreen();
const title = queryTitle();
expect(title).not.toBeNull();
expect(
title?.querySelector('.player-controls__title-primary')?.textContent
).toContain('Breaking Code');
expect(
title?.querySelector('.player-controls__title-secondary')
?.textContent
).toContain('S01E02');
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
fixture.detectChanges();
expect(queryTitle()).toBeNull();
});
it('renders a single line when no secondary text is provided', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Channel One',
secondary: null,
});
fixture.detectChanges();
enterFullscreen();
const title = queryTitle();
expect(title).not.toBeNull();
expect(
title?.querySelector('.player-controls__title-secondary')
).toBeNull();
});
it('stays hidden for a blank primary title and when controls are disabled', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: ' ',
secondary: 'S01E02',
});
fixture.detectChanges();
enterFullscreen();
expect(queryTitle()).toBeNull();
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: null,
});
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(queryTitle()).toBeNull();
});
it('follows the auto-hide visibility of the controls bar', () => {
jest.useFakeTimers();
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: 'S01E02',
});
fixture.detectChanges();
enterFullscreen();
expect(
queryTitle()?.classList.contains(
'player-controls__title--visible'
)
).toBe(true);
setState({ status: 'playing' });
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
expect(
queryTitle()?.classList.contains(
'player-controls__title--visible'
)
).toBe(false);
surface.dispatchEvent(new MouseEvent('pointermove'));
fixture.detectChanges();
expect(
queryTitle()?.classList.contains(
'player-controls__title--visible'
)
).toBe(true);
});
});
@@ -72,6 +72,59 @@
}
}
.player-controls__title {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 4px;
// Generous bottom padding lets the scrim fall below the text and fade out.
padding: 20px 24px 40px;
opacity: 0;
// Purely informative: never intercept clicks meant for the video surface.
pointer-events: none;
transform: translateY(-8px);
// Mirror of the bottom bar's scrim so the title reads over bright video.
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.35) 55%,
transparent 100%
);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
}
.player-controls__title--visible {
opacity: 1;
transform: translateY(0);
}
.player-controls__title-primary {
overflow: hidden;
color: var(--pc-text);
font-size: 1.15rem;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.player-controls__title-secondary {
overflow: hidden;
color: var(--pc-text-dim);
font-size: 0.85rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.player-controls__bar {
position: absolute;
right: 0;
@@ -21,11 +21,15 @@ import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { formatTime, speedLabel } from './controls-format.utils';
import type { PlayerController } from './player-controls.model';
import type {
PlayerController,
PlayerMediaTitle,
} from './player-controls.model';
@Component({
selector: 'app-player-controls',
@@ -45,6 +49,7 @@ export class PlayerControlsComponent implements OnDestroy {
readonly playerSurface = input<HTMLElement | null>(null);
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly barHovered = signal(false);
@@ -86,29 +91,25 @@ export class PlayerControlsComponent implements OnDestroy {
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
private readonly controllerVolume = computed(() => this.state().volume);
readonly scrubPosition = signal<number | null>(null);
readonly timelineDuration = computed(() => {
const duration = this.state().durationSeconds;
return typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: 0;
});
readonly timelineValue = computed(
() =>
this.normalizeTimelineValue(
this.scrubPosition() ?? this.state().positionSeconds
) ?? 0
);
readonly timelineProgress = computed(() => {
const duration = this.timelineDuration();
return this.state().canSeek && duration > 0
? (this.timelineValue() / duration) * 100
: 0;
});
private readonly timeline = new ControlsTimeline(this.state);
readonly scrubPosition = this.timeline.scrubPosition;
readonly timelineDuration = this.timeline.duration;
readonly timelineValue = this.timeline.value;
readonly timelineProgress = this.timeline.progress;
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
// The page around the player already names the content; the overlay only
// fills that gap in fullscreen, where no other chrome is visible.
readonly fullscreenMediaTitle = computed<PlayerMediaTitle | null>(() => {
if (!this.showControls() || !this.isFullscreen()) {
return null;
}
const mediaTitle = this.mediaTitle();
return mediaTitle?.primary?.trim() ? mediaTitle : null;
});
private readonly vm = createControlsViewModel({
state: this.state,
capabilities: this.capabilities,
@@ -246,11 +247,11 @@ export class PlayerControlsComponent implements OnDestroy {
}
onTimelineInput(event: Event): void {
this.reveal();
this.scrubPosition.set(this.readTimelineValue(event));
this.scrubPosition.set(this.timeline.readEventValue(event));
}
onTimelineCommit(event: Event): void {
this.reveal();
const target = this.readTimelineValue(event);
const target = this.timeline.readEventValue(event);
this.scrubPosition.set(null);
if (
target === null ||
@@ -331,24 +332,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.volume.adjust(delta);
this.reveal();
}
private readTimelineValue(event: Event): number | null {
return this.normalizeTimelineValue(
Number((event.target as HTMLInputElement).value)
);
}
private normalizeTimelineValue(value: number): number | null {
if (!Number.isFinite(value)) {
return null;
}
const duration = this.state().durationSeconds;
const upperBound =
typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: Number.POSITIVE_INFINITY;
return Math.min(Math.max(0, value), upperBound);
}
private closePopovers(): void {
if (!this.menus.anyOpen()) {
return;
@@ -28,6 +28,18 @@ export interface PlayerTrack {
selected: boolean;
}
/**
* Content title lines rendered over the player while the controls are visible
* in fullscreen. Hosts pass display-ready strings; the controls never derive
* them from playback data.
*/
export interface PlayerMediaTitle {
/** Movie title, channel name, or series name. */
primary: string;
/** Optional second line, e.g. the "S01E03" label for series episodes. */
secondary?: string | null;
}
export interface PlayerPreset<T> {
value: T;
label: string;
@@ -60,6 +60,7 @@
<app-web-player-view
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[playback]="playback()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
@@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
@@ -123,6 +124,78 @@ describe('PortalInlinePlayerComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
describe('playerMediaTitle', () => {
it('uses the series title with the episode label for episode playback', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
});
fixture.componentRef.setInput('episodeMetadata', {
label: 'S01E02',
title: 'Episode 2',
seasonNumber: 1,
episodeNumber: 2,
});
fixture.componentRef.setInput('seriesTitle', 'Breaking Code');
fixture.detectChanges();
expect(component.playerMediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(webPlayer.mediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
});
it('falls back to the playback title when no series title is provided', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
});
fixture.componentRef.setInput('episodeMetadata', {
label: 'S01E02',
seasonNumber: 1,
episodeNumber: 2,
});
fixture.detectChanges();
expect(component.playerMediaTitle()).toEqual({
primary: 'Episode 2',
secondary: 'S01E02',
});
});
it('is a single line for movie playback and ignores the series title', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/vod/1.mp4',
title: 'Some Movie',
});
fixture.componentRef.setInput('seriesTitle', 'Unrelated Series');
fixture.detectChanges();
expect(component.playerMediaTitle()).toEqual({
primary: 'Some Movie',
secondary: null,
});
});
it('is null without a playback title', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/vod/1.mp4',
title: '',
});
fixture.detectChanges();
expect(component.playerMediaTitle()).toBeNull();
});
});
it('emits backClicked (not closed) from the back button in the now-playing bar', () => {
let backCount = 0;
let closedCount = 0;
@@ -19,6 +19,7 @@ import {
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
import type {
SeriesEpisodeMetadata,
@@ -47,6 +48,9 @@ export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
/** Series name for the fullscreen title overlay. Xtream episode playback
* carries the episode title, so the series name must come from the host. */
readonly seriesTitle = input<string | null>(null);
private readonly settingsStore = inject(SettingsStore);
// Strip only live-channel titles — VOD/series titles ("Mission:
// Impossible - Fallout") must never lose their leading segment.
@@ -111,6 +115,17 @@ export class PortalInlinePlayerComponent {
? `${metadata.label} - ${metadata.title}`
: metadata.label;
});
readonly playerMediaTitle = computed<PlayerMediaTitle | null>(() => {
const metadata = this.episodeMetadata();
const primary = (
(metadata ? this.seriesTitle() : null) || this.title()
)?.trim();
if (!primary) {
return null;
}
return { primary, secondary: metadata?.label ?? null };
});
readonly closed = output<void>();
/** Back arrow in the now-playing bar: route-level back, not just close. */
@@ -13,6 +13,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -25,6 +25,7 @@ import {
} from '../playback-diagnostics/playback-diagnostics.util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -77,6 +78,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -0,0 +1,136 @@
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '../playback-diagnostics/playback-diagnostics.util';
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
return `${getDiagnosticTranslationBase(issue)}.TITLE`;
}
export function getDiagnosticDescriptionKey(
issue: PlaybackDiagnostic,
supportsManagedExternalPlayers: boolean
): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!supportsManagedExternalPlayers
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
export function getDiagnosticMeta(issue: PlaybackDiagnostic): string {
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
}
return issue.container || issue.mimeType || '';
}
export function getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
export function getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: issue.details ?? '',
},
].filter(({ value }) => value.trim().length > 0);
}
function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
case PlaybackDiagnosticCode.UnsupportedCodec:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
case PlaybackDiagnosticCode.MediaDecodeError:
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
case PlaybackDiagnosticCode.NetworkError:
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
case PlaybackDiagnosticCode.BrowserAccessError:
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
case PlaybackDiagnosticCode.DrmOrEncryption:
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
}
}
function formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
default:
return '';
}
}
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
switch (source) {
case 'hls':
return 'HLS.js';
case 'mpegts':
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'source':
return 'Stream metadata';
default:
return source;
}
}
@@ -2,6 +2,7 @@
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -20,6 +21,7 @@
@defer (on immediate) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -39,6 +41,7 @@
@defer (on immediate) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -57,6 +60,7 @@
} @else if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
@@ -68,6 +72,7 @@
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -35,6 +35,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({ selector: 'app-vjs-player', template: '' })
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -50,6 +51,7 @@ class StubVjsPlayerComponent {
@Component({ selector: 'app-html-video-player', template: '' })
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -66,6 +68,7 @@ class StubHtmlVideoPlayerComponent {
@Component({ selector: 'app-art-player', template: '' })
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -82,6 +85,7 @@ class StubArtPlayerComponent {
@Component({ selector: 'app-embedded-mpv-player', template: '' })
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -35,6 +35,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -53,6 +54,7 @@ class StubVjsPlayerComponent {
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -72,6 +74,7 @@ class StubHtmlVideoPlayerComponent {
})
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -91,6 +94,7 @@ class StubArtPlayerComponent {
})
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -165,6 +169,47 @@ describe('WebPlayerViewComponent', () => {
expect(fixture.nativeElement.classList).toContain('web-player-view');
});
describe('resolvedMediaTitle', () => {
it('prefers an explicit media title over the playback title', async () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: 'S01E02',
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.resolvedMediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(player.mediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
});
it('falls back to the playback title as a single line', () => {
fixture.detectChanges();
expect(component.resolvedMediaTitle()).toEqual({
primary: 'Example Movie',
secondary: null,
});
});
it('is null when the title falls back to the raw stream URL', () => {
fixture.componentRef.setInput('title', '');
fixture.detectChanges();
expect(component.resolvedMediaTitle()).toBeNull();
});
});
it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => {
const requests: unknown[] = [];
runtimeCapabilities.supportsManagedExternalPlayers = true;
@@ -30,23 +30,24 @@ import { ArtPlayerComponent } from '../art-player/art-player.component';
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import {
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
} from '../player-controls';
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './web-player-view-diagnostics.utils';
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
@@ -93,6 +94,9 @@ export class WebPlayerViewComponent {
showCaptions = input<boolean>(false);
playerOverride = input<VideoPlayer | null>(null);
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
/** Display-ready title lines for the fullscreen overlay; hosts with richer
* context (e.g. series name + episode label) pass it explicitly. */
mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
@@ -157,6 +161,20 @@ export class WebPlayerViewComponent {
this.settings()?.player ??
VideoPlayer.VideoJs
);
readonly resolvedMediaTitle = computed<PlayerMediaTitle | null>(() => {
const explicit = this.mediaTitle();
if (explicit?.primary?.trim()) {
return explicit;
}
const playback = this.resolvedPlayback();
const title = playback.title?.trim();
// resolvedPlayback() falls back to the stream URL as title; a raw URL
// is not a watchable overlay title.
if (!title || title === playback.streamUrl) {
return null;
}
return { primary: title, secondary: null };
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
constructor() {
@@ -259,99 +277,16 @@ export class WebPlayerViewComponent {
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
}
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
return `${this.getDiagnosticTranslationBase(issue)}.TITLE`;
}
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!this.runtime.supportsManagedExternalPlayers
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
getDiagnosticMeta(issue: PlaybackDiagnostic): string {
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
}
return issue.container || issue.mimeType || '';
}
getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: this.formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: this.formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: issue.details ?? '',
},
].filter(({ value }) => value.trim().length > 0);
}
private getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
case PlaybackDiagnosticCode.UnsupportedCodec:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
case PlaybackDiagnosticCode.MediaDecodeError:
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
case PlaybackDiagnosticCode.NetworkError:
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
case PlaybackDiagnosticCode.BrowserAccessError:
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
case PlaybackDiagnosticCode.DrmOrEncryption:
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
}
return getDiagnosticDescriptionKey(
issue,
this.runtime.supportsManagedExternalPlayers
);
}
private getHeaderValue(
@@ -367,34 +302,4 @@ export class WebPlayerViewComponent {
);
return matchingKey ? headers[matchingKey] : undefined;
}
private formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
default:
return '';
}
}
private formatDiagnosticSource(
source: PlaybackDiagnostic['source']
): string {
switch (source) {
case 'hls':
return 'HLS.js';
case 'mpegts':
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'source':
return 'Stream metadata';
default:
return source;
}
}
}