diff --git a/packages/griffith-utils/src/__tests__/ua.spec.ts b/packages/griffith-utils/src/__tests__/ua.spec.ts index 4db8c4b7..15784c58 100644 --- a/packages/griffith-utils/src/__tests__/ua.spec.ts +++ b/packages/griffith-utils/src/__tests__/ua.spec.ts @@ -2,14 +2,30 @@ * @jest-environment jsdom */ +import ua, {parseUA} from '../ua' + test('ua', () => { - Object.defineProperty(window.navigator, 'userAgent', { - value: - 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Mobile Safari/537.36', - }) - // eslint-disable-next-line @typescript-eslint/no-var-requires - const {isMobile, isAndroid, isSafari} = require('../ua') - expect(isMobile).toBe(true) - expect(isAndroid).toBe(true) - expect(isSafari).toBe(false) + expect(ua).toMatchInlineSnapshot(` + Object { + "isAndroid": false, + "isIE": false, + "isMobile": false, + "isSafari": false, + } + `) +}) + +test('parse', () => { + expect( + parseUA( + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Mobile Safari/537.36' + ) + ).toMatchInlineSnapshot(` + Object { + "isAndroid": true, + "isIE": false, + "isMobile": true, + "isSafari": false, + } + `) }) diff --git a/packages/griffith-utils/src/ua.ts b/packages/griffith-utils/src/ua.ts index 9d21803c..31f0faf2 100644 --- a/packages/griffith-utils/src/ua.ts +++ b/packages/griffith-utils/src/ua.ts @@ -1,13 +1,13 @@ -export const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) - -export const isAndroid = /(android)/i.test(navigator.userAgent) +export function parseUA(userAgent: string) { + return { + isIE: /MSIE|Trident/i.test(userAgent), + isMobile: /iPhone|iPad|iPod|Android/i.test(userAgent), + isAndroid: /(android)/i.test(userAgent), + isSafari: /^((?!chrome|android).)*safari/i.test(userAgent), + } +} -export const isSafari = /^((?!chrome|android).)*safari/i.test( - navigator.userAgent +export default parseUA( + // TODO: 加一个 context 让各处访问更好 + typeof navigator !== 'undefined' ? navigator.userAgent : '' ) - -export default { - isMobile, - isAndroid, - isSafari, -} diff --git a/packages/griffith/src/components/Controller.tsx b/packages/griffith/src/components/Controller.tsx index 86b8697d..a4e2f0a2 100644 --- a/packages/griffith/src/components/Controller.tsx +++ b/packages/griffith/src/components/Controller.tsx @@ -1,8 +1,6 @@ -import React, {useContext, useEffect, useRef, useState} from 'react' +import React, {useState} from 'react' import {css} from 'aphrodite/no-important' import clamp from 'lodash/clamp' -import * as displayIcons from './icons/display/index' -import * as controllerIcons from './icons/controller/index' import {ProgressDot} from '../types' import PlayButtonItem from './items/PlayButtonItem' import TimelineItem from './items/TimelineItem' @@ -16,8 +14,6 @@ import PlaybackRateMenuItem from './items/PlaybackRateMenuItem' import PageFullScreenButtonItem from './items/PageFullScreenButtonItem' import useHandler from '../hooks/useHandler' import useBoolean from '../hooks/useBoolean' -import {useActionToastDispatch} from './ActionToast' -import VideoSourceContext from '../contexts/VideoSourceContext' type ControllerProps = { standalone?: boolean @@ -31,11 +27,11 @@ type ControllerProps = { isPip: boolean onDragStart?: () => void onDragEnd?: () => void - onPlay?: () => void - onPause?: () => void + onTogglePlay?: () => void onSeek?: (currentTime: number) => void onQualityChange?: (...args: any[]) => any onVolumeChange?: (volume: number) => void + onToggleMuted?: () => void onToggleFullScreen?: () => void onTogglePageFullScreen?: () => void onTogglePip?: (...args: any[]) => void @@ -89,7 +85,6 @@ function Controller(props: ControllerProps) { onTogglePageFullScreen, onTogglePip, showPip, - standalone, progressDots, hiddenPlayButton, hiddenTimeline, @@ -101,166 +96,24 @@ function Controller(props: ControllerProps) { shouldShowPageFullScreenButton, onProgressDotHover, onProgressDotLeave, - onPause, - onPlay, + onTogglePlay, onSeek, + onToggleMuted, onVolumeChange, } = props - const {playbackRates, currentPlaybackRate, setCurrentPlaybackRate} = - useContext(VideoSourceContext) - const actionToastDispatch = useActionToastDispatch() + const [isVolumeHovered, isVolumeHoveredSwitch] = useBoolean() const [slideTime, setSlideTime] = useState() - const prevVolumeRef = useRef(1) - - const rotatePlaybackRate = (dir: 'next' | 'prev') => { - const index = playbackRates?.findIndex( - (x) => x.value === currentPlaybackRate.value - ) - if (index >= 0) { - const next = playbackRates[index + (dir === 'next' ? 1 : -1)] - if (next) { - actionToastDispatch({icon: displayIcons.play, label: next.text}) - setCurrentPlaybackRate(next) - } - } - } const handleDragMove = useHandler((slideTime: number) => { setSlideTime(clamp(slideTime, 0, duration)) }) - const handleTogglePlay = () => { - if (isPlaying) { - onPause?.() - } else { - onPlay?.() - } - } - const handleSeek = useHandler((currentTime: number) => { - currentTime = clamp(currentTime, 0, duration) - if (onSeek) { - onSeek(currentTime) - setSlideTime(void 0) - } + onSeek?.(clamp(currentTime, 0, duration)) + setSlideTime(void 0) }) - const handleVolumeChange = useHandler((value: number, showToast = false) => { - value = clamp(value, 0, 1) - if (showToast) { - actionToastDispatch({ - icon: value ? controllerIcons.volume : controllerIcons.muted, - label: `${(value * 100).toFixed(0)}%`, - }) - } - onVolumeChange?.(value) - }) - - const handleToggleMuted = useHandler((showToast = false) => { - if (volume) { - prevVolumeRef.current = volume - } - handleVolumeChange(volume ? 0 : prevVolumeRef.current, showToast) - }) - - const handleKeyDown = useHandler((event: KeyboardEvent) => { - // 防止冲突,有修饰键按下时不触发自定义热键 - if (event.altKey || event.ctrlKey || event.metaKey) { - return - } - let handled = true - - switch (event.key) { - case ' ': - case 'k': - case 'K': - actionToastDispatch({ - icon: isPlaying ? displayIcons.pause : displayIcons.play, - }) - handleTogglePlay() - break - - case 'Enter': - case 'f': - case 'F': - onToggleFullScreen?.() - break - case 'Escape': - if (isPageFullScreen) { - onTogglePageFullScreen?.() - } - break - case 'ArrowLeft': - handleSeek(currentTime - 5) - break - - case 'ArrowRight': - handleSeek(currentTime + 5) - break - - case 'j': - case 'J': - handleSeek(currentTime - 10) - break - - case 'l': - case 'L': - handleSeek(currentTime + 10) - break - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - handleSeek((duration / 10) * Number(event.key)) - break - - case 'm': - case 'M': - handleToggleMuted(true) - break - - case 'ArrowUp': - // 静音状态下调整可能不切换为非静音更好(设置一成临时的,切换后再应用临时状态) - handleVolumeChange(volume + 0.05, true) - break - - case 'ArrowDown': - handleVolumeChange(volume - 0.05, true) - break - - case '<': - rotatePlaybackRate('prev') - break - - case '>': - rotatePlaybackRate('next') - break - - default: - handled = false - break - } - if (handled) { - event.preventDefault() - } - }) - - useEffect(() => { - if (standalone) { - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('keydown', handleKeyDown) - } - } - }, [handleKeyDown, standalone]) - const displayedCurrentTime = slideTime || currentTime return ( @@ -284,7 +137,7 @@ function Controller(props: ControllerProps) { {!hiddenPlayButton && ( handleTogglePlay()} + onClick={() => onTogglePlay?.()} /> )} {hiddenTimeline &&
} @@ -318,8 +171,8 @@ function Controller(props: ControllerProps) { menuShown={isVolumeHovered} onMouseEnter={isVolumeHoveredSwitch.on} onMouseLeave={isVolumeHoveredSwitch.off} - onToggleMuted={handleToggleMuted} - onChange={handleVolumeChange} + onToggleMuted={onToggleMuted} + onChange={onVolumeChange} /> )}
diff --git a/packages/griffith/src/components/Player.tsx b/packages/griffith/src/components/Player.tsx index 4a98781e..1cc82f73 100644 --- a/packages/griffith/src/components/Player.tsx +++ b/packages/griffith/src/components/Player.tsx @@ -28,7 +28,7 @@ import ObjectFitProvider from '../contexts/ObjectFitProvider' import LocaleProvider from '../contexts/LocaleProvider' import TranslatedText from './TranslatedText' import Icon from './Icon' -import * as icons from './icons/display/index' +import * as displayIcons from './icons/display/index' import Loader from './Loader' import Video from './Video' import Controller from './Controller' @@ -46,8 +46,10 @@ import { import styles, {hiddenOrShownStyle} from './Player.styles' import useBoolean from '../hooks/useBoolean' import useMount from '../hooks/useMount' +import useHandler from '../hooks/useHandler' +import usePlayerShortcuts from './usePlayerShortcuts' + const CONTROLLER_HIDE_DELAY = 3000 -const {isMobile} = ua // 被 Provider 包装后的属性 type InnerPlayerProps = { @@ -137,7 +139,7 @@ const InnerPlayer: React.FC = ({ }) => { const {emitEvent, subscribeAction} = useContext(InternalMessageContext) const {currentSrc} = useContext(VideoSourceContext) - const rootRef = useRef(null) + const [root, setRoot] = useState(null) const videoRef = useRef<{ root: HTMLVideoElement seek(currentTime: number): void @@ -158,7 +160,7 @@ const InnerPlayer: React.FC = ({ const [isControllerDragging, isControllerDraggingSwitch] = useBoolean() const [hovered, hoveredSwitch] = useBoolean() const [pressed, pressedSwitch] = useBoolean() - const [isEnterPageFullScreen, isEnterPageFullScreenSwitch] = useBoolean() + const [isPageFullScreen, isPageFullScreenSwitch] = useBoolean() const [isLoading, isLoadingSwitch] = useBoolean() useEffect(() => { @@ -255,13 +257,9 @@ const InnerPlayer: React.FC = ({ const handleClickToTogglePlay = () => { // 仅点击覆盖层触发提示(控制条上的按钮点击不需要) actionToastDispatch({ - icon: isPlaying ? icons.pause : icons.play, + icon: isPlaying ? displayIcons.pause : displayIcons.play, }) - if (isPlaying) { - handlePause() - } else { - handlePlay() - } + handleTogglePlay() } const handlePlay = () => { @@ -275,7 +273,7 @@ const InnerPlayer: React.FC = ({ isLoadingSwitch.on() } // workaround a bug in IE about replaying a video. - if (currentTime !== 0) { + if (ua.isIE && currentTime !== 0) { handleSeek(0) } } @@ -334,17 +332,17 @@ const InnerPlayer: React.FC = ({ setCurrentTime(currentTime) } - const handleVideoVolumeChange = (volume: number) => { + const handleVideoVolumeChange = useHandler((volume: number) => { volume = Math.round(volume * 100) / 100 setVolume(volume) storage.set('@griffith/history-volume', volume) - } + }) - const handleSeek = (value: number) => { + const handleSeek = useHandler((value: number) => { setCurrentTime(value) // TODO 想办法去掉这个实例方法调用 videoRef.current?.seek(value) - } + }) const handleLoadingChange = (value: boolean) => { value ? isLoadingSwitch.on() : isLoadingSwitch.off() @@ -363,7 +361,7 @@ const InnerPlayer: React.FC = ({ setBuffered(value) } - const handleToggleFullScreen = () => { + const handleToggleFullScreen = useHandler(() => { if (BigScreen.enabled) { const onEnter = () => { return emitEvent(EVENTS.ENTER_FULLSCREEN) @@ -371,31 +369,31 @@ const InnerPlayer: React.FC = ({ const onExit = () => { return emitEvent(EVENTS.EXIT_FULLSCREEN) } - BigScreen?.toggle(rootRef.current!, onEnter, onExit) + BigScreen?.toggle(root!, onEnter, onExit) } - } + }) - const handleTogglePageFullScreen = () => { + const handleTogglePageFullScreen = useHandler(() => { // 如果当前正在全屏就先关闭全屏 if (Boolean(BigScreen.element) && !Pip.pictureInPictureElement) { handleToggleFullScreen() } - if (isEnterPageFullScreen) { - isEnterPageFullScreenSwitch.off() + if (isPageFullScreen) { + isPageFullScreenSwitch.off() emitEvent(EVENTS.EXIT_PAGE_FULLSCREEN) } else { - isEnterPageFullScreenSwitch.on() + isPageFullScreenSwitch.on() emitEvent(EVENTS.ENTER_PAGE_FULLSCREEN) } - } + }) - const handleTogglePip = () => { - if (isEnterPageFullScreen) { - isEnterPageFullScreenSwitch.off() + const handleTogglePip = useHandler(() => { + if (isPageFullScreen) { + isPageFullScreenSwitch.off() emitEvent(EVENTS.EXIT_PAGE_FULLSCREEN) } Pip.toggle() - } + }) const hideControllerTimerRef = useRef( null @@ -449,6 +447,38 @@ const InnerPlayer: React.FC = ({ emitEvent(EVENTS.LEAVE_PROGRESS_DOT) } + const handleTogglePlay = useHandler(() => { + if (isPlaying) { + handlePause() + } else { + handlePlay() + } + }) + + const prevVolumeRef = useRef(volume) + const handleToggleMuted = useHandler(() => { + if (volume) { + prevVolumeRef.current = volume + } + handleVideoVolumeChange(volume ? 0 : prevVolumeRef.current) + }) + + usePlayerShortcuts({ + root, + prevVolumeRef, + isPlaying, + volume, + currentTime, + duration, + standalone, + isPageFullScreen, + onTogglePlay: handleTogglePlay, + onToggleFullScreen: handleToggleFullScreen, + onTogglePageFullScreen: handleTogglePageFullScreen, + onVolumeChange: handleVideoVolumeChange, + onSeek: handleSeek, + }) + const isPip = Boolean(Pip.pictureInPictureElement) // Safari 会将 pip 状态视为全屏 const isFullScreen = Boolean(BigScreen.element) && !isPip @@ -459,14 +489,13 @@ const InnerPlayer: React.FC = ({ const videoDataLoaded = !isLoading || currentTime !== 0 const renderController = videoDataLoaded && isPlaybackStarted - const controlsOverlay = !isMobile && ( + const controlsOverlay = !ua.isMobile && (
{isPlaybackStarted && isLoading && (
)} -
{ @@ -535,12 +564,12 @@ const InnerPlayer: React.FC = ({ progressDots={progressDots} buffered={bufferedTime} isFullScreen={isFullScreen} - isPageFullScreen={isEnterPageFullScreen} + isPageFullScreen={isPageFullScreen} isPip={isPip} onDragStart={isControllerDraggingSwitch.on} onDragEnd={isControllerDraggingSwitch.off} - onPlay={handlePlay} - onPause={handlePause} + onTogglePlay={handleTogglePlay} + onToggleMuted={handleToggleMuted} onSeek={handleSeek} onVolumeChange={handleVideoVolumeChange} onToggleFullScreen={handleToggleFullScreen} @@ -569,19 +598,21 @@ const InnerPlayer: React.FC = ({ className={css( styles.root, isFullScreen && styles.fullScreened, - isEnterPageFullScreen && styles.pageFullScreen + isPageFullScreen && styles.pageFullScreen )} onMouseLeave={handleMouseLeave} onMouseEnter={handleMouseEnter} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onMouseMove={handleShowController} - ref={rootRef} + ref={setRoot} + tabIndex={-1} + aria-label={title} >
@@ -659,9 +690,10 @@ const InnerPlayer: React.FC = ({
)} {controlsOverlay} + {error && (
- + {error.message && (
{error.message}
)} diff --git a/packages/griffith/src/components/usePlayerShortcuts.ts b/packages/griffith/src/components/usePlayerShortcuts.ts new file mode 100644 index 00000000..ba738127 --- /dev/null +++ b/packages/griffith/src/components/usePlayerShortcuts.ts @@ -0,0 +1,171 @@ +import * as displayIcons from './icons/display/index' +import * as controllerIcons from './icons/controller/index' +import {useEffect, useContext} from 'react' +import clamp from 'lodash/clamp' +import useHandler from '../hooks/useHandler' +import {useActionToastDispatch} from './ActionToast' +import VideoSourceContext from '../contexts/VideoSourceContext' + +type Options = { + root: HTMLDivElement | null + prevVolumeRef: React.MutableRefObject + isPlaying: boolean + isPageFullScreen: boolean + duration: number + volume: number + currentTime: number + standalone?: boolean + onVolumeChange: (value: number) => void + onTogglePlay: () => void + onToggleFullScreen: () => void + onTogglePageFullScreen: () => void + onSeek: (currentTime: number) => void +} + +const usePlayerShortcuts = ({ + root, + prevVolumeRef, + isPlaying, + isPageFullScreen, + volume, + duration, + currentTime, + standalone, + onVolumeChange, + onTogglePlay, + onToggleFullScreen, + onTogglePageFullScreen, + onSeek, +}: Options) => { + const actionToastDispatch = useActionToastDispatch() + const {playbackRates, currentPlaybackRate, setCurrentPlaybackRate} = + useContext(VideoSourceContext) + + const rotatePlaybackRate = (dir: 'next' | 'prev') => { + const index = playbackRates?.findIndex( + (x) => x.value === currentPlaybackRate.value + ) + if (index >= 0) { + const next = playbackRates[index + (dir === 'next' ? 1 : -1)] + if (next) { + actionToastDispatch({icon: displayIcons.play, label: next.text}) + setCurrentPlaybackRate(next) + } + } + } + + const handleVolumeChange = useHandler((value: number, showToast = false) => { + value = clamp(value, 0, 1) + if (showToast) { + actionToastDispatch({ + icon: value ? controllerIcons.volume : controllerIcons.muted, + label: `${(value * 100).toFixed(0)}%`, + }) + } + onVolumeChange?.(value) + }) + + const handleSeek = useHandler((currentTime: number) => { + onSeek(clamp(currentTime, 0, duration)) + }) + + const handleKeyDown = useHandler((event: KeyboardEvent) => { + // 防止冲突,有修饰键按下时不触发自定义热键 + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + + let handled = true + switch (event.key) { + case ' ': + case 'k': + case 'K': + actionToastDispatch({ + icon: isPlaying ? displayIcons.pause : displayIcons.play, + }) + onTogglePlay() + break + + case 'Enter': + case 'f': + case 'F': + onToggleFullScreen() + break + case 'Escape': + if (isPageFullScreen) { + onTogglePageFullScreen() + } + break + case 'ArrowLeft': + handleSeek(currentTime - 5) + break + + case 'ArrowRight': + handleSeek(currentTime + 5) + break + + case 'j': + case 'J': + handleSeek(currentTime - 10) + break + + case 'l': + case 'L': + handleSeek(currentTime + 10) + break + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + handleSeek((duration / 10) * Number(event.key)) + break + + case 'm': + case 'M': + handleVolumeChange(volume ? 0 : prevVolumeRef.current, true) + break + + case 'ArrowUp': + // 静音状态下调整可能不切换为非静音更好(设置一成临时的,切换后再应用临时状态) + handleVolumeChange(volume + 0.05, true) + break + + case 'ArrowDown': + handleVolumeChange(volume - 0.05, true) + break + + case '<': + rotatePlaybackRate('prev') + break + + case '>': + rotatePlaybackRate('next') + break + + default: + handled = false + break + } + if (handled) { + event.preventDefault() + } + }) + + useEffect(() => { + const el = standalone ? document.body : root + if (el) { + el.addEventListener('keydown', handleKeyDown) + return () => { + el.removeEventListener('keydown', handleKeyDown) + } + } + }, [handleKeyDown, root, standalone]) +} + +export default usePlayerShortcuts diff --git a/packages/griffith/src/utils/parseUA.ts b/packages/griffith/src/utils/parseUA.ts new file mode 100644 index 00000000..7e80745a --- /dev/null +++ b/packages/griffith/src/utils/parseUA.ts @@ -0,0 +1,8 @@ +export default function parseUA(userAgent: string) { + return { + isIE: /MSIE|Trident/i.test(userAgent), + isMobile: /iPhone|iPad|iPod|Android/i.test(userAgent), + isAndroid: /(android)/i.test(userAgent), + isSafari: /^((?!chrome|android).)*safari/i.test(userAgent), + } +}