Skip to content

Commit 1ce14d8

Browse files
committed
feat: add keyboard shortcuts popup + improve fast play
1 parent ab73527 commit 1ce14d8

File tree

8 files changed

+480
-11
lines changed

8 files changed

+480
-11
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import videojs from 'video.js';
2+
import { VJS_EVENTS } from 'constants/player';
3+
4+
const PRIMARY_SHORTCUTS = [
5+
{ keys: ['Space', 'K'], label: __('Play/Pause (hold to speed up)') },
6+
{ keys: ['J', 'L'], label: __('Seek -10s / +10s') },
7+
{ keys: ['Left', 'Right'], label: __('Seek -5s / +5s') },
8+
{ keys: ['Up', 'Down'], label: __('Volume up/down') },
9+
{ keys: 'M', label: __('Mute/unmute') },
10+
{ keys: 'F', label: __('Fullscreen') },
11+
];
12+
13+
const SECONDARY_SHORTCUTS = [
14+
{ keys: ['Shift', '?'], separator: ' + ', label: __('Show shortcuts') },
15+
{ keys: ['Shift', '.'], separator: ' + ', label: __('Speed up') },
16+
{ keys: ['Shift', ','], separator: ' + ', label: __('Slow down') },
17+
{ keys: '0-9', label: __('Jump to 0-90%') },
18+
{ keys: 'T', label: __('Theater mode') },
19+
{ keys: ['Shift', 'N'], separator: ' + ', label: __('Play next') },
20+
{ keys: ['Shift', 'P'], separator: ' + ', label: __('Play previous') },
21+
{ keys: ',', label: __('Back one frame (paused)') },
22+
{ keys: '.', label: __('Forward one frame (paused)') },
23+
];
24+
25+
function formatKeys(keys, separator) {
26+
const parts = Array.isArray(keys) ? keys : [keys];
27+
const joiner = separator || ' / ';
28+
return parts.map((key) => `<kbd>${key}</kbd>`).join(joiner);
29+
}
30+
31+
function renderShortcutItems(items) {
32+
return items
33+
.map((item) => {
34+
const label = item.label;
35+
const isCompact = typeof label === 'string' && (label.length > 30 || label.indexOf('(') !== -1);
36+
const actionClass = isCompact ? 'vjs-shortcuts-action vjs-shortcuts-action--compact' : 'vjs-shortcuts-action';
37+
return `
38+
<li class="vjs-shortcuts-item">
39+
<div class="vjs-shortcuts-keys">${formatKeys(item.keys, item.separator)}</div>
40+
<div class="${actionClass}">${label}</div>
41+
</li>
42+
`;
43+
})
44+
.join('');
45+
}
46+
47+
function buildOverlayMarkup() {
48+
return `
49+
<div class="vjs-shortcuts-card">
50+
<div class="vjs-shortcuts-header">
51+
<div class="vjs-shortcuts-title">${__('Keyboard shortcuts')}</div>
52+
<div class="vjs-shortcuts-actions">
53+
<button type="button" class="vjs-shortcuts-toggle"></button>
54+
<button type="button" class="vjs-shortcuts-close" aria-label="${__('Close')}">${__('Close')}</button>
55+
</div>
56+
</div>
57+
<div class="vjs-shortcuts-body">
58+
<ul class="vjs-shortcuts-list vjs-shortcuts-list--primary">
59+
${renderShortcutItems(PRIMARY_SHORTCUTS)}
60+
</ul>
61+
<ul class="vjs-shortcuts-list vjs-shortcuts-list--secondary">
62+
${renderShortcutItems(SECONDARY_SHORTCUTS)}
63+
</ul>
64+
</div>
65+
</div>
66+
`;
67+
}
68+
69+
export function ensureKeyboardShortcutsOverlay(player) {
70+
if (!player) return null;
71+
if (player.keyboardShortcutsOverlay) return player.keyboardShortcutsOverlay;
72+
73+
const overlayEl = videojs.dom.createEl('div', {
74+
className: 'vjs-shortcuts-overlay',
75+
});
76+
overlayEl.setAttribute('role', 'dialog');
77+
overlayEl.setAttribute('aria-label', __('Keyboard shortcuts'));
78+
overlayEl.setAttribute('aria-hidden', 'true');
79+
overlayEl.innerHTML = buildOverlayMarkup();
80+
81+
const playerEl = player.el();
82+
if (!playerEl) return null;
83+
playerEl.appendChild(overlayEl);
84+
85+
const closeButton = overlayEl.querySelector('.vjs-shortcuts-close');
86+
const toggleButton = overlayEl.querySelector('.vjs-shortcuts-toggle');
87+
88+
let isOpen = false;
89+
let isExpanded = false;
90+
91+
const updateToggleLabel = () => {
92+
if (toggleButton) {
93+
toggleButton.textContent = __(isExpanded ? 'Show fewer' : 'Show all');
94+
toggleButton.setAttribute('aria-expanded', String(isExpanded));
95+
}
96+
};
97+
98+
const setExpanded = (expanded) => {
99+
isExpanded = expanded;
100+
overlayEl.classList.toggle('vjs-shortcuts-overlay--expanded', isExpanded);
101+
updateToggleLabel();
102+
};
103+
104+
const open = () => {
105+
if (isOpen) return;
106+
isOpen = true;
107+
overlayEl.classList.add('vjs-shortcuts-overlay--open');
108+
overlayEl.setAttribute('aria-hidden', 'false');
109+
player.userActive(true);
110+
};
111+
112+
const close = () => {
113+
if (!isOpen) return;
114+
isOpen = false;
115+
overlayEl.classList.remove('vjs-shortcuts-overlay--open');
116+
overlayEl.setAttribute('aria-hidden', 'true');
117+
};
118+
119+
const toggle = (forceState) => {
120+
if (typeof forceState === 'boolean') {
121+
forceState ? open() : close();
122+
return;
123+
}
124+
isOpen ? close() : open();
125+
};
126+
127+
updateToggleLabel();
128+
129+
if (closeButton) {
130+
closeButton.addEventListener('click', () => close());
131+
}
132+
133+
if (toggleButton) {
134+
toggleButton.addEventListener('click', () => setExpanded(!isExpanded));
135+
}
136+
137+
overlayEl.addEventListener('click', (event) => {
138+
if (event.target === overlayEl) close();
139+
});
140+
141+
player.on(VJS_EVENTS.PLAYER_CLOSED, () => close());
142+
143+
const api = {
144+
open,
145+
close,
146+
toggle,
147+
setExpanded,
148+
isOpen: () => isOpen,
149+
};
150+
151+
player.keyboardShortcutsOverlay = api;
152+
player.toggleKeyboardShortcutsOverlay = toggle;
153+
154+
return api;
155+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
.video-js {
2+
.vjs-shortcuts-overlay {
3+
align-items: center;
4+
background: rgba(0, 0, 0, 0.65);
5+
display: none;
6+
bottom: 0;
7+
justify-content: center;
8+
left: 0;
9+
position: absolute;
10+
right: 0;
11+
top: 0;
12+
z-index: 30;
13+
}
14+
15+
.vjs-shortcuts-overlay--open {
16+
display: flex;
17+
}
18+
19+
.vjs-shortcuts-card {
20+
background: rgba(0, 0, 0, 0.88);
21+
border: 1px solid rgba(255, 255, 255, 0.1);
22+
border-radius: 8px;
23+
box-sizing: border-box;
24+
color: #fff;
25+
display: flex;
26+
flex-direction: column;
27+
max-height: calc(100% - 16px);
28+
max-width: 520px;
29+
overflow: hidden;
30+
padding: 14px 16px;
31+
width: min(78vw, 520px);
32+
}
33+
34+
.vjs-shortcuts-header {
35+
align-items: center;
36+
display: flex;
37+
flex-direction: row;
38+
flex-wrap: wrap;
39+
gap: 10px;
40+
justify-content: flex-start;
41+
margin-bottom: 8px;
42+
}
43+
44+
.vjs-shortcuts-actions {
45+
align-items: center;
46+
display: flex;
47+
gap: 8px;
48+
flex-wrap: wrap;
49+
justify-content: flex-start;
50+
}
51+
52+
.vjs-shortcuts-title {
53+
align-self: flex-start;
54+
font-size: 16px;
55+
font-weight: 600;
56+
}
57+
58+
.vjs-shortcuts-close,
59+
.vjs-shortcuts-toggle {
60+
background: transparent;
61+
border: 1px solid rgba(255, 255, 255, 0.2);
62+
border-radius: 6px;
63+
color: #fff;
64+
cursor: pointer;
65+
font-size: 12px;
66+
padding: 6px 10px;
67+
}
68+
69+
.vjs-shortcuts-body {
70+
box-sizing: border-box;
71+
display: grid;
72+
gap: 6px;
73+
grid-template-columns: 1fr;
74+
min-height: 0;
75+
overflow-x: hidden;
76+
overflow-y: auto;
77+
width: 100%;
78+
}
79+
80+
.vjs-shortcuts-overlay--expanded .vjs-shortcuts-body {
81+
column-gap: 52px;
82+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
83+
}
84+
85+
.vjs-shortcuts-list {
86+
box-sizing: border-box;
87+
display: grid;
88+
gap: 4px;
89+
list-style: none;
90+
margin: 0;
91+
min-width: 0;
92+
padding: 0;
93+
width: 100%;
94+
}
95+
96+
.vjs-shortcuts-overlay--expanded .vjs-shortcuts-list--secondary {
97+
display: grid;
98+
}
99+
100+
.vjs-shortcuts-list--secondary {
101+
display: none;
102+
}
103+
104+
.vjs-shortcuts-item {
105+
align-items: center;
106+
display: grid;
107+
column-gap: 3px;
108+
min-width: 0;
109+
row-gap: 3px;
110+
grid-template-columns: minmax(88px, 116px) 1fr;
111+
}
112+
113+
.vjs-shortcuts-keys {
114+
display: flex;
115+
flex-wrap: wrap;
116+
gap: 6px;
117+
min-width: 0;
118+
}
119+
120+
.vjs-shortcuts-keys kbd {
121+
background: rgba(255, 255, 255, 0.12);
122+
border: 1px solid rgba(255, 255, 255, 0.2);
123+
border-radius: 4px;
124+
display: inline-block;
125+
font-size: 11px;
126+
padding: 1px 5px;
127+
}
128+
129+
.vjs-shortcuts-action {
130+
font-size: 12px;
131+
line-height: 1.25;
132+
min-width: 0;
133+
opacity: 0.85;
134+
overflow-wrap: anywhere;
135+
}
136+
137+
.vjs-shortcuts-action--compact {
138+
font-size: 11px;
139+
line-height: 1.15;
140+
}
141+
142+
.vjs-shortcuts-overlay--expanded .vjs-shortcuts-card {
143+
max-width: 720px;
144+
width: min(92vw, 720px);
145+
}
146+
147+
@media (max-width: 720px) {
148+
.vjs-shortcuts-card {
149+
max-height: calc(100% - 16px);
150+
padding: 12px 12px;
151+
width: calc(100% - 24px);
152+
}
153+
154+
.vjs-shortcuts-overlay--expanded .vjs-shortcuts-body {
155+
grid-template-columns: 1fr;
156+
}
157+
158+
.vjs-shortcuts-title {
159+
font-size: 14px;
160+
}
161+
162+
.vjs-shortcuts-close,
163+
.vjs-shortcuts-toggle {
164+
font-size: 11px;
165+
padding: 5px 8px;
166+
}
167+
168+
.vjs-shortcuts-item {
169+
gap: 4px;
170+
grid-template-columns: minmax(88px, 120px) 1fr;
171+
}
172+
173+
.vjs-shortcuts-action {
174+
font-size: 11px;
175+
}
176+
}
177+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import videojs from 'video.js';
2+
import { VJS_COMP } from 'constants/player';
3+
4+
const SettingMenuItem = videojs.getComponent('SettingMenuItem');
5+
6+
class KeyboardShortcutsMenuItem extends SettingMenuItem {
7+
constructor(player, options) {
8+
super(player, {
9+
...options,
10+
label: __('Keyboard shortcuts'),
11+
name: VJS_COMP.KEYBOARD_SHORTCUTS_MENU_ITEM,
12+
icon: '',
13+
});
14+
15+
this.addClass('vjs-setting-keyboard-shortcuts');
16+
}
17+
18+
createEl() {
19+
const { icon, label } = this.options_;
20+
return videojs.dom.createEl('li', {
21+
className: 'vjs-menu-item vjs-setting-menu-item',
22+
innerHTML: `
23+
<div class="vjs-icon-placeholder ${icon || ''}"></div>
24+
<div class="vjs-setting-menu-label">${this.localize(label)}</div>
25+
<div class="vjs-spacer"></div>
26+
`,
27+
});
28+
}
29+
30+
handleClick() {
31+
const player = this.player_;
32+
if (player && typeof player.toggleKeyboardShortcutsOverlay === 'function') {
33+
player.toggleKeyboardShortcutsOverlay(true);
34+
}
35+
36+
const menuButton = this.menu && this.menu.options_ && this.menu.options_.menuButton;
37+
if (menuButton && typeof menuButton.hideMenu === 'function') {
38+
menuButton.hideMenu();
39+
}
40+
}
41+
}
42+
43+
videojs.registerComponent(VJS_COMP.KEYBOARD_SHORTCUTS_MENU_ITEM, KeyboardShortcutsMenuItem);
44+
export default KeyboardShortcutsMenuItem;

0 commit comments

Comments
 (0)