fix(playback): make playback keyboard shortcuts work without shared controls (#1398)

* fix(playback): make playback keyboard shortcuts work without shared controls

With the default configuration (Video.js, webPlayerSharedControls off) the
playback shortcuts advertised in the in-app help and README — Space/K, F,
arrow seek/volume, M — silently did nothing: ControlsShortcuts only exists
inside app-player-controls, which never renders on the preference-off path.

Attach a LegacyPlayerShortcuts wrapper (same arbitration and ignore rules)
in the vendor-chrome HTML5, Video.js, and ArtPlayer players, forwarding the
commands to each engine's own API. Seek stays gated on authoritative VOD
metadata plus a finite positive duration, and a visible playback diagnostic
disables the keys. The legacy ArtPlayer chrome now passes hotkey:false —
its focus-scoped vendor hotkeys ignore defaultPrevented and would
double-handle every key — with its Escape-exits-web-fullscreen behavior
restored by the new wiring.

The playback entries in the in-app shortcut help and README drop their
embedded-MPV-only qualifier, since the keys now work in every runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB

* fix(playback): restore audible volume when M unmutes at zero volume

Addresses the Codex review finding on #1398: after arrowing the volume
down to zero (which mutes), M flipped muted off while leaving the volume
at 0, so the player looked unmuted but stayed silent — in all three
legacy engine adapters.

Mirror the shared controls' ControlsVolume semantics with a per-adapter
LegacyMuteMemory: muting remembers the audible volume, and unmuting while
the volume sits at zero restores it, with the same 0.5 fallback when
nothing was remembered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-09 23:33:12 +02:00
1 parent 6ad9f3ff8a
commit e5bb127ede
25 files changed
+1160 -19

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: playback
---
Playback keyboard shortcuts now work with the default player setup: Space/K
(play/pause), F (fullscreen), ←/→ (seek 5s in VOD), ↑/↓ (volume), and M (mute)
act on the built-in HTML5, Video.js, and ArtPlayer players without turning on
shared player controls — matching what the in-app shortcut list already
promised.
+16 -3
View File
@@ -384,7 +384,11 @@ Key files:
metadata, while a visible playback diagnostic disables both shared surface
interaction and shortcuts and exits the HTML5 shell's own fullscreen so the
diagnostic actions remain visible. The preference-off path keeps native
controls and legacy series navigation unchanged.
controls and legacy series navigation unchanged, while the playback keyboard
shortcuts (Space/K, F, arrow seek/volume, M) attach through
`LegacyPlayerShortcuts` with commands acting on the native video element
(`html-video-legacy-shortcuts.ts`); seek requires authoritative VOD metadata
plus a finite positive duration, and a visible diagnostic disables the keys.
- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a
component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech
video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,
@@ -395,7 +399,10 @@ Key files:
The shared-controls path disables native controls, Video.js
click/double-click/hotkey actions, and spatial navigation;
diagnostic gating and owned-fullscreen exit match HTML5. The preference-off
path keeps the existing Video.js skin and legacy series navigation unchanged.
path keeps the existing Video.js skin and legacy series navigation unchanged
(still without `userActions.hotkeys`), while the playback keyboard shortcuts
attach through `LegacyPlayerShortcuts` and drive the player API so the
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).
- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
@@ -407,7 +414,13 @@ Key files:
and a transparent capture layer gives shared controls exclusive click and
double-click ownership. Diagnostic interaction gating and owned-fullscreen
exit match the other web players. The preference-off path keeps the legacy
ArtPlayer skin, source behavior, and series navigation unchanged.
ArtPlayer skin, source behavior, and series navigation unchanged, while the
playback keyboard shortcuts attach through `LegacyPlayerShortcuts` using the
vendor setters ArtPlayer's own hotkeys used
(`art-player-legacy-shortcuts.ts`); the legacy chrome passes `hotkey: false`
because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would
double-handle every key, and the wiring restores its Escape-exits-web-
fullscreen behavior.
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
+1 -1
View File
@@ -1063,7 +1063,7 @@ engine` (restart required) or
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
contracts: `docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendoLine truncated
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adaptLine truncated
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
+3 -3
View File
@@ -81,9 +81,9 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
| Global | `Enter` in workspace search | Submit the current search |
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
| Navigation | `0-9` | Select an M3U channel by number |
| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app |
| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app |
| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app |
| Playback | `Space` / `K` | Play or pause playback |
| Playback | `F` | Toggle player fullscreen |
| Playback | `ArrowLeft` / `ArrowRight` | Seek VOD playback by 5 seconds |
| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% |
| Playback | `M` | Mute audio |
| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection |
@@ -333,6 +333,45 @@ their `transitionKey`. Session disposal, retry, channel changes, and engine
handoff therefore clear stale recording ownership without showing a false
`RECORDING_SAVED` confirmation.
### Vendor-chrome (preference-off) keyboard shortcuts
With `webPlayerSharedControls` off, `app-player-controls` never renders, so no
`ControlsShortcuts` instance existed and the playback keys advertised in the
in-app help silently did nothing. The vendor-chrome HTML5, Video.js, and
ArtPlayer hosts therefore attach `LegacyPlayerShortcuts`
(`legacy-player-shortcuts.ts`) — a thin wrapper over the same
`ControlsShortcuts` arbitration and ignore rules — and forward the commands
straight to the engine:
- **HTML5** (`html-video-legacy-shortcuts.ts`) acts on the native video
element. Play goes through the component's session so playback diagnostics
stay owned there, and F fullscreens the video element itself, matching what
the native controls' own fullscreen button does.
- **Video.js** (`vjs-legacy-shortcuts.ts`) goes through the player API so the
vendor control bar stays in sync; F uses the player's
`requestFullscreen`/`exitFullscreen`. The legacy configuration still never
enables `userActions.hotkeys`.
- **ArtPlayer** (`art-player-legacy-shortcuts.ts`) uses the vendor setters its
own hotkeys used (`toggle`, `forward`/`backward`, `volume`, `muted`,
`fullscreen`), so ArtPlayer's notices and UI stay in sync. The legacy chrome
now passes `hotkey: false` — ArtPlayer's focus-scoped hotkeys ignore
`defaultPrevented` and would double-handle every key — and the wiring
restores the one behavior lost with it: Escape exits `fullscreenWeb`.
Shared legacy rules: seek is gated on authoritative `isLive` plus a finite,
positive duration (ArtPlayer gates on `art.duration`, the same value its seek
setter clamps against, so an unknown duration never jumps to zero); volume
steps by ±5% and syncs muted state the way `applyVideoVolume` does (raising
out of mute unmutes, reaching zero mutes); M mirrors `ControlsVolume`'s mute
memory through `LegacyMuteMemory` — muting remembers the audible volume, and
unmuting while the volume sits at zero restores it (same 0.5 fallback), so M
can never leave the player silently "unmuted"; `isAvailable` is the host's
`interactionEnabled`, so a visible playback diagnostic disables the keys; and
Escape defaults to a no-op without consuming the key, because the vendor
chrome owns its own overlays. Instances attach in the component's legacy
branch and detach on destroy; the arbitration registry is shared with
shared-controls instances, so exactly one owner handles each key.
### Timeline scrubbing
Timeline input is previewed locally while the user drags. The slider value,
@@ -631,6 +670,7 @@ libs/ui/playback/src/lib/player-controls/
├── controls-menu-selection.ts
├── controls-menu-state.ts
├── controls-shortcuts.ts
├── legacy-player-shortcuts.ts
├── controls-surface.ts
├── controls-view-model.ts
├── controls-visibility.ts
@@ -116,31 +116,28 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [
order: 20,
},
{
id: 'embedded-mpv-play-pause',
id: 'play-pause',
group: 'playback',
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE',
icon: 'play_arrow',
keys: ['Space', 'K'],
order: 10,
electronOnly: true,
},
{
id: 'embedded-mpv-fullscreen',
id: 'toggle-fullscreen',
group: 'playback',
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.TOGGLE_FULLSCREEN',
icon: 'fullscreen',
keys: ['F'],
order: 20,
electronOnly: true,
},
{
id: 'embedded-mpv-seek',
id: 'seek',
group: 'playback',
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.SEEK',
icon: 'swap_horiz',
keys: ['ArrowLeft', 'ArrowRight'],
order: 30,
electronOnly: true,
},
{
id: 'adjust-volume',
@@ -22,11 +22,13 @@ describe('keyboard shortcuts registry', () => {
expect(ids).not.toContain('open-global-search');
expect(ids).not.toContain('open-recently-viewed');
expect(ids).not.toContain('embedded-mpv-play-pause');
expect(ids).not.toContain('embedded-mpv-fullscreen');
expect(ids).not.toContain('embedded-mpv-seek');
expect(ids).not.toContain('close-player-popovers');
expect(ids).toContain('open-command-palette');
// Playback shortcuts run in every runtime: the built-in web players
// attach them through the legacy shortcut wiring in the PWA too.
expect(ids).toContain('play-pause');
expect(ids).toContain('toggle-fullscreen');
expect(ids).toContain('seek');
expect(ids).toContain('adjust-volume');
expect(ids).toContain('mute-audio');
});
@@ -55,7 +57,7 @@ describe('keyboard shortcuts registry', () => {
isElectron: true,
});
expect(findChordLabels(groups, 'embedded-mpv-seek')).toEqual([
expect(findChordLabels(groups, 'seek')).toEqual([
['←'],
['→'],
]);
@@ -0,0 +1,143 @@
import type Artplayer from 'artplayer';
import type { LegacyPlayerShortcuts } from '../player-controls';
import { attachArtPlayerLegacyShortcuts } from './art-player-legacy-shortcuts';
interface MockArt {
muted: boolean;
volume: number;
currentTime: number;
duration: number;
fullscreen: boolean;
fullscreenWeb: boolean;
forward: number | null;
backward: number | null;
toggle: jest.Mock;
}
describe('attachArtPlayerLegacyShortcuts', () => {
let shortcuts: LegacyPlayerShortcuts;
let isLive: boolean;
let art: MockArt;
beforeEach(() => {
isLive = false;
art = {
muted: false,
volume: 0.5,
currentTime: 100,
duration: 600,
fullscreen: false,
fullscreenWeb: false,
forward: null,
backward: null,
toggle: jest.fn(),
};
shortcuts = attachArtPlayerLegacyShortcuts({
player: () => art as unknown as Artplayer,
hostElement: () => null,
isAvailable: () => true,
isLive: () => isLive,
});
});
afterEach(() => {
shortcuts.detach();
});
it('toggles play and pause with Space through the ArtPlayer API', () => {
dispatchKey(' ');
expect(art.toggle).toHaveBeenCalledTimes(1);
});
it('seeks through the vendor forward and backward setters', () => {
dispatchKey('ArrowRight');
expect(art.forward).toBe(5);
dispatchKey('ArrowLeft');
expect(art.backward).toBe(5);
});
it('does not seek live playback or unknown durations', () => {
isLive = true;
expect(dispatchKey('ArrowRight')).toBe(false);
isLive = false;
art.duration = 0;
expect(dispatchKey('ArrowRight')).toBe(false);
expect(art.forward).toBeNull();
});
it('adjusts volume in five percent steps and syncs muted state', () => {
dispatchKey('ArrowUp');
expect(art.volume).toBeCloseTo(0.55);
expect(art.muted).toBe(false);
art.volume = 0.03;
dispatchKey('ArrowDown');
expect(art.volume).toBe(0);
expect(art.muted).toBe(true);
dispatchKey('ArrowUp');
expect(art.volume).toBeCloseTo(0.05);
expect(art.muted).toBe(false);
});
it('toggles mute with M', () => {
dispatchKey('m');
expect(art.muted).toBe(true);
expect(art.volume).toBe(0.5);
dispatchKey('m');
expect(art.muted).toBe(false);
expect(art.volume).toBe(0.5);
});
it('restores an audible volume when unmuting from zero', () => {
art.volume = 0.03;
dispatchKey('ArrowDown');
expect(art.volume).toBe(0);
expect(art.muted).toBe(true);
dispatchKey('m');
expect(art.muted).toBe(false);
expect(art.volume).toBe(0.5);
});
it('restores the remembered volume when unmuting after mute zeroed out', () => {
art.volume = 0.7;
dispatchKey('m');
expect(art.muted).toBe(true);
dispatchKey('ArrowDown');
expect(art.volume).toBe(0);
dispatchKey('m');
expect(art.muted).toBe(false);
expect(art.volume).toBe(0.7);
});
it('toggles the vendor fullscreen with F', () => {
dispatchKey('f');
expect(art.fullscreen).toBe(true);
dispatchKey('f');
expect(art.fullscreen).toBe(false);
});
it('exits web fullscreen on Escape, mirroring the disabled vendor hotkey', () => {
dispatchKey('Escape');
expect(art.fullscreenWeb).toBe(false);
art.fullscreenWeb = true;
dispatchKey('Escape');
expect(art.fullscreenWeb).toBe(false);
});
});
function dispatchKey(key: string): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
@@ -0,0 +1,88 @@
import type Artplayer from 'artplayer';
import { LegacyMuteMemory, LegacyPlayerShortcuts } from '../player-controls';
export interface ArtPlayerLegacyShortcutOptions {
player: () => Artplayer | null;
hostElement: () => HTMLElement | null;
isAvailable: () => boolean;
isLive: () => boolean;
}
/**
* App-level keyboard shortcuts for the vendor-chrome ArtPlayer. ArtPlayer's
* own `hotkey` option is disabled in the legacy chrome (it only fired while
* the player was focused and does not honor `defaultPrevented`, so keeping it
* would double-handle every key); these handlers go through the same ArtPlayer
* setters its hotkeys used, so vendor notices and UI stay in sync. Seeking is
* gated on `art.duration` — the same value ArtPlayer's seek setter clamps
* against — so a stream with unknown duration never jumps to zero.
*/
export function attachArtPlayerLegacyShortcuts(
options: ArtPlayerLegacyShortcutOptions
): LegacyPlayerShortcuts {
const shortcuts = new LegacyPlayerShortcuts();
const muteMemory = new LegacyMuteMemory();
shortcuts.attach({
isAvailable: options.isAvailable,
hostElement: options.hostElement,
canSeek: () => {
const duration = options.player()?.duration ?? NaN;
return (
!options.isLive() && Number.isFinite(duration) && duration > 0
);
},
canToggleFullscreen: () => options.player() !== null,
onEscape: () => {
// Restores the vendor hotkey behavior the disabled `hotkey`
// option used to provide.
const player = options.player();
if (player?.fullscreenWeb) {
player.fullscreenWeb = false;
}
},
togglePaused: () => {
options.player()?.toggle();
},
toggleFullscreen: () => {
const player = options.player();
if (player) {
player.fullscreen = !player.fullscreen;
}
},
seekBy: (deltaSeconds) => {
const player = options.player();
if (!player) {
return;
}
if (deltaSeconds >= 0) {
player.forward = deltaSeconds;
} else {
player.backward = -deltaSeconds;
}
},
adjustVolume: (delta) => {
const player = options.player();
if (!player) {
return;
}
const current = player.muted ? 0 : player.volume;
const next = Math.max(0, Math.min(1, current + delta));
player.volume = next;
player.muted = next <= 0;
},
toggleMute: () => {
const player = options.player();
if (!player) {
return;
}
if (player.muted) {
player.volume = muteMemory.unmuteVolume(player.volume);
player.muted = false;
} else {
muteMemory.rememberIfAudible(player.volume);
player.muted = true;
}
},
});
return shortcuts;
}
@@ -6,7 +6,7 @@ import {
} from './art-player-setup';
describe('ArtPlayer setup', () => {
it('preserves the complete legacy chrome when shared controls are disabled', () => {
it('preserves the legacy chrome but hands the keyboard to app shortcuts', () => {
expect(buildArtPlayerChrome(false)).toEqual({
pip: true,
autoPlayback: true,
@@ -19,6 +19,7 @@ describe('ArtPlayer setup', () => {
fullscreen: true,
fullscreenWeb: true,
airplay: true,
hotkey: false,
});
});
@@ -24,6 +24,10 @@ export function buildArtPlayerChrome(
fullscreen: true,
fullscreenWeb: true,
airplay: true,
// The app-level legacy shortcuts own the keyboard: ArtPlayer's
// focus-scoped hotkeys ignore `defaultPrevented` and would
// double-handle every key they cover.
hotkey: false,
};
}
@@ -10,9 +10,11 @@ export class MockArtplayer {
readonly on = jest.fn();
readonly off = jest.fn();
readonly destroy = jest.fn();
readonly toggle = jest.fn();
readonly currentTime = 0;
readonly duration = 0;
seek = 0;
muted = false;
volume: number;
constructor(readonly options: Record<string, unknown>) {
@@ -212,7 +212,9 @@ describe('ArtPlayerComponent', () => {
fullscreenWeb: true,
})
);
expect(artPlayerInstances[0].options['hotkey']).toBeUndefined();
// The app-level legacy shortcuts own the keyboard instead of
// ArtPlayer's focus-scoped hotkeys.
expect(artPlayerInstances[0].options['hotkey']).toBe(false);
expect(
fixture.debugElement.query(By.css('app-player-controls'))
).toBeNull();
@@ -223,6 +225,32 @@ describe('ArtPlayerComponent', () => {
).toBeNull();
});
it('drives playback keyboard shortcuts through the legacy player', () => {
createComponent({
url: 'https://example.com/movie.mp4',
name: 'Movie',
});
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
expect(artPlayerInstances[0].toggle).toHaveBeenCalledTimes(1);
fixture.destroy();
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
expect(artPlayerInstances[0].toggle).toHaveBeenCalledTimes(1);
});
it('emits a playback issue when mpegts.js reports an unsupported codec', () => {
createComponent({
url: 'https://example.com/live/channel.ts',
@@ -15,6 +15,7 @@ import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
@@ -22,6 +23,7 @@ import {
} from '../player-controls';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { attachArtPlayerLegacyShortcuts } from './art-player-legacy-shortcuts';
import {
buildArtPlayerChrome,
exitOwnedArtPlayerFullscreen,
@@ -75,6 +77,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
private player: Artplayer | null = null;
private sourceSession: ArtPlayerSourceSession | null = null;
private videoSession: ArtPlayerVideoSession | null = null;
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
ngOnInit(): void {
this.seriesNavigationSignal.set(this.seriesNavigation());
@@ -82,6 +85,15 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
} else {
// Survives the channel-change destroy/init cycle: the handlers
// read the current player lazily.
this.legacyShortcuts = attachArtPlayerLegacyShortcuts({
player: () => this.player,
hostElement: () => this.playerRoot()?.nativeElement ?? null,
isAvailable: () => this.interactionEnabled(),
isLive: () => this.isLive(),
});
}
this.initPlayer();
}
@@ -123,6 +135,8 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
}
ngOnDestroy(): void {
this.legacyShortcuts?.detach();
this.legacyShortcuts = null;
this.destroyPlayer();
}
@@ -0,0 +1,143 @@
import type { LegacyPlayerShortcuts } from '../player-controls';
import { attachHtmlVideoLegacyShortcuts } from './html-video-legacy-shortcuts';
describe('attachHtmlVideoLegacyShortcuts', () => {
let shortcuts: LegacyPlayerShortcuts;
let video: HTMLVideoElement;
let isLive: boolean;
let paused: boolean;
let duration: number;
let play: jest.Mock;
let pause: jest.Mock;
beforeEach(() => {
isLive = false;
paused = true;
duration = 600;
play = jest.fn();
pause = jest.fn();
video = document.createElement('video');
Object.defineProperty(video, 'paused', { get: () => paused });
Object.defineProperty(video, 'duration', { get: () => duration });
video.pause = pause;
video.volume = 0.5;
video.currentTime = 100;
shortcuts = attachHtmlVideoLegacyShortcuts({
video: () => video,
hostElement: () => null,
isAvailable: () => true,
isLive: () => isLive,
play,
});
});
afterEach(() => {
shortcuts.detach();
});
it('plays through the session callback and pauses the element with Space', () => {
dispatchKey(' ');
expect(play).toHaveBeenCalledTimes(1);
paused = false;
dispatchKey(' ');
expect(pause).toHaveBeenCalledTimes(1);
});
it('seeks by five seconds, clamped to the media bounds', () => {
dispatchKey('ArrowRight');
expect(video.currentTime).toBe(105);
video.currentTime = 2;
dispatchKey('ArrowLeft');
expect(video.currentTime).toBe(0);
video.currentTime = 598;
dispatchKey('ArrowRight');
expect(video.currentTime).toBe(600);
});
it('does not seek live playback or unknown durations', () => {
isLive = true;
expect(dispatchKey('ArrowRight')).toBe(false);
isLive = false;
duration = NaN;
expect(dispatchKey('ArrowRight')).toBe(false);
expect(video.currentTime).toBe(100);
});
it('adjusts volume in five percent steps and syncs muted state', () => {
dispatchKey('ArrowUp');
expect(video.volume).toBeCloseTo(0.55);
expect(video.muted).toBe(false);
video.volume = 0.03;
dispatchKey('ArrowDown');
expect(video.volume).toBe(0);
expect(video.muted).toBe(true);
dispatchKey('ArrowUp');
expect(video.volume).toBeCloseTo(0.05);
expect(video.muted).toBe(false);
});
it('toggles mute with M', () => {
dispatchKey('m');
expect(video.muted).toBe(true);
expect(video.volume).toBe(0.5);
dispatchKey('m');
expect(video.muted).toBe(false);
expect(video.volume).toBe(0.5);
});
it('restores an audible volume when unmuting from zero', () => {
video.volume = 0.03;
dispatchKey('ArrowDown');
expect(video.volume).toBe(0);
expect(video.muted).toBe(true);
dispatchKey('m');
expect(video.muted).toBe(false);
expect(video.volume).toBe(0.5);
});
it('restores the remembered volume when unmuting after mute zeroed out', () => {
video.volume = 0.7;
dispatchKey('m');
expect(video.muted).toBe(true);
dispatchKey('ArrowDown');
expect(video.volume).toBe(0);
dispatchKey('m');
expect(video.muted).toBe(false);
expect(video.volume).toBe(0.7);
});
it('fullscreens the video element itself with F', () => {
const requestFullscreen = jest.fn(() => Promise.resolve());
(
video as HTMLVideoElement & { requestFullscreen: jest.Mock }
).requestFullscreen = requestFullscreen;
dispatchKey('f');
expect(requestFullscreen).toHaveBeenCalledTimes(1);
});
it('does not offer fullscreen when the element cannot enter it', () => {
// jsdom video elements have no requestFullscreen implementation.
expect(dispatchKey('f')).toBe(false);
});
});
function dispatchKey(key: string): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
@@ -0,0 +1,91 @@
import { LegacyMuteMemory, LegacyPlayerShortcuts } from '../player-controls';
import {
applyVideoCurrentTime,
applyVideoVolume,
} from '../player-controls/web-video-controls.media-helpers';
export interface HtmlVideoLegacyShortcutOptions {
video: () => HTMLVideoElement;
hostElement: () => HTMLElement | null;
isAvailable: () => boolean;
isLive: () => boolean;
/** Play through the component's session so diagnostics stay owned there. */
play: () => void;
}
/**
* App-level keyboard shortcuts for the vendor-chrome HTML5 player: commands
* act on the native video element, matching what its built-in controls do.
*/
export function attachHtmlVideoLegacyShortcuts(
options: HtmlVideoLegacyShortcutOptions
): LegacyPlayerShortcuts {
const shortcuts = new LegacyPlayerShortcuts();
const muteMemory = new LegacyMuteMemory();
shortcuts.attach({
isAvailable: options.isAvailable,
hostElement: options.hostElement,
canSeek: () => {
const duration = options.video().duration;
return (
!options.isLive() && Number.isFinite(duration) && duration > 0
);
},
canToggleFullscreen: () =>
typeof options.video().requestFullscreen === 'function',
togglePaused: () => {
const video = options.video();
if (video.paused) {
options.play();
} else {
video.pause();
}
},
toggleFullscreen: () => toggleVideoElementFullscreen(options.video()),
seekBy: (deltaSeconds) => {
const video = options.video();
applyVideoCurrentTime(
video,
video.currentTime + deltaSeconds,
() => video.duration
);
},
adjustVolume: (delta) => {
const video = options.video();
applyVideoVolume(video, (video.muted ? 0 : video.volume) + delta);
},
toggleMute: () => {
const video = options.video();
if (video.muted) {
applyVideoVolume(video, muteMemory.unmuteVolume(video.volume));
} else {
muteMemory.rememberIfAudible(video.volume);
video.muted = true;
}
},
});
return shortcuts;
}
/**
* Fullscreens the video element itself, matching what the native controls'
* own fullscreen button does for this player.
*/
function toggleVideoElementFullscreen(video: HTMLVideoElement): void {
if (document.fullscreenElement) {
if (
document.fullscreenElement === video &&
typeof document.exitFullscreen === 'function'
) {
void Promise.resolve(document.exitFullscreen()).catch(
() => undefined
);
}
return;
}
try {
void Promise.resolve(video.requestFullscreen()).catch(() => undefined);
} catch {
// Fullscreen requests can be rejected synchronously.
}
}
@@ -97,6 +97,30 @@ describe('HtmlVideoPlayerComponent', () => {
expect(adapterAttach).not.toHaveBeenCalled();
});
it('drives playback keyboard shortcuts against the native video element', () => {
const video = component.videoPlayer.nativeElement;
expect(video.muted).toBe(false);
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'm',
bubbles: true,
cancelable: true,
})
);
expect(video.muted).toBe(true);
fixture.destroy();
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'm',
bubbles: true,
cancelable: true,
})
);
expect(video.muted).toBe(true);
});
it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => {
const el = component.videoPlayer.nativeElement;
const removeSpy = jest.spyOn(el, 'removeEventListener');
@@ -23,6 +23,7 @@ import {
getPlaybackMediaExtensionFromUrl,
} from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
@@ -37,6 +38,7 @@ import {
setNativeVideoSource,
} from '../web-video-support/web-video-native-source.util';
import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks';
import { attachHtmlVideoLegacyShortcuts } from './html-video-legacy-shortcuts';
import { HtmlVideoElementSession } from './html-video-element-session';
import {
emitFatalHlsPlaybackError,
@@ -109,6 +111,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
*/
private captionTracks: WebVideoSourceTracks | null = null;
private videoSession: HtmlVideoElementSession | null = null;
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
ngOnInit() {
if (this.sharedControls) {
@@ -129,6 +132,13 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
showCaptions: () => this.showCaptions(),
vendorCaptionControls: true,
});
this.legacyShortcuts = attachHtmlVideoLegacyShortcuts({
video: () => this.videoPlayer.nativeElement,
hostElement: () => this.playerRoot()?.nativeElement ?? null,
isAvailable: () => this.interactionEnabled(),
isLive: () => this.isLive(),
play: () => this.handlePlayOperation(),
});
}
if (this.controlsSource) {
this.bindControlsSource(this.controlsSource);
@@ -342,6 +352,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
* Destroy hls instance on component destroy and clean up event listener
*/
ngOnDestroy(): void {
this.legacyShortcuts?.detach();
this.legacyShortcuts = null;
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.captionTracks?.destroy();
@@ -5,6 +5,7 @@ export * from './controls-menu-state';
export * from './controls-feedback';
export * from './controls-fullscreen';
export * from './controls-shortcuts';
export * from './legacy-player-shortcuts';
export * from './controls-surface';
export * from './controls-timeline';
export * from './controls-view-model';
@@ -0,0 +1,125 @@
import { LegacyPlayerShortcuts } from './legacy-player-shortcuts';
describe('LegacyPlayerShortcuts', () => {
let shortcuts: LegacyPlayerShortcuts;
let handlers: {
isAvailable: jest.Mock<boolean, []>;
hostElement: jest.Mock<HTMLElement | null, []>;
canSeek: jest.Mock<boolean, []>;
canToggleFullscreen: jest.Mock<boolean, []>;
togglePaused: jest.Mock;
toggleFullscreen: jest.Mock;
seekBy: jest.Mock;
adjustVolume: jest.Mock;
toggleMute: jest.Mock;
};
beforeEach(() => {
shortcuts = new LegacyPlayerShortcuts();
handlers = {
isAvailable: jest.fn(() => true),
hostElement: jest.fn(() => null),
canSeek: jest.fn(() => true),
canToggleFullscreen: jest.fn(() => true),
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('treats Escape as a no-op by default without consuming the key', () => {
expect(dispatchKey('Escape')).toBe(false);
expect(handlers.togglePaused).not.toHaveBeenCalled();
});
it('forwards Escape to a provided handler', () => {
shortcuts.detach();
const onEscape = jest.fn();
shortcuts.attach({ ...handlers, onEscape });
dispatchKey('Escape');
expect(onEscape).toHaveBeenCalledTimes(1);
});
it('respects the seek and fullscreen gates', () => {
handlers.canSeek.mockReturnValue(false);
handlers.canToggleFullscreen.mockReturnValue(false);
expect(dispatchKey('ArrowRight')).toBe(false);
expect(dispatchKey('f')).toBe(false);
expect(handlers.seekBy).not.toHaveBeenCalled();
expect(handlers.toggleFullscreen).not.toHaveBeenCalled();
});
it('does nothing while unavailable', () => {
handlers.isAvailable.mockReturnValue(false);
expect(dispatchKey(' ')).toBe(false);
expect(dispatchKey('m')).toBe(false);
expect(handlers.togglePaused).not.toHaveBeenCalled();
expect(handlers.toggleMute).not.toHaveBeenCalled();
});
it('opts out while the host element sits inside an inert region', () => {
const inertRegion = document.createElement('div');
inertRegion.setAttribute('inert', '');
const host = document.createElement('div');
inertRegion.appendChild(host);
document.body.appendChild(inertRegion);
handlers.hostElement.mockReturnValue(host);
try {
expect(dispatchKey(' ')).toBe(false);
expect(handlers.togglePaused).not.toHaveBeenCalled();
} finally {
inertRegion.remove();
}
});
it('stops handling keys after detach', () => {
shortcuts.detach();
dispatchKey(' ');
expect(handlers.togglePaused).not.toHaveBeenCalled();
});
});
function dispatchKey(key: string, init: KeyboardEventInit = {}): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
@@ -0,0 +1,84 @@
import { ControlsShortcuts } from './controls-shortcuts';
/**
* Engine commands the vendor-chrome (shared-controls-off) web players expose
* to the app-level keyboard shortcuts.
*/
export interface LegacyPlayerShortcutHandlers {
isAvailable: () => boolean;
/** Component root, used to opt out while an ancestor is `inert`. */
hostElement: () => HTMLElement | null;
canSeek: () => boolean;
canToggleFullscreen: () => boolean;
/**
* Escape is delivered to every attached instance regardless of ownership;
* vendor chrome owns its own overlays, so this defaults to a no-op.
*/
onEscape?: () => void;
togglePaused: () => void;
toggleFullscreen: () => void;
seekBy: (deltaSeconds: number) => void;
adjustVolume: (delta: number) => void;
toggleMute: () => void;
}
/**
* Keyboard shortcuts for the vendor-chrome web players.
*
* `ControlsShortcuts` normally lives inside `app-player-controls`, which only
* renders in shared-controls mode — with the preference off no instance was
* attached, so the playback shortcuts advertised in the in-app help (Space/K,
* F, arrow seek/volume, M) silently did nothing. This wrapper attaches the
* same arbitration and ignore rules on behalf of the legacy players and
* forwards the commands straight to the engine. Play/pause and volume are
* gated only on availability: the owning component attaches after its engine
* exists and detaches on destroy, so a mounted instance can always execute
* them.
*/
export class LegacyPlayerShortcuts {
private readonly shortcuts = new ControlsShortcuts();
attach(handlers: LegacyPlayerShortcutHandlers): void {
this.shortcuts.attach({
isAvailable: handlers.isAvailable,
hostElement: handlers.hostElement,
canTogglePaused: () => true,
canSeek: handlers.canSeek,
canAdjustVolume: () => true,
canToggleFullscreen: handlers.canToggleFullscreen,
onEscape: handlers.onEscape ?? (() => undefined),
togglePaused: handlers.togglePaused,
toggleFullscreen: handlers.toggleFullscreen,
seekBy: handlers.seekBy,
adjustVolume: handlers.adjustVolume,
toggleMute: handlers.toggleMute,
});
}
detach(): void {
this.shortcuts.detach();
}
}
/**
* Mute memory for the legacy engine adapters, mirroring the shared controls'
* `ControlsVolume`: muting remembers the audible volume, and unmuting while
* the volume sits at zero restores it (same 0.5 fallback), so M can never
* leave the player silently "unmuted".
*/
export class LegacyMuteMemory {
private lastAudibleVolume: number | null = null;
rememberIfAudible(volume: number): void {
if (Number.isFinite(volume) && volume > 0) {
this.lastAudibleVolume = volume;
}
}
unmuteVolume(currentVolume: number): number {
if (Number.isFinite(currentVolume) && currentVolume > 0) {
return currentVolume;
}
return this.lastAudibleVolume ?? 0.5;
}
}
@@ -0,0 +1,175 @@
import type { LegacyPlayerShortcuts } from '../player-controls';
import { attachVjsLegacyShortcuts } from './vjs-legacy-shortcuts';
import type { VideoJsPlayer } from './vjs-player.types';
describe('attachVjsLegacyShortcuts', () => {
let shortcuts: LegacyPlayerShortcuts;
let isLive: boolean;
let mock: {
pausedValue: boolean;
mutedValue: boolean;
volumeValue: number;
currentTimeValue: number;
durationValue: number;
fullscreenValue: boolean;
play: jest.Mock;
pause: jest.Mock;
requestFullscreen: jest.Mock;
exitFullscreen: jest.Mock;
player: VideoJsPlayer;
};
beforeEach(() => {
isLive = false;
mock = {
pausedValue: true,
mutedValue: false,
volumeValue: 0.5,
currentTimeValue: 100,
durationValue: 600,
fullscreenValue: false,
play: jest.fn(() => Promise.resolve()),
pause: jest.fn(),
requestFullscreen: jest.fn(),
exitFullscreen: jest.fn(),
player: null as unknown as VideoJsPlayer,
};
mock.player = {
paused: jest.fn(() => mock.pausedValue),
play: mock.play,
pause: mock.pause,
muted: jest.fn((value?: boolean) => {
if (value !== undefined) {
mock.mutedValue = value;
}
return mock.mutedValue;
}),
volume: jest.fn((value?: number) => {
if (value !== undefined) {
mock.volumeValue = value;
}
return mock.volumeValue;
}),
currentTime: jest.fn((value?: number) => {
if (value !== undefined) {
mock.currentTimeValue = value;
}
return mock.currentTimeValue;
}),
duration: jest.fn(() => mock.durationValue),
isFullscreen: jest.fn(() => mock.fullscreenValue),
requestFullscreen: mock.requestFullscreen,
exitFullscreen: mock.exitFullscreen,
} as unknown as VideoJsPlayer;
shortcuts = attachVjsLegacyShortcuts({
player: () => mock.player,
hostElement: () => null,
isAvailable: () => true,
isLive: () => isLive,
});
});
afterEach(() => {
shortcuts.detach();
});
it('toggles play and pause with Space', () => {
dispatchKey(' ');
expect(mock.play).toHaveBeenCalledTimes(1);
mock.pausedValue = false;
dispatchKey(' ');
expect(mock.pause).toHaveBeenCalledTimes(1);
});
it('seeks VOD by five seconds through the player API, clamped to bounds', () => {
dispatchKey('ArrowRight');
expect(mock.currentTimeValue).toBe(105);
mock.currentTimeValue = 2;
dispatchKey('ArrowLeft');
expect(mock.currentTimeValue).toBe(0);
mock.currentTimeValue = 598;
dispatchKey('ArrowRight');
expect(mock.currentTimeValue).toBe(600);
});
it('does not seek live playback or unknown durations', () => {
isLive = true;
expect(dispatchKey('ArrowRight')).toBe(false);
isLive = false;
mock.durationValue = NaN;
expect(dispatchKey('ArrowRight')).toBe(false);
expect(mock.currentTimeValue).toBe(100);
});
it('adjusts volume in five percent steps and syncs muted state', () => {
dispatchKey('ArrowUp');
expect(mock.volumeValue).toBeCloseTo(0.55);
expect(mock.mutedValue).toBe(false);
mock.volumeValue = 0.03;
dispatchKey('ArrowDown');
expect(mock.volumeValue).toBe(0);
expect(mock.mutedValue).toBe(true);
// Raising the volume out of the muted state unmutes.
dispatchKey('ArrowUp');
expect(mock.volumeValue).toBeCloseTo(0.05);
expect(mock.mutedValue).toBe(false);
});
it('toggles mute with M', () => {
dispatchKey('m');
expect(mock.mutedValue).toBe(true);
expect(mock.volumeValue).toBe(0.5);
dispatchKey('m');
expect(mock.mutedValue).toBe(false);
expect(mock.volumeValue).toBe(0.5);
});
it('restores an audible volume when unmuting from zero', () => {
mock.volumeValue = 0.03;
dispatchKey('ArrowDown');
expect(mock.volumeValue).toBe(0);
expect(mock.mutedValue).toBe(true);
dispatchKey('m');
expect(mock.mutedValue).toBe(false);
expect(mock.volumeValue).toBe(0.5);
});
it('restores the remembered volume when unmuting after mute zeroed out', () => {
mock.volumeValue = 0.7;
dispatchKey('m');
expect(mock.mutedValue).toBe(true);
dispatchKey('ArrowDown');
expect(mock.volumeValue).toBe(0);
dispatchKey('m');
expect(mock.mutedValue).toBe(false);
expect(mock.volumeValue).toBe(0.7);
});
it('toggles the vendor fullscreen with F', () => {
dispatchKey('f');
expect(mock.requestFullscreen).toHaveBeenCalledTimes(1);
mock.fullscreenValue = true;
dispatchKey('f');
expect(mock.exitFullscreen).toHaveBeenCalledTimes(1);
});
});
function dispatchKey(key: string): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
@@ -0,0 +1,91 @@
import { LegacyMuteMemory, LegacyPlayerShortcuts } from '../player-controls';
import type { VideoJsPlayer } from './vjs-player.types';
export interface VjsLegacyShortcutOptions {
player: () => VideoJsPlayer | null;
hostElement: () => HTMLElement | null;
isAvailable: () => boolean;
isLive: () => boolean;
}
/**
* App-level keyboard shortcuts for the vendor-chrome Video.js player: the
* legacy configuration never enables `userActions.hotkeys`, so commands go
* through the player API, which keeps the vendor control bar in sync.
*/
export function attachVjsLegacyShortcuts(
options: VjsLegacyShortcutOptions
): LegacyPlayerShortcuts {
const shortcuts = new LegacyPlayerShortcuts();
const muteMemory = new LegacyMuteMemory();
shortcuts.attach({
isAvailable: options.isAvailable,
hostElement: options.hostElement,
canSeek: () => {
const duration = options.player()?.duration() ?? NaN;
return (
!options.isLive() && Number.isFinite(duration) && duration > 0
);
},
canToggleFullscreen: () => options.player() !== null,
togglePaused: () => {
const player = options.player();
if (!player) {
return;
}
if (player.paused()) {
void Promise.resolve(player.play()).catch(() => undefined);
} else {
player.pause();
}
},
toggleFullscreen: () => {
const player = options.player();
if (!player) {
return;
}
try {
const request = player.isFullscreen()
? player.exitFullscreen()
: player.requestFullscreen();
void Promise.resolve(request).catch(() => undefined);
} catch {
// Fullscreen requests can be rejected synchronously.
}
},
seekBy: (deltaSeconds) => {
const player = options.player();
if (!player) {
return;
}
const duration = player.duration() ?? NaN;
const target = (player.currentTime() ?? 0) + deltaSeconds;
const upperBound = Number.isFinite(duration) ? duration : target;
player.currentTime(Math.max(0, Math.min(target, upperBound)));
},
adjustVolume: (delta) => {
const player = options.player();
if (!player) {
return;
}
const current = player.muted() ? 0 : (player.volume() ?? 0);
const next = Math.max(0, Math.min(1, current + delta));
player.volume(next);
player.muted(next <= 0);
},
toggleMute: () => {
const player = options.player();
if (!player) {
return;
}
if (player.muted()) {
player.volume(muteMemory.unmuteVolume(player.volume() ?? 0));
player.muted(false);
} else {
muteMemory.rememberIfAudible(player.volume() ?? 0);
player.muted(true);
}
},
});
return shortcuts;
}
@@ -94,6 +94,32 @@ describe('VjsPlayerComponent', () => {
).toBeNull();
});
it('drives playback keyboard shortcuts through the legacy player', () => {
render({
sources: [{ src: 'https://example.test/movie.mp4' }],
isLive: false,
});
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
expect(harness.play).toHaveBeenCalledTimes(1);
fixture.destroy();
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
expect(harness.play).toHaveBeenCalledTimes(1);
});
it('does not reload Video.js when options keep the same source', () => {
const source = {
src: 'https://example.test/live/playlist.m3u8',
@@ -406,6 +432,7 @@ describe('VjsPlayerComponent', () => {
function createPlayerHarness() {
const listeners = new Map<string, Set<() => void>>();
let volumeValue = 0.5;
let mutedValue = false;
const harness = {
currentVideo: document.createElement('video'),
currentError: null as NativePlaybackErrorInput | null,
@@ -413,6 +440,7 @@ function createPlayerHarness() {
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
play: jest.fn(() => Promise.resolve()),
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
harness.paused = true;
@@ -426,6 +454,12 @@ function createPlayerHarness() {
}
return volumeValue;
}),
muted: jest.fn((value?: boolean) => {
if (value !== undefined) {
mutedValue = value;
}
return mutedValue;
}),
emit(event: string) {
for (const listener of listeners.get(event) ?? []) {
listener();
@@ -451,6 +485,11 @@ function createPlayerHarness() {
}),
pause: harness.pause,
paused: jest.fn(() => harness.paused),
play: harness.play,
muted: harness.muted,
isFullscreen: jest.fn(() => false),
requestFullscreen: jest.fn(),
exitFullscreen: jest.fn(),
reset: harness.reset,
src: harness.src,
tech: jest.fn(() => ({
@@ -25,6 +25,7 @@ import {
createPlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
@@ -35,6 +36,7 @@ import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-pl
import { logVjsAudioTracks, setupVjsAudioTrackMenu } from './vjs-audio-tracks';
import { VjsLegacyTracks } from './vjs-legacy-tracks';
import { VjsMpegTsSession } from './vjs-mpegts-session';
import { attachVjsLegacyShortcuts } from './vjs-legacy-shortcuts';
import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge';
import {
createVjsPlayerOptions,
@@ -113,6 +115,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
private controlsBridge: VjsPlayerControlsBridge | null = null;
/** Track ownership for the legacy chrome; null with shared controls. */
private legacyTracks: VjsLegacyTracks | null = null;
/** Keyboard shortcuts for the legacy chrome; null with shared controls. */
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
private desiredSource: VideoPlayerSource | null = null;
private readyHandled = false;
private destroyed = false;
@@ -138,6 +142,14 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
) as unknown as VideoJsPlayer;
this.bindPlayerEvents();
initializeVjsPlugins(this.player);
if (!this.sharedControls) {
this.legacyShortcuts = attachVjsLegacyShortcuts({
player: () => this.player ?? null,
hostElement: () => this.playerRoot()?.nativeElement ?? null,
isAvailable: () => this.interactionEnabled(),
isLive: () => this.options().isLive !== false,
});
}
}
ngOnChanges(changes: SimpleChanges): void {
@@ -185,6 +197,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
ngOnDestroy(): void {
this.destroyed = true;
this.legacyShortcuts?.detach();
this.legacyShortcuts = null;
this.resetCoordinator.destroy();
this.controlsBridge?.destroy();
this.controlsBridge = null;