From 1ec47624ed406324c1ba7329a71b3e73a6f773dd Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 16 Jul 2026 16:03:39 +0200 Subject: [PATCH] fix(embedded-mpv): latch buffered recording outcomes --- ...mbedded-mpv-controls-recording-feedback.ts | 42 +++++++ .../embedded-mpv-controls-recording.ts | 93 +++++++------- ...mpv-controls.adapter.command-order.spec.ts | 114 +++++++++--------- .../embedded-mpv-controls.adapter.ts | 6 +- 4 files changed, 142 insertions(+), 113 deletions(-) create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-feedback.ts diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-feedback.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-feedback.ts new file mode 100644 index 000000000..60979d4c0 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording-feedback.ts @@ -0,0 +1,42 @@ +import type { TranslateService } from '@ngx-translate/core'; + +export const RECORDING_FEEDBACK = { + RAW: 'raw', + TRANSLATED: 'translated', +} as const; + +export 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 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; + +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-recording.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls-recording.ts index 36d40aff7..efa39035c 100644 --- 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 @@ -3,7 +3,11 @@ import type { EmbeddedMpvSession, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import type { TranslateService } from '@ngx-translate/core'; +import { + RECORDING_FEEDBACK, + RECORDING_TRANSLATION, + type RecordingFeedback, +} from './embedded-mpv-controls-recording-feedback'; import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; const RECORDING_ACK_TIMEOUT_MS = 5000; @@ -14,40 +18,13 @@ const RECORDING_OPERATION = { 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 RecordingOutcome { readonly feedback: RecordingFeedback | null; readonly autoDismiss?: boolean; + readonly expectedActive?: boolean; } interface PendingRecordingOperation { @@ -115,10 +92,7 @@ export class EmbeddedMpvControlsRecording { sawErrorClear: initialError === null, }; this.setFeedback(null); - this.acknowledgementTimer = window.setTimeout( - () => this.handleAcknowledgementTimeout(generation), - RECORDING_ACK_TIMEOUT_MS - ); + this.scheduleAcknowledgementTimeout(generation); const command = kind === RECORDING_OPERATION.START ? this.controller.startRecording( @@ -149,6 +123,19 @@ export class EmbeddedMpvControlsRecording { this.setFeedback(null); return; } + const observedOutcome = pending.observedOutcome; + if ( + pending.commandSettled && + observedOutcome?.expectedActive !== undefined && + !this.recordingError(session) && + session.recording?.active === observedOutcome.expectedActive + ) { + this.completePending( + observedOutcome.feedback, + observedOutcome.autoDismiss + ); + return; + } if ( this.recordingSnapshotIdentity(session) === pending.baselineSnapshotIdentity @@ -176,7 +163,10 @@ export class EmbeddedMpvControlsRecording { return; } if (pending.kind === RECORDING_OPERATION.START) { - this.observeOutcome(pending, { feedback: null }); + this.observeOutcome(pending, { + feedback: null, + expectedActive: pending.expectedActive, + }); return; } const targetPath = recording.targetPath ?? pending.targetPath; @@ -189,6 +179,7 @@ export class EmbeddedMpvControlsRecording { } : null, autoDismiss: Boolean(targetPath), + expectedActive: pending.expectedActive, }); } @@ -235,7 +226,7 @@ export class EmbeddedMpvControlsRecording { if ( !pending || pending.generation !== generation || - pending.observedOutcome + (pending.observedOutcome && !pending.commandSettled) ) { return; } @@ -282,11 +273,14 @@ export class EmbeddedMpvControlsRecording { if (!reconciledPending || reconciledPending.generation !== generation) { return; } - if (reconciledPending.observedOutcome) { - this.completePending( - reconciledPending.observedOutcome.feedback, - reconciledPending.observedOutcome.autoDismiss - ); + const outcome = reconciledPending.observedOutcome; + if (outcome && outcome.expectedActive === undefined) { + this.completePending(outcome.feedback, outcome.autoDismiss); + return; + } + if (outcome) { + this.setFeedback(outcome.feedback); + this.scheduleAcknowledgementTimeout(generation); return; } if (reconciledPending.timeoutFeedback) { @@ -379,6 +373,14 @@ export class EmbeddedMpvControlsRecording { } } + private scheduleAcknowledgementTimeout(generation: number): void { + this.clearAcknowledgementTimer(); + this.acknowledgementTimer = window.setTimeout( + () => this.handleAcknowledgementTimeout(generation), + RECORDING_ACK_TIMEOUT_MS + ); + } + private clearMessageTimer(): void { if (this.messageTimer !== null) { window.clearTimeout(this.messageTimer); @@ -386,14 +388,3 @@ export class EmbeddedMpvControlsRecording { } } } -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.command-order.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts index 3e25dfb10..f9786f23c 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts @@ -21,6 +21,8 @@ interface Deferred { readonly resolve: (value: T) => void; } +type RecordingState = EmbeddedMpvSession['recording'] | null; + function deferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((resolver) => { @@ -92,13 +94,10 @@ function createController() { .fn, [string]>() .mockResolvedValue(undefined), startRecording: jest - .fn< - Promise, - [string | undefined, string] - >() + .fn, [string | undefined, string]>() .mockResolvedValue({ active: false }), stopRecording: jest - .fn, []>() + .fn, []>() .mockResolvedValue({ active: true }), }; } @@ -170,16 +169,13 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { controller.session.set(session({ recording: stoppedRecording })); return stoppedRecording; }); - adapter.commands.toggleRecording(); await flushPromises(); - expect(adapter.state().recording.active).toBe(true); adapter.commands.toggleRecording(); expect(controller.stopRecording).toHaveBeenCalledTimes(1); await flushPromises(); jest.advanceTimersByTime(ACK_TIMEOUT_MS); - expect(adapter.state().recording.message).toBeNull(); }); @@ -207,10 +203,8 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { controller.session.set(session({ recording: startedRecording })); return startedRecording; }); - adapter.commands.toggleRecording(); await flushPromises(); - expect(adapter.state().recording.message).toBe( `Saved to ${targetPath}` ); @@ -222,7 +216,7 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { }); it.each(['start', 'stop'] as const)( - 'serializes a delayed %s command and finalizes its stored acknowledgement', + 'latches a buffered %s success through stale settlement', async (operation) => { const targetPath = '/recordings/live.ts'; const baselineRecording = @@ -240,63 +234,69 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { startedAt: '2026-07-16T10:00:03.000Z', } : { active: false, targetPath }; - const command = deferred(); - const lateCommand = deferred< - EmbeddedMpvSession['recording'] | null - >(); - controller.session.set(session({ recording: baselineRecording })); - if (operation === 'start') { - controller.startRecording - .mockReturnValueOnce(command.promise) - .mockReturnValueOnce(lateCommand.promise); - } else { - controller.stopRecording - .mockReturnValueOnce(command.promise) - .mockReturnValueOnce(lateCommand.promise); - } + const command = deferred(); + const lateCommand = deferred(); + const successMessage = + operation === 'stop' ? `Saved to ${targetPath}` : null; + const failureMessage = `Failed to ${operation} recording`; + const setRecording = (recording: RecordingState) => + controller.session.set(session({ recording })); + const originalCommand = + operation === 'start' + ? controller.startRecording + : controller.stopRecording; + const inverseCommand = + operation === 'start' + ? controller.stopRecording + : controller.startRecording; + setRecording(baselineRecording); + originalCommand + .mockReturnValueOnce(command.promise) + .mockReturnValueOnce(lateCommand.promise); + inverseCommand.mockImplementation(async () => { + setRecording(baselineRecording); + return baselineRecording; + }); adapter.commands.toggleRecording(); - controller.session.set( - session({ recording: acknowledgedRecording }) - ); + setRecording(acknowledgedRecording); jest.advanceTimersByTime(ACK_TIMEOUT_MS + 1); expect(adapter.state().recording.message).toBeNull(); adapter.commands.toggleRecording(); - expect(controller.startRecording).toHaveBeenCalledTimes( - operation === 'start' ? 1 : 0 - ); - expect(controller.stopRecording).toHaveBeenCalledTimes( - operation === 'stop' ? 1 : 0 - ); - controller.session.set(session({ recording: baselineRecording })); + expect(originalCommand).toHaveBeenCalledTimes(1); + expect(inverseCommand).not.toHaveBeenCalled(); + setRecording(baselineRecording); command.resolve(baselineRecording); await flushPromises(); - expect(adapter.state().recording.message).toBe( - operation === 'stop' ? `Saved to ${targetPath}` : null - ); + expect(adapter.state().recording.message).toBe(successMessage); + adapter.commands.toggleRecording(); + expect(originalCommand).toHaveBeenCalledTimes(1); + expect(inverseCommand).not.toHaveBeenCalled(); + setRecording(acknowledgedRecording); + TestBed.tick(); + adapter.commands.toggleRecording(); + expect(originalCommand).toHaveBeenCalledTimes(1); + expect(inverseCommand).toHaveBeenCalledTimes(1); + await flushPromises(); adapter.commands.toggleRecording(); jest.advanceTimersByTime(ACK_TIMEOUT_MS); - expect(adapter.state().recording.message).toBe( - operation === 'start' - ? 'Failed to start recording' - : 'Failed to stop recording' - ); - controller.session.set( - session({ recording: acknowledgedRecording }) - ); + expect(adapter.state().recording.message).toBe(failureMessage); + setRecording(acknowledgedRecording); TestBed.tick(); - expect(adapter.state().recording.message).toBe( - operation === 'stop' ? `Saved to ${targetPath}` : null - ); + expect(adapter.state().recording.message).toBe(successMessage); adapter.commands.toggleRecording(); - expect(controller.startRecording).toHaveBeenCalledTimes( - operation === 'start' ? 2 : 0 - ); - expect(controller.stopRecording).toHaveBeenCalledTimes( - operation === 'stop' ? 2 : 0 - ); - controller.session.set(session({ recording: baselineRecording })); + expect(originalCommand).toHaveBeenCalledTimes(2); + expect(inverseCommand).toHaveBeenCalledTimes(1); + setRecording(baselineRecording); lateCommand.resolve(baselineRecording); await flushPromises(); + adapter.commands.toggleRecording(); + expect(originalCommand).toHaveBeenCalledTimes(2); + expect(inverseCommand).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(ACK_TIMEOUT_MS); + expect(adapter.state().recording.message).toBe(failureMessage); + adapter.commands.toggleRecording(); + expect(originalCommand).toHaveBeenCalledTimes(3); + expect(inverseCommand).toHaveBeenCalledTimes(1); } ); @@ -305,7 +305,6 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { const command = deferred(); controller.session.set(session({ recording: { active: true } })); controller.stopRecording.mockReturnValue(command.promise); - adapter.commands.toggleRecording(); controller.session.set( session({ recording: { active: false, targetPath } }) @@ -316,7 +315,6 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { ); command.resolve({ active: true }); await flushPromises(); - expect(adapter.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 20976d677..7af4d54f7 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,8 @@ import { readStoredVolume, subtitleTrackLabel, } from './embedded-mpv-format.utils'; -import { - EmbeddedMpvControlsRecording, - resolveRecordingFeedback, -} from './embedded-mpv-controls-recording'; +import { resolveRecordingFeedback } from './embedded-mpv-controls-recording-feedback'; +import { EmbeddedMpvControlsRecording } from './embedded-mpv-controls-recording'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; export interface EmbeddedMpvControlsContext {