fix(embedded-mpv): correlate recording control updates

This commit is contained in:
4gray committed 2026-07-16 15:00:43 +02:00
1 parent bace3540e9
commit 7035807f5d
4 files changed
+905 -234

No files matched your search

@@ -0,0 +1,339 @@
import { signal } from '@angular/core';
import type {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { TranslateService } from '@ngx-translate/core';
import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
const RECORDING_ACK_TIMEOUT_MS = 5000;
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
const RECORDING_OPERATION = {
START: 'start',
STOP: 'stop',
} as const;
const RECORDING_FEEDBACK = {
RAW: 'raw',
TRANSLATED: 'translated',
} as const;
const RECORDING_TRANSLATION = {
START_FAILED: 'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START',
STOP_FAILED: 'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP',
SAVED_TO: 'EMBEDDED_MPV.PLAYER.SAVED_TO',
} as const;
type RecordingOperation =
(typeof RECORDING_OPERATION)[keyof typeof RECORDING_OPERATION];
type RecordingTranslationKey =
(typeof RECORDING_TRANSLATION)[keyof typeof RECORDING_TRANSLATION];
interface RawRecordingFeedback {
readonly kind: typeof RECORDING_FEEDBACK.RAW;
readonly text: string;
}
interface TranslatedRecordingFeedback {
readonly kind: typeof RECORDING_FEEDBACK.TRANSLATED;
readonly key: RecordingTranslationKey;
readonly params?: Readonly<Record<string, string>>;
}
export type RecordingFeedback =
| RawRecordingFeedback
| TranslatedRecordingFeedback;
interface PendingRecordingOperation {
readonly generation: number;
readonly kind: RecordingOperation;
readonly playbackIdentity: string;
readonly sessionId: string;
readonly expectedActive: boolean;
readonly initialError: string | null;
readonly targetPath: string | null;
commandSnapshotIdentity: string | null;
commandSettled: boolean;
sawErrorClear: boolean;
}
export interface RecordingToggleContext {
readonly folder: string;
readonly playback: ResolvedPortalPlayback;
readonly playbackIdentity: string;
readonly session: EmbeddedMpvSession;
}
export class EmbeddedMpvControlsRecording {
private readonly feedbackState = signal<RecordingFeedback | null>(null);
readonly feedback = this.feedbackState.asReadonly();
private pending: PendingRecordingOperation | null = null;
private operationGeneration = 0;
private acknowledgementTimer: number | null = null;
private messageTimer: number | null = null;
private ownerIdentity: string | null | undefined;
private destroyed = false;
constructor(private readonly controller: EmbeddedMpvSessionController) {}
toggle(context: RecordingToggleContext): void {
if (this.destroyed || this.pending) {
return;
}
const recording = context.session.recording;
const initialError = this.recordingError(context.session);
const kind = recording?.active
? RECORDING_OPERATION.STOP
: RECORDING_OPERATION.START;
const generation = ++this.operationGeneration;
this.pending = {
generation,
kind,
playbackIdentity: context.playbackIdentity,
sessionId: context.session.id,
expectedActive: kind === RECORDING_OPERATION.START,
initialError,
targetPath: recording?.targetPath ?? null,
commandSnapshotIdentity: null,
commandSettled: false,
sawErrorClear: initialError === null,
};
this.setFeedback(null);
this.acknowledgementTimer = window.setTimeout(
() => this.handleAcknowledgementTimeout(generation),
RECORDING_ACK_TIMEOUT_MS
);
const command =
kind === RECORDING_OPERATION.START
? this.controller.startRecording(
context.folder,
context.playback.title
)
: this.controller.stopRecording();
void command.then(
() => this.markCommandSettled(generation),
() => this.markCommandSettled(generation)
);
}
reconcile(
session: EmbeddedMpvSession | null,
playbackIdentity: string | null
): void {
const pending = this.pending;
if (!pending) {
return;
}
if (
!session ||
session.id !== pending.sessionId ||
playbackIdentity !== pending.playbackIdentity
) {
this.cancelPending();
return;
}
if (
!pending.commandSettled ||
this.recordingSnapshotIdentity(session) ===
pending.commandSnapshotIdentity
) {
return;
}
const recording = session.recording;
if (!recording) {
return;
}
const error = this.recordingError(session);
if (error) {
if (pending.sawErrorClear || error !== pending.initialError) {
this.completePending({
kind: RECORDING_FEEDBACK.RAW,
text: error,
});
}
return;
}
pending.sawErrorClear = true;
if (recording.active !== pending.expectedActive) {
return;
}
if (pending.kind === RECORDING_OPERATION.START) {
this.completePending(null);
return;
}
const targetPath = recording.targetPath ?? pending.targetPath;
this.completePending(
targetPath
? {
kind: RECORDING_FEEDBACK.TRANSLATED,
key: RECORDING_TRANSLATION.SAVED_TO,
params: { path: targetPath },
}
: null,
Boolean(targetPath)
);
}
syncOwner(playbackIdentity: string | null, sessionId: string | null): void {
const nextIdentity =
playbackIdentity === null
? null
: JSON.stringify([playbackIdentity, sessionId]);
if (this.ownerIdentity === undefined) {
this.ownerIdentity = nextIdentity;
return;
}
if (this.ownerIdentity === nextIdentity) {
return;
}
this.ownerIdentity = nextIdentity;
this.setFeedback(null);
if (
this.pending &&
(this.pending.playbackIdentity !== playbackIdentity ||
this.pending.sessionId !== sessionId)
) {
this.cancelPending();
}
}
destroy(): void {
this.destroyed = true;
this.cancelPending();
this.clearMessageTimer();
}
private handleAcknowledgementTimeout(generation: number): void {
const pending = this.pending;
if (!pending || pending.generation !== generation) {
return;
}
const currentSession = this.controller.session();
const currentOwnerIdentity = JSON.stringify([
pending.playbackIdentity,
pending.sessionId,
]);
const error =
currentSession?.id === pending.sessionId &&
this.ownerIdentity === currentOwnerIdentity
? this.recordingError(currentSession)
: null;
this.completePending({
...(error
? { kind: RECORDING_FEEDBACK.RAW, text: error }
: {
kind: RECORDING_FEEDBACK.TRANSLATED,
key:
pending.kind === RECORDING_OPERATION.START
? RECORDING_TRANSLATION.START_FAILED
: RECORDING_TRANSLATION.STOP_FAILED,
}),
});
}
private markCommandSettled(generation: number): void {
const pending = this.pending;
if (!pending || pending.generation !== generation) {
return;
}
pending.commandSettled = true;
pending.commandSnapshotIdentity = this.recordingSnapshotIdentity(
this.controller.session()
);
}
private recordingSnapshotIdentity(
session: EmbeddedMpvSession | null
): string | null {
if (!session) {
return null;
}
const recording = session.recording;
return JSON.stringify([
session.id,
recording?.active ?? false,
recording?.targetPath ?? null,
recording?.startedAt ?? null,
recording?.error ?? null,
]);
}
private recordingError(session: EmbeddedMpvSession): string | null {
const error = session.recording?.error;
return error?.trim() ? error : null;
}
private completePending(
feedback: RecordingFeedback | null,
autoDismiss = false
): void {
this.clearAcknowledgementTimer();
this.pending = null;
this.setFeedback(feedback, autoDismiss);
}
private cancelPending(): void {
this.operationGeneration += 1;
this.pending = null;
this.clearAcknowledgementTimer();
}
private setFeedback(
feedback: RecordingFeedback | null,
autoDismiss = false
): void {
if (this.destroyed) {
return;
}
this.clearMessageTimer();
this.feedbackState.set(feedback);
if (!feedback || !autoDismiss) {
return;
}
const timerId = window.setTimeout(() => {
if (!this.destroyed && this.feedbackState() === feedback) {
this.feedbackState.set(null);
}
if (this.messageTimer === timerId) {
this.messageTimer = null;
}
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
this.messageTimer = timerId;
}
private clearAcknowledgementTimer(): void {
if (this.acknowledgementTimer !== null) {
window.clearTimeout(this.acknowledgementTimer);
this.acknowledgementTimer = null;
}
}
private clearMessageTimer(): void {
if (this.messageTimer !== null) {
window.clearTimeout(this.messageTimer);
this.messageTimer = null;
}
}
}
export function resolveRecordingFeedback(
feedback: RecordingFeedback | null,
translate: TranslateService
): string | null {
if (!feedback) {
return null;
}
return feedback.kind === RECORDING_FEEDBACK.RAW
? feedback.text
: translate.instant(feedback.key, feedback.params);
}
@@ -190,88 +190,6 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
expect(controller.startRecording).not.toHaveBeenCalled();
});
it('starts recording with the configured folder and playback title', async () => {
controller.startRecording.mockResolvedValue({
active: true,
startedAt: new Date().toISOString(),
});
adapter.commands.toggleRecording();
await flushPromises();
expect(controller.startRecording).toHaveBeenCalledWith(
'/recordings',
'Live news'
);
expect(adapter.state().recording.message).toBeNull();
});
it('reports detailed and translated generic recording-start failures', async () => {
controller.startRecording.mockResolvedValue({
active: false,
error: 'Disk is read-only',
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe('Disk is read-only');
controller.startRecording.mockResolvedValue(null);
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe(
'Failed to start recording'
);
});
it('reports a saved path after stop and auto-dismisses it after five seconds', async () => {
controller.session.set(
session({
recording: {
active: true,
startedAt: '2026-07-16T10:00:00.000Z',
},
})
);
controller.stopRecording.mockResolvedValue({
active: false,
targetPath: '/recordings/live-news.ts',
});
adapter.commands.toggleRecording();
await flushPromises();
expect(controller.stopRecording).toHaveBeenCalledTimes(1);
expect(adapter.state().recording.message).toBe(
'Saved to /recordings/live-news.ts'
);
jest.advanceTimersByTime(4999);
expect(adapter.state().recording.message).toBe(
'Saved to /recordings/live-news.ts'
);
jest.advanceTimersByTime(1);
expect(adapter.state().recording.message).toBeNull();
});
it('reports detailed and translated generic recording-stop failures', async () => {
controller.session.set(session({ recording: { active: true } }));
controller.stopRecording.mockResolvedValue({
active: false,
error: 'Muxer failed',
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe('Muxer failed');
controller.stopRecording.mockResolvedValue(null);
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe(
'Failed to stop recording'
);
});
it('computes recording elapsed time and refreshes it every second', () => {
controller.session.set(
session({
@@ -330,31 +248,98 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
expect(adapter.state().audioTracks[0].label).toBe('DE Audio 1');
});
it('keeps a newer persistent message when an older saved-message timer expires', async () => {
controller.session.set(session({ recording: { active: true } }));
controller.stopRecording.mockResolvedValue({
active: false,
targetPath: '/recordings/first.ts',
});
it('relocalizes saved feedback for every translate event source', async () => {
const targetPath = '/recordings/live.ts';
controller.session.set(
session({ recording: { active: true, targetPath } })
);
adapter.commands.toggleRecording();
await flushPromises();
controller.session.set(
session({ recording: { active: false, targetPath } })
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(
'Saved to /recordings/first.ts'
`Saved to ${targetPath}`
);
controller.stopRecording.mockResolvedValue({
active: false,
error: 'Newer failure',
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe('Newer failure');
translate.setTranslation('de', translations('DE '));
translate.use('de');
expect(adapter.state().recording.message).toBe(
`DE Saved to ${targetPath}`
);
jest.advanceTimersByTime(5000);
expect(adapter.state().recording.message).toBe('Newer failure');
translate.setTranslation('de', translations('Updated '));
expect(adapter.state().recording.message).toBe(
`Updated Saved to ${targetPath}`
);
translate.setTranslation('fr', translations('FR '));
translate.use('');
translate.setDefaultLang('fr');
expect(adapter.state().recording.message).toBe(
`FR Saved to ${targetPath}`
);
});
it('clears the elapsed interval when recording stops', () => {
it('times out and relocalizes generic feedback for every translate event source', async () => {
adapter.commands.toggleRecording();
await flushPromises();
jest.advanceTimersByTime(5000);
expect(adapter.state().recording.message).toBe(
'Failed to start recording'
);
translate.setTranslation('de', translations('DE '));
translate.use('de');
expect(adapter.state().recording.message).toBe(
'DE Failed to start recording'
);
translate.setTranslation('de', translations('Updated '));
expect(adapter.state().recording.message).toBe(
'Updated Failed to start recording'
);
translate.setTranslation('fr', translations('FR '));
translate.use('');
translate.setDefaultLang('fr');
expect(adapter.state().recording.message).toBe(
'FR Failed to start recording'
);
});
it('retains the pre-stop path and protects newer feedback from the saved timer', async () => {
const targetPath = '/recordings/original.ts';
controller.session.set(
session({ recording: { active: true, targetPath } })
);
adapter.commands.toggleRecording();
await flushPromises();
controller.session.set(session({ recording: { active: false } }));
TestBed.tick();
expect(adapter.state().recording.message).toBe(
`Saved to ${targetPath}`
);
adapter.commands.toggleRecording();
await flushPromises();
controller.session.set(
session({
recording: {
active: false,
error: 'Newer addon failure',
},
})
);
TestBed.tick();
jest.advanceTimersByTime(5000);
expect(adapter.state().recording.message).toBe('Newer addon failure');
});
it('does not restart the elapsed interval for position-only snapshots', () => {
const setIntervalSpy = jest.spyOn(window, 'setInterval');
const clearIntervalSpy = jest.spyOn(window, 'clearInterval');
controller.session.set(
session({
@@ -366,31 +351,48 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
);
TestBed.tick();
controller.session.set(session({ recording: { active: false } }));
controller.session.set(
session({
positionSeconds: 1,
recording: {
active: true,
startedAt: '2026-07-16T10:00:00.000Z',
},
})
);
TestBed.tick();
controller.session.set(
session({
positionSeconds: 2,
recording: {
active: true,
startedAt: '2026-07-16T10:00:00.000Z',
},
})
);
TestBed.tick();
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
expect(clearIntervalSpy).not.toHaveBeenCalled();
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
});
it('clears an active elapsed interval on destruction', () => {
const clearIntervalSpy = jest.spyOn(window, 'clearInterval');
controller.session.set(
session({
recording: {
active: true,
startedAt: '2026-07-16T10:00:00.000Z',
},
})
);
TestBed.tick();
TestBed.resetTestingModule();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
it('clears message timers on destruction and prevents late mutation', async () => {
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
controller.session.set(session({ recording: { active: true } }));
controller.stopRecording.mockResolvedValue({
active: false,
targetPath: '/recordings/final.ts',
});
adapter.commands.toggleRecording();
await flushPromises();
const state = adapter.state;
expect(state().recording.message).toBe('Saved to /recordings/final.ts');
TestBed.resetTestingModule();
expect(clearTimeoutSpy).toHaveBeenCalled();
jest.advanceTimersByTime(5000);
expect(state().recording.message).toBe('Saved to /recordings/final.ts');
clearTimeoutSpy.mockRestore();
});
});
@@ -0,0 +1,395 @@
import { signal, WritableSignal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
EmbeddedMpvSupport,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
const ACK_TIMEOUT_MS = 5000;
const LIVE_PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/live',
title: 'Live news',
isLive: true,
};
const REPLACEMENT_PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/replacement',
title: 'Replacement news',
isLive: true,
};
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolver) => {
resolve = resolver;
});
return { promise, resolve };
}
function support(): EmbeddedMpvSupport {
return {
supported: true,
platform: 'darwin',
engine: 'frame-copy',
capabilities: {
subtitles: true,
playbackSpeed: true,
aspectOverride: true,
screenshot: false,
recording: true,
},
};
}
function session(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'session-1',
title: LIVE_PLAYBACK.title,
streamUrl: LIVE_PLAYBACK.streamUrl,
status: 'playing',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-07-16T10:00:00.000Z',
updatedAt: '2026-07-16T10:00:01.000Z',
...overrides,
};
}
function createController() {
return {
support: signal<EmbeddedMpvSupport | null>(support()),
session: signal<EmbeddedMpvSession | null>(session()),
stalled: signal(false),
togglePaused: jest.fn<Promise<void>, []>().mockResolvedValue(undefined),
seekTo: jest.fn<Promise<void>, [number]>().mockResolvedValue(undefined),
seekBy: jest.fn<Promise<boolean>, [number]>().mockResolvedValue(true),
applyVolume: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setAudioTrack: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setSubtitleTrack: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setSpeed: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setAspect: jest
.fn<Promise<void>, [string]>()
.mockResolvedValue(undefined),
startRecording: jest
.fn<
Promise<EmbeddedMpvSession['recording'] | null>,
[string | undefined, string]
>()
.mockResolvedValue({ active: false }),
stopRecording: jest
.fn<Promise<EmbeddedMpvSession['recording'] | null>, []>()
.mockResolvedValue({ active: true }),
};
}
function translations(prefix = ''): object {
return {
EMBEDDED_MPV: {
PLAYER: {
PLAYBACK_FAILED: `${prefix}Playback failed`,
CHECKING_SUPPORT: `${prefix}Checking support`,
NOT_AVAILABLE: `${prefix}Not available`,
LOADING_STREAM: `${prefix}Loading stream`,
TRACK_DEFAULT: `${prefix}Default`,
AUDIO_TRACK_FALLBACK: `${prefix}Audio {{index}}`,
SUBTITLE_TRACK_FALLBACK: `${prefix}Subtitle {{index}}`,
RECORDING_FAILED_TO_START: `${prefix}Failed to start recording`,
RECORDING_FAILED_TO_STOP: `${prefix}Failed to stop recording`,
SAVED_TO: `${prefix}Saved to {{path}}`,
},
},
};
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
let adapter: EmbeddedMpvControlsAdapter;
let controller: ReturnType<typeof createController>;
let translate: TranslateService;
let playback: WritableSignal<ResolvedPortalPlayback>;
beforeEach(() => {
jest.useFakeTimers();
controller = createController();
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
EmbeddedMpvControlsAdapter,
{
provide: EmbeddedMpvSessionController,
useValue: controller,
},
],
});
translate = TestBed.inject(TranslateService);
translate.setTranslation('en', translations());
translate.setDefaultLang('en');
translate.use('en');
adapter = TestBed.inject(EmbeddedMpvControlsAdapter);
playback = signal(LIVE_PLAYBACK);
adapter.configure({
playback,
seriesNavigation: signal(null),
recordingFolder: signal('/recordings'),
});
TestBed.tick();
});
afterEach(() => {
TestBed.resetTestingModule();
jest.useRealTimers();
});
it('waits for a same-session active transition instead of trusting a stale start result', async () => {
controller.startRecording.mockImplementation(async () => {
const staleRecording = {
active: false,
error: 'stale error',
};
controller.session.set(session({ recording: staleRecording }));
return staleRecording;
});
adapter.commands.toggleRecording();
await flushPromises();
TestBed.tick();
expect(controller.startRecording).toHaveBeenCalledWith(
'/recordings',
LIVE_PLAYBACK.title
);
expect(adapter.state().recording.message).toBeNull();
controller.session.update((current) => ({
...(current ?? session()),
positionSeconds: 1,
updatedAt: '2026-07-16T10:00:02.000Z',
}));
TestBed.tick();
controller.session.set(
session({
recording: {
active: true,
targetPath: '/recordings/live.ts',
startedAt: '2026-07-16T10:00:02.000Z',
},
})
);
TestBed.tick();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.active).toBe(true);
expect(adapter.state().recording.message).toBeNull();
});
it('waits for an inactive transition before reporting a saved stop', async () => {
const targetPath = '/recordings/live.ts';
controller.session.set(
session({
recording: {
active: true,
targetPath,
startedAt: '2026-07-16T10:00:02.000Z',
},
})
);
controller.stopRecording.mockResolvedValue({
active: true,
targetPath,
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBeNull();
controller.session.set(
session({ recording: { active: false, targetPath } })
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(
`Saved to ${targetPath}`
);
jest.advanceTimersByTime(ACK_TIMEOUT_MS - 1);
expect(adapter.state().recording.message).toBe(
`Saved to ${targetPath}`
);
jest.advanceTimersByTime(1);
expect(adapter.state().recording.message).toBeNull();
});
it('uses a later same-session recording error as raw failure feedback', async () => {
const addonError = ' Addon rejected stream-record ';
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBeNull();
controller.session.set(
session({
recording: {
active: false,
error: addonError,
},
})
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(addonError);
translate.setTranslation('en', translations('Updated '));
expect(adapter.state().recording.message).toBe(addonError);
});
it('reports the same addon error again when a retry times out', async () => {
const addonError = 'Disk is still full';
controller.session.set(
session({ recording: { active: false, error: addonError } })
);
adapter.commands.toggleRecording();
await flushPromises();
controller.session.set(
session({
updatedAt: '2026-07-16T10:00:03.000Z',
recording: { active: false, error: addonError },
})
);
TestBed.tick();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBe(addonError);
});
it('serializes two immediate start toggles while acknowledgement is pending', async () => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.startRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(1);
command.resolve({ active: false });
await flushPromises();
});
it('serializes two immediate stop toggles while acknowledgement is pending', async () => {
controller.session.set(
session({
recording: {
active: true,
targetPath: '/recordings/live.ts',
},
})
);
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.stopRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
adapter.commands.toggleRecording();
expect(controller.stopRecording).toHaveBeenCalledTimes(1);
command.resolve({ active: true });
await flushPromises();
});
it.each(['playback', 'session'] as const)(
'invalidates a pending result after %s identity replacement',
async (replacement) => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.startRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
if (replacement === 'playback') {
playback.set(REPLACEMENT_PLAYBACK);
} else {
controller.session.set(
session({
id: 'session-2',
streamUrl: REPLACEMENT_PLAYBACK.streamUrl,
})
);
}
TestBed.tick();
command.resolve(null);
await flushPromises();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBeNull();
}
);
it.each(['playback', 'session'] as const)(
'clears recording feedback after %s identity changes',
async (replacement) => {
adapter.commands.toggleRecording();
await flushPromises();
controller.session.set(
session({
recording: {
active: false,
error: 'Current channel failure',
},
})
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(
'Current channel failure'
);
if (replacement === 'playback') {
playback.set(REPLACEMENT_PLAYBACK);
} else {
controller.session.set(session({ id: 'session-2' }));
}
TestBed.tick();
expect(adapter.state().recording.message).toBeNull();
}
);
it.each(['start', 'stop'] as const)(
'ignores a deferred %s result after destruction',
async (operation) => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
if (operation === 'start') {
controller.startRecording.mockReturnValue(command.promise);
} else {
controller.session.set(
session({ recording: { active: true } })
);
controller.stopRecording.mockReturnValue(command.promise);
}
adapter.commands.toggleRecording();
const state = adapter.state;
TestBed.resetTestingModule();
command.resolve(null);
await flushPromises();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(state().recording.message).toBeNull();
}
);
});
@@ -35,10 +35,12 @@ import {
readStoredVolume,
subtitleTrackLabel,
} from './embedded-mpv-format.utils';
import {
EmbeddedMpvControlsRecording,
resolveRecordingFeedback,
} from './embedded-mpv-controls-recording';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
export interface EmbeddedMpvControlsContext {
readonly playback: Signal<ResolvedPortalPlayback>;
readonly seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
@@ -66,10 +68,20 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
private readonly configuredContext =
signal<EmbeddedMpvControlsContext | null>(null);
private readonly recordingControls = new EmbeddedMpvControlsRecording(
this.controller
);
private readonly recordingActive = computed(
() => this.controller.session()?.recording?.active === true
);
private readonly activeSessionId = computed(
() => this.controller.session()?.id ?? null
);
private readonly playbackIdentity = computed(() => {
const playback = this.configuredContext()?.playback();
return playback ? JSON.stringify(playback) : null;
});
private readonly recordingTick = signal(Date.now());
private readonly recordingMessage = signal<string | null>(null);
private recordingMessageTimer: number | null = null;
private destroyed = false;
readonly capabilities = computed<PlayerControlsCapabilities>(() => {
const context = this.configuredContext();
@@ -159,7 +171,10 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
elapsedSeconds: this.recordingElapsedSeconds(
recording?.startedAt
),
message: this.recordingMessage(),
message: resolveRecordingFeedback(
this.recordingControls.feedback(),
this.translate
),
},
canPreviousEpisode:
hasSeriesNavigation && seriesNavigation?.canPrevious === true,
@@ -182,7 +197,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
constructor() {
effect((onCleanup) => {
if (this.controller.session()?.recording?.active !== true) {
if (!this.recordingActive()) {
return;
}
@@ -194,10 +209,23 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
onCleanup(() => window.clearInterval(intervalId));
});
this.destroyRef.onDestroy(() => {
this.destroyed = true;
this.clearRecordingMessageTimer();
effect(() => {
const playbackIdentity = this.playbackIdentity();
const sessionId = this.activeSessionId();
untracked(() =>
this.recordingControls.syncOwner(playbackIdentity, sessionId)
);
});
effect(() => {
const session = this.controller.session();
const playbackIdentity = this.playbackIdentity();
untracked(() =>
this.recordingControls.reconcile(session, playbackIdentity)
);
});
this.destroyRef.onDestroy(() => this.recordingControls.destroy());
}
configure(context: EmbeddedMpvControlsContext): void {
@@ -276,12 +304,15 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
return Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
}
private async toggleRecording(): Promise<void> {
private toggleRecording(): void {
const context = this.configuredContext();
const support = this.controller.support();
const session = this.controller.session();
const playbackIdentity = this.playbackIdentity();
if (
!context ||
!session ||
!playbackIdentity ||
!support?.supported ||
support.capabilities?.recording !== true ||
!this.isLivePlayback(context.playback()) ||
@@ -290,107 +321,11 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
return;
}
if (session?.recording?.active === true) {
await this.stopRecording();
return;
}
await this.startRecording(context);
}
private async startRecording(
context: EmbeddedMpvControlsContext
): Promise<void> {
this.setRecordingMessage(null);
try {
const recording = await this.controller.startRecording(
context.recordingFolder(),
context.playback().title
);
if (this.destroyed || recording?.active) {
return;
}
this.setRecordingMessage(
recording?.error ??
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
)
);
} catch {
this.setRecordingMessage(
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
)
);
}
}
private async stopRecording(): Promise<void> {
try {
const recording = await this.controller.stopRecording();
if (this.destroyed) {
return;
}
if (recording?.targetPath) {
this.setRecordingMessage(
this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', {
path: recording.targetPath,
}),
{ autoDismiss: true }
);
return;
}
this.setRecordingMessage(
recording?.error ??
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
)
);
} catch {
this.setRecordingMessage(
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
)
);
}
}
private setRecordingMessage(
message: string | null,
options: { autoDismiss?: boolean } = {}
): void {
if (this.destroyed) {
return;
}
this.clearRecordingMessageTimer();
this.recordingMessage.set(message);
if (!message || !options.autoDismiss) {
return;
}
const timerId = window.setTimeout(() => {
if (!this.destroyed && this.recordingMessage() === message) {
this.recordingMessage.set(null);
}
if (this.recordingMessageTimer === timerId) {
this.recordingMessageTimer = null;
}
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
this.recordingMessageTimer = timerId;
}
private clearRecordingMessageTimer(): void {
if (this.recordingMessageTimer === null) {
return;
}
window.clearTimeout(this.recordingMessageTimer);
this.recordingMessageTimer = null;
this.recordingControls.toggle({
folder: context.recordingFolder(),
playback: context.playback(),
playbackIdentity,
session,
});
}
}