fix(embedded-mpv): reset controls on engine handoff

This commit is contained in:
4gray committed 2026-07-16 16:40:18 +02:00
1 parent bbc854ff31
commit 688e478c73
7 files changed
+236 -23

No files matched your search

@@ -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<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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> = {}
): 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<EmbeddedMpvSupport | null>(support('frame-copy')),
session: signal<EmbeddedMpvSession | null>(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<EmbeddedMpvSession['recording'] | null>,
[string | undefined, string]
>()
.mockResolvedValue({ active: false }),
stopRecording: jest
.fn<Promise<EmbeddedMpvSession['recording'] | null>, []>()
.mockResolvedValue({ active: true }),
};
}
async function flushPromises(): Promise<void> {
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<EmbeddedMpvSession['recording'] | null>();
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);
});
});
@@ -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 ||
@@ -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);
@@ -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;
@@ -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);
@@ -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 (
@@ -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);