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 066189a5e..e7f7803d8 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 @@ -53,7 +53,7 @@ interface PendingRecordingOperation { readonly expectedActive: boolean; readonly initialError: string | null; readonly targetPath: string | null; - commandSnapshotIdentity: string | null; + readonly baselineSnapshotIdentity: string | null; commandSettled: boolean; sawErrorClear: boolean; } @@ -76,7 +76,10 @@ export class EmbeddedMpvControlsRecording { private ownerIdentity: string | null | undefined; private destroyed = false; - constructor(private readonly controller: EmbeddedMpvSessionController) {} + constructor( + private readonly controller: EmbeddedMpvSessionController, + private readonly currentPlaybackIdentity: () => string | null + ) {} toggle(context: RecordingToggleContext): void { if (this.destroyed || this.pending) { @@ -97,7 +100,9 @@ export class EmbeddedMpvControlsRecording { expectedActive: kind === RECORDING_OPERATION.START, initialError, targetPath: recording?.targetPath ?? null, - commandSnapshotIdentity: null, + baselineSnapshotIdentity: this.recordingSnapshotIdentity( + context.session + ), commandSettled: false, sawErrorClear: initialError === null, }; @@ -139,7 +144,7 @@ export class EmbeddedMpvControlsRecording { if ( !pending.commandSettled || this.recordingSnapshotIdentity(session) === - pending.commandSnapshotIdentity + pending.baselineSnapshotIdentity ) { return; } @@ -246,8 +251,9 @@ export class EmbeddedMpvControlsRecording { return; } pending.commandSettled = true; - pending.commandSnapshotIdentity = this.recordingSnapshotIdentity( - this.controller.session() + this.reconcile( + this.controller.session(), + this.currentPlaybackIdentity() ); } 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 new file mode 100644 index 000000000..71dbd4ab5 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts @@ -0,0 +1,258 @@ +import { signal } 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, +}; + +interface Deferred { + readonly promise: Promise; + readonly 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(): object { + return { + EMBEDDED_MPV: { + PLAYER: { + RECORDING_FAILED_TO_START: 'Failed to start recording', + RECORDING_FAILED_TO_STOP: 'Failed to stop recording', + SAVED_TO: 'Saved to {{path}}', + }, + }, + }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EmbeddedMpvControlsAdapter command/session ordering', () => { + let adapter: EmbeddedMpvControlsAdapter; + let controller: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + controller = createController(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + EmbeddedMpvControlsAdapter, + { + provide: EmbeddedMpvSessionController, + useValue: controller, + }, + ], + }); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', translations()); + translate.setDefaultLang('en'); + translate.use('en'); + + adapter = TestBed.inject(EmbeddedMpvControlsAdapter); + adapter.configure({ + playback: signal(LIVE_PLAYBACK), + seriesNavigation: signal(null), + recordingFolder: signal('/recordings'), + }); + TestBed.tick(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + jest.useRealTimers(); + }); + + it('accepts a start acknowledgement written immediately before command resolution', async () => { + const startedRecording = { + active: true, + startedAt: '2026-07-16T10:00:02.000Z', + }; + controller.startRecording.mockImplementation(async () => { + controller.session.set(session({ recording: startedRecording })); + return startedRecording; + }); + controller.stopRecording.mockImplementation(async () => { + const stoppedRecording = { active: false }; + 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(); + }); + + it('accepts a stop acknowledgement written immediately before command resolution', async () => { + const targetPath = '/recordings/live.ts'; + const stoppedRecording = { active: false, targetPath }; + controller.session.set( + session({ + recording: { + active: true, + targetPath, + startedAt: '2026-07-16T10:00:02.000Z', + }, + }) + ); + controller.stopRecording.mockImplementation(async () => { + controller.session.set(session({ recording: stoppedRecording })); + return stoppedRecording; + }); + controller.startRecording.mockImplementation(async () => { + const startedRecording = { + active: true, + startedAt: '2026-07-16T10:00:03.000Z', + }; + controller.session.set(session({ recording: startedRecording })); + return startedRecording; + }); + + adapter.commands.toggleRecording(); + await flushPromises(); + + expect(adapter.state().recording.message).toBe( + `Saved to ${targetPath}` + ); + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(1); + await flushPromises(); + jest.advanceTimersByTime(ACK_TIMEOUT_MS); + expect(adapter.state().recording.message).toBeNull(); + }); + + it('waits for a post-settlement acknowledgement when a stale response restores the baseline', async () => { + 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', + }, + }) + ); + TestBed.tick(); + controller.session.set(session({ recording: { active: false } })); + command.resolve({ active: false }); + await flushPromises(); + + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(1); + + controller.session.set( + session({ + recording: { + active: true, + startedAt: '2026-07-16T10:00:03.000Z', + }, + }) + ); + TestBed.tick(); + adapter.commands.toggleRecording(); + + expect(controller.stopRecording).toHaveBeenCalledTimes(1); + }); +}); 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 4c5a9ba96..38358fa4e 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 @@ -174,7 +174,6 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => { controller.startRecording.mockImplementation(async () => { const staleRecording = { active: false, - error: 'stale error', }; controller.session.set(session({ recording: staleRecording })); return staleRecording; @@ -247,20 +246,15 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => { expect(adapter.state().recording.message).toBeNull(); }); - it('uses a later same-session recording error as raw failure feedback', async () => { + it('uses a same-session recording error present at command settlement', async () => { const addonError = ' Addon rejected stream-record '; + controller.startRecording.mockImplementation(async () => { + const failedRecording = { active: false, error: addonError }; + controller.session.set(session({ recording: failedRecording })); + return failedRecording; + }); 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 ')); 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 ee486c9f6..20976d677 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 @@ -68,8 +68,13 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { private readonly configuredContext = signal(null); + private readonly playbackIdentity = computed(() => { + const playback = this.configuredContext()?.playback(); + return playback ? JSON.stringify(playback) : null; + }); private readonly recordingControls = new EmbeddedMpvControlsRecording( - this.controller + this.controller, + () => this.playbackIdentity() ); private readonly recordingActive = computed( () => this.controller.session()?.recording?.active === true @@ -77,10 +82,6 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { 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()); readonly capabilities = computed(() => {