diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts new file mode 100644 index 000000000..434cbf868 --- /dev/null +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -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; + 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; +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts new file mode 100644 index 000000000..9b80e2dfb --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.spec.ts @@ -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, + }); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts new file mode 100644 index 000000000..29fbe2ffc --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts @@ -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 { + 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 { + 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}`); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts new file mode 100644 index 000000000..c9140dd44 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts @@ -0,0 +1,104 @@ +import { EmbeddedMpvShortcuts } from './embedded-mpv-shortcuts'; + +describe('EmbeddedMpvShortcuts', () => { + let shortcuts: EmbeddedMpvShortcuts; + let handlers: { + isAvailable: jest.Mock; + 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; +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts new file mode 100644 index 000000000..f4a27a224 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts @@ -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'); + }); +});