import { DestroyRef, Injectable, Signal, computed, effect, inject, signal, untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { EmbeddedMpvSession, EmbeddedMpvSupport, RecordingStartMetadata, RecordingStoppedEvent, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; import { merge } from 'rxjs'; import { DEFAULT_ASPECT_PRESETS, DEFAULT_PLAYER_CAPABILITIES, DEFAULT_SPEED_PRESETS, createEmptyControlsState, } from '../player-controls/player-controls-defaults'; import { PlayerController, PlayerControlsCapabilities, PlayerControlsCommands, PlayerControlsState, PlayerStatus, } from '../player-controls/player-controls.model'; import type { PlayerStreamStatsSource } from '../player-controls/player-stream-stats.model'; import { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { audioTrackLabel, readStoredVolume, subtitleTrackLabel, } from './embedded-mpv-format.utils'; import { resolveRecordingFeedback } from './embedded-mpv-controls-recording-feedback'; import { EmbeddedMpvControlsRecording } from './embedded-mpv-controls-recording'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; import { EmbeddedMpvSubtitleSettings } from './embedded-mpv-subtitle-settings'; import { toPlayerStreamStats } from './embedded-mpv-stream-stats'; export interface EmbeddedMpvControlsContext { readonly playback: Signal; readonly seriesNavigation: Signal; readonly recordingFolder: Signal; /** Channel/EPG snapshot the live host captured for the recording tracker. */ readonly recordingMetadata?: Signal; } interface MappedPlayerStatus { status: PlayerStatus; statusMessage: string; } @Injectable() export class EmbeddedMpvControlsAdapter implements PlayerController { private readonly controller = inject(EmbeddedMpvSessionController); private readonly translate = inject(TranslateService); private readonly destroyRef = inject(DestroyRef); private readonly translationsTick = toSignal( merge( this.translate.onLangChange, this.translate.onTranslationChange, this.translate.onDefaultLangChange ), { initialValue: null } ); private readonly configuredContext = signal(null); private readonly playbackIdentity = computed(() => { const playback = this.configuredContext()?.playback(); return playback ? JSON.stringify(playback) : null; }); private readonly recordingPlaybackIdentity = computed(() => this.controller.support()?.engine === 'frame-copy' ? this.playbackIdentity() : null ); private readonly recordingControls = new EmbeddedMpvControlsRecording( this.controller, () => this.recordingPlaybackIdentity() ); private readonly recordingActive = computed( () => this.controller.support()?.engine === 'frame-copy' && this.controller.session()?.recording?.active === true ); private readonly activeSessionId = computed( () => this.controller.session()?.id ?? null ); private readonly subtitleSettings = new EmbeddedMpvSubtitleSettings( this.controller ); private readonly recordingTransitionKey = computed(() => { const playbackIdentity = this.recordingPlaybackIdentity(); const sessionId = this.activeSessionId(); return playbackIdentity === null || sessionId === null ? null : JSON.stringify([playbackIdentity, sessionId]); }); private readonly recordingTick = signal(Date.now()); /** * A boolean (not the stats object) so a session update that only moves the * numbers never invalidates the capability set downstream. */ private readonly hasStreamStats = computed( () => toPlayerStreamStats(this.controller.session()) !== null ); readonly streamStats: PlayerStreamStatsSource = { sample: () => toPlayerStreamStats(this.controller.session()), }; readonly capabilities = computed(() => { const context = this.configuredContext(); if (!context) { return DEFAULT_PLAYER_CAPABILITIES; } const isLive = this.isLivePlayback(context.playback()); const optionalCapabilities = this.controller.support()?.capabilities; return { ...DEFAULT_PLAYER_CAPABILITIES, seek: !isLive, volume: true, audioTracks: true, subtitles: optionalCapabilities?.subtitles ?? false, externalSubtitles: optionalCapabilities?.externalSubtitles ?? false, subtitleDelay: optionalCapabilities?.subtitleDelay ?? false, subtitleStyle: optionalCapabilities?.subtitleStyle ?? false, playbackSpeed: optionalCapabilities?.playbackSpeed ?? false, aspectRatio: optionalCapabilities?.aspectOverride ?? false, recording: optionalCapabilities?.recording ?? false, pictureInPicture: false, fullscreen: true, seriesNavigation: !isLive && context.seriesNavigation() !== null, streamStats: this.hasStreamStats(), }; }); readonly state = computed(() => { const context = this.configuredContext(); if (!context) { return createEmptyControlsState(); } this.translationsTick(); const support = this.controller.support(); const session = this.controller.session(); const playback = context.playback(); const isLive = this.isLivePlayback(playback); const durationSeconds = isLive ? null : (session?.durationSeconds ?? null); const seriesNavigation = context.seriesNavigation(); const hasSeriesNavigation = !isLive && seriesNavigation !== null; const mappedStatus = this.mapStatus(support, session); const recording = session?.recording; return { ...mappedStatus, stalled: this.controller.stalled(), positionSeconds: Math.max(0, session?.positionSeconds ?? 0), durationSeconds, isLive, canSeek: !isLive && (durationSeconds ?? 0) > 0, volume: session?.volume ?? readStoredVolume(), audioTracks: (session?.audioTracks ?? []).map((track, index) => ({ id: track.id, label: 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' ), }), selected: track.selected, })), subtitleTracks: (session?.subtitleTracks ?? []).map( (track, index) => ({ id: track.id, label: 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' ), }), selected: track.selected, }) ), subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0, subtitleDelaySeconds: this.subtitleSettings.delaySeconds(), subtitleStyle: this.subtitleSettings.style(), // MPV demuxes one program; no HLS/DASH rendition list is surfaced. qualityLevels: [], qualityAutoEnabled: true, playbackSpeed: session?.playbackSpeed ?? 1, speedPresets: DEFAULT_SPEED_PRESETS, aspectRatio: session?.aspectOverride ?? 'no', aspectPresets: DEFAULT_ASPECT_PRESETS, recording: { active: recording?.active === true, elapsedSeconds: this.recordingElapsedSeconds( recording?.startedAt ), message: resolveRecordingFeedback( this.recordingControls.feedback(), this.translate ), transitionKey: this.recordingTransitionKey(), }, pictureInPictureActive: false, canPictureInPicture: false, canPreviousEpisode: hasSeriesNavigation && seriesNavigation?.canPrevious === true, canNextEpisode: hasSeriesNavigation && seriesNavigation?.canNext === true, }; }); readonly commands: PlayerControlsCommands = { togglePlay: () => void this.controller.togglePaused(), seekTo: (seconds) => void this.controller.seekTo(seconds), seekBy: (deltaSeconds) => void this.controller.seekBy(deltaSeconds), setVolume: (value) => void this.controller.applyVolume(value), setAudioTrack: (id) => void this.controller.setAudioTrack(id), setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id), addExternalSubtitleFile: () => this.subtitleSettings.addExternalSubtitle(), setSubtitleDelay: (seconds) => this.subtitleSettings.setDelay(seconds), setSubtitleStyle: (style) => this.subtitleSettings.setStyle(style), setQualityLevel: () => undefined, setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed), setAspectRatio: (value) => void this.controller.setAspect(value), toggleRecording: () => void this.toggleRecording(), togglePictureInPicture: () => undefined, }; constructor() { effect((onCleanup) => { if (!this.recordingActive()) { return; } untracked(() => this.recordingTick.set(Date.now())); const intervalId = window.setInterval( () => this.recordingTick.set(Date.now()), 1000 ); onCleanup(() => window.clearInterval(intervalId)); }); effect(() => { const playbackIdentity = this.recordingPlaybackIdentity(); const sessionId = this.activeSessionId(); untracked(() => this.recordingControls.syncOwner(playbackIdentity, sessionId) ); }); // Delay is per-session; the persisted style is re-applied per session. effect(() => { const sessionId = this.activeSessionId(); untracked(() => this.subtitleSettings.syncSession(sessionId)); }); effect(() => { const session = this.controller.session(); const playbackIdentity = this.recordingPlaybackIdentity(); untracked(() => this.recordingControls.reconcile(session, playbackIdentity) ); }); this.destroyRef.onDestroy(() => this.recordingControls.destroy()); } configure(context: EmbeddedMpvControlsContext): void { this.configuredContext.set(context); } private isLivePlayback(playback: ResolvedPortalPlayback): boolean { if (typeof playback.isLive === 'boolean') { return playback.isLive; } return !playback.contentInfo; } private mapStatus( support: EmbeddedMpvSupport | null, session: EmbeddedMpvSession | null ): MappedPlayerStatus { if (!support) { return { status: 'loading', statusMessage: this.translate.instant( 'EMBEDDED_MPV.PLAYER.CHECKING_SUPPORT' ), }; } if (!support.supported) { return { status: 'idle', statusMessage: support.reason ?? this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE'), }; } if (session?.reconnect) { // A dropped stream the main process is reloading: a spinner with // the attempt count reads better than the underlying mpv error. return { status: 'loading', statusMessage: this.translate.instant( 'EMBEDDED_MPV.PLAYER.RECONNECTING', { attempt: session.reconnect.attempt, maxAttempts: session.reconnect.maxAttempts, } ), }; } if (!session || session.status === 'loading') { return { status: 'loading', statusMessage: this.translate.instant( 'EMBEDDED_MPV.PLAYER.LOADING_STREAM' ), }; } const statusPlayback = this.configuredContext()?.playback(); const liveEnded = session.status === 'ended' && statusPlayback !== undefined && this.isLivePlayback(statusPlayback); switch (session.status) { case 'playing': case 'paused': case 'idle': return { status: session.status, statusMessage: '' }; case 'ended': case 'closed': if (liveEnded) { // A broadcast never ends on its own: surface it like an // error so the shared controls offer a way back. return { status: 'error', statusMessage: this.translate.instant( 'EMBEDDED_MPV.PLAYER.STREAM_ENDED' ), }; } return { status: 'ended', statusMessage: '' }; case 'error': return { status: 'error', statusMessage: session.error ?? this.translate.instant( 'EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED' ), }; } } private recordingElapsedSeconds(startedAt: string | undefined): number { 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)); } private toggleRecording(): void { const context = this.configuredContext(); const support = this.controller.support(); const session = this.controller.session(); const playbackIdentity = this.recordingPlaybackIdentity(); if ( !context || !session || !playbackIdentity || !support?.supported || support.engine !== 'frame-copy' || support.capabilities?.recording !== true || !this.isLivePlayback(context.playback()) || session?.status === 'error' ) { return; } this.recordingControls.toggle({ folder: context.recordingFolder(), playback: context.playback(), playbackIdentity, session, metadata: context.recordingMetadata?.() ?? null, }); } }