[codex] Cover playback stack helpers (#1026)

* cover playback stack helpers

* harden playback session controller specs

---------

Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
4grayand4gray authored and GitHub committed 2026-06-06 17:47:50 +02:00
1 parent 6a5400e11a
commit 0fa050f4c3
5 files changed
+712

No files matched your search

@@ -0,0 +1,169 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { AudioPlayerComponent } from './audio-player.component';
describe('AudioPlayerComponent', () => {
let fixture: ComponentFixture<AudioPlayerComponent>;
let component: AudioPlayerComponent;
let store: { dispatch: jest.Mock };
let playSpy: jest.SpyInstance;
let pauseSpy: jest.SpyInstance;
let loadSpy: jest.SpyInstance;
beforeEach(async () => {
localStorage.clear();
store = { dispatch: jest.fn() };
playSpy = jest
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
pauseSpy = jest
.spyOn(HTMLMediaElement.prototype, 'pause')
.mockImplementation(() => undefined);
loadSpy = jest
.spyOn(HTMLMediaElement.prototype, 'load')
.mockImplementation(() => undefined);
await TestBed.configureTestingModule({
imports: [AudioPlayerComponent, TranslateModule.forRoot()],
providers: [{ provide: Store, useValue: store }],
}).compileComponents();
});
afterEach(() => {
fixture?.destroy();
localStorage.clear();
jest.restoreAllMocks();
});
function createComponent(url = 'https://example.com/radio.mp3') {
fixture = TestBed.createComponent(AudioPlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('url', url);
fixture.componentRef.setInput('channelName', 'Example Radio');
fixture.detectChanges();
return fixture.nativeElement.querySelector('audio') as HTMLAudioElement;
}
it('restores the persisted volume and applies it when a stream loads', () => {
localStorage.setItem('volume', '0.42');
const audio = createComponent();
expect(component.volume()).toBe(0.42);
expect(audio.src).toBe('https://example.com/radio.mp3');
expect(audio.volume).toBe(0.42);
expect(loadSpy).toHaveBeenCalled();
expect(playSpy).toHaveBeenCalled();
expect(component.playState()).toBe('play');
});
it('clamps volume updates and persists them to localStorage', () => {
const audio = createComponent();
component.setVolume(1.5);
expect(component.volume()).toBe(1);
expect(audio.volume).toBe(1);
expect(localStorage.getItem('volume')).toBe('1');
component.setVolume(-0.25);
expect(component.volume()).toBe(0);
expect(audio.volume).toBe(0);
expect(localStorage.getItem('volume')).toBe('0');
});
it('handles volume and mute keyboard shortcuts without focusing inputs', () => {
createComponent();
component.setVolume(0.5);
const upEvent = createKeyboardEvent('ArrowUp');
component.handleKeyboard(upEvent);
expect(upEvent.preventDefault).toHaveBeenCalled();
expect(component.volume()).toBe(0.55);
const downEvent = createKeyboardEvent('ArrowDown');
component.handleKeyboard(downEvent);
expect(downEvent.preventDefault).toHaveBeenCalled();
expect(component.volume()).toBe(0.5);
const muteEvent = createKeyboardEvent('M');
component.handleKeyboard(muteEvent);
expect(muteEvent.preventDefault).toHaveBeenCalled();
expect(component.isMuted()).toBe(true);
expect(component.volume()).toBe(0);
const inputEvent = createKeyboardEvent('ArrowUp', {
target: document.createElement('input'),
});
component.handleKeyboard(inputEvent);
expect(inputEvent.preventDefault).not.toHaveBeenCalled();
expect(component.volume()).toBe(0);
});
it('restores the previous volume when unmuting', () => {
const audio = createComponent();
component.setVolume(0.65);
component.mute();
expect(audio.muted).toBe(true);
expect(component.isMuted()).toBe(true);
expect(component.volume()).toBe(0);
component.mute();
expect(audio.muted).toBe(false);
expect(component.isMuted()).toBe(false);
expect(component.volume()).toBe(0.65);
expect(localStorage.getItem('volume')).toBe('0.65');
});
it('emits or dispatches adjacent channel switches based on input mode', () => {
createComponent();
const emitted: string[] = [];
component.channelSwitchRequested.subscribe((direction) =>
emitted.push(direction)
);
component.switchChannel('next');
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
direction: 'next',
})
);
expect(emitted).toEqual([]);
fixture.componentRef.setInput('dispatchAdjacentChannelAction', false);
fixture.detectChanges();
component.switchChannel('previous');
expect(emitted).toEqual(['previous']);
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('pauses the stream when the component is destroyed', () => {
createComponent();
fixture.destroy();
expect(pauseSpy).toHaveBeenCalled();
});
});
function createKeyboardEvent(
key: string,
options: { target?: EventTarget } = {}
): KeyboardEvent & { preventDefault: jest.Mock } {
const event = new KeyboardEvent('keydown', { key }) as KeyboardEvent & {
preventDefault: jest.Mock;
};
Object.defineProperty(event, 'preventDefault', {
configurable: true,
value: jest.fn(),
});
if (options.target) {
Object.defineProperty(event, 'target', {
configurable: true,
value: options.target,
});
}
return event;
}
@@ -0,0 +1,79 @@
import {
aspectLabel,
audioTrackLabel,
formatTime,
measureBounds,
persistVolume,
readStoredVolume,
subtitleTrackLabel,
volumeIcon,
volumeLabel,
} from './embedded-mpv-format.utils';
describe('embedded MPV format utilities', () => {
afterEach(() => {
localStorage.clear();
});
it('formats playback time with hour rollover and safe null values', () => {
expect(formatTime(null)).toBe('0:00');
expect(formatTime(-30)).toBe('0:00');
expect(formatTime(75.9)).toBe('1:15');
expect(formatTime(3661)).toBe('1:01:01');
});
it('clamps stored volume reads and persists raw volume values', () => {
localStorage.setItem('volume', '2');
expect(readStoredVolume()).toBe(1);
localStorage.setItem('volume', '-0.5');
expect(readStoredVolume()).toBe(0);
localStorage.setItem('volume', 'not-a-number');
expect(readStoredVolume()).toBe(1);
persistVolume(0.35);
expect(localStorage.getItem('volume')).toBe('0.35');
});
it('builds readable labels for tracks, aspects, and volume', () => {
expect(
audioTrackLabel(
{
id: 1,
language: 'eng',
selected: false,
defaultTrack: true,
},
0
)
).toContain('Default');
expect(
subtitleTrackLabel({ id: 2, selected: false }, 1)
).toContain('Subtitle 2');
expect(aspectLabel('16:9')).toBe('16:9');
expect(aspectLabel('custom')).toBe('custom');
expect(volumeIcon(0)).toBe('volume_off');
expect(volumeIcon(0.25)).toBe('volume_down');
expect(volumeIcon(0.75)).toBe('volume_up');
expect(volumeLabel(0.755)).toBe('Volume 76%');
});
it('rounds host bounds and keeps minimum native view dimensions', () => {
const host = {
getBoundingClientRect: () => ({
left: 10.4,
top: 20.6,
width: 0,
height: 0.2,
}),
} as HTMLElement;
expect(measureBounds(host)).toEqual({
x: 10,
y: 21,
width: 1,
height: 1,
});
});
});
@@ -0,0 +1,294 @@
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
describe('EmbeddedMpvSessionController', () => {
let electron: {
platform: string;
getEmbeddedMpvSupport: jest.Mock;
prepareEmbeddedMpv: jest.Mock;
createEmbeddedMpvSession: jest.Mock;
loadEmbeddedMpvPlayback: jest.Mock;
disposeEmbeddedMpvSession: jest.Mock;
setEmbeddedMpvBounds: jest.Mock;
onEmbeddedMpvSessionUpdate: jest.Mock;
setEmbeddedMpvPaused: jest.Mock;
seekEmbeddedMpv: jest.Mock;
setEmbeddedMpvVolume: jest.Mock;
};
let sessionUpdate: ((session: EmbeddedMpvSession) => void) | null;
let unsubscribeSessionUpdate: jest.Mock;
let testingModuleDestroyed: boolean;
beforeEach(() => {
testingModuleDestroyed = false;
sessionUpdate = null;
unsubscribeSessionUpdate = jest.fn();
electron = {
platform: 'darwin',
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
supported: true,
platform: 'darwin',
}),
prepareEmbeddedMpv: jest.fn().mockResolvedValue({
supported: true,
platform: 'darwin',
}),
createEmbeddedMpvSession: jest
.fn()
.mockResolvedValue(createSession({ id: 'mpv-1' })),
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
onEmbeddedMpvSessionUpdate: jest.fn((callback) => {
sessionUpdate = callback;
return unsubscribeSessionUpdate;
}),
setEmbeddedMpvPaused: jest.fn().mockResolvedValue(
createSession({
id: 'mpv-1',
status: 'paused',
})
),
seekEmbeddedMpv: jest.fn().mockResolvedValue(
createSession({
id: 'mpv-1',
positionSeconds: 15,
})
),
setEmbeddedMpvVolume: jest.fn().mockResolvedValue(
createSession({
id: 'mpv-1',
volume: 0.25,
})
),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: electron,
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class MockResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
},
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) => {
return window.setTimeout(() => callback(0), 0);
},
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: (handle: number) => window.clearTimeout(handle),
});
TestBed.configureTestingModule({
providers: [EmbeddedMpvSessionController],
});
});
afterEach(() => {
if (!testingModuleDestroyed) {
TestBed.resetTestingModule();
}
delete (window as unknown as { electron?: unknown }).electron;
jest.restoreAllMocks();
});
function destroyTestingModule(): void {
TestBed.resetTestingModule();
testingModuleDestroyed = true;
}
it('loads support and unsubscribes from session updates on destroy', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(() => controller.support() !== null, 'support to load');
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalled();
expect(controller.support()).toEqual({
supported: true,
platform: 'darwin',
});
destroyTestingModule();
expect(unsubscribeSessionUpdate).toHaveBeenCalled();
});
it('starts a session, forwards matching updates, and disposes on teardown', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
const host = createHost();
const playback = createPlayback();
const teardown = controller.startSession(host, playback, 0.7);
expect(controller.session()).toEqual(
expect.objectContaining({
id: 'embedded-mpv-starting',
status: 'loading',
volume: 0.7,
})
);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
expect(electron.prepareEmbeddedMpv).toHaveBeenCalled();
expect(electron.createEmbeddedMpvSession).toHaveBeenCalledWith(
{ x: 11, y: 21, width: 640, height: 360 },
'Example Movie',
0.7
);
expect(electron.loadEmbeddedMpvPlayback).toHaveBeenCalledWith(
'mpv-1',
playback
);
expect(controller.sessionId()).toBe('mpv-1');
sessionUpdate?.(createSession({ id: 'other', status: 'paused' }));
expect(controller.session()?.status).toBe('playing');
sessionUpdate?.(createSession({ id: 'mpv-1', status: 'paused' }));
expect(controller.session()?.status).toBe('paused');
teardown();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-1'
);
});
it('sets an error session when Electron cannot create playback', async () => {
electron.prepareEmbeddedMpv.mockRejectedValueOnce(
new Error('native module missing')
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.startSession(createHost(), createPlayback(), 0.5);
await waitFor(
() => controller.session()?.status === 'error',
'error session to be set'
);
expect(controller.session()).toEqual(
expect.objectContaining({
id: 'embedded-mpv-error',
status: 'error',
error: 'native module missing',
})
);
expect(controller.sessionId()).toBeNull();
});
it('forwards playback commands and updates session snapshots', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
controller.session.set(
createSession({
id: 'mpv-1',
status: 'playing',
positionSeconds: 3,
})
);
await controller.togglePaused();
expect(electron.setEmbeddedMpvPaused).toHaveBeenCalledWith(
'mpv-1',
true
);
expect(controller.session()?.status).toBe('paused');
await controller.seekBy(-30);
expect(electron.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 0);
expect(controller.session()?.positionSeconds).toBe(15);
await controller.applyVolume(0.25);
expect(electron.setEmbeddedMpvVolume).toHaveBeenCalledWith(
'mpv-1',
0.25
);
expect(controller.session()?.volume).toBe(0.25);
});
it('swallows command errors so stale IPC races do not clear current state', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
const current = createSession({ id: 'mpv-1', volume: 0.8 });
controller.sessionId.set('mpv-1');
controller.session.set(current);
electron.setEmbeddedMpvVolume.mockRejectedValueOnce(
new Error('session disposed')
);
await controller.applyVolume(0.25);
expect(controller.session()).toBe(current);
});
});
function createHost(): HTMLElement {
return {
getBoundingClientRect: () => ({
left: 10.6,
top: 20.5,
width: 640,
height: 360,
}),
} as HTMLElement;
}
function createPlayback(): ResolvedPortalPlayback {
return {
streamUrl: 'https://example.com/movie.mp4',
title: 'Example Movie',
};
}
function createSession(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 10,
durationSeconds: 120,
volume: 0.7,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-06-02T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:01.000Z',
...overrides,
};
}
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (condition()) {
return;
}
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
throw new Error(`Timed out waiting for ${description}`);
}
@@ -0,0 +1,104 @@
import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts';
describe('EmbeddedMpvShortcuts', () => {
let shortcuts: EmbeddedMpvShortcuts;
let handlers: {
isAvailable: jest.Mock<boolean, []>;
onEscape: jest.Mock;
togglePaused: jest.Mock;
toggleFullscreen: jest.Mock;
seekBy: jest.Mock;
adjustVolume: jest.Mock;
toggleMute: jest.Mock;
};
beforeEach(() => {
shortcuts = new EmbeddedMpvShortcuts();
handlers = {
isAvailable: jest.fn(() => true),
onEscape: jest.fn(),
togglePaused: jest.fn(),
toggleFullscreen: jest.fn(),
seekBy: jest.fn(),
adjustVolume: jest.fn(),
toggleMute: jest.fn(),
};
shortcuts.attach(handlers);
});
afterEach(() => {
shortcuts.detach();
});
it('forwards playback, seek, volume, and fullscreen shortcuts', () => {
expect(dispatchKey(' ')).toBe(true);
expect(dispatchKey('ArrowLeft')).toBe(true);
expect(dispatchKey('ArrowRight')).toBe(true);
expect(dispatchKey('ArrowUp')).toBe(true);
expect(dispatchKey('ArrowDown')).toBe(true);
expect(dispatchKey('f')).toBe(true);
expect(dispatchKey('M')).toBe(true);
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
expect(handlers.seekBy).toHaveBeenCalledWith(-5);
expect(handlers.seekBy).toHaveBeenCalledWith(5);
expect(handlers.adjustVolume).toHaveBeenCalledWith(0.05);
expect(handlers.adjustVolume).toHaveBeenCalledWith(-0.05);
expect(handlers.toggleFullscreen).toHaveBeenCalledTimes(1);
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
});
it('always allows escape to close popovers even when playback is unavailable', () => {
handlers.isAvailable.mockReturnValue(false);
expect(dispatchKey('Escape')).toBe(false);
expect(dispatchKey('k')).toBe(false);
expect(handlers.onEscape).toHaveBeenCalledTimes(1);
expect(handlers.togglePaused).not.toHaveBeenCalled();
});
it('ignores shortcuts inside form controls', () => {
const input = document.createElement('input');
const select = document.createElement('select');
document.body.append(input, select);
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowUp',
bubbles: true,
cancelable: true,
})
);
select.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'm',
bubbles: true,
cancelable: true,
})
);
expect(handlers.adjustVolume).not.toHaveBeenCalled();
expect(handlers.toggleMute).not.toHaveBeenCalled();
input.remove();
select.remove();
});
it('detaches its document listener', () => {
shortcuts.detach();
dispatchKey('k');
expect(handlers.togglePaused).not.toHaveBeenCalled();
});
});
function dispatchKey(key: string): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
@@ -0,0 +1,66 @@
import {
EmbeddedMpvFeedback,
EmbeddedMpvMenuState,
} from './embedded-mpv-ui-state';
describe('EmbeddedMpvMenuState', () => {
it('keeps only one menu open at a time', () => {
const menus = new EmbeddedMpvMenuState();
menus.open('volume');
expect(menus.volumeOpen()).toBe(true);
expect(menus.anyOpen()).toBe(true);
menus.open('audio');
expect(menus.volumeOpen()).toBe(false);
expect(menus.audioOpen()).toBe(true);
menus.toggle('audio');
expect(menus.audioOpen()).toBe(false);
expect(menus.anyOpen()).toBe(false);
});
});
describe('EmbeddedMpvFeedback', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('flashes feedback and clears the previous timeout when replaced', () => {
const feedback = new EmbeddedMpvFeedback();
feedback.flash('volume_up', '60%', 700);
expect(feedback.current()).toEqual({
icon: 'volume_up',
label: '60%',
key: 1,
});
jest.advanceTimersByTime(500);
feedback.flash('volume_down', '55%', 700);
expect(feedback.current()).toEqual({
icon: 'volume_down',
label: '55%',
key: 2,
});
jest.advanceTimersByTime(699);
expect(feedback.current()?.label).toBe('55%');
jest.advanceTimersByTime(1);
expect(feedback.current()).toBeNull();
});
it('disposes pending feedback timers', () => {
const feedback = new EmbeddedMpvFeedback();
feedback.flash('volume_off', 'Muted', 700);
feedback.dispose();
jest.advanceTimersByTime(700);
expect(feedback.current()?.label).toBe('Muted');
});
});