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 e7f7803d8..36d40aff7 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 @@ -45,6 +45,11 @@ export type RecordingFeedback = | RawRecordingFeedback | TranslatedRecordingFeedback; +interface RecordingOutcome { + readonly feedback: RecordingFeedback | null; + readonly autoDismiss?: boolean; +} + interface PendingRecordingOperation { readonly generation: number; readonly kind: RecordingOperation; @@ -54,6 +59,8 @@ interface PendingRecordingOperation { readonly initialError: string | null; readonly targetPath: string | null; readonly baselineSnapshotIdentity: string | null; + observedOutcome: RecordingOutcome | null; + timeoutFeedback: RecordingFeedback | null; commandSettled: boolean; sawErrorClear: boolean; } @@ -85,7 +92,6 @@ export class EmbeddedMpvControlsRecording { if (this.destroyed || this.pending) { return; } - const recording = context.session.recording; const initialError = this.recordingError(context.session); const kind = recording?.active @@ -103,6 +109,8 @@ export class EmbeddedMpvControlsRecording { baselineSnapshotIdentity: this.recordingSnapshotIdentity( context.session ), + observedOutcome: null, + timeoutFeedback: null, commandSettled: false, sawErrorClear: initialError === null, }; @@ -111,7 +119,6 @@ export class EmbeddedMpvControlsRecording { () => this.handleAcknowledgementTimeout(generation), RECORDING_ACK_TIMEOUT_MS ); - const command = kind === RECORDING_OPERATION.START ? this.controller.startRecording( @@ -139,16 +146,15 @@ export class EmbeddedMpvControlsRecording { playbackIdentity !== pending.playbackIdentity ) { this.cancelPending(); + this.setFeedback(null); return; } if ( - !pending.commandSettled || this.recordingSnapshotIdentity(session) === - pending.baselineSnapshotIdentity + pending.baselineSnapshotIdentity ) { return; } - const recording = session.recording; if (!recording) { return; @@ -156,9 +162,11 @@ export class EmbeddedMpvControlsRecording { const error = this.recordingError(session); if (error) { if (pending.sawErrorClear || error !== pending.initialError) { - this.completePending({ - kind: RECORDING_FEEDBACK.RAW, - text: error, + this.observeOutcome(pending, { + feedback: { + kind: RECORDING_FEEDBACK.RAW, + text: error, + }, }); } return; @@ -167,23 +175,21 @@ export class EmbeddedMpvControlsRecording { if (recording.active !== pending.expectedActive) { return; } - if (pending.kind === RECORDING_OPERATION.START) { - this.completePending(null); + this.observeOutcome(pending, { feedback: null }); return; } - const targetPath = recording.targetPath ?? pending.targetPath; - this.completePending( - targetPath + this.observeOutcome(pending, { + feedback: targetPath ? { kind: RECORDING_FEEDBACK.TRANSLATED, key: RECORDING_TRANSLATION.SAVED_TO, params: { path: targetPath }, } : null, - Boolean(targetPath) - ); + autoDismiss: Boolean(targetPath), + }); } syncOwner(playbackIdentity: string | null, sessionId: string | null): void { @@ -198,7 +204,6 @@ export class EmbeddedMpvControlsRecording { if (this.ownerIdentity === nextIdentity) { return; } - this.ownerIdentity = nextIdentity; this.setFeedback(null); if ( @@ -211,17 +216,29 @@ export class EmbeddedMpvControlsRecording { } destroy(): void { + this.setFeedback(null); this.destroyed = true; this.cancelPending(); - this.clearMessageTimer(); } private handleAcknowledgementTimeout(generation: number): void { - const pending = this.pending; + let pending = this.pending; if (!pending || pending.generation !== generation) { return; } - + this.acknowledgementTimer = null; + this.reconcile( + this.controller.session(), + this.currentPlaybackIdentity() + ); + pending = this.pending; + if ( + !pending || + pending.generation !== generation || + pending.observedOutcome + ) { + return; + } const currentSession = this.controller.session(); const currentOwnerIdentity = JSON.stringify([ pending.playbackIdentity, @@ -232,7 +249,7 @@ export class EmbeddedMpvControlsRecording { this.ownerIdentity === currentOwnerIdentity ? this.recordingError(currentSession) : null; - this.completePending({ + const feedback: RecordingFeedback = { ...(error ? { kind: RECORDING_FEEDBACK.RAW, text: error } : { @@ -242,7 +259,13 @@ export class EmbeddedMpvControlsRecording { ? RECORDING_TRANSLATION.START_FAILED : RECORDING_TRANSLATION.STOP_FAILED, }), - }); + }; + pending.timeoutFeedback = feedback; + if (pending.commandSettled) { + this.completePending(feedback); + return; + } + this.setFeedback(feedback); } private markCommandSettled(generation: number): void { @@ -255,6 +278,39 @@ export class EmbeddedMpvControlsRecording { this.controller.session(), this.currentPlaybackIdentity() ); + const reconciledPending = this.pending; + if (!reconciledPending || reconciledPending.generation !== generation) { + return; + } + if (reconciledPending.observedOutcome) { + this.completePending( + reconciledPending.observedOutcome.feedback, + reconciledPending.observedOutcome.autoDismiss + ); + return; + } + if (reconciledPending.timeoutFeedback) { + this.completePending(reconciledPending.timeoutFeedback); + } + } + + private observeOutcome( + pending: PendingRecordingOperation, + outcome: RecordingOutcome + ): void { + if (this.pending !== pending || pending.observedOutcome) { + return; + } + pending.observedOutcome = outcome; + this.clearAcknowledgementTimer(); + if (pending.commandSettled) { + this.completePending(outcome.feedback, outcome.autoDismiss); + } else if ( + pending.timeoutFeedback || + outcome.feedback?.kind === RECORDING_FEEDBACK.RAW + ) { + this.setFeedback(outcome.feedback, outcome.autoDismiss); + } } private recordingSnapshotIdentity( @@ -305,7 +361,6 @@ export class EmbeddedMpvControlsRecording { if (!feedback || !autoDismiss) { return; } - const timerId = window.setTimeout(() => { if (!this.destroyed && this.feedbackState() === feedback) { this.feedbackState.set(null); @@ -331,7 +386,6 @@ export class EmbeddedMpvControlsRecording { } } } - export function resolveRecordingFeedback( feedback: RecordingFeedback | null, translate: TranslateService 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 71dbd4ab5..3e25dfb10 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 @@ -221,20 +221,161 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { expect(adapter.state().recording.message).toBeNull(); }); - it('waits for a post-settlement acknowledgement when a stale response restores the baseline', async () => { + it.each(['start', 'stop'] as const)( + 'serializes a delayed %s command and finalizes its stored acknowledgement', + async (operation) => { + const targetPath = '/recordings/live.ts'; + const baselineRecording = + operation === 'start' + ? { active: false } + : { + active: true, + startedAt: '2026-07-16T10:00:02.000Z', + }; + const acknowledgedRecording = + operation === 'start' + ? { + active: true, + targetPath, + 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); + } + adapter.commands.toggleRecording(); + controller.session.set( + session({ recording: 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 })); + command.resolve(baselineRecording); + await flushPromises(); + expect(adapter.state().recording.message).toBe( + operation === 'stop' ? `Saved to ${targetPath}` : null + ); + 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 }) + ); + TestBed.tick(); + expect(adapter.state().recording.message).toBe( + operation === 'stop' ? `Saved to ${targetPath}` : null + ); + 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 })); + lateCommand.resolve(baselineRecording); + await flushPromises(); + } + ); + + it('discards a stored acknowledgement when the session changes before settlement', async () => { + const targetPath = '/recordings/live.ts'; + 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 } }) + ); + TestBed.tick(); + controller.session.set( + session({ id: 'session-2', recording: { active: false } }) + ); + command.resolve({ active: true }); + await flushPromises(); + + expect(adapter.state().recording.message).toBeNull(); + }); + + it('shows a pre-settlement recording error without unlocking the command', async () => { + const addonError = ' Addon rejected stream-record '; const command = deferred(); controller.startRecording.mockReturnValue(command.promise); adapter.commands.toggleRecording(); controller.session.set( session({ - recording: { - active: true, - startedAt: '2026-07-16T10:00:02.000Z', - }, + recording: { active: false, error: addonError }, }) ); TestBed.tick(); + + expect(adapter.state().recording.message).toBe(addonError); + jest.advanceTimersByTime(ACK_TIMEOUT_MS + 1); + expect(adapter.state().recording.message).toBe(addonError); + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(1); + + controller.session.set(session({ recording: { active: false } })); + command.resolve({ active: false }); + await flushPromises(); + + expect(adapter.state().recording.message).toBe(addonError); + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(2); + }); + + it('keeps an unacknowledged timed-out command serialized until its promise settles', async () => { + const command = deferred(); + controller.startRecording.mockReturnValue(command.promise); + + adapter.commands.toggleRecording(); + jest.advanceTimersByTime(ACK_TIMEOUT_MS); + + expect(adapter.state().recording.message).toBe( + 'Failed to start recording' + ); + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(1); + + command.resolve({ active: false }); + await flushPromises(); + + expect(adapter.state().recording.message).toBe( + 'Failed to start recording' + ); + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(2); + }); + + it('waits for a post-settlement acknowledgement when only baseline snapshots arrive', async () => { + const command = deferred(); + controller.startRecording.mockReturnValue(command.promise); + + adapter.commands.toggleRecording(); controller.session.set(session({ recording: { active: false } })); command.resolve({ active: false }); await flushPromises(); 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 index 38358fa4e..e71a15118 100644 --- 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 @@ -378,6 +378,12 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => { } adapter.commands.toggleRecording(); const state = adapter.state; + const bufferedRecording = + operation === 'start' + ? { active: false, error: 'Buffered failure' } + : { active: false, targetPath: '/recordings/live.ts' }; + controller.session.set(session({ recording: bufferedRecording })); + TestBed.tick(); TestBed.resetTestingModule(); command.resolve(null);