Skip to content

Commit

Permalink
feat(player): new keyboard action display in default layout
Browse files Browse the repository at this point in the history
  • Loading branch information
mihar-22 committed Dec 26, 2023
1 parent 698e575 commit 52890b0
Show file tree
Hide file tree
Showing 20 changed files with 493 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
- new `crossOrigin` prop on slider video component ([d699c7b](https://github.com/vidstack/player/commit/d699c7b9eb6a583a052f60e562853f7b727f7c5c))
- new `crossOrigin` prop on thumbnail components ([fa9ee2d](https://github.com/vidstack/player/commit/fa9ee2d26465b2c22e081f605849ccfca2a9f102))
- support new thumbnail src types ([842f7c4](https://github.com/vidstack/player/commit/842f7c422b44842bda48a5e9c5a39cb5572861e8))
- new keyboard action display in default layout ([4cc543f](https://github.com/vidstack/player/commit/4cc543f986551c999f0b22fd1e434a1e6df05273))

#### Player (React)

Expand Down
13 changes: 9 additions & 4 deletions packages/react/src/components/layouts/default/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,28 @@ import type { DefaultLayoutIcons } from './icons';
export const DefaultLayoutContext = React.createContext<DefaultLayoutContext>({} as any);

interface DefaultLayoutContext {
thumbnails: ThumbnailSrc | null;
thumbnails: ThumbnailSrc;
menuContainer?: React.RefObject<HTMLElement | null>;
translations?: DefaultLayoutTranslations | null;
isSmallLayout: boolean;
showMenuDelay?: number;
showTooltipDelay?: number;
hideQualityBitrate?: boolean;
showTooltipDelay: number;
hideQualityBitrate: boolean;
menuGroup: 'top' | 'bottom';
noModal: boolean;
Icons: DefaultLayoutIcons;
slots?: unknown;
sliderChaptersMinWidth: number;
disableTimeSlider: boolean;
noGestures?: boolean;
noGestures: boolean;
noKeyboardActionDisplay: boolean;
}

export function useDefaultLayoutLang(word: keyof DefaultLayoutTranslations) {
const { translations } = React.useContext(DefaultLayoutContext);
return i18n(translations, word);
}

export function i18n(translations: any, word: string) {
return translations?.[word] ?? word;
}
26 changes: 26 additions & 0 deletions packages/react/src/components/layouts/default/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ export const defaultLayoutIcons: DefaultLayoutIcons = {
Settings: createIcon(settingsIconPaths),
Speed: createIcon(odometerIconPaths),
},
KeyboardAction: {
Play: createIcon(playIconPaths),
Pause: createIcon(pauseIconPaths),
Mute: createIcon(muteIconPaths),
VolumeUp: createIcon(volumeHighIconPaths),
VolumeDown: createIcon(volumeLowIconPaths),
EnterFullscreen: createIcon(enterFullscreenIconPaths),
ExitFullscreen: createIcon(exitFullscreenIconPaths),
EnterPiP: createIcon(enterPIPIconPaths),
ExitPiP: createIcon(exitPIPIconPaths),
CaptionsOn: createIcon(ccOnIconPaths),
CaptionsOff: createIcon(ccIconPaths),
},
};

export interface DefaultLayoutIconProps
Expand Down Expand Up @@ -116,4 +129,17 @@ export interface DefaultLayoutIcons {
Settings: DefaultLayoutIcon;
Speed: DefaultLayoutIcon;
};
KeyboardAction?: {
Play: DefaultLayoutIcon;
Pause: DefaultLayoutIcon;
Mute: DefaultLayoutIcon;
VolumeUp: DefaultLayoutIcon;
VolumeDown: DefaultLayoutIcon;
EnterFullscreen: DefaultLayoutIcon;
ExitFullscreen: DefaultLayoutIcon;
EnterPiP: DefaultLayoutIcon;
ExitPiP: DefaultLayoutIcon;
CaptionsOn: DefaultLayoutIcon;
CaptionsOff: DefaultLayoutIcon;
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as React from 'react';

import { computed, effect, scoped, useContext } from 'maverick.js';
import { useReactScope, useSignal } from 'maverick.js/react';
import { camelToKebabCase } from 'maverick.js/std';
import { mediaContext, type DefaultLayoutTranslations } from 'vidstack';

import { useMediaState } from '../../../hooks/use-media-state';
import { DefaultLayoutContext, i18n } from './context';
import type { DefaultLayoutIcons } from './icons';

function DefaultVideoKeyboardActionDisplay() {
const scope = useReactScope(),
{ translations, Icons, noKeyboardActionDisplay } = React.useContext(DefaultLayoutContext),
[visible, setVisible] = React.useState(false),
[Icon, setIcon] = React.useState<any>(null),
$lastKeyboardAction = useMediaState('lastKeyboardAction');

const actionDataAttr = React.useMemo(() => {
const action = $lastKeyboardAction?.action;
return action && visible ? camelToKebabCase(action) : null;
}, [visible, $lastKeyboardAction]);

const className = React.useMemo(() => `vds-kb-action${!visible ? ' hidden' : ''}`, [visible]);

const $$text = React.useMemo(() => scoped(() => computed(getText), scope)!, [scope]),
$text = useSignal($$text);

const $$statusLabel = React.useMemo(
() => scoped(() => computed(() => getStatusLabel(translations)), scope)!,
[scope, translations],
),
$statusLabel = useSignal($$statusLabel);

React.useEffect(() => {
scoped(() => {
effect(() => {
const Icon = getIcon(Icons.KeyboardAction);
setIcon(() => Icon);
});
}, scope);
}, [scope, Icons]);

React.useEffect(() => {
setVisible(!!$lastKeyboardAction);
const id = setTimeout(() => setVisible(false), 500);
return () => {
setVisible(false);
window.clearTimeout(id);
};
}, [$lastKeyboardAction]);

if (noKeyboardActionDisplay) return null;

return (
<div className={className} data-action={actionDataAttr}>
<div className="vds-kb-text-wrapper">
<div className="vds-kb-text">{$text}</div>
</div>
{Icon ? (
<div className="vds-kb-bezel" role="status" aria-label={$statusLabel}>
<div className="vds-kb-icon">
<Icon />
</div>
</div>
) : null}
</div>
);
}

DefaultVideoKeyboardActionDisplay.displayName = 'DefaultVideoKeyboardActionDisplay';
export { DefaultVideoKeyboardActionDisplay };

function getText() {
const { $state } = useContext(mediaContext),
action = $state.lastKeyboardAction()?.action;
switch (action) {
case 'toggleMuted':
return $state.muted() ? '0%' : getVolumeText($state.volume());
case 'volumeUp':
case 'volumeDown':
return getVolumeText($state.volume());
default:
return '';
}
}

function getVolumeText(volume: number) {
return `${Math.round(volume * 100)}%`;
}

function getIcon(Icons?: DefaultLayoutIcons['KeyboardAction']) {
const { $state } = useContext(mediaContext),
action = $state.lastKeyboardAction()?.action;
switch (action) {
case 'togglePaused':
return !$state.paused() ? Icons?.Play : Icons?.Pause;
case 'toggleMuted':
return $state.muted() || $state.volume() === 0
? Icons?.Mute
: $state.volume() >= 0.5
? Icons?.VolumeUp
: Icons?.VolumeDown;
case 'toggleFullscreen':
return $state.fullscreen() ? Icons?.EnterFullscreen : Icons?.ExitFullscreen;
case 'togglePictureInPicture':
return $state.pictureInPicture() ? Icons?.EnterPiP : Icons?.ExitPiP;
case 'toggleCaptions':
return $state.hasCaptions()
? $state.textTrack()
? Icons?.CaptionsOn
: Icons?.CaptionsOff
: null;
case 'volumeUp':
return Icons?.VolumeUp;
case 'volumeDown':
return Icons?.VolumeDown;
default:
return null;
}
}

function getStatusLabel(translations?: DefaultLayoutTranslations | null) {
const text = getStatusText(translations);
return text ? i18n(translations, text) : null;
}

function getStatusText(translations?: DefaultLayoutTranslations | null): any {
const { $state } = useContext(mediaContext),
action = $state.lastKeyboardAction()?.action;
switch (action) {
case 'togglePaused':
return !$state.paused() ? 'Play' : 'Pause';
case 'toggleFullscreen':
return $state.fullscreen() ? 'Enter Fullscreen' : 'Exit Fullscreen';
case 'togglePictureInPicture':
return $state.pictureInPicture() ? 'Enter PiP' : 'Exit PiP';
case 'toggleCaptions':
return $state.textTrack() ? 'Closed-Captions On' : 'Closed-Captions Off';
case 'toggleMuted':
case 'volumeUp':
case 'volumeDown':
return $state.muted() || $state.volume() === 0
? 'Mute'
: `${Math.round($state.volume() * 100)}% ${i18n(translations, 'Volume')}`;
default:
return null;
}
}
12 changes: 9 additions & 3 deletions packages/react/src/components/layouts/default/shared-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export interface DefaultMediaLayoutProps<Slots = unknown> extends PrimitiveProps
*
* @see {@link https://www.vidstack.io/docs/player/core-concepts/loading#thumbnails}
*/
thumbnails?: ThumbnailSrc | null;
thumbnails?: ThumbnailSrc;
/**
* Translation map from english to your desired language for words used throughout the layout.
*/
Expand Down Expand Up @@ -134,6 +134,10 @@ export interface DefaultMediaLayoutProps<Slots = unknown> extends PrimitiveProps
* Whether all gestures such as pressing to play or seek should not be active.
*/
noGestures?: boolean;
/**
* Whether keyboard actions should not be displayed.
*/
noKeyboardActionDisplay?: boolean;
}

export interface CreateDefaultMediaLayout {
Expand Down Expand Up @@ -169,6 +173,7 @@ export function createDefaultMediaLayout({
sliderChaptersMinWidth = 600,
disableTimeSlider = false,
noGestures = false,
noKeyboardActionDisplay = false,
slots,
children,
...props
Expand Down Expand Up @@ -210,6 +215,7 @@ export function createDefaultMediaLayout({
sliderChaptersMinWidth,
disableTimeSlider,
noGestures,
noKeyboardActionDisplay,
Icons: icons,
}}
>
Expand Down Expand Up @@ -668,12 +674,12 @@ function DefaultSettingsMenu({ tooltip, placement, portalClass, slots }: Default
$$hasMenuItems = React.useMemo(
() =>
computed(() => {
const { canSetPlaybackRate, canSetQuality, qualities, audioTracks, textTracks } = $state;
const { canSetPlaybackRate, canSetQuality, qualities, audioTracks, hasCaptions } = $state;
return (
canSetPlaybackRate() ||
(canSetQuality() && qualities().length) ||
audioTracks().length ||
textTracks().filter(isTrackCaptionKind).length
hasCaptions()
);
}),
[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Gesture } from '../../ui/gesture';
import * as Spinner from '../../ui/spinner';
import { Time } from '../../ui/time';
import { DefaultLayoutContext } from './context';
import { DefaultVideoKeyboardActionDisplay } from './keyboard-action-display';
import {
createDefaultMediaLayout,
DefaultCaptionButton,
Expand Down Expand Up @@ -78,6 +79,7 @@ function DefaultVideoLargeLayout() {
return (
<>
<DefaultVideoGestures />
<DefaultVideoKeyboardActionDisplay />
{slot(slots, 'bufferingIndicator', <DefaultBufferingIndicator />)}
{slot(slots, 'captions', <Captions className="vds-captions" />)}
<Controls.Root className="vds-controls">
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/hooks/use-thumbnails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
* @docs {@link https://www.vidstack.io/docs/player/api/hooks/use-thumbnails}
*/
export function useThumbnails(
src: ThumbnailSrc | null,
src: ThumbnailSrc,
crossOrigin?: MediaCrossOrigin | null,
): ThumbnailImage[] {
const scope = useReactScope(),
Expand Down
3 changes: 2 additions & 1 deletion packages/vidstack/mangle.json
Original file line number Diff line number Diff line change
Expand Up @@ -652,5 +652,6 @@
"_processVTTCues": "bl",
"_resolveBaseUrl": "dl",
"_resolveData": "fl",
"_resolveURL": "gl"
"_resolveURL": "gl",
"_processImages": "jl"
}
Loading

0 comments on commit 52890b0

Please sign in to comment.