diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts new file mode 100644 index 000000000..066189a5e --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts @@ -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>; +} + +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(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); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts index ab8f9a1af..da14adcbc 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.messages.spec.ts @@ -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(); - }); }); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts new file mode 100644 index 000000000..4c5a9ba96 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.races.spec.ts @@ -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 { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((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 { + 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(support()), + session: signal(session()), + stalled: signal(false), + togglePaused: jest.fn, []>().mockResolvedValue(undefined), + seekTo: jest.fn, [number]>().mockResolvedValue(undefined), + seekBy: jest.fn, [number]>().mockResolvedValue(true), + applyVolume: jest + .fn, [number]>() + .mockResolvedValue(undefined), + setAudioTrack: jest + .fn, [number]>() + .mockResolvedValue(undefined), + setSubtitleTrack: jest + .fn, [number]>() + .mockResolvedValue(undefined), + setSpeed: jest + .fn, [number]>() + .mockResolvedValue(undefined), + setAspect: jest + .fn, [string]>() + .mockResolvedValue(undefined), + startRecording: jest + .fn< + Promise, + [string | undefined, string] + >() + .mockResolvedValue({ active: false }), + stopRecording: jest + .fn, []>() + .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 { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => { + let adapter: EmbeddedMpvControlsAdapter; + let controller: ReturnType; + let translate: TranslateService; + let playback: WritableSignal; + + 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(); + 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(); + 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(); + 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(); + 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(); + } + ); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index aa7a2140b..ee486c9f6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -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; readonly seriesNavigation: Signal; @@ -66,10 +68,20 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { private readonly configuredContext = signal(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(null); - private recordingMessageTimer: number | null = null; - private destroyed = false; readonly capabilities = computed(() => { 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 { + 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 { - 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 { - 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, + }); } }