mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(embedded-mpv): reset controls on engine handoff
This commit is contained in:
1 parent
bbc854ff31
commit
688e478c73
7 files changed
+236
-23
No files matched your search
+158
@@ -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 ||
|
||||
|
||||
+28
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user