mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(embedded-mpv): use shared controls for frame-copy
This commit is contained in:
1 parent
1ec47624ed
commit
7913b75017
5 files changed
+665
-27
No files matched your search
@@ -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 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showControls() && isSupported()) {
|
||||
@if (isFrameCopyEngine() && isSupported()) {
|
||||
<app-player-controls
|
||||
[controller]="sharedControls"
|
||||
[playerSurface]="playerSurface()"
|
||||
[showControls]="showControls()"
|
||||
[shortcutsEnabled]="sharedShortcutsEnabled()"
|
||||
(previousEpisodeRequested)="requestPreviousEpisode()"
|
||||
(nextEpisodeRequested)="requestNextEpisode()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!isFrameCopyEngine() && showControls() && isSupported()) {
|
||||
<div
|
||||
class="embedded-mpv-player__controls"
|
||||
[class.embedded-mpv-player__controls--visible]="
|
||||
@@ -104,14 +119,18 @@
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="isLoading()"
|
||||
(click)="togglePaused()"
|
||||
[attr.aria-label]="(isPaused()
|
||||
[attr.aria-label]="
|
||||
(isPaused()
|
||||
? 'EMBEDDED_MPV.PLAYER.PLAY'
|
||||
: 'EMBEDDED_MPV.PLAYER.PAUSE'
|
||||
) | translate"
|
||||
[matTooltip]="(isPaused()
|
||||
) | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
(isPaused()
|
||||
? 'EMBEDDED_MPV.PLAYER.PLAY'
|
||||
: 'EMBEDDED_MPV.PLAYER.PAUSE'
|
||||
) | translate"
|
||||
) | translate
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>{{
|
||||
@@ -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"
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
@@ -482,7 +505,7 @@
|
||||
: {
|
||||
speed: speedLabel(
|
||||
playbackSpeed()
|
||||
)
|
||||
),
|
||||
}
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
@@ -557,7 +580,7 @@
|
||||
: {
|
||||
aspect: aspectLabel(
|
||||
aspectOverride()
|
||||
)
|
||||
),
|
||||
}
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
|
||||
+305
@@ -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<EmbeddedMpvPlayerComponent>[] = [];
|
||||
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<EmbeddedMpvPlayerComponent>
|
||||
): PlayerControlsComponent | null {
|
||||
return (
|
||||
fixture.debugElement.query(By.directive(PlayerControlsComponent))
|
||||
?.componentInstance ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function root(
|
||||
fixture: ComponentFixture<EmbeddedMpvPlayerComponent>
|
||||
): HTMLElement {
|
||||
return fixture.debugElement.query(By.css('.embedded-mpv-player'))
|
||||
.nativeElement;
|
||||
}
|
||||
|
||||
function canvas(
|
||||
fixture: ComponentFixture<EmbeddedMpvPlayerComponent>
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
+251
@@ -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<EmbeddedMpvPlayerComponent>[] = [];
|
||||
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<EmbeddedMpvPlayerComponent>
|
||||
): HTMLElement {
|
||||
return fixture.debugElement.query(By.css('.embedded-mpv-player'))
|
||||
.nativeElement;
|
||||
}
|
||||
|
||||
function canvas(
|
||||
fixture: ComponentFixture<EmbeddedMpvPlayerComponent>
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ElementRef<HTMLDivElement>>('viewport');
|
||||
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('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<string | null>(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() ||
|
||||
|
||||
Reference in new issue
Block a user