mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
188f5c4b56
commit
6dc8a10d52
30 files changed
+705
-170
No files matched your search
+1
@@ -156,6 +156,7 @@
|
||||
detail-player
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesTitle]="serial.info.name"
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+191
@@ -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()"
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user