Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial keyboard shortcut support #263

Merged
merged 14 commits into from
Jul 22, 2022
Merged
50 changes: 50 additions & 0 deletions docs/media-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ Example (disabling gestures via `gestures-disabled`):
</media-controller>
```

- `nohotkeys` - Use this to turn off keyboard shortcuts.

Example (hotkeys disabled):

```html
<media-controller nohotkeys>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

<video
slot="media"
src="https://stream.mux.com/DS00Spx1CV902MCtPj5WknGlR102V5HFkDe/high.mp4"
></video>
<media-control-bar>
<media-seek-backward-button></media-seek-backward-button>
<media-play-button></media-play-button>
<media-seek-forward-button></media-seek-forward-button>
<media-mute-button></media-mute-button>
<media-volume-range></media-volume-range>
<media-time-range></media-time-range>
<media-time-display></media-time-display>
</media-control-bar>
</media-controller>
```

# Styling

- `aspect-ratio` - While this is [a standard CSS style](https://css-tricks.com/almanac/properties/a/aspect-ratio/), it's fairly new, and you're likely to want to use it frequently on `<media-controller/>`, at least for `video` use cases. Most often, you'll want the `aspect-ratio` to match your video content's aspect ratio.
Expand Down Expand Up @@ -272,3 +294,31 @@ As we work through other common use cases, both internally and with the communit
- Example use cases:
- [Basic example](https://media-chrome.mux.dev/examples/basic.html) ([view source](https://github.com/muxinc/media-chrome/blob/main/examples/basic.html))
- [Spotify theme](https://media-chrome.mux.dev/examples/themes/spotify-theme.html) ([view source](https://github.com/muxinc/media-chrome/blob/main/examples/themes/spotify-theme.html))

## Keyboard Shortcuts

By default, Media Controller has keyboard shortcuts that will trigger behavior when specific keys are pressed when the focus is inside the Media Controller.
The following controls are supported:
| Key | Behavior |
|---------|-------------------|
| Space | Toggle Playback |
| `k` | Toggle Playback |
| `m` | Toggle mute |
| `f` | Toggle fullscreen |
| ⬅ | Seek back 10s |
| ➡ | Seek forward 10s |

If you are implementing an interactive element that uses any of these keys, you can stopPropagation in your `keyup` handler. Alternatively, you can add a `keysUsed` property on the element or a `keysused` attribute. The values are those that match the `key` property on the KeyboardEvent. You can find a list of those values [on mdn](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values). Additionally, since the DOM list can't have the Space key represented as `" "`, we will accept `Space` as an alternative name for it.
Example (`keysused` attribute):
```html
<media-time-range keysused="ArrowLeft ArrowRight Space"></media-time-range>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😎

```

Example (`keysUsed` property):
```js
class MyInteractiveElement extends window.HTMLElement {
get keysUsed() {
return ['Enter', ' '];
}
}
```
10 changes: 6 additions & 4 deletions src/js/media-chrome-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ template.innerHTML = `
</style>
`;

const ButtonPressedKeys = ['Enter', ' '];

class MediaChromeButton extends window.HTMLElement {
static get observedAttributes() {
return [MediaUIAttributes.MEDIA_CONTROLLER];
Expand Down Expand Up @@ -107,7 +105,7 @@ class MediaChromeButton extends window.HTMLElement {
// but this should be good enough for most use cases.
const keyUpHandler = (e) => {
const { key } = e;
if (!ButtonPressedKeys.includes(key)) {
if (!this.keysUsed.includes(key)) {
this.removeEventListener('keyup', keyUpHandler);
return;
}
Expand All @@ -117,7 +115,7 @@ class MediaChromeButton extends window.HTMLElement {

this.addEventListener('keydown', (e) => {
const { metaKey, altKey, key } = e;
if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
if (metaKey || altKey || !this.keysUsed.includes(key)) {
this.removeEventListener('keyup', keyUpHandler);
return;
}
Expand Down Expand Up @@ -161,6 +159,10 @@ class MediaChromeButton extends window.HTMLElement {
}
}

get keysUsed() {
return ['Enter', ' '];
}

handleClick() {}
}

Expand Down
4 changes: 4 additions & 0 deletions src/js/media-chrome-range.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ class MediaChromeRange extends window.HTMLElement {

return colorArray;
}

get keysUsed() {
return ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'];
}
}

defineCustomElement('media-chrome-range', MediaChromeRange);
Expand Down
144 changes: 140 additions & 4 deletions src/js/media-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@ const {
MEDIA_PLAYBACK_RATE_REQUEST,
} = MediaUIEvents;

const ButtonPressedKeys = ['Enter', ' ', 'f', 'm', 'k', 'ArrowLeft', 'ArrowRight'];
const DEFAULT_SEEK_OFFSET = 10;

/**
* Media Controller should not mimic the HTMLMediaElement API.
* @see https://github.com/muxinc/media-chrome/pull/182#issuecomment-1067370339
*/
Comment on lines +51 to +54
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved from the bottom of the class here, which seems more appropriate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class MediaController extends MediaContainer {
static get observedAttributes() {
return super.observedAttributes.concat('nohotkeys');
}

constructor() {
super();

Expand Down Expand Up @@ -477,6 +488,20 @@ class MediaController extends MediaContainer {
);
},
};

this.enableHotkeys();
}

attributeChangedCallback(attrName, oldValue, newValue) {
if (attrName === 'nohotkeys') {
if (newValue !== oldValue && newValue === '') {
this.disableHotkeys();
} else if (newValue !== oldValue && newValue === null) {
this.enableHotkeys();
}
}

super.attributeChangedCallback(attrName, oldValue, newValue);
}

mediaSetCallback(media) {
Expand Down Expand Up @@ -710,10 +735,121 @@ class MediaController extends MediaContainer {
els.splice(index, 1);
}

/**
* Media Controller should not mimic the HTMLMediaElement API.
* @see https://github.com/muxinc/media-chrome/pull/182#issuecomment-1067370339
*/
#keyUpHandler(e) {
const { key } = e;
if (!ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', this.#keyUpHandler);
return;
}

this.keyboardShortcutHandler(e);
}

#keyDownHandler(e) {
const { metaKey, altKey, key } = e;
if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', this.#keyUpHandler);
return;
}
this.addEventListener('keyup', this.#keyUpHandler);
}

enableHotkeys() {
this.addEventListener('keydown', this.#keyDownHandler);
}

disableHotkeys() {
this.removeEventListener('keydown', this.#keyDownHandler);
this.removeEventListener('keyup', this.#keyUpHandler);
}

keyboardShortcutHandler(e) {
// if the event's key is already handled by the target, skip keyboard shortcuts
// keysUsed is either an attribute or a property.
// The attribute is a DOM array and the property is a JS array
// In the attribute Space represents the space key and gets convered to ' '
const keysUsed = (e.target.getAttribute('keysused')?.split(' ') ?? e.target?.keysUsed ?? [])
.map(key => key === 'Space' ? ' ' : key)
.filter(Boolean);

if (keysUsed.includes(e.key)) {
return;
}

let eventName, currentTimeStr, currentTime, detail, evt;
const seekOffset = DEFAULT_SEEK_OFFSET;

// These event triggers were copied from the revelant buttons
switch (e.key) {
case ' ':
case 'k':
eventName =
this.getAttribute(MediaUIAttributes.MEDIA_PAUSED) != null
? MediaUIEvents.MEDIA_PLAY_REQUEST
: MediaUIEvents.MEDIA_PAUSE_REQUEST;
this.dispatchEvent(
new window.CustomEvent(eventName, { composed: true, bubbles: true })
);
break;

case 'm':
eventName =
this.getAttribute(MediaUIAttributes.MEDIA_VOLUME_LEVEL) === 'off'
? MediaUIEvents.MEDIA_UNMUTE_REQUEST
: MediaUIEvents.MEDIA_MUTE_REQUEST;
this.dispatchEvent(
new window.CustomEvent(eventName, { composed: true, bubbles: true })
);
break;

case 'f':
eventName =
this.getAttribute(MediaUIAttributes.MEDIA_IS_FULLSCREEN) != null
? MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST
: MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST;
this.dispatchEvent(
new window.CustomEvent(eventName, { composed: true, bubbles: true })
);
break;

case 'ArrowLeft':
currentTimeStr = this.getAttribute(
MediaUIAttributes.MEDIA_CURRENT_TIME
);
currentTime =
currentTimeStr && !Number.isNaN(+currentTimeStr)
? +currentTimeStr
: DEFAULT_TIME;
detail = Math.max(currentTime - seekOffset, 0);
evt = new window.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
});
this.dispatchEvent(evt);
break;

case 'ArrowRight':
currentTimeStr = this.getAttribute(
MediaUIAttributes.MEDIA_CURRENT_TIME
);
currentTime =
currentTimeStr && !Number.isNaN(+currentTimeStr)
? +currentTimeStr
: DEFAULT_TIME;
detail = Math.max(currentTime + seekOffset, 0);
evt = new window.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, {
composed: true,
bubbles: true,
detail,
});
this.dispatchEvent(evt);
break;

default:
break;
}
}
}

const getPaused = (controller) => {
Expand Down