diff --git a/AGENTS.md b/AGENTS.md index a910c9b14..10c032ebf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,13 @@ Key files: engine-neutral `PlayerController` contract, standalone `app-player-controls`, generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. +- In fullscreen, `app-player-controls` shows a pointer-transparent media-title + overlay at the top while controls are revealed (`mediaTitle` input: + movie/channel/series name, plus an `S01E03` second line for episodes). Series + names flow from the Xtream/Stalker detail views through + `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`; + movie and live hosts fall back to `playback.title`, skipping raw stream-URL + fallbacks. Outside fullscreen the overlay stays hidden. - Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into diff --git a/CLAUDE.md b/CLAUDE.md index 536b830aa..621f520b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -746,7 +746,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 4ee51bd55..c8aba51a9 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -225,9 +225,29 @@ It owns only transient presentation behavior: - `ControlsVolume` — persisted/optimistic volume state reconciled from controller state; - `ControlsShortcuts` — document keyboard routing; -- `ControlsSurface` — pointer/click/double-click surface interactions; and +- `ControlsSurface` — pointer/click/double-click surface interactions; +- `ControlsTimeline` — scrub state and timeline projections; and - `controls-view-model.ts` — derived display state. +### Fullscreen media title + +The component accepts an optional `mediaTitle` input +(`PlayerMediaTitle { primary, secondary? }`) with display-ready strings — the +movie title, channel name, or series name, plus an optional second line such +as the `S01E03` episode label. The overlay renders at the top of the player +only in fullscreen while the controls are revealed, follows the same +auto-hide transition as the bottom bar, and is pointer-transparent. Outside +fullscreen the surrounding page chrome already names the content, so the +overlay stays hidden. + +Hosts supply the value: `WebPlayerViewComponent` derives a single-line title +from the resolved playback (skipping raw stream-URL fallbacks) unless its own +`mediaTitle` input was set, and `PortalInlinePlayerComponent` builds the +two-line series form from its `seriesTitle` input plus the episode metadata +label. The Xtream and Stalker series detail views pass the series name via +`seriesTitle`; movie and live hosts need no extra wiring because +`playback.title` already names the content. + ### Keyboard ownership Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index af79c3b2f..5f2f152cf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -156,6 +156,7 @@ detail-player [playback]="playback" [episodeMetadata]="inlineEpisodeMetadata()" + [seriesTitle]="serial.info.name" [seriesNavigation]="inlineSeriesNavigation()" (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 693fc5824..d7ef8a0b5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -53,6 +53,7 @@ class StubSeasonContainerComponent { class StubPortalInlinePlayerComponent { readonly playback = input(null); readonly episodeMetadata = input(null); + readonly seriesTitle = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output(); readonly closed = output(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index b7f715a8a..4ad4a1ae7 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -165,6 +165,7 @@ detail-player [playback]="playback" [episodeMetadata]="inlineEpisodeMetadata()" + [seriesTitle]="item.info.name" [seriesNavigation]="inlineSeriesNavigation()" (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index b3997d8ea..c0decadb2 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -54,6 +54,7 @@ class StubSeasonContainerComponent { class StubPortalInlinePlayerComponent { readonly playback = input(null); readonly episodeMetadata = input(null); + readonly seriesTitle = input(null); readonly seriesNavigation = input(null); readonly timeUpdate = output(); readonly closed = output(); diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.html b/libs/ui/playback/src/lib/art-player/art-player.component.html index 9b70e7856..1aa103113 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.html +++ b/libs/ui/playback/src/lib/art-player/art-player.component.html @@ -10,6 +10,7 @@ (null); readonly isLive = input(true); readonly interactionEnabled = input(true); + readonly mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 492ddc6fd..a6e4cd5f0 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -98,6 +98,7 @@ (null); + readonly mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index 192f42e97..6ac0f8913 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -10,6 +10,7 @@ (null); @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts new file mode 100644 index 000000000..b6ac54130 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts @@ -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(null); + + constructor(private readonly state: Signal) {} + + 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); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/index.ts b/libs/ui/playback/src/lib/player-controls/index.ts index 33c589d19..9fc2a65ea 100644 --- a/libs/ui/playback/src/lib/player-controls/index.ts +++ b/libs/ui/playback/src/lib/player-controls/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index ecf13b621..585db0b26 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -9,6 +9,23 @@ } +@if (fullscreenMediaTitle(); as mediaTitle) { +
+
+ {{ mediaTitle.primary }} +
+ @if (mediaTitle.secondary) { +
+ {{ mediaTitle.secondary }} +
+ } +
+} + @if (showControls()) {
= signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let component: PlayerControlsComponent; + let fake: ReturnType; + let surface: HTMLElement; + let fullscreenElement: Element | null; + + const setState = (overrides: Partial) => + 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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index c3de35ba9..bd4de88fa 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -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; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index 1a74ba787..08fb2e1c9 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -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(null); readonly showControls = input(true); readonly shortcutsEnabled = input(true); + readonly mediaTitle = input(null); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); 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(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(() => { + 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; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts index 25185df21..bb486f0e0 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -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 { value: T; label: string; diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 868f571c9..b935d10e9 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -60,6 +60,7 @@ ({})); class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); + readonly mediaTitle = input(null); readonly playback = input(null); readonly startTime = input(0); readonly seriesNavigation = input(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; diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 5a1ba0c15..aeeca9d7a 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -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(null); readonly episodeMetadata = input(null); readonly seriesNavigation = input(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(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(() => { + 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(); /** Back arrow in the now-playing bar: route-level back, not just close. */ diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index b4ab37eb4..269f42e2b 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -13,6 +13,7 @@ (null); readonly interactionEnabled = input(true); readonly showCaptions = input(false); + readonly mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts new file mode 100644 index 000000000..c5f17c630 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 686578c01..77546f72b 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -2,6 +2,7 @@ @defer (on immediate) { ({})); @Component({ selector: 'app-vjs-player', template: '' }) class StubVjsPlayerComponent { readonly options = input(); + readonly mediaTitle = input(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(); + readonly mediaTitle = input(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(); + readonly mediaTitle = input(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(); + readonly mediaTitle = input(null); readonly recordingFolder = input(''); readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 444cdf566..9b9d19e63 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -35,6 +35,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); }) class StubVjsPlayerComponent { readonly options = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); @@ -53,6 +54,7 @@ class StubVjsPlayerComponent { }) class StubHtmlVideoPlayerComponent { readonly channel = input(); + readonly mediaTitle = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -72,6 +74,7 @@ class StubHtmlVideoPlayerComponent { }) class StubArtPlayerComponent { readonly channel = input(); + readonly mediaTitle = input(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(); + readonly mediaTitle = input(null); readonly recordingFolder = input(''); readonly seriesNavigation = input(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; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 6d77cf6f7..8e2244764 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(false); playerOverride = input(null); seriesNavigation = input(null); + /** Display-ready title lines for the fullscreen overlay; hosts with richer + * context (e.g. series name + episode label) pass it explicitly. */ + mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number; @@ -157,6 +161,20 @@ export class WebPlayerViewComponent { this.settings()?.player ?? VideoPlayer.VideoJs ); + readonly resolvedMediaTitle = computed(() => { + 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; - } - } }