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() ||