mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user