From 7913b75017405333dfc43d352584653bb142dd43 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 16 Jul 2026 16:17:39 +0200 Subject: [PATCH] feat(embedded-mpv): use shared controls for frame-copy --- .../embedded-mpv-player.component.html | 51 ++- ...v-player.component.shared-controls.spec.ts | 305 ++++++++++++++++++ ...ayer.component.shared-interactions.spec.ts | 251 ++++++++++++++ .../embedded-mpv-player.component.spec.ts | 11 +- .../embedded-mpv-player.component.ts | 74 ++++- 5 files changed, 665 insertions(+), 27 deletions(-) create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index ab234334c..305a53bac 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -3,10 +3,14 @@ class="embedded-mpv-player" [class.embedded-mpv-player--fullscreen]="isFullscreen()" [class.embedded-mpv-player--controls-enabled]=" - showControls() && isSupported() + !isFrameCopyEngine() && showControls() && isSupported() + " + [class.embedded-mpv-player--controls-visible]=" + !isFrameCopyEngine() && controlsAreVisible() + " + [class.embedded-mpv-player--cursor-hidden]=" + !isFrameCopyEngine() && hideCursor() " - [class.embedded-mpv-player--controls-visible]="controlsAreVisible()" - [class.embedded-mpv-player--cursor-hidden]="hideCursor()" (pointerenter)="onPlayerInteraction()" (pointermove)="onPlayerInteraction()" (click)="onPlayerInteraction()" @@ -90,7 +94,18 @@ } - @if (showControls() && isSupported()) { + @if (isFrameCopyEngine() && isSupported()) { + + } + + @if (!isFrameCopyEngine() && showControls() && isSupported()) {
{{ @@ -266,14 +285,18 @@ type="button" class="embedded-mpv-player__control-button" (click)="toggleMute()" - [attr.aria-label]="(volume() <= 0 + [attr.aria-label]=" + (volume() <= 0 ? 'EMBEDDED_MPV.PLAYER.UNMUTE' : 'EMBEDDED_MPV.PLAYER.MUTE' - ) | translate" - [matTooltip]="(volume() <= 0 + ) | translate + " + [matTooltip]=" + (volume() <= 0 ? 'EMBEDDED_MPV.PLAYER.UNMUTE' : 'EMBEDDED_MPV.PLAYER.MUTE' - ) | translate" + ) | translate + " matTooltipPosition="above" > {{ volumeIcon() }} @@ -482,7 +505,7 @@ : { speed: speedLabel( playbackSpeed() - ) + ), } " matTooltipPosition="above" @@ -557,7 +580,7 @@ : { aspect: aspectLabel( aspectOverride() - ) + ), } " matTooltipPosition="above" 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 new file mode 100644 index 000000000..25c7f7711 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts @@ -0,0 +1,305 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import type { + EmbeddedMpvEngine, + EmbeddedMpvSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { PlayerControlsComponent } from '../player-controls/player-controls.component'; +import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; +import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component'; +import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; + +const PLAYBACK: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/movie.mp4', + title: 'Movie', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'movie', + }, +}; + +const READY_SESSION: EmbeddedMpvSession = { + id: 'session-1', + title: 'Movie', + streamUrl: PLAYBACK.streamUrl, + status: 'playing', + positionSeconds: 30, + durationSeconds: 120, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + startedAt: '2026-07-16T10:00:00Z', + updatedAt: '2026-07-16T10:00:00Z', +}; + +const overlayActive = signal(false); + +describe('EmbeddedMpvPlayerComponent shared controls host', () => { + const fixtures: ComponentFixture[] = []; + let fullscreenElement: Element | null; + let fullscreenElementDescriptor: PropertyDescriptor | undefined; + + beforeEach(async () => { + localStorage.removeItem('volume'); + overlayActive.set(false); + fullscreenElementDescriptor = Object.getOwnPropertyDescriptor( + document, + 'fullscreenElement' + ); + fullscreenElement = null; + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + + await TestBed.configureTestingModule({ + imports: [EmbeddedMpvPlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: EmbeddedMpvOverlayVisibilityService, + useValue: { overlayActive }, + }, + ], + }).compileComponents(); + }); + + afterEach(() => { + for (const fixture of fixtures.splice(0)) { + fixture.destroy(); + } + restoreProperty( + document, + 'fullscreenElement', + fullscreenElementDescriptor + ); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function restoreProperty( + target: object, + property: PropertyKey, + descriptor: PropertyDescriptor | undefined + ): void { + if (descriptor) { + Object.defineProperty(target, property, descriptor); + return; + } + Reflect.deleteProperty(target, property); + } + + function support(engine: EmbeddedMpvEngine) { + return { + supported: true, + platform: 'darwin', + engine, + capabilities: { + subtitles: true, + playbackSpeed: true, + aspectOverride: true, + screenshot: true, + recording: true, + }, + } as const; + } + + function render( + engine: EmbeddedMpvEngine = 'frame-copy', + showControls = true + ) { + const fixture = TestBed.createComponent(EmbeddedMpvPlayerComponent); + fixtures.push(fixture); + fixture.componentRef.setInput('playback', PLAYBACK); + fixture.componentRef.setInput('showControls', showControls); + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: true, + }); + const component = fixture.componentInstance; + const controller = fixture.debugElement.injector.get( + EmbeddedMpvSessionController + ); + controller.support.set(support(engine)); + controller.session.set({ ...READY_SESSION }); + fixture.detectChanges(); + fixture.detectChanges(); + return { fixture, component, controller }; + } + + function sharedControls( + fixture: ComponentFixture + ): PlayerControlsComponent | null { + return ( + fixture.debugElement.query(By.directive(PlayerControlsComponent)) + ?.componentInstance ?? null + ); + } + + function root( + fixture: ComponentFixture + ): HTMLElement { + return fixture.debugElement.query(By.css('.embedded-mpv-player')) + .nativeElement; + } + + function canvas( + fixture: ComponentFixture + ): HTMLCanvasElement { + return fixture.debugElement.query(By.css('[data-embedded-mpv-frame]')) + .nativeElement; + } + + it('renders the frame-copy canvas with shared controls and no legacy dock', () => { + const { fixture } = render(); + const playerRoot = root(fixture); + + expect(canvas(fixture)).toBeTruthy(); + expect(sharedControls(fixture)).not.toBeNull(); + expect( + fixture.debugElement.query(By.css('.embedded-mpv-player__controls')) + ).toBeNull(); + expect( + playerRoot.classList.contains( + 'embedded-mpv-player--controls-enabled' + ) + ).toBe(false); + expect( + playerRoot.classList.contains( + 'embedded-mpv-player--controls-visible' + ) + ).toBe(false); + }); + + it('keeps native playback on the legacy dock without shared controls', () => { + const { fixture } = render('native'); + + expect(sharedControls(fixture)).toBeNull(); + expect( + fixture.debugElement.query(By.css('.embedded-mpv-player__controls')) + ).not.toBeNull(); + expect( + root(fixture).classList.contains( + 'embedded-mpv-player--controls-enabled' + ) + ).toBe(true); + }); + + it('preserves the host previous and next episode guards', () => { + const { fixture, component } = render(); + const controls = sharedControls(fixture); + const previous = jest.fn(); + const next = jest.fn(); + component.previousEpisodeRequested.subscribe(previous); + component.nextEpisodeRequested.subscribe(next); + + controls?.previousEpisodeRequested.emit(); + controls?.nextEpisodeRequested.emit(); + expect(previous).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalled(); + + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: false, + canNext: true, + autoplayEnabled: true, + }); + fixture.detectChanges(); + controls?.previousEpisodeRequested.emit(); + controls?.nextEpisodeRequested.emit(); + + expect(previous).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('syncs bounds on fullscreen changes without revealing legacy state', () => { + const { fixture, component, controller } = render(); + const triggerBoundsSync = jest.spyOn(controller, 'triggerBoundsSync'); + component.controlsVisible.set(false); + fullscreenElement = root(fixture); + + document.dispatchEvent(new Event('fullscreenchange')); + + expect(component.isFullscreen()).toBe(true); + expect(triggerBoundsSync).toHaveBeenCalledTimes(1); + expect(component.controlsVisible()).toBe(false); + }); + + it('never renders both control systems across engine transitions', () => { + const { fixture, controller } = render(); + jest.useFakeTimers(); + const togglePaused = jest + .spyOn(controller, 'togglePaused') + .mockResolvedValue(undefined); + expect(sharedControls(fixture)).not.toBeNull(); + + controller.support.set(support('native')); + fixture.detectChanges(); + expect(sharedControls(fixture)).toBeNull(); + expect( + fixture.debugElement.query(By.css('.embedded-mpv-player__controls')) + ).not.toBeNull(); + fixture.debugElement + .query(By.css('.embedded-mpv-player__viewport')) + .nativeElement.dispatchEvent( + new MouseEvent('click', { bubbles: true }) + ); + + controller.support.set(support('frame-copy')); + fixture.detectChanges(); + expect(sharedControls(fixture)).not.toBeNull(); + expect( + fixture.debugElement.query(By.css('.embedded-mpv-player__controls')) + ).toBeNull(); + jest.advanceTimersByTime(1000); + expect(togglePaused).not.toHaveBeenCalled(); + }); + + it('leaves legacy pointer and popover state untouched on frame-copy', () => { + const { fixture, component } = render(); + component.controlsVisible.set(false); + component.menus.open('audio'); + + root(fixture).dispatchEvent(new MouseEvent('pointermove')); + root(fixture).dispatchEvent( + new FocusEvent('focusin', { bubbles: true }) + ); + document.dispatchEvent( + new MouseEvent('pointermove', { clientX: 0, clientY: 0 }) + ); + document.body.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true }) + ); + + expect(component.controlsVisible()).toBe(false); + expect(component.menus.audioOpen()).toBe(true); + }); + + it('uses only the adapter recording interval for frame-copy', () => { + const { fixture, controller } = render(); + const setInterval = jest.spyOn(window, 'setInterval'); + + controller.session.update((session) => + session + ? { + ...session, + recording: { + active: true, + startedAt: new Date().toISOString(), + }, + } + : session + ); + fixture.detectChanges(); + + expect(setInterval).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts new file mode 100644 index 000000000..eb02c9517 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts @@ -0,0 +1,251 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import type { + EmbeddedMpvSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; +import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component'; +import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; + +const PLAYBACK: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/movie.mp4', + title: 'Movie', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'movie', + }, +}; + +const READY_SESSION: EmbeddedMpvSession = { + id: 'session-1', + title: 'Movie', + streamUrl: PLAYBACK.streamUrl, + status: 'playing', + positionSeconds: 30, + durationSeconds: 120, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + startedAt: '2026-07-16T10:00:00Z', + updatedAt: '2026-07-16T10:00:00Z', +}; + +const overlayActive = signal(false); + +describe('EmbeddedMpvPlayerComponent shared controls interactions', () => { + const fixtures: ComponentFixture[] = []; + let requestFullscreen: jest.Mock; + let requestFullscreenDescriptor: PropertyDescriptor | undefined; + let exitFullscreenDescriptor: PropertyDescriptor | undefined; + + beforeEach(async () => { + localStorage.removeItem('volume'); + overlayActive.set(false); + requestFullscreenDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'requestFullscreen' + ); + exitFullscreenDescriptor = Object.getOwnPropertyDescriptor( + document, + 'exitFullscreen' + ); + requestFullscreen = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { + configurable: true, + writable: true, + value: requestFullscreen, + }); + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + writable: true, + value: jest.fn().mockResolvedValue(undefined), + }); + await TestBed.configureTestingModule({ + imports: [EmbeddedMpvPlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: EmbeddedMpvOverlayVisibilityService, + useValue: { overlayActive }, + }, + ], + }).compileComponents(); + }); + + afterEach(() => { + for (const fixture of fixtures.splice(0)) { + fixture.destroy(); + } + restoreProperty( + HTMLElement.prototype, + 'requestFullscreen', + requestFullscreenDescriptor + ); + restoreProperty(document, 'exitFullscreen', exitFullscreenDescriptor); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function restoreProperty( + target: object, + property: PropertyKey, + descriptor: PropertyDescriptor | undefined + ): void { + if (descriptor) { + Object.defineProperty(target, property, descriptor); + return; + } + Reflect.deleteProperty(target, property); + } + + function render(showControls = true) { + const fixture = TestBed.createComponent(EmbeddedMpvPlayerComponent); + fixtures.push(fixture); + fixture.componentRef.setInput('playback', PLAYBACK); + fixture.componentRef.setInput('showControls', showControls); + const controller = fixture.debugElement.injector.get( + EmbeddedMpvSessionController + ); + controller.support.set({ + supported: true, + platform: 'darwin', + engine: 'frame-copy', + capabilities: { + subtitles: true, + playbackSpeed: true, + aspectOverride: true, + screenshot: true, + recording: true, + }, + }); + controller.session.set({ ...READY_SESSION }); + fixture.detectChanges(); + fixture.detectChanges(); + return { fixture, controller }; + } + + function root( + fixture: ComponentFixture + ): HTMLElement { + return fixture.debugElement.query(By.css('.embedded-mpv-player')) + .nativeElement; + } + + function canvas( + fixture: ComponentFixture + ): HTMLCanvasElement { + return fixture.debugElement.query(By.css('[data-embedded-mpv-frame]')) + .nativeElement; + } + + function pressKey(key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + return event; + } + + it('toggles pause exactly once after the shared click grace period', () => { + const { fixture, controller } = render(); + jest.useFakeTimers(); + const togglePaused = jest + .spyOn(controller, 'togglePaused') + .mockResolvedValue(undefined); + + canvas(fixture).dispatchEvent( + new MouseEvent('click', { bubbles: true }) + ); + jest.advanceTimersByTime(249); + expect(togglePaused).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + + expect(togglePaused).toHaveBeenCalledTimes(1); + }); + + it('double-clicks fullscreen exactly once on the player root', () => { + const { fixture, controller } = render(); + jest.useFakeTimers(); + const togglePaused = jest + .spyOn(controller, 'togglePaused') + .mockResolvedValue(undefined); + + canvas(fixture).dispatchEvent( + new MouseEvent('click', { bubbles: true }) + ); + canvas(fixture).dispatchEvent( + new MouseEvent('dblclick', { bubbles: true }) + ); + jest.advanceTimersByTime(1000); + + expect(togglePaused).not.toHaveBeenCalled(); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + expect(requestFullscreen.mock.instances[0]).toBe(root(fixture)); + }); + + it('routes a keyboard playback toggle through shared controls once', () => { + const { controller } = render(); + const togglePaused = jest + .spyOn(controller, 'togglePaused') + .mockResolvedValue(undefined); + + const event = pressKey('k'); + + expect(event.defaultPrevented).toBe(true); + expect(togglePaused).toHaveBeenCalledTimes(1); + }); + + it('detaches shared surface and keyboard behavior when controls are hidden', () => { + const { fixture, controller } = render(false); + jest.useFakeTimers(); + const togglePaused = jest + .spyOn(controller, 'togglePaused') + .mockResolvedValue(undefined); + + canvas(fixture).dispatchEvent( + new MouseEvent('click', { bubbles: true }) + ); + canvas(fixture).dispatchEvent( + new MouseEvent('dblclick', { bubbles: true }) + ); + pressKey('k'); + jest.advanceTimersByTime(1000); + + expect(togglePaused).not.toHaveBeenCalled(); + expect(requestFullscreen).not.toHaveBeenCalled(); + }); + + it('disables shared keyboard shortcuts while an app overlay is active', () => { + const { fixture, controller } = render(); + const togglePaused = jest + .spyOn(controller, 'togglePaused') + .mockResolvedValue(undefined); + overlayActive.set(true); + fixture.detectChanges(); + + const event = pressKey('k'); + + expect(event.defaultPrevented).toBe(false); + expect(togglePaused).not.toHaveBeenCalled(); + }); + + it('does not let the legacy Escape handler mutate frame-copy menus', () => { + const { fixture } = render(); + const component = fixture.componentInstance; + component.menus.open('audio'); + + pressKey('Escape'); + + expect(component.menus.audioOpen()).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts index 38236e087..4116af872 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts @@ -55,6 +55,7 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => { controller.support.set({ supported: true, platform: 'darwin', + engine: 'native', capabilities: { subtitles: false, playbackSpeed: false, @@ -97,7 +98,10 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [EmbeddedMpvPlayerHostComponent, TranslateModule.forRoot()], + imports: [ + EmbeddedMpvPlayerHostComponent, + TranslateModule.forRoot(), + ], providers: [ { provide: EmbeddedMpvOverlayVisibilityService, @@ -233,9 +237,8 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => { describe('timeline scrubbing', () => { const slider = () => - fixture.debugElement.query( - By.css('.embedded-mpv-player__slider') - ).nativeElement as HTMLInputElement; + fixture.debugElement.query(By.css('.embedded-mpv-player__slider')) + .nativeElement as HTMLInputElement; const dispatch = (type: string, value: string) => { slider().value = value; 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 c61cf0e01..e01701744 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 @@ -23,7 +23,9 @@ import { EmbeddedMpvAudioTrack, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; +import { PlayerControlsComponent } from '../player-controls/player-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; +import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts'; @@ -61,9 +63,10 @@ const VIEWPORT_CLICK_PAUSE_DELAY_MS = 250; MatIconModule, MatProgressSpinnerModule, MatTooltipModule, + PlayerControlsComponent, TranslatePipe, ], - providers: [EmbeddedMpvSessionController], + providers: [EmbeddedMpvControlsAdapter, EmbeddedMpvSessionController], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'embedded-mpv-player-host', @@ -102,13 +105,20 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { ), { initialValue: null } ); - private readonly controller = inject(EmbeddedMpvSessionController); + readonly controller = inject(EmbeddedMpvSessionController); + readonly sharedControls = inject(EmbeddedMpvControlsAdapter); private readonly shortcuts = new EmbeddedMpvShortcuts(); readonly menus = new EmbeddedMpvMenuState(); readonly feedback = new EmbeddedMpvFeedback(); readonly viewport = viewChild>('viewport'); readonly playerRoot = viewChild>('playerRoot'); + readonly playerSurface = computed( + () => this.playerRoot()?.nativeElement ?? null + ); + readonly sharedShortcutsEnabled = computed( + () => !this.overlayVisibility.overlayActive() + ); readonly support = this.controller.support; readonly session = this.controller.session; @@ -188,9 +198,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { ); } if (!session || session.status === 'loading') { - return this.translate.instant( - 'EMBEDDED_MPV.PLAYER.LOADING_STREAM' - ); + return this.translate.instant('EMBEDDED_MPV.PLAYER.LOADING_STREAM'); } return ''; }); @@ -303,6 +311,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { private readonly recordingMessage = signal(null); private readonly onDocumentPointerDown = (event: PointerEvent) => { + if (this.isFrameCopyEngine()) { + return; + } const playerRoot = this.playerRoot()?.nativeElement; if (!playerRoot || event.composedPath().includes(playerRoot)) { return; @@ -310,6 +321,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.closePopovers(); }; private readonly onDocumentPointerMove = (event: PointerEvent) => { + if (this.isFrameCopyEngine()) { + return; + } const playerRoot = this.playerRoot()?.nativeElement; if ( playerRoot && @@ -324,11 +338,19 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.isFullscreen.set( Boolean(playerRoot && document.fullscreenElement === playerRoot) ); - this.revealControls(); + if (!this.isFrameCopyEngine()) { + this.revealControls(); + } this.controller.triggerBoundsSync(); }; constructor() { + this.sharedControls.configure({ + playback: this.playback, + seriesNavigation: this.seriesNavigation, + recordingFolder: this.recordingFolder, + }); + if (typeof document !== 'undefined') { document.addEventListener( 'fullscreenchange', @@ -370,8 +392,14 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { }); this.shortcuts.attach({ - isAvailable: () => !this.overlayVisibility.overlayActive(), - onEscape: () => this.closePopovers(), + isAvailable: () => + !this.isFrameCopyEngine() && + !this.overlayVisibility.overlayActive(), + onEscape: () => { + if (!this.isFrameCopyEngine()) { + this.closePopovers(); + } + }, togglePaused: () => void this.togglePaused(), toggleFullscreen: () => void this.toggleFullscreen(), seekBy: (delta) => void this.seekBy(delta), @@ -447,7 +475,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { }); effect((onCleanup) => { - if (!this.isRecording()) { + if (this.isFrameCopyEngine() || !this.isRecording()) { return; } this.recordingTick.set(Date.now()); @@ -457,6 +485,17 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { ); onCleanup(() => window.clearInterval(intervalId)); }); + + effect(() => { + if (!this.isFrameCopyEngine()) { + return; + } + untracked(() => { + this.clearControlsHideTimer(); + this.clearViewportClickTimer(); + this.menus.closeAll(); + }); + }); } ngOnDestroy(): void { @@ -493,10 +532,16 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } onPlayerInteraction(): void { + if (this.isFrameCopyEngine()) { + return; + } this.revealControls(); } onViewportClick(event: MouseEvent): void { + if (this.isFrameCopyEngine()) { + return; + } const target = event.target as HTMLElement | null; if (target?.closest('button, input, [role="slider"]')) { return; @@ -519,6 +564,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } onPlayerDblClick(event: MouseEvent): void { + if (this.isFrameCopyEngine()) { + return; + } const target = event.target as HTMLElement | null; if (target?.closest('button, input, [role="slider"]')) { return; @@ -845,6 +893,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } private revealControls(scheduleHide = true): void { + if (this.isFrameCopyEngine()) { + this.clearControlsHideTimer(); + return; + } this.controlsVisible.set(true); if (scheduleHide) { this.clearControlsHideTimer(); @@ -853,6 +905,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } private scheduleControlsHide(): void { + if (this.isFrameCopyEngine()) { + this.clearControlsHideTimer(); + return; + } if ( !this.isPlaying() || this.menus.anyOpen() ||