diff --git a/.changes/playback-keyboard-shortcuts-default-players.md b/.changes/playback-keyboard-shortcuts-default-players.md new file mode 100644 index 000000000..e81531cee --- /dev/null +++ b/.changes/playback-keyboard-shortcuts-default-players.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e5d0fd220..0e393ee2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 78d8c9cc5..4105d8e85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.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 — 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 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 vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/README.md b/README.md index 9ddc18150..36f800724 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 323af7f7b..61e220f3b 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts index 306e95abe..8f5761aa3 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.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', diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts index 4852806bd..4eaac6390 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts @@ -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([ ['←'], ['→'], ]); diff --git a/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts new file mode 100644 index 000000000..daf3a91ad --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts new file mode 100644 index 000000000..7487c12e6 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts index f02a4b006..f2d5e17d2 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts @@ -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, }); }); diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.ts index 3cd91304e..45238494e 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.ts @@ -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, }; } diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec-fixtures.ts index edf8de332..c99baad44 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec-fixtures.ts @@ -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) { diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 166deb8b6..5deeda309 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -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', diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 8b659eba0..7d3843cb7 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.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(); } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts new file mode 100644 index 000000000..52109d76b --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts new file mode 100644 index 000000000..f4b48bb8b --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts @@ -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. + } +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index db0fda8fd..d441a9f6b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -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'); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 41f9c4a25..49eb7161e 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -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(); diff --git a/libs/ui/playback/src/lib/player-controls/index.ts b/libs/ui/playback/src/lib/player-controls/index.ts index 9fc2a65ea..1b5d68896 100644 --- a/libs/ui/playback/src/lib/player-controls/index.ts +++ b/libs/ui/playback/src/lib/player-controls/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.spec.ts b/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.spec.ts new file mode 100644 index 000000000..9f5931f52 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.spec.ts @@ -0,0 +1,125 @@ +import { LegacyPlayerShortcuts } from './legacy-player-shortcuts'; + +describe('LegacyPlayerShortcuts', () => { + let shortcuts: LegacyPlayerShortcuts; + let handlers: { + isAvailable: jest.Mock; + hostElement: jest.Mock; + canSeek: jest.Mock; + canToggleFullscreen: jest.Mock; + 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; +} diff --git a/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts new file mode 100644 index 000000000..f9554c04a --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts new file mode 100644 index 000000000..05c13b738 --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts new file mode 100644 index 000000000..0ea5b2839 --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index ae289b05c..5f6851f95 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -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 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(() => ({ diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 2b2ea6974..d2b47b5a7 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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;