Files
iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts
T

940 lines
34 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
computed,
effect,
inject,
input,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
import {
EmbeddedMpvAudioTrack,
RecordingStartMetadata,
RecordingStoppedEvent,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import type {
PlayerMediaTitle,
PlayerUpNextItem,
PlayerTimelineSegment,
} 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';
import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels';
import { EmbeddedMpvLegacyInteractions } from './embedded-mpv-legacy-interactions';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts';
import {
EmbeddedMpvFeedback,
EmbeddedMpvMenuState,
} from './embedded-mpv-ui-state';
import {
ASPECT_PRESETS,
HIDDEN_BOUNDS,
SPEED_PRESETS,
aspectLabel,
audioTrackLabel,
formatTime,
measureBounds,
persistVolume,
readStoredVolume,
speedLabel,
subtitleTrackLabel,
volumeIcon,
} from './embedded-mpv-format.utils';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
@Component({
selector: 'app-embedded-mpv-player',
templateUrl: './embedded-mpv-player.component.html',
styleUrl: './embedded-mpv-player.component.scss',
imports: [
EmbeddedMpvDockPanelComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
PlayerControlsComponent,
TranslatePipe,
],
providers: [EmbeddedMpvControlsAdapter, EmbeddedMpvSessionController],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'embedded-mpv-player-host',
},
})
export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly playback = input.required<ResolvedPortalPlayback>();
readonly showControls = input(true);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
/** Clean recording stop — the host answers with EPG stop enrichment. */
readonly recordingStopped = output<RecordingStoppedEvent>();
private readonly overlayVisibility = inject(
EmbeddedMpvOverlayVisibilityService
);
private readonly translate = inject(TranslateService);
/**
* Ticks when the active language or a loaded translation file changes.
* translate.instant() is invisible to the signal graph, so every
* computed()/template helper that calls it must read this signal first —
* otherwise labels keep the previous language (or the raw key when the
* component mounts before the translation file finishes loading).
*/
private readonly translationsTick = toSignal(
merge(
this.translate.onLangChange,
this.translate.onTranslationChange,
this.translate.onDefaultLangChange
),
{ initialValue: null }
);
readonly controller = inject(EmbeddedMpvSessionController);
readonly sharedControls = inject(EmbeddedMpvControlsAdapter);
private readonly shortcuts = new EmbeddedMpvShortcuts();
readonly menus = new EmbeddedMpvMenuState();
readonly feedback = new EmbeddedMpvFeedback();
readonly dockPanels: EmbeddedMpvDockPanelState;
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
readonly playerSurface = computed(
() => this.playerRoot()?.nativeElement ?? null
);
/** Element that owns DOM fullscreen: the host's target, else the root. */
readonly fullscreenSurface = computed(
() => this.fullscreenTarget() ?? this.playerSurface()
);
readonly sharedShortcutsEnabled = computed(
() => !this.overlayVisibility.overlayActive()
);
readonly support = this.controller.support;
readonly session = this.controller.session;
readonly stalled = this.controller.stalled;
readonly volume = signal(readStoredVolume());
readonly isFullscreen = signal(false);
readonly controlsVisible = signal(true);
readonly speedPresets = SPEED_PRESETS;
readonly aspectPresets = ASPECT_PRESETS;
readonly isSupported = computed(() => this.support()?.supported ?? false);
readonly isFrameCopyEngine = computed(
() => this.support()?.engine === 'frame-copy'
);
readonly capabilities = computed(
() =>
this.support()?.capabilities ?? {
subtitles: false,
playbackSpeed: false,
aspectOverride: false,
screenshot: false,
recording: false,
}
);
readonly isLoading = computed(
() =>
!this.support() ||
this.session()?.status === 'loading' ||
(!this.session() && this.isSupported())
);
readonly isPaused = computed(
() =>
this.session()?.status === 'paused' ||
this.session()?.status === 'idle' ||
this.session()?.status === 'ended'
);
readonly isPlaying = computed(() => this.session()?.status === 'playing');
readonly isErrored = computed(() => this.session()?.status === 'error');
/**
* The main process is waiting to reload a dropped stream or has the
* reload in flight (see embedded-mpv-reconnect.ts); shown like an error
* with a countdown-free "attempt N of M" line, Retry still available.
*/
readonly reconnectInfo = computed(() => this.session()?.reconnect ?? null);
readonly isReconnecting = computed(() => this.reconnectInfo() !== null);
readonly isLivePlayback = computed(() => {
const playback = this.playback();
if (typeof playback.isLive === 'boolean') {
return playback.isLive;
}
return !playback.contentInfo;
});
/**
* A live stream that ended and is not being reconnected: a broadcast
* never ends on its own, so this is a loss the viewer must be able to
* act on — the `ended` state otherwise looks like a paused player.
*/
readonly isLiveEnded = computed(
() => this.isLivePlayback() && this.session()?.status === 'ended'
);
readonly canSeek = computed(
() =>
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
);
readonly canFullscreen = computed(
() =>
typeof document !== 'undefined' &&
Boolean(this.fullscreenSurface()?.requestFullscreen) &&
Boolean(document.exitFullscreen)
);
readonly statusLabel = computed(() => {
this.translationsTick();
const session = this.session();
if (session?.reconnect) {
return this.translate.instant('EMBEDDED_MPV.PLAYER.RECONNECTING', {
attempt: session.reconnect.attempt,
maxAttempts: session.reconnect.maxAttempts,
});
}
if (session?.status === 'error') {
return (
session.error ??
this.translate.instant('EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED')
);
}
if (session?.status === 'ended' && this.isLivePlayback()) {
return this.translate.instant('EMBEDDED_MPV.PLAYER.STREAM_ENDED');
}
if (!this.support()) {
return this.translate.instant(
'EMBEDDED_MPV.PLAYER.CHECKING_SUPPORT'
);
}
if (!this.isSupported()) {
return (
this.support()?.reason ??
this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE')
);
}
if (!session || session.status === 'loading') {
return this.translate.instant('EMBEDDED_MPV.PLAYER.LOADING_STREAM');
}
return '';
});
readonly fullscreenLabel = computed(() => {
this.translationsTick();
return this.translate.instant(
this.isFullscreen()
? 'EMBEDDED_MPV.PLAYER.EXIT_FULLSCREEN'
: 'EMBEDDED_MPV.PLAYER.ENTER_FULLSCREEN'
);
});
readonly audioTracks = computed(() => this.session()?.audioTracks ?? []);
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
readonly subtitleTracks = computed(
() => this.session()?.subtitleTracks ?? []
);
readonly hasSubtitleTracks = computed(
() => this.subtitleTracks().length > 0
);
readonly selectedSubtitleTrackId = computed(
() => this.session()?.selectedSubtitleTrackId ?? null
);
readonly playbackSpeed = computed(() => this.session()?.playbackSpeed ?? 1);
readonly aspectOverride = computed(
() => this.session()?.aspectOverride ?? 'no'
);
readonly volumeIcon = computed(() => volumeIcon(this.volume()));
readonly volumeLabel = computed(() => {
this.translationsTick();
return this.translate.instant('EMBEDDED_MPV.PLAYER.VOLUME_LABEL', {
percent: Math.round(this.volume() * 100),
});
});
readonly volumePercent = computed(
() => `${Math.round(this.volume() * 100)}%`
);
readonly dockPanelBackLabel = computed(() => {
this.translationsTick();
return this.translate.instant('EMBEDDED_MPV.PLAYER.BACK');
});
/**
* Non-null while the user drags the timeline: the slider and time label
* preview this value locally and the single seek IPC call is deferred to
* the release (`change`) event instead of firing per drag pixel.
*/
readonly scrubPosition = signal<number | null>(null);
readonly timelineValue = computed(
() =>
this.scrubPosition() ??
Math.max(0, this.session()?.positionSeconds ?? 0)
);
readonly controlsAreVisible = computed(
() =>
this.showControls() &&
this.isSupported() &&
(this.controlsVisible() ||
this.isLoading() ||
this.isPaused() ||
this.menus.anyOpen() ||
Boolean(this.statusLabel()))
);
readonly hideCursor = computed(
() =>
this.isFullscreen() &&
this.isPlaying() &&
!this.controlsAreVisible()
);
readonly canRecord = computed(
() =>
this.capabilities().recording &&
this.isLivePlayback() &&
this.isSupported() &&
!this.isErrored()
);
readonly isRecording = computed(
() => this.session()?.recording?.active === true
);
readonly showSeriesNavigation = computed(
() => !this.isLivePlayback() && this.seriesNavigation() !== null
);
readonly canPreviousEpisode = computed(
() =>
this.showSeriesNavigation() &&
this.seriesNavigation()?.canPrevious === true
);
readonly canNextEpisode = computed(
() =>
this.showSeriesNavigation() &&
this.seriesNavigation()?.canNext === true
);
readonly recordingElapsed = computed(() => {
const startedAt = this.session()?.recording?.startedAt;
this.recordingTick();
if (!startedAt) {
return 0;
}
const startedAtMs = Date.parse(startedAt);
if (!Number.isFinite(startedAtMs)) {
return 0;
}
return Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
});
readonly recordingStatusText = computed(() => {
if (this.isRecording()) {
return `REC ${formatTime(this.recordingElapsed())}`;
}
return this.recordingMessage();
});
private mutedVolume = 0;
private recordingMessageTimer: number | null = null;
private lastEndedSessionId: string | null = null;
private readonly recordingTick = signal(Date.now());
private readonly recordingMessage = signal<string | null>(null);
private readonly legacyInteractions: EmbeddedMpvLegacyInteractions;
private readonly onFullscreenChange = () => {
this.syncFullscreenState();
this.legacyInteractions.revealControls();
this.controller.triggerBoundsSync();
};
private syncFullscreenState(): void {
const surface = this.fullscreenSurface();
this.isFullscreen.set(
Boolean(
surface &&
typeof document !== 'undefined' &&
document.fullscreenElement === surface
)
);
}
constructor() {
this.legacyInteractions = new EmbeddedMpvLegacyInteractions({
isAvailable: () => !this.isFrameCopyEngine(),
playerRoot: () => this.playerRoot()?.nativeElement ?? null,
menus: this.menus,
controlsVisible: this.controlsVisible,
isLoading: this.isLoading,
isErrored: this.isErrored,
isStalled: this.stalled,
isPlaying: this.isPlaying,
statusLabel: this.statusLabel,
togglePaused: () => this.togglePaused(),
toggleFullscreen: () => this.toggleFullscreen(),
});
this.legacyInteractions.attach();
this.dockPanels = new EmbeddedMpvDockPanelState({
menus: this.menus,
audioTracks: this.audioTracks,
subtitleTracks: this.subtitleTracks,
selectedSubtitleTrackId: this.selectedSubtitleTrackId,
playbackSpeed: this.playbackSpeed,
aspectOverride: this.aspectOverride,
translateLabel: (key) => {
this.translationsTick();
return this.translate.instant(key);
},
audioTrackLabel: (track, index) => this.trackLabel(track, index),
subtitleTrackLabel: (track, index) =>
this.subtitleLabel(track, index),
aspectLabel: (aspect) => this.aspectLabel(aspect),
selectAudioTrack: (trackId) => void this.selectAudioTrack(trackId),
selectSubtitleTrack: (trackId) =>
void this.selectSubtitleTrack(trackId),
selectSpeed: (speed) => void this.selectSpeed(speed),
selectAspect: (aspect) => void this.selectAspect(aspect),
closePanels: () => this.legacyInteractions.closePopovers(),
playerRoot: () => this.playerRoot()?.nativeElement ?? null,
revealControls: () => this.legacyInteractions.revealControls(),
});
this.sharedControls.configure({
playback: this.playback,
seriesNavigation: this.seriesNavigation,
recordingFolder: this.recordingFolder,
recordingMetadata: this.recordingMetadata,
});
// Single owner of the "a recording just stopped" edge, for every
// engine and every trigger — the controls button, the download
// manager's Stop (which talks to the main process directly and never
// reaches this component's own toggle), or mpv stopping on its own.
// The host answers with EPG stop enrichment.
let previousRecording: {
targetPath: string;
startedAt: string | null;
epgChannelId: string | null;
sourceItemKey: string | null;
} | null = null;
effect(() => {
const recording = this.controller.session()?.recording;
untracked(() => {
if (recording?.active && recording.targetPath) {
previousRecording = {
targetPath: recording.targetPath,
startedAt: recording.startedAt ?? null,
// Captured while it is still the recorded channel: a
// channel switch auto-stops the recording and moves
// the host's state on before the stop is handled.
epgChannelId:
previousRecording?.epgChannelId ??
this.recordingMetadata()?.epgChannelId ??
null,
sourceItemKey:
previousRecording?.sourceItemKey ??
this.recordingMetadata()?.sourceItemKey ??
null,
};
return;
}
if (!previousRecording) {
return;
}
const stopped = previousRecording;
previousRecording = null;
this.recordingStopped.emit({
targetPath: stopped.targetPath,
startedAt: stopped.startedAt,
endedAt: new Date().toISOString(),
epgChannelId: stopped.epgChannelId,
sourceItemKey: stopped.sourceItemKey,
});
});
});
if (typeof document !== 'undefined') {
document.addEventListener(
'fullscreenchange',
this.onFullscreenChange
);
}
// A player remounted for the next episode can start inside the
// host-owned fullscreen; no `fullscreenchange` fires for it, so the
// state is read once the fullscreen surface resolves.
effect(() => {
void this.fullscreenSurface();
untracked(() => this.syncFullscreenState());
});
this.controller.setBoundsProvider((host) => {
// The frame-copy engine paints into an ordinary DOM canvas:
// dialogs and overlays stack above it natively, so the
// hide-offscreen compositor workaround must not shrink its
// render size.
if (this.isFrameCopyEngine()) {
return measureBounds(host);
}
if (this.overlayVisibility.overlayActive()) {
return HIDDEN_BOUNDS;
}
// Control menus render as horizontal panels inside the
// fixed-height dock strip below the video host, so open menus
// never require shrinking the native MPV view.
return measureBounds(host);
});
this.shortcuts.attach({
isAvailable: () =>
this.legacyInteractions.isAvailable() &&
!this.overlayVisibility.overlayActive(),
hostElement: () => this.playerRoot()?.nativeElement ?? null,
arrowKeysBlocked: () => this.menus.dockPanelOpen(),
onEscape: () => this.legacyInteractions.closePopovers(),
togglePaused: () => void this.togglePaused(),
toggleFullscreen: () => void this.toggleFullscreen(),
seekBy: (delta) => void this.seekBy(delta),
adjustVolume: (delta) => this.adjustVolume(delta),
toggleMute: () => this.toggleMute(),
});
effect((onCleanup) => {
const viewport = this.viewport();
const playback = this.playback();
const supported = this.isSupported();
this.controller.retryToken();
if (
!viewport ||
!playback.streamUrl ||
!supported ||
!window.electron
) {
return;
}
// volume is read untracked so adjusting it during playback does
// not re-trigger this effect (which would tear down and recreate
// the session, restarting the stream from the beginning).
// Subsequent volume changes flow through controller.applyVolume.
this.setRecordingMessage(null);
const teardown = this.controller.startSession(
viewport.nativeElement,
playback,
untracked(() => this.volume())
);
onCleanup(teardown);
});
effect(() => {
this.overlayVisibility.overlayActive();
this.controller.triggerBoundsSync();
});
effect(() => {
const panelOpen = this.menus.dockPanelOpen();
untracked(() => this.dockPanels.handlePanelOpenChange(panelOpen));
});
effect(() => {
const session = this.session();
if (!session) {
this.lastEndedSessionId = null;
return;
}
// Side effects must not pull in transitive signal deps via
// scheduleControlsHide — otherwise opening a popover, pausing, or
// hovering would re-run this body and re-emit timeUpdate (which
// could feed back into playback inputs and restart the stream).
untracked(() => {
this.volume.set(session.volume);
this.timeUpdate.emit({
currentTime: session.positionSeconds,
duration: session.durationSeconds ?? 0,
playing: session.status === 'playing',
});
this.legacyInteractions.scheduleControlsHide();
});
});
effect(() => {
const session = this.session();
if (!session || session.status !== 'ended') {
return;
}
if (this.lastEndedSessionId === session.id) {
return;
}
this.lastEndedSessionId = session.id;
this.playbackEnded.emit();
});
effect((onCleanup) => {
if (this.isFrameCopyEngine() || !this.isRecording()) {
return;
}
this.recordingTick.set(Date.now());
const intervalId = window.setInterval(
() => this.recordingTick.set(Date.now()),
1000
);
onCleanup(() => window.clearInterval(intervalId));
});
effect(() => {
const isFrameCopyEngine = this.isFrameCopyEngine();
untracked(() => {
this.feedback.clear();
this.setRecordingMessage(null);
this.legacyInteractions.handleEngineTransition(
isFrameCopyEngine
);
});
});
}
ngOnDestroy(): void {
this.shortcuts.detach();
this.feedback.dispose();
if (typeof document !== 'undefined') {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange
);
}
this.legacyInteractions.dispose();
this.clearRecordingMessageTimer();
}
onPlayerInteraction(): void {
this.legacyInteractions.onPlayerInteraction();
}
onViewportClick(event: MouseEvent): void {
this.legacyInteractions.onViewportClick(event);
}
onPlayerDblClick(event: MouseEvent): void {
this.legacyInteractions.onPlayerDblClick(event);
}
async togglePaused(): Promise<void> {
this.legacyInteractions.revealControls();
await this.controller.togglePaused();
}
async toggleFullscreen(): Promise<void> {
this.legacyInteractions.revealControls();
const surface = this.fullscreenSurface();
if (!surface || !this.canFullscreen()) {
return;
}
try {
if (document.fullscreenElement === surface) {
await document.exitFullscreen();
} else {
await surface.requestFullscreen();
}
} catch {
return;
} finally {
this.controller.triggerBoundsSync();
}
}
async seekBy(deltaSeconds: number): Promise<void> {
this.legacyInteractions.revealControls();
const ok = await this.controller.seekBy(deltaSeconds);
if (ok) {
this.feedback.flash(
deltaSeconds >= 0 ? 'forward_10' : 'replay_10',
`${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s`
);
}
}
requestPreviousEpisode(): void {
this.legacyInteractions.revealControls();
if (!this.canPreviousEpisode()) {
return;
}
this.previousEpisodeRequested.emit();
}
requestNextEpisode(): void {
this.legacyInteractions.revealControls();
if (!this.canNextEpisode()) {
return;
}
this.nextEpisodeRequested.emit();
}
onTimelineInput(event: Event): void {
this.legacyInteractions.revealControls();
this.scrubPosition.set(
Number((event.target as HTMLInputElement).value)
);
}
async onTimelineCommit(event: Event): Promise<void> {
const target = Number((event.target as HTMLInputElement).value);
this.scrubPosition.set(null);
await this.controller.seekTo(target);
}
onVolumeInput(event: Event): void {
const next = Number((event.target as HTMLInputElement).value);
this.applyVolume(next);
this.legacyInteractions.revealControls(false);
}
onVolumeWheel(event: WheelEvent): void {
event.preventDefault();
this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
}
onVolumeHoverEnter(): void {
this.legacyInteractions.onVolumeHoverEnter();
}
onVolumeHoverLeave(): void {
this.legacyInteractions.onVolumeHoverLeave();
}
toggleMute(): void {
const current = this.volume();
if (current > 0) {
this.mutedVolume = current;
this.applyVolume(0);
this.feedback.flash(
'volume_off',
this.translate.instant('EMBEDDED_MPV.PLAYER.MUTED')
);
} else {
const restored = this.mutedVolume || 0.5;
this.applyVolume(restored);
this.feedback.flash(
volumeIcon(restored),
`${Math.round(restored * 100)}%`
);
}
this.legacyInteractions.revealControls();
}
toggleAudioMenu(): void {
this.dockPanels.toggle('audio');
}
toggleSubtitleMenu(): void {
this.dockPanels.toggle('subtitle');
}
toggleSpeedMenu(): void {
this.dockPanels.toggle('speed');
}
toggleAspectMenu(): void {
this.dockPanels.toggle('aspect');
}
async selectAudioTrack(trackId: number): Promise<void> {
this.legacyInteractions.revealControls(false);
await this.controller.setAudioTrack(trackId);
this.menus.close('audio');
this.legacyInteractions.scheduleControlsHide();
}
async selectSubtitleTrack(trackId: number): Promise<void> {
this.legacyInteractions.revealControls(false);
await this.controller.setSubtitleTrack(trackId);
this.menus.close('subtitle');
this.legacyInteractions.scheduleControlsHide();
}
async selectSpeed(speed: number): Promise<void> {
this.legacyInteractions.revealControls(false);
await this.controller.setSpeed(speed);
this.menus.close('speed');
this.legacyInteractions.scheduleControlsHide();
}
async selectAspect(aspect: string): Promise<void> {
this.legacyInteractions.revealControls(false);
await this.controller.setAspect(aspect);
this.menus.close('aspect');
this.legacyInteractions.scheduleControlsHide();
}
async toggleRecording(): Promise<void> {
if (!this.canRecord()) {
return;
}
this.legacyInteractions.revealControls(false);
if (this.isRecording()) {
const recording = await this.controller.stopRecording();
if (recording?.targetPath) {
// recordingStopped is emitted by the session watcher in the
// constructor, which also covers stops triggered elsewhere.
this.setRecordingMessage(
this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', {
path: recording.targetPath,
}),
{
autoDismiss: true,
}
);
this.feedback.flash(
'check_circle',
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_SAVED'
),
1200
);
} else if (recording?.error) {
this.setRecordingMessage(recording.error);
this.flashRecordingFailed();
} else {
this.setRecordingMessage(
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
)
);
this.flashRecordingFailed();
}
this.legacyInteractions.scheduleControlsHide();
return;
}
this.setRecordingMessage(null);
const recording = await this.controller.startRecording(
this.recordingFolder(),
this.playback().title,
this.recordingMetadata() ?? undefined
);
if (recording?.active) {
this.feedback.flash(
'fiber_manual_record',
this.translate.instant('EMBEDDED_MPV.PLAYER.RECORDING'),
900
);
} else if (recording?.error) {
this.setRecordingMessage(recording.error);
this.flashRecordingFailed();
} else {
this.setRecordingMessage(
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
)
);
this.flashRecordingFailed();
}
}
private flashRecordingFailed(): void {
this.feedback.flash(
'error_outline',
this.translate.instant('EMBEDDED_MPV.PLAYER.RECORDING_FAILED'),
1200
);
}
retry(): void {
this.controller.retry();
}
// These helpers run inside the template's reactive context, so the
// translationsTick() read makes them re-evaluate on language changes.
formatTime = formatTime;
trackLabel = (track: EmbeddedMpvAudioTrack, index: number) => {
this.translationsTick();
return audioTrackLabel(track, index, {
fallback: this.translate.instant(
'EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK',
{ index: index + 1 }
),
defaultLabel: this.translate.instant(
'EMBEDDED_MPV.PLAYER.TRACK_DEFAULT'
),
});
};
subtitleLabel = (track: EmbeddedMpvAudioTrack, index: number) => {
this.translationsTick();
return subtitleTrackLabel(track, index, {
fallback: this.translate.instant(
'EMBEDDED_MPV.PLAYER.SUBTITLE_TRACK_FALLBACK',
{ index: index + 1 }
),
defaultLabel: this.translate.instant(
'EMBEDDED_MPV.PLAYER.TRACK_DEFAULT'
),
});
};
speedLabel = speedLabel;
aspectLabel = (aspect: string) => {
this.translationsTick();
return aspect === 'no'
? this.translate.instant('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT')
: aspectLabel(aspect);
};
private setRecordingMessage(
message: string | null,
options: { autoDismiss?: boolean } = {}
): void {
this.clearRecordingMessageTimer();
this.recordingMessage.set(message);
if (!message || !options.autoDismiss) {
return;
}
this.recordingMessageTimer = window.setTimeout(() => {
if (this.recordingMessage() === message) {
this.recordingMessage.set(null);
}
this.recordingMessageTimer = null;
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
}
private clearRecordingMessageTimer(): void {
if (this.recordingMessageTimer === null) {
return;
}
window.clearTimeout(this.recordingMessageTimer);
this.recordingMessageTimer = null;
}
private adjustVolume(delta: number): void {
const next = Math.max(0, Math.min(1, this.volume() + delta));
this.applyVolume(next);
this.legacyInteractions.revealControls();
this.feedback.flash(volumeIcon(next), `${Math.round(next * 100)}%`);
}
private applyVolume(value: number): void {
this.volume.set(value);
persistVolume(value);
void this.controller.applyVolume(value);
}
}
export type EmbeddedMpvAudioTrackView = EmbeddedMpvAudioTrack;