diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.engine-handoff.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.engine-handoff.spec.ts new file mode 100644 index 000000000..e1477467a --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.engine-handoff.spec.ts @@ -0,0 +1,158 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + EmbeddedMpvEngine, + EmbeddedMpvSession, + EmbeddedMpvSupport, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { TranslateModule } from '@ngx-translate/core'; +import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; +import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; + +const PLAYBACK: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/live.ts', + 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(engine: EmbeddedMpvEngine): EmbeddedMpvSupport { + return { + supported: true, + platform: 'darwin', + engine, + capabilities: { + subtitles: true, + playbackSpeed: true, + aspectOverride: true, + screenshot: false, + recording: true, + }, + }; +} + +function session( + overrides: Partial = {} +): EmbeddedMpvSession { + return { + id: 'session-1', + title: PLAYBACK.title, + streamUrl: 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('frame-copy')), + session: signal(session()), + stalled: signal(false), + togglePaused: jest.fn().mockResolvedValue(undefined), + seekTo: jest.fn().mockResolvedValue(undefined), + seekBy: jest.fn().mockResolvedValue(true), + applyVolume: jest.fn().mockResolvedValue(undefined), + setAudioTrack: jest.fn().mockResolvedValue(undefined), + setSubtitleTrack: jest.fn().mockResolvedValue(undefined), + setSpeed: jest.fn().mockResolvedValue(undefined), + setAspect: jest.fn().mockResolvedValue(undefined), + startRecording: jest + .fn< + Promise, + [string | undefined, string] + >() + .mockResolvedValue({ active: false }), + stopRecording: jest + .fn, []>() + .mockResolvedValue({ active: true }), + }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EmbeddedMpvControlsAdapter engine handoff', () => { + afterEach(() => { + TestBed.resetTestingModule(); + jest.useRealTimers(); + }); + + it('cancels frame-copy recording work before a native session can reconcile it', async () => { + jest.useFakeTimers(); + const controller = createController(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + EmbeddedMpvControlsAdapter, + { + provide: EmbeddedMpvSessionController, + useValue: controller, + }, + ], + }); + const adapter = TestBed.inject(EmbeddedMpvControlsAdapter); + adapter.configure({ + playback: signal(PLAYBACK), + seriesNavigation: signal(null), + recordingFolder: signal('/recordings'), + }); + TestBed.tick(); + const command = deferred(); + controller.startRecording.mockReturnValueOnce(command.promise); + + adapter.commands.toggleRecording(); + expect(controller.startRecording).toHaveBeenCalledTimes(1); + + controller.support.set(support('native')); + TestBed.tick(); + controller.session.set( + session({ + recording: { + active: false, + error: 'Late native recording failure', + }, + }) + ); + TestBed.tick(); + command.resolve({ active: false }); + await flushPromises(); + jest.advanceTimersByTime(5000); + TestBed.tick(); + + expect(adapter.state().recording.message).toBeNull(); + + controller.session.set(session()); + controller.support.set(support('frame-copy')); + TestBed.tick(); + adapter.commands.toggleRecording(); + + expect(controller.startRecording).toHaveBeenCalledTimes(2); + }); +}); 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 7b14e8db0..f4dd0963e 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 @@ -70,9 +70,14 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { const playback = this.configuredContext()?.playback(); return playback ? JSON.stringify(playback) : null; }); + private readonly recordingPlaybackIdentity = computed(() => + this.controller.support()?.engine === 'frame-copy' + ? this.playbackIdentity() + : null + ); private readonly recordingControls = new EmbeddedMpvControlsRecording( this.controller, - () => this.playbackIdentity() + () => this.recordingPlaybackIdentity() ); private readonly recordingActive = computed( () => @@ -211,7 +216,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { }); effect(() => { - const playbackIdentity = this.playbackIdentity(); + const playbackIdentity = this.recordingPlaybackIdentity(); const sessionId = this.activeSessionId(); untracked(() => this.recordingControls.syncOwner(playbackIdentity, sessionId) @@ -220,7 +225,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { effect(() => { const session = this.controller.session(); - const playbackIdentity = this.playbackIdentity(); + const playbackIdentity = this.recordingPlaybackIdentity(); untracked(() => this.recordingControls.reconcile(session, playbackIdentity) ); @@ -309,7 +314,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { const context = this.configuredContext(); const support = this.controller.support(); const session = this.controller.session(); - const playbackIdentity = this.playbackIdentity(); + const playbackIdentity = this.recordingPlaybackIdentity(); if ( !context || !session || diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts index 34589aac2..d5f4edb44 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts @@ -233,6 +233,17 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => { expect(component.controlsVisible()).toBe(false); }); + it('syncs fullscreen state when shared controls attach after entering fullscreen', () => { + const { fixture, controller } = render('native'); + fullscreenElement = root(fixture); + document.dispatchEvent(new Event('fullscreenchange')); + + controller.support.set(support('frame-copy')); + fixture.detectChanges(); + + expect(sharedControls(fixture)?.isFullscreen()).toBe(true); + }); + it('never renders both control systems across engine transitions', () => { const { fixture, controller } = render(); jest.useFakeTimers(); @@ -263,6 +274,23 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => { expect(togglePaused).not.toHaveBeenCalled(); }); + it('clears a native volume-close timer during a frame-copy handoff', () => { + jest.useFakeTimers(); + const { fixture, component, controller } = render('native'); + component.onVolumeHoverEnter(); + component.onVolumeHoverLeave(); + + controller.support.set(support('frame-copy')); + fixture.detectChanges(); + controller.support.set(support('native')); + fixture.detectChanges(); + component.menus.open('volume'); + + jest.advanceTimersByTime(220); + + expect(component.menus.volumeOpen()).toBe(true); + }); + it('leaves legacy pointer and popover state untouched on frame-copy', () => { const { fixture, component } = render(); component.controlsVisible.set(false); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index e01701744..b6a5c1678 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -492,6 +492,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } untracked(() => { this.clearControlsHideTimer(); + this.clearVolumeCloseTimer(); this.clearViewportClickTimer(); this.menus.closeAll(); }); @@ -515,15 +516,19 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.onDocumentPointerMove ); } - if (this.volumeCloseTimer !== null) { - clearTimeout(this.volumeCloseTimer); - this.volumeCloseTimer = null; - } + this.clearVolumeCloseTimer(); this.clearRecordingMessageTimer(); this.clearControlsHideTimer(); this.clearViewportClickTimer(); } + private clearVolumeCloseTimer(): void { + if (this.volumeCloseTimer !== null) { + window.clearTimeout(this.volumeCloseTimer); + this.volumeCloseTimer = null; + } + } + private clearViewportClickTimer(): void { if (this.viewportClickTimer !== null) { window.clearTimeout(this.viewportClickTimer); @@ -651,17 +656,12 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } onVolumeHoverEnter(): void { - if (this.volumeCloseTimer !== null) { - clearTimeout(this.volumeCloseTimer); - this.volumeCloseTimer = null; - } + this.clearVolumeCloseTimer(); this.menus.open('volume'); } onVolumeHoverLeave(): void { - if (this.volumeCloseTimer !== null) { - clearTimeout(this.volumeCloseTimer); - } + this.clearVolumeCloseTimer(); this.volumeCloseTimer = window.setTimeout(() => { this.menus.close('volume'); this.volumeCloseTimer = null; diff --git a/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts index 480f98b39..3ab31754c 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts @@ -17,8 +17,9 @@ describe('ControlsFullscreen', () => { target = document.createElement('div'); requestFullscreen = jest.fn().mockResolvedValue(undefined); exitFullscreen = jest.fn().mockResolvedValue(undefined); - (target as HTMLElement & { requestFullscreen: jest.Mock }).requestFullscreen = - requestFullscreen; + ( + target as HTMLElement & { requestFullscreen: jest.Mock } + ).requestFullscreen = requestFullscreen; originalExit = document.exitFullscreen; document.exitFullscreen = exitFullscreen; setFullscreenElement(null); @@ -71,6 +72,18 @@ describe('ControlsFullscreen', () => { fs.dispose(); }); + it('synchronizes an existing fullscreen target without invoking onChange', () => { + const onChange = jest.fn(); + const fs = new ControlsFullscreen(() => target, onChange); + setFullscreenElement(target); + + fs.sync(); + + expect(fs.isFullscreen()).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + fs.dispose(); + }); + it('stops reacting to fullscreenchange after dispose', () => { const onChange = jest.fn(); const fs = new ControlsFullscreen(() => target, onChange); diff --git a/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts index 9893ae612..99d3f3320 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts @@ -10,10 +10,7 @@ export class ControlsFullscreen { readonly isFullscreen = signal(false); private readonly onFullscreenChange = () => { - const target = this.target(); - this.isFullscreen.set( - Boolean(target && document.fullscreenElement === target) - ); + this.sync(); this.onChange?.(); }; @@ -29,6 +26,17 @@ export class ControlsFullscreen { } } + sync(): void { + const target = this.target(); + this.isFullscreen.set( + Boolean( + target && + typeof document !== 'undefined' && + document.fullscreenElement === target + ) + ); + } + canFullscreen(): boolean { const target = this.target(); return ( diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index 2b350312c..1a657daf1 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -151,7 +151,9 @@ export class PlayerControlsComponent implements OnDestroy { toggleMute: () => this.toggleMute(), }); effect((onCleanup) => { - const surface = this.showControls() ? this.playerSurface() : null; + const playerSurface = this.playerSurface(); + const surface = this.showControls() ? playerSurface : null; + this.fullscreen.sync(); onCleanup(this.surface.attachSurface(surface)); }); effect(() => { @@ -279,7 +281,6 @@ export class PlayerControlsComponent implements OnDestroy { this.volume.set(Number((event.target as HTMLInputElement).value)); this.reveal({ scheduleHide: false }); } - onVolumeWheel(event: WheelEvent): void { event.preventDefault(); this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);