diff --git a/src/lib/transport/playback.ts b/src/lib/transport/playback.ts index 34c7c83..25a45cd 100644 --- a/src/lib/transport/playback.ts +++ b/src/lib/transport/playback.ts @@ -96,6 +96,58 @@ export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number { return nextRate; } +export function togglePiP(): void { + if (document.pictureInPictureElement) { + document.exitPictureInPicture().catch((e) => { + console.error('Exit PiP failed:', e); + }); + } else { + const videoEl = getVideo(); + if (videoEl && document.pictureInPictureEnabled) { + videoEl.requestPictureInPicture().catch((e) => { + console.error('PiP request failed:', e); + }); + } + } +} + +export function isPiPActive(): boolean { + return !!document.pictureInPictureElement; +} + +export function toggleFullscreen(): void { + if (document.fullscreenElement) { + document.exitFullscreen().catch((e) => { + console.error('Exit fullscreen failed:', e); + }); + } else { + const videoEl = getVideo(); + if (!videoEl) return; + if (videoEl.requestFullscreen) { + videoEl.requestFullscreen().catch((e) => { + console.error('Fullscreen request failed:', e); + }); + } else if ((videoEl as HTMLVideoElement & { webkitEnterFullscreen?: () => void }).webkitEnterFullscreen) { + (videoEl as HTMLVideoElement & { webkitEnterFullscreen: () => void }).webkitEnterFullscreen(); + } + } +} + +export function isFullscreenActive(): boolean { + return !!document.fullscreenElement; +} + +export function setPlaybackRate(rate: number): void { + const videoEl = getVideo(); + if (videoEl) { + videoEl.playbackRate = rate; + } +} + +export function getPlaybackRate(): number { + return getVideo()?.playbackRate ?? 1; +} + export type TransportKeyAction = | 'play-pause' | 'frame-back' diff --git a/tests/lib/transport/playback.test.ts b/tests/lib/transport/playback.test.ts new file mode 100644 index 0000000..7436cc0 --- /dev/null +++ b/tests/lib/transport/playback.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the videoSession store (playback.ts imports it) +vi.mock('$lib/stores/videoSession.svelte', () => ({ + session: { + duration: 120, + currentTime: 0, + fps: 30, + isPlaying: false, + keyframePositions: [], + }, +})); + +import { + setVideoElement, + togglePiP, + isPiPActive, + toggleFullscreen, + isFullscreenActive, + setPlaybackRate, + getPlaybackRate, +} from '$lib/transport/playback'; + +function createMockVideoElement(): HTMLVideoElement { + const el = document.createElement('video'); + // PiP + (el as any).requestPictureInPicture = vi.fn().mockResolvedValue({}); + // Fullscreen + el.requestFullscreen = vi.fn().mockResolvedValue(undefined); + return el; +} + +describe('playback — PiP helpers', () => { + let mockVideo: HTMLVideoElement; + + beforeEach(() => { + mockVideo = createMockVideoElement(); + setVideoElement(mockVideo); + // Reset PiP state + Object.defineProperty(document, 'pictureInPictureElement', { + value: null, + writable: true, + configurable: true, + }); + Object.defineProperty(document, 'pictureInPictureEnabled', { + value: true, + writable: true, + configurable: true, + }); + }); + + it('isPiPActive returns false when no PiP element', () => { + expect(isPiPActive()).toBe(false); + }); + + it('togglePiP calls requestPictureInPicture', () => { + togglePiP(); + expect((mockVideo as any).requestPictureInPicture).toHaveBeenCalled(); + }); + + it('isPiPActive returns true when PiP element exists', () => { + Object.defineProperty(document, 'pictureInPictureElement', { + value: mockVideo, + writable: true, + configurable: true, + }); + expect(isPiPActive()).toBe(true); + }); +}); + +describe('playback — fullscreen helpers', () => { + let mockVideo: HTMLVideoElement; + + beforeEach(() => { + mockVideo = createMockVideoElement(); + setVideoElement(mockVideo); + Object.defineProperty(document, 'fullscreenElement', { + value: null, + writable: true, + configurable: true, + }); + }); + + it('isFullscreenActive returns false when not fullscreen', () => { + expect(isFullscreenActive()).toBe(false); + }); + + it('toggleFullscreen calls requestFullscreen when not fullscreen', () => { + toggleFullscreen(); + expect(mockVideo.requestFullscreen).toHaveBeenCalled(); + }); + + it('isFullscreenActive returns true when fullscreen element exists', () => { + Object.defineProperty(document, 'fullscreenElement', { + value: mockVideo, + writable: true, + configurable: true, + }); + expect(isFullscreenActive()).toBe(true); + }); +}); + +describe('playback — playback rate helpers', () => { + let mockVideo: HTMLVideoElement; + + beforeEach(() => { + mockVideo = createMockVideoElement(); + setVideoElement(mockVideo); + }); + + it('getPlaybackRate returns 1 by default', () => { + expect(getPlaybackRate()).toBe(1); + }); + + it('setPlaybackRate changes the video playback rate', () => { + setPlaybackRate(1.5); + expect(mockVideo.playbackRate).toBe(1.5); + }); + + it('getPlaybackRate returns the current rate', () => { + setPlaybackRate(2); + expect(getPlaybackRate()).toBe(2); + }); + + it('getPlaybackRate returns 1 when no video element', () => { + setVideoElement(null); + expect(getPlaybackRate()).toBe(1); + }); +});