feat(playback): add picture-in-picture controls contract

This commit is contained in:
4gray committed 2026-07-17 18:55:21 +02:00
1 parent 7e06682f6e
commit 387d02d275
12 files changed
+50 -16

No files matched your search

@@ -198,6 +198,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
playbackSpeed: true,
aspectRatio: true,
recording: true,
pictureInPicture: false,
fullscreen: true,
seriesNavigation: true,
});
@@ -222,6 +223,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
playbackSpeed: false,
aspectRatio: false,
recording: false,
pictureInPicture: false,
fullscreen: true,
seriesNavigation: true,
});
@@ -263,6 +265,8 @@ describe('EmbeddedMpvControlsAdapter', () => {
speedPresets: DEFAULT_SPEED_PRESETS,
aspectRatio: '16:9',
aspectPresets: DEFAULT_ASPECT_PRESETS,
pictureInPictureActive: false,
canPictureInPicture: false,
canPreviousEpisode: true,
canNextEpisode: false,
});
@@ -374,6 +378,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
it('delegates every non-recording shared-controls command', () => {
configure();
expect(() => adapter.commands.togglePictureInPicture()).not.toThrow();
adapter.commands.togglePlay();
adapter.commands.seekTo(45);
adapter.commands.seekBy(-10);
@@ -114,6 +114,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
playbackSpeed: optionalCapabilities?.playbackSpeed ?? false,
aspectRatio: optionalCapabilities?.aspectOverride ?? false,
recording: optionalCapabilities?.recording ?? false,
pictureInPicture: false,
fullscreen: true,
seriesNavigation: !isLive && context.seriesNavigation() !== null,
};
@@ -190,6 +191,8 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
),
transitionKey: this.recordingTransitionKey(),
},
pictureInPictureActive: false,
canPictureInPicture: false,
canPreviousEpisode:
hasSeriesNavigation && seriesNavigation?.canPrevious === true,
canNextEpisode:
@@ -207,6 +210,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed),
setAspectRatio: (value) => void this.controller.setAspect(value),
toggleRecording: () => void this.toggleRecording(),
togglePictureInPicture: () => undefined,
};
constructor() {
@@ -21,6 +21,7 @@ describe('ControlsMenuSelection', () => {
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
menus = new ControlsMenuState();
visibility = new ControlsVisibility(() => false);
@@ -0,0 +1,14 @@
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
describe('player-controls defaults', () => {
it('defaults Picture-in-Picture to unsupported and inactive', () => {
expect(DEFAULT_PLAYER_CAPABILITIES.pictureInPicture).toBe(false);
expect(createEmptyControlsState()).toMatchObject({
pictureInPictureActive: false,
canPictureInPicture: false,
});
});
});
@@ -12,6 +12,7 @@ export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = {
playbackSpeed: false,
aspectRatio: false,
recording: false,
pictureInPicture: false,
fullscreen: false,
seriesNavigation: false,
};
@@ -51,6 +52,8 @@ export function createEmptyControlsState(): PlayerControlsState {
aspectRatio: 'no',
aspectPresets: DEFAULT_ASPECT_PRESETS,
recording: { active: false, elapsedSeconds: 0, message: null },
pictureInPictureActive: false,
canPictureInPicture: false,
canPreviousEpisode: false,
canNextEpisode: false,
};
@@ -33,6 +33,7 @@ function createFakeController(): FakeController {
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
return {
controller: { capabilities, state, commands },
@@ -30,6 +30,7 @@ function createFakeController() {
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
@@ -28,6 +28,7 @@ function createFakeController() {
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
@@ -30,6 +30,7 @@ function createFakeController() {
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
@@ -298,23 +299,17 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
});
describe('keyboard shortcuts', () => {
it('does not consume or toggle playback while loading', () => {
setState({ status: 'loading' });
fixture.detectChanges();
it.each(['loading', 'error'] as const)(
'does not consume or toggle playback while status is %s',
(status) => {
setState({ status });
fixture.detectChanges();
expect(pressKey(' ')).toBe(false);
expect(pressKey('k')).toBe(false);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('does not consume or toggle playback after an error', () => {
setState({ status: 'error' });
fixture.detectChanges();
expect(pressKey(' ')).toBe(false);
expect(pressKey('k')).toBe(false);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
expect(pressKey(' ')).toBe(false);
expect(pressKey('k')).toBe(false);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
}
);
it('Escape closes an open menu', () => {
component.toggleMenu('subtitle');
@@ -27,6 +27,7 @@ function createFakeController() {
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = {
capabilities,
@@ -17,6 +17,7 @@ export interface PlayerControlsCapabilities {
playbackSpeed: boolean;
aspectRatio: boolean;
recording: boolean;
pictureInPicture: boolean;
fullscreen: boolean;
seriesNavigation: boolean;
}
@@ -61,6 +62,8 @@ export interface PlayerControlsState {
aspectRatio: string;
aspectPresets: ReadonlyArray<PlayerPreset<string>>;
recording: PlayerRecordingState;
pictureInPictureActive: boolean;
canPictureInPicture: boolean;
canPreviousEpisode: boolean;
canNextEpisode: boolean;
}
@@ -76,6 +79,7 @@ export interface PlayerControlsCommands {
setPlaybackSpeed(speed: number): void;
setAspectRatio(value: string): void;
toggleRecording(): void;
togglePictureInPicture(): void;
}
/**
@@ -115,6 +115,7 @@ export class WebVideoControlsAdapter implements PlayerController {
subtitles: hasSubtitles,
aspectRatio: false,
recording: false,
pictureInPicture: false,
seriesNavigation: !isLive && this.seriesNavigation() !== null,
};
});
@@ -151,6 +152,8 @@ export class WebVideoControlsAdapter implements PlayerController {
aspectRatio: 'no',
aspectPresets: DEFAULT_ASPECT_PRESETS,
recording: { active: false, elapsedSeconds: 0, message: null },
pictureInPictureActive: false,
canPictureInPicture: false,
canPreviousEpisode:
seriesNavCapable && seriesNav?.canPrevious === true,
canNextEpisode: seriesNavCapable && seriesNav?.canNext === true,
@@ -170,6 +173,7 @@ export class WebVideoControlsAdapter implements PlayerController {
setPlaybackSpeed: (speed) => this.applySpeed(speed),
setAspectRatio: () => undefined,
toggleRecording: () => undefined,
togglePictureInPicture: () => undefined,
};
/** Binds to a `<video>` element and starts maintaining the state signal. */