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

746 lines
24 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
OnDestroy,
Output,
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 {
EmbeddedMpvAudioTrack,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
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,
MENU_OPEN_BOTTOM_CUTOUT_PX,
SPEED_PRESETS,
aspectLabel,
audioTrackLabel,
formatTime,
measureBounds,
persistVolume,
readStoredVolume,
speedLabel,
subtitleTrackLabel,
volumeIcon,
volumeLabel,
} from './embedded-mpv-format.utils';
const HIDE_CONTROLS_DELAY_MS = 2500;
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
const VOLUME_POPOVER_CLOSE_DELAY_MS = 220;
@Component({
selector: 'app-embedded-mpv-player',
templateUrl: './embedded-mpv-player.component.html',
styleUrl: './embedded-mpv-player.component.scss',
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
],
providers: [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 seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
private readonly overlayVisibility = inject(
EmbeddedMpvOverlayVisibilityService
);
private readonly controller = inject(EmbeddedMpvSessionController);
private readonly shortcuts = new EmbeddedMpvShortcuts();
readonly menus = new EmbeddedMpvMenuState();
readonly feedback = new EmbeddedMpvFeedback();
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
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 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');
readonly isLivePlayback = computed(() => {
const playback = this.playback();
if (typeof playback.isLive === 'boolean') {
return playback.isLive;
}
return !playback.contentInfo;
});
readonly canSeek = computed(
() =>
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
);
readonly canFullscreen = computed(
() =>
typeof document !== 'undefined' &&
Boolean(this.playerRoot()?.nativeElement.requestFullscreen) &&
Boolean(document.exitFullscreen)
);
readonly statusLabel = computed(() => {
const session = this.session();
if (session?.status === 'error') {
return session.error ?? 'Embedded MPV playback failed.';
}
if (!this.support()) {
return 'Checking embedded MPV support...';
}
if (!this.isSupported()) {
return (
this.support()?.reason ??
'Embedded MPV is not available in this environment.'
);
}
if (!session || session.status === 'loading') {
return 'Loading stream…';
}
return '';
});
readonly fullscreenLabel = computed(() =>
this.isFullscreen() ? 'Exit fullscreen' : '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(() => volumeLabel(this.volume()));
readonly timelineValue = computed(() =>
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 controlsHideTimer: number | null = null;
private volumeCloseTimer: number | null = null;
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 onDocumentPointerDown = (event: PointerEvent) => {
const playerRoot = this.playerRoot()?.nativeElement;
if (!playerRoot || event.composedPath().includes(playerRoot)) {
return;
}
this.closePopovers();
};
private readonly onDocumentPointerMove = (event: PointerEvent) => {
const playerRoot = this.playerRoot()?.nativeElement;
if (
playerRoot &&
!event.composedPath().includes(playerRoot) &&
this.isPointerInsidePlayer(event)
) {
this.revealControls();
}
};
private readonly onFullscreenChange = () => {
const playerRoot = this.playerRoot()?.nativeElement;
this.isFullscreen.set(
Boolean(playerRoot && document.fullscreenElement === playerRoot)
);
this.revealControls();
this.controller.triggerBoundsSync();
};
constructor() {
if (typeof document !== 'undefined') {
document.addEventListener(
'fullscreenchange',
this.onFullscreenChange
);
document.addEventListener(
'pointerdown',
this.onDocumentPointerDown
);
document.addEventListener(
'pointermove',
this.onDocumentPointerMove,
{ passive: true }
);
}
this.controller.setBoundsProvider((host) => {
if (this.overlayVisibility.overlayActive()) {
return HIDDEN_BOUNDS;
}
const rect = measureBounds(host);
if (this.menus.anyOpen()) {
return {
...rect,
height: Math.max(
1,
rect.height - MENU_OPEN_BOTTOM_CUTOUT_PX
),
};
}
return rect;
});
this.shortcuts.attach({
isAvailable: () => !this.overlayVisibility.overlayActive(),
onEscape: () => this.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.menus.anyOpen();
this.controller.triggerBoundsSync();
});
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,
});
this.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.isRecording()) {
return;
}
this.recordingTick.set(Date.now());
const intervalId = window.setInterval(
() => this.recordingTick.set(Date.now()),
1000
);
onCleanup(() => window.clearInterval(intervalId));
});
}
ngOnDestroy(): void {
this.shortcuts.detach();
this.feedback.dispose();
if (typeof document !== 'undefined') {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange
);
document.removeEventListener(
'pointerdown',
this.onDocumentPointerDown
);
document.removeEventListener(
'pointermove',
this.onDocumentPointerMove
);
}
if (this.volumeCloseTimer !== null) {
clearTimeout(this.volumeCloseTimer);
this.volumeCloseTimer = null;
}
this.clearRecordingMessageTimer();
this.clearControlsHideTimer();
}
onPlayerInteraction(): void {
this.revealControls();
}
onPlayerDblClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input, [role="slider"]')) {
return;
}
void this.toggleFullscreen();
}
async togglePaused(): Promise<void> {
this.revealControls();
await this.controller.togglePaused();
}
async toggleFullscreen(): Promise<void> {
this.revealControls();
const playerRoot = this.playerRoot()?.nativeElement;
if (!playerRoot || !this.canFullscreen()) {
return;
}
try {
if (document.fullscreenElement === playerRoot) {
await document.exitFullscreen();
} else {
await playerRoot.requestFullscreen();
}
} catch {
return;
} finally {
this.controller.triggerBoundsSync();
}
}
async seekBy(deltaSeconds: number): Promise<void> {
this.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.revealControls();
if (!this.canPreviousEpisode()) {
return;
}
this.previousEpisodeRequested.emit();
}
requestNextEpisode(): void {
this.revealControls();
if (!this.canNextEpisode()) {
return;
}
this.nextEpisodeRequested.emit();
}
async onTimelineInput(event: Event): Promise<void> {
this.revealControls();
const target = Number((event.target as HTMLInputElement).value);
await this.controller.seekTo(target);
}
onVolumeInput(event: Event): void {
const next = Number((event.target as HTMLInputElement).value);
this.applyVolume(next);
this.revealControls(false);
}
onVolumeWheel(event: WheelEvent): void {
event.preventDefault();
this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
}
onVolumeHoverEnter(): void {
if (this.volumeCloseTimer !== null) {
clearTimeout(this.volumeCloseTimer);
this.volumeCloseTimer = null;
}
this.menus.open('volume');
}
onVolumeHoverLeave(): void {
if (this.volumeCloseTimer !== null) {
clearTimeout(this.volumeCloseTimer);
}
this.volumeCloseTimer = window.setTimeout(() => {
this.menus.close('volume');
this.volumeCloseTimer = null;
}, VOLUME_POPOVER_CLOSE_DELAY_MS);
}
toggleMute(): void {
const current = this.volume();
if (current > 0) {
this.mutedVolume = current;
this.applyVolume(0);
this.feedback.flash('volume_off', 'Muted');
} else {
const restored = this.mutedVolume || 0.5;
this.applyVolume(restored);
this.feedback.flash(
volumeIcon(restored),
`${Math.round(restored * 100)}%`
);
}
this.revealControls();
}
toggleAudioMenu(): void {
this.menus.toggle('audio');
this.revealControls();
}
toggleSubtitleMenu(): void {
this.menus.toggle('subtitle');
this.revealControls();
}
toggleSpeedMenu(): void {
this.menus.toggle('speed');
this.revealControls();
}
toggleAspectMenu(): void {
this.menus.toggle('aspect');
this.revealControls();
}
async selectAudioTrack(trackId: number): Promise<void> {
this.revealControls(false);
await this.controller.setAudioTrack(trackId);
this.menus.close('audio');
this.scheduleControlsHide();
}
async selectSubtitleTrack(trackId: number): Promise<void> {
this.revealControls(false);
await this.controller.setSubtitleTrack(trackId);
this.menus.close('subtitle');
this.scheduleControlsHide();
}
async selectSpeed(speed: number): Promise<void> {
this.revealControls(false);
await this.controller.setSpeed(speed);
this.menus.close('speed');
this.scheduleControlsHide();
}
async selectAspect(aspect: string): Promise<void> {
this.revealControls(false);
await this.controller.setAspect(aspect);
this.menus.close('aspect');
this.scheduleControlsHide();
}
async toggleRecording(): Promise<void> {
if (!this.canRecord()) {
return;
}
this.revealControls(false);
if (this.isRecording()) {
const recording = await this.controller.stopRecording();
if (recording?.targetPath) {
this.setRecordingMessage(`Saved to ${recording.targetPath}`, {
autoDismiss: true,
});
this.feedback.flash('check_circle', 'Recording saved', 1200);
} else if (recording?.error) {
this.setRecordingMessage(recording.error);
this.feedback.flash('error_outline', 'Recording failed', 1200);
} else {
this.setRecordingMessage('Recording failed to stop.');
this.feedback.flash('error_outline', 'Recording failed', 1200);
}
this.scheduleControlsHide();
return;
}
this.setRecordingMessage(null);
const recording = await this.controller.startRecording(
this.recordingFolder(),
this.playback().title
);
if (recording?.active) {
this.feedback.flash('fiber_manual_record', 'Recording', 900);
} else if (recording?.error) {
this.setRecordingMessage(recording.error);
this.feedback.flash('error_outline', 'Recording failed', 1200);
} else {
this.setRecordingMessage('Recording failed to start.');
this.feedback.flash('error_outline', 'Recording failed', 1200);
}
}
retry(): void {
this.controller.retry();
}
formatTime = formatTime;
trackLabel = audioTrackLabel;
subtitleLabel = subtitleTrackLabel;
speedLabel = speedLabel;
aspectLabel = aspectLabel;
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.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);
}
private closePopovers(): void {
if (!this.menus.anyOpen()) {
return;
}
this.menus.closeAll();
this.controller.triggerBoundsSync();
this.scheduleControlsHide();
}
private revealControls(scheduleHide = true): void {
this.controlsVisible.set(true);
if (scheduleHide) {
this.clearControlsHideTimer();
this.scheduleControlsHide();
}
}
private scheduleControlsHide(): void {
if (
!this.isPlaying() ||
this.menus.anyOpen() ||
Boolean(this.statusLabel())
) {
this.clearControlsHideTimer();
return;
}
if (!this.controlsVisible() || this.controlsHideTimer !== null) {
return;
}
this.controlsHideTimer = window.setTimeout(() => {
if (
this.isPlaying() &&
!this.menus.anyOpen() &&
!this.statusLabel()
) {
this.controlsVisible.set(false);
}
}, HIDE_CONTROLS_DELAY_MS);
}
private clearControlsHideTimer(): void {
if (this.controlsHideTimer === null) {
return;
}
window.clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
private isPointerInsidePlayer(event: PointerEvent): boolean {
const playerRoot = this.playerRoot()?.nativeElement;
if (!playerRoot) {
return false;
}
const rect = playerRoot.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
}
export type EmbeddedMpvAudioTrackView = EmbeddedMpvAudioTrack;