Files
iptvnator/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts
T
4grayandClaude Fable 5.1 c5518fa1ff feat(playback): restyle the shared player controls as the Hybrid dock
Phase 1 of the "Hybrid" (design variant E) player controls redesign for
app-player-controls, shared by HTML5, Video.js, ArtPlayer and frame-copy
Embedded MPV:

- dock without a surface of its own over a 55% bottom scrim, mirrored by
  the top scrim; fixed --pc-* overlay palette (accent blue, cyan, violet)
- three-column control row with a filled accent play circle; previous /
  -10s / play / +10s / next transport order
- volume slider inline on wide players, behind the popover when compact
  (ControlsLayout, ResizeObserver at 720px of container width)
- drawn timeline track with knob, remaining time (−7:03) instead of the
  total, and a hover time label (ControlsTimelineHover); the range input
  stays as the interaction and accessibility layer
- glass popovers; 40px/12px icon buttons (32px/9px compact)

Docs: player-controls-contract (dock, scrims, layout modes), UI guidelines
palette note. Release note added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 21:08:15 +02:00

463 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent interactions', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const queryAll = (selector: string) =>
Array.from(
fixture.nativeElement.querySelectorAll(selector)
) as HTMLElement[];
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
describe('timeline', () => {
it('ignores seekBy while the stream cannot seek', () => {
setState({ canSeek: false });
fixture.detectChanges();
component.seekBy(10);
expect(fake.commands.seekBy).not.toHaveBeenCalled();
expect(component.feedback.current()).toBeNull();
});
it('flashes seek feedback with a signed label', () => {
jest.useFakeTimers();
setCapabilities({ seek: true });
setState({ canSeek: true, durationSeconds: 600 });
fixture.detectChanges();
component.seekBy(-10);
fixture.detectChanges();
expect(fake.commands.seekBy).toHaveBeenCalledWith(-10);
const feedback = query('.player-controls__feedback');
expect(feedback?.textContent).toContain('replay_10');
expect(feedback?.textContent).toContain('-10s');
jest.advanceTimersByTime(800);
fixture.detectChanges();
expect(query('.player-controls__feedback')).toBeNull();
jest.useRealTimers();
});
});
describe('volume', () => {
beforeEach(() => {
setCapabilities({ volume: true });
fixture.detectChanges();
});
it('opens the popover on hover and applies slider input', () => {
component.volumeInteractions.hoverEnter();
fixture.detectChanges();
const slider = query(
'.player-controls__slider--volume'
) as HTMLInputElement;
expect(slider).not.toBeNull();
slider.value = '0.3';
slider.dispatchEvent(new Event('input'));
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.3);
expect(component.displayVolume()).toBe(0.3);
});
it('closes the popover shortly after hover leave', () => {
jest.useFakeTimers();
component.volumeInteractions.hoverEnter();
fixture.detectChanges();
expect(component.menus.volumeOpen()).toBe(true);
component.volumeInteractions.hoverLeave();
jest.advanceTimersByTime(300);
fixture.detectChanges();
expect(component.menus.volumeOpen()).toBe(false);
jest.useRealTimers();
});
it('adjusts volume via wheel and flashes feedback', () => {
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100, cancelable: true })
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.feedback.current()?.label).toBe('95%');
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: -100 }));
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
});
it('mutes on click and restores the previous volume on unmute', () => {
const muteButton = query('[aria-label="EMBEDDED_MPV.PLAYER.MUTE"]');
muteButton?.click();
fixture.detectChanges();
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.displayVolume()).toBe(0);
expect(component.volumeIcon()).toBe('volume_off');
query('[aria-label="EMBEDDED_MPV.PLAYER.UNMUTE"]')?.click();
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
expect(component.displayVolume()).toBe(1);
});
it('reconciles the optimistic volume with controller state', () => {
setState({ volume: 0.25 });
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.25);
expect(component.volumePercent()).toBe(25);
});
it('keeps optimistic volume across capability and visibility changes', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
expect(component.displayVolume()).toBe(0.95);
setState({ positionSeconds: 15, volume: 1 });
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.95);
setCapabilities({ volume: true, playbackSpeed: true });
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.95);
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.95);
});
it('reapplies persisted volume when the capability returns', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
fake.commands.setVolume.mockClear();
setCapabilities({ volume: false });
setState({ volume: 1 });
fixture.detectChanges();
setCapabilities({ volume: true });
fixture.detectChanges();
expect(fake.commands.setVolume).toHaveBeenCalledTimes(1);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.displayVolume()).toBe(0.95);
});
it('reconciles volume when the controller changes at the same value', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
expect(component.displayVolume()).toBe(0.95);
localStorage.removeItem('volume');
const replacement = createFakeController();
replacement.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
});
fixture.componentRef.setInput('controller', replacement.controller);
fixture.detectChanges();
expect(component.displayVolume()).toBe(1);
});
});
describe('menu selections', () => {
it('applies a speed preset and closes the menu', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click();
fixture.detectChanges();
expect(component.menus.speedOpen()).toBe(true);
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('1.5×')
);
preset?.click();
fixture.detectChanges();
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(component.menus.speedOpen()).toBe(false);
});
it('applies an aspect preset and closes the menu', () => {
setCapabilities({ aspectRatio: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click();
fixture.detectChanges();
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('16:9')
);
preset?.click();
fixture.detectChanges();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.menus.aspectOpen()).toBe(false);
});
it('disables subtitles via the Off entry', () => {
setCapabilities({ subtitles: true });
setState({
subtitleTracks: [{ id: 1, label: 'English', selected: true }],
subtitlesEnabled: true,
});
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click();
fixture.detectChanges();
queryAll('.player-controls__track')[0]?.click();
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(component.menus.subtitleOpen()).toBe(false);
});
it('opening one menu closes the others', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
fixture.detectChanges();
component.toggleMenu('speed');
component.toggleMenu('aspect');
expect(component.menus.speedOpen()).toBe(false);
expect(component.menus.aspectOpen()).toBe(true);
expect(component.anyMenuOpen()).toBe(true);
});
});
describe('recording', () => {
it('does nothing when recording is not available', () => {
component.toggleRecording();
expect(fake.commands.toggleRecording).not.toHaveBeenCalled();
});
it('toggles recording when capable and live', () => {
setCapabilities({ recording: true });
setState({ isLive: true });
fixture.detectChanges();
query('.player-controls__record-button')?.click();
expect(fake.commands.toggleRecording).toHaveBeenCalledTimes(1);
});
it('flashes feedback on recording start and stop transitions', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('de', {
EMBEDDED_MPV: {
PLAYER: {
RECORDING: 'Aufnahme',
RECORDING_SAVED: 'Aufnahme gespeichert',
},
},
});
translate.use('de');
setState({
isLive: true,
recording: { active: true, elapsedSeconds: 0, message: null },
});
fixture.detectChanges();
expect(component.feedback.current()?.label).toBe('Aufnahme');
setState({ isLive: true });
fixture.detectChanges();
expect(component.feedback.current()?.label).toBe(
'Aufnahme gespeichert'
);
});
});
describe('reveal/hide interplay', () => {
beforeEach(() => {
jest.useFakeTimers();
setState({ status: 'playing' });
fixture.detectChanges();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('keeps the controls visible while a menu is open, hides after close', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
component.toggleMenu('speed');
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
component.menuSelection.speed(1.25);
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
});
it('stays visible while a status message is showing', () => {
setState({ status: 'playing', statusMessage: 'Buffering…' });
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
});
it('does not toggle playback from the surface while stalled', () => {
const surface = document.createElement('div');
fixture.componentRef.setInput('playerSurface', surface);
setState({ status: 'playing', stalled: true });
fixture.detectChanges();
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.advanceTimersByTime(250);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('re-reveals hidden controls on reveal()', () => {
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
component.reveal();
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
});
});
describe('volume popover on touch', () => {
const pointerTypedEvent = (
type: string,
pointerType: string
): MouseEvent => {
const event = new MouseEvent(type, { bubbles: true });
Object.defineProperty(event, 'pointerType', {
value: pointerType,
});
return event;
};
beforeEach(() => {
setCapabilities({ volume: true });
fixture.detectChanges();
});
it('opens the popover on the first tap instead of muting', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
expect(component.menus.volumeOpen()).toBe(true);
expect(fake.commands.setVolume).not.toHaveBeenCalled();
});
it('toggles mute on a tap while the popover is open', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
});
it('mutes on a tap when the slider is already inline (wide dock)', () => {
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch'),
{ inlineSlider: true }
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.menus.volumeOpen()).toBe(false);
});
it('mutes directly on a mouse click without opening the popover', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'mouse'));
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.menus.volumeOpen()).toBe(false);
});
it('ignores the pointerenter a tap synthesizes', () => {
component.volumeInteractions.hoverEnter(
pointerTypedEvent('pointerenter', 'touch')
);
expect(component.menus.volumeOpen()).toBe(false);
});
it('keeps a tap-opened popover open through touch focusout', () => {
jest.useFakeTimers();
try {
document.dispatchEvent(
pointerTypedEvent('pointerdown', 'touch')
);
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
component.volumeInteractions.hoverLeave(new FocusEvent('focusout'));
jest.advanceTimersByTime(1000);
expect(component.menus.volumeOpen()).toBe(true);
} finally {
jest.useRealTimers();
}
});
});
});