Files
iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts

424 lines
16 KiB
TypeScript

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<ResolvedPortalPlayback>;
readonly seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
readonly recordingFolder: Signal<string>;
/** Channel/EPG snapshot the live host captured for the recording tracker. */
readonly recordingMetadata?: Signal<RecordingStartMetadata | null>;
}
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<EmbeddedMpvControlsContext | null>(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<PlayerControlsCapabilities>(() => {
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<PlayerControlsState>(() => {
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,
});
}
}