80
common/resources/client/player/ui/audio-player.tsx
Executable file
80
common/resources/client/player/ui/audio-player.tsx
Executable file
@@ -0,0 +1,80 @@
|
||||
import {Fragment} from 'react';
|
||||
import {PlayerContext} from '@common/player/player-context';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {PlayerOutlet} from '@common/player/ui/player-outlet';
|
||||
import {PlayButton} from '@common/player/ui/controls/play-button';
|
||||
import {VolumeControls} from '@common/player/ui/controls/volume-controls';
|
||||
import {Seekbar} from '@common/player/ui/controls/seeking/seekbar';
|
||||
import {FormattedCurrentTime} from '@common/player/ui/controls/formatted-current-time';
|
||||
import {FormattedPlayerDuration} from '@common/player/ui/controls/formatted-player-duration';
|
||||
import {PlaybackOptionsButton} from '@common/player/ui/controls/playback-options-button';
|
||||
import clsx from 'clsx';
|
||||
import {SeekButton} from '@common/player/ui/controls/seeking/seek-button';
|
||||
import {Forward10Icon} from '@common/icons/material/Forward10';
|
||||
import {UndoIcon} from '@common/icons/material/Undo';
|
||||
|
||||
const mediaItem: MediaItem = {
|
||||
id: 'test1',
|
||||
src: 'storage/title-videos/pLiHKnN3dXz0Ep0rVrgiZ4mSS0lyDV8fnrcwmDOE.mp4',
|
||||
poster: 'https://peach.blender.org/wp-content/uploads/bbb-splash.png',
|
||||
provider: 'htmlAudio',
|
||||
};
|
||||
|
||||
const mediaItem2: MediaItem = {
|
||||
id: 'test2',
|
||||
src: '0G3_kG5FFfQ',
|
||||
provider: 'youtube',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export function AudioPlayer({className}: Props) {
|
||||
return (
|
||||
<PlayerContext
|
||||
id="audio"
|
||||
options={{
|
||||
initialData: {
|
||||
queue: [mediaItem2, mediaItem],
|
||||
cuedMediaId: 'test1',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={clsx(className, 'shadow rounded')}>
|
||||
<Player />
|
||||
</div>
|
||||
</PlayerContext>
|
||||
);
|
||||
}
|
||||
|
||||
function Player() {
|
||||
return (
|
||||
<Fragment>
|
||||
<PlayerOutlet className="w-full h-full" />
|
||||
<Controls />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Controls() {
|
||||
return (
|
||||
<div className="flex items-center gap-24 p-14 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<SeekButton seconds="-15">
|
||||
<UndoIcon />
|
||||
</SeekButton>
|
||||
<PlayButton />
|
||||
<SeekButton seconds="+15">
|
||||
<Forward10Icon />
|
||||
</SeekButton>
|
||||
</div>
|
||||
<FormattedCurrentTime className="min-w-40 text-right" />
|
||||
<Seekbar fillColor="bg-black" trackColor="bg-black/20" />
|
||||
<FormattedPlayerDuration className="min-w-40 text-right" />
|
||||
<div className="flex items-center gap-4">
|
||||
<PlaybackOptionsButton />
|
||||
<VolumeControls fillColor="bg-black" trackColor="bg-black/20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
common/resources/client/player/ui/controls/buffering-spinner.tsx
Executable file
41
common/resources/client/player/ui/controls/buffering-spinner.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
ProgressCircle,
|
||||
ProgressCircleProps,
|
||||
} from '@common/ui/progress/progress-circle';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
trackColor?: string;
|
||||
fillColor?: string;
|
||||
size?: ProgressCircleProps['size'];
|
||||
}
|
||||
export function BufferingSpinner({
|
||||
className,
|
||||
trackColor,
|
||||
fillColor,
|
||||
size,
|
||||
}: Props) {
|
||||
const isActive = usePlayerStore(
|
||||
s =>
|
||||
// YouTube will already show a spinner, no need for a custom one
|
||||
(s.isBuffering && s.providerName !== 'youtube') ||
|
||||
(s.playbackStarted && !s.providerReady)
|
||||
);
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive && (
|
||||
<m.div {...opacityAnimation} className={className}>
|
||||
<ProgressCircle
|
||||
isIndeterminate
|
||||
trackColor={trackColor}
|
||||
fillColor={fillColor}
|
||||
size={size}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
19
common/resources/client/player/ui/controls/formatted-current-time.tsx
Executable file
19
common/resources/client/player/ui/controls/formatted-current-time.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {useCurrentTime} from '@common/player/hooks/use-current-time';
|
||||
import {FormattedDuration} from '@common/i18n/formatted-duration';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export function FormattedCurrentTime({className}: Props) {
|
||||
const duration = usePlayerStore(s => s.mediaDuration);
|
||||
const currentTime = useCurrentTime();
|
||||
return (
|
||||
<span className={className}>
|
||||
<FormattedDuration
|
||||
seconds={currentTime}
|
||||
addZeroToFirstUnit={duration >= 600}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
17
common/resources/client/player/ui/controls/formatted-player-duration.tsx
Executable file
17
common/resources/client/player/ui/controls/formatted-player-duration.tsx
Executable file
@@ -0,0 +1,17 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {FormattedDuration} from '@common/i18n/formatted-duration';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export function FormattedPlayerDuration({className}: Props) {
|
||||
const duration = usePlayerStore(s => s.mediaDuration);
|
||||
return (
|
||||
<span className={className}>
|
||||
<FormattedDuration
|
||||
seconds={duration}
|
||||
addZeroToFirstUnit={duration >= 600}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
61
common/resources/client/player/ui/controls/fullscreen-button.tsx
Executable file
61
common/resources/client/player/ui/controls/fullscreen-button.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaFullscreenExitIcon} from '@common/icons/media/media-fullscreen-exit';
|
||||
import {MediaFullscreenIcon} from '@common/icons/media/media-fullscreen';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function FullscreenButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const isFullscreen = usePlayerStore(s => s.isFullscreen);
|
||||
const canFullscreen = usePlayerStore(s => s.canFullscreen);
|
||||
|
||||
if (!canFullscreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelMessage = trans(
|
||||
isFullscreen
|
||||
? message('Exit fullscreen (f)')
|
||||
: message('Enter fullscreen (f)')
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message={labelMessage} />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={labelMessage}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (isFullscreen) {
|
||||
player.exitFullscreen();
|
||||
} else {
|
||||
player.enterFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFullscreen ? <MediaFullscreenExitIcon /> : <MediaFullscreenIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
46
common/resources/client/player/ui/controls/next-button.tsx
Executable file
46
common/resources/client/player/ui/controls/next-button.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaNextIcon} from '@common/icons/media/media-next';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
export function NextButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
stopPropagation,
|
||||
}: Props) {
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message="Next" />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={e => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
player.playNext();
|
||||
}}
|
||||
>
|
||||
<MediaNextIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
60
common/resources/client/player/ui/controls/pip-button.tsx
Executable file
60
common/resources/client/player/ui/controls/pip-button.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaPictureInPictureExitIcon} from '@common/icons/media/media-picture-in-picture-exit';
|
||||
import {MediaPictureInPictureIcon} from '@common/icons/media/media-picture-in-picture';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function PipButton({size = 'md', iconSize, color, className}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const isPip = usePlayerStore(s => s.isPip);
|
||||
const canPip = usePlayerStore(s => s.canPip);
|
||||
|
||||
if (!canPip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelMessage = trans(
|
||||
isPip
|
||||
? message('Exit picture-in-picture (p)')
|
||||
: message('Enter picture-in-picture (p)')
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message={labelMessage} />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={labelMessage}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (isPip) {
|
||||
player.exitPip();
|
||||
} else {
|
||||
player.enterPip();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPip ? (
|
||||
<MediaPictureInPictureExitIcon />
|
||||
) : (
|
||||
<MediaPictureInPictureIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
55
common/resources/client/player/ui/controls/play-button.tsx
Executable file
55
common/resources/client/player/ui/controls/play-button.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {MediaPauseIcon} from '@common/icons/media/media-pause';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
export function PlayButton({
|
||||
size = 'md',
|
||||
iconSize = 'xl',
|
||||
color,
|
||||
stopPropagation,
|
||||
}: Props) {
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const player = usePlayerActions();
|
||||
|
||||
const label = isPlaying ? (
|
||||
<Trans message="Pause (k)" />
|
||||
) : (
|
||||
<Trans message="Play (k)" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} usePortal={false}>
|
||||
<IconButton
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
disabled={!playerReady}
|
||||
onClick={e => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (isPlaying) {
|
||||
player.pause();
|
||||
} else {
|
||||
player.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <MediaPauseIcon /> : <MediaPlayIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
338
common/resources/client/player/ui/controls/playback-options-button.tsx
Executable file
338
common/resources/client/player/ui/controls/playback-options-button.tsx
Executable file
@@ -0,0 +1,338 @@
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ReactNode, useState} from 'react';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {Button, ButtonProps} from '@common/ui/buttons/button';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {ArrowRightIcon} from '@common/icons/material/ArrowRight';
|
||||
import {useDarkThemeVariables} from '@common/ui/themes/use-dark-theme-variables';
|
||||
import {MediaSettingsIcon} from '@common/icons/media/media-settings';
|
||||
import {MediaPlaybackSpeedCircleIcon} from '@common/icons/media/media-playback-speed-circle';
|
||||
import {MediaSettingsMenuIcon} from '@common/icons/media/media-settings-menu';
|
||||
import {MediaClosedCaptionsIcon} from '@common/icons/media/media-closed-captions';
|
||||
import {MediaArrowLeftIcon} from '@common/icons/media/media-arrow-left';
|
||||
import {MediaLanguageIcon} from '@common/icons/media/media-language';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
type OptionsPanel = 'rate' | 'quality' | 'captions' | 'options' | 'language';
|
||||
|
||||
const Panels = {
|
||||
rate: PlaybackRatePanel,
|
||||
quality: PlaybackQualityPanel,
|
||||
options: OptionsListPanel,
|
||||
captions: CaptionsPanel,
|
||||
language: LanguagePanel,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function PlaybackOptionsButton({
|
||||
color,
|
||||
size,
|
||||
iconSize,
|
||||
className,
|
||||
}: Props) {
|
||||
const darkThemeVars = useDarkThemeVariables();
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="popover"
|
||||
mobileType="tray"
|
||||
placement="top-end"
|
||||
usePortal={!!isMobile}
|
||||
>
|
||||
<IconButton
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
>
|
||||
<MediaSettingsIcon />
|
||||
</IconButton>
|
||||
<Dialog size="w-256" style={darkThemeVars}>
|
||||
<DialogBody padding="p-0">
|
||||
<PlaybackOptionsPanel />
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackOptionsPanel() {
|
||||
const [activePanel, setActivePanel] = useState<OptionsPanel>('options');
|
||||
const PanelComponent = Panels[activePanel];
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
<PanelComponent
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={setActivePanel}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionsPanelProps {
|
||||
activePanel: OptionsPanel;
|
||||
onActivePanelChange: (panel: OptionsPanel) => void;
|
||||
}
|
||||
function OptionsListPanel({onActivePanelChange}: OptionsPanelProps) {
|
||||
const activeRate = usePlayerStore(s => s.playbackRate);
|
||||
const availableQualities = usePlayerStore(s => s.playbackQualities);
|
||||
const activeQuality = usePlayerStore(s => s.playbackQuality);
|
||||
|
||||
const availableTextTracks = usePlayerStore(s => s.textTracks);
|
||||
const textTrackId = usePlayerStore(s => s.currentTextTrack);
|
||||
const currentTextTrack = availableTextTracks[textTrackId];
|
||||
|
||||
const availableAudioTracks = usePlayerStore(s => s.audioTracks);
|
||||
const audioTrackId = usePlayerStore(s => s.currentAudioTrack);
|
||||
const currentAudioTrack = availableAudioTracks[audioTrackId];
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{x: '-100%', opacity: 0}}
|
||||
animate={{x: 0, opacity: 1}}
|
||||
exit={{x: '100%', opacity: 0}}
|
||||
transition={{type: 'tween', duration: 0.14}}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
startIcon={<MediaPlaybackSpeedCircleIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2">
|
||||
{activeRate}x
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('rate')}
|
||||
>
|
||||
<Trans message="Speed" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
isDisabled={!availableQualities.length}
|
||||
startIcon={<MediaSettingsMenuIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{activeQuality ? activeQuality : <Trans message="Auto" />}
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('quality')}
|
||||
>
|
||||
<Trans message="Quality" />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
isDisabled={!availableTextTracks.length}
|
||||
startIcon={<MediaClosedCaptionsIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{currentTextTrack ? (
|
||||
currentTextTrack.label
|
||||
) : (
|
||||
<Trans message="None" />
|
||||
)}
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('captions')}
|
||||
>
|
||||
<Trans message="Subtitles/CC" />
|
||||
</ListItem>
|
||||
{availableAudioTracks.length > 1 && (
|
||||
<ListItem
|
||||
startIcon={<MediaLanguageIcon />}
|
||||
endSection={
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{currentAudioTrack ? (
|
||||
currentAudioTrack.label
|
||||
) : (
|
||||
<Trans message="None" />
|
||||
)}
|
||||
<ArrowRightIcon size="sm" />
|
||||
</div>
|
||||
}
|
||||
onSelected={() => onActivePanelChange('language')}
|
||||
>
|
||||
<Trans message="Language" />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackRatePanel({
|
||||
activePanel,
|
||||
onActivePanelChange,
|
||||
}: OptionsPanelProps) {
|
||||
const activeRate = usePlayerStore(s => s.playbackRate);
|
||||
const availableRates = usePlayerStore(s => s.playbackRates);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Playback speed" />}
|
||||
>
|
||||
<List>
|
||||
{availableRates.map(rate => (
|
||||
<ListItem
|
||||
key={rate}
|
||||
showCheckmark
|
||||
isSelected={activeRate === rate}
|
||||
onSelected={() => {
|
||||
player.setPlaybackRate(rate);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{rate}x
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackQualityPanel({
|
||||
activePanel,
|
||||
onActivePanelChange,
|
||||
}: OptionsPanelProps) {
|
||||
const activeQuality = usePlayerStore(s => s.playbackQuality);
|
||||
const availableQualities = usePlayerStore(s => s.playbackQualities);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Playback quality" />}
|
||||
>
|
||||
<List>
|
||||
{availableQualities.map(quality => (
|
||||
<ListItem
|
||||
capitalizeFirst
|
||||
key={quality}
|
||||
showCheckmark
|
||||
isSelected={activeQuality === quality}
|
||||
onSelected={() => {
|
||||
player.setPlaybackQuality(quality);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{quality}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionsPanel({activePanel, onActivePanelChange}: OptionsPanelProps) {
|
||||
const currentTextTrack = usePlayerStore(s => s.currentTextTrack);
|
||||
const textTracks = usePlayerStore(s => s.textTracks);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Subtitles/Captions" />}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
key="off"
|
||||
showCheckmark
|
||||
isSelected={currentTextTrack === -1}
|
||||
onSelected={() => {
|
||||
player.setCurrentTextTrack(-1);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
<Trans message="Off" />
|
||||
</ListItem>
|
||||
{textTracks.map((track, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
showCheckmark
|
||||
isSelected={currentTextTrack === index}
|
||||
onSelected={() => {
|
||||
player.setCurrentTextTrack(index);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{track.label}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguagePanel({activePanel, onActivePanelChange}: OptionsPanelProps) {
|
||||
const currentAudioTrack = usePlayerStore(s => s.currentAudioTrack);
|
||||
const audioTracks = usePlayerStore(s => s.audioTracks);
|
||||
const player = usePlayerActions();
|
||||
|
||||
return (
|
||||
<PanelLayout
|
||||
activePanel={activePanel}
|
||||
onActivePanelChange={onActivePanelChange}
|
||||
title={<Trans message="Language" />}
|
||||
>
|
||||
<List>
|
||||
{audioTracks.map((track, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
showCheckmark
|
||||
isSelected={currentAudioTrack === index}
|
||||
onSelected={() => {
|
||||
player.setCurrentAudioTrack(index);
|
||||
onActivePanelChange('options');
|
||||
}}
|
||||
>
|
||||
{track.label}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</PanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelLayoutProps extends OptionsPanelProps {
|
||||
children: ReactNode;
|
||||
title: ReactNode;
|
||||
}
|
||||
function PanelLayout({onActivePanelChange, children, title}: PanelLayoutProps) {
|
||||
return (
|
||||
<m.div
|
||||
initial={{x: '100%', opacity: 0}}
|
||||
animate={{x: 0, opacity: 1}}
|
||||
exit={{x: '-100%', opacity: 0}}
|
||||
transition={{type: 'tween', duration: 0.14}}
|
||||
>
|
||||
<div className="border-b p-10">
|
||||
<Button
|
||||
className="w-full"
|
||||
color="white"
|
||||
justify="justify-start"
|
||||
startIcon={<MediaArrowLeftIcon />}
|
||||
onClick={() => onActivePanelChange('options')}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
42
common/resources/client/player/ui/controls/player-poster.tsx
Executable file
42
common/resources/client/player/ui/controls/player-poster.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import clsx from 'clsx';
|
||||
import {HTMLAttributes, ReactElement} from 'react';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
hideDuringPlayback?: boolean;
|
||||
fallback?: ReactElement;
|
||||
}
|
||||
export function PlayerPoster({
|
||||
className,
|
||||
hideDuringPlayback = true,
|
||||
fallback,
|
||||
...domProps
|
||||
}: Props) {
|
||||
const posterUrl = usePlayerStore(s => s.posterUrl);
|
||||
const shouldHidePoster = usePlayerStore(
|
||||
s =>
|
||||
hideDuringPlayback && s.playbackStarted && s.providerName !== 'htmlAudio',
|
||||
);
|
||||
if (!posterUrl && !fallback) return null;
|
||||
return (
|
||||
<div
|
||||
{...domProps}
|
||||
className={clsx(
|
||||
'pointer-events-none flex max-h-full w-full items-center justify-center bg-black transition-opacity',
|
||||
shouldHidePoster ? 'opacity-0' : 'opacity-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<img
|
||||
loading="lazy"
|
||||
src={posterUrl}
|
||||
alt=""
|
||||
className="max-h-full w-full flex-shrink-0 object-cover"
|
||||
/>
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
common/resources/client/player/ui/controls/previous-button.tsx
Executable file
46
common/resources/client/player/ui/controls/previous-button.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaPreviousIcon} from '@common/icons/media/media-previous';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
export function PreviousButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
stopPropagation,
|
||||
}: Props) {
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message="Previous" />}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={e => {
|
||||
if (stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
player.playPrevious();
|
||||
}}
|
||||
>
|
||||
<MediaPreviousIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
54
common/resources/client/player/ui/controls/repeat-button.tsx
Executable file
54
common/resources/client/player/ui/controls/repeat-button.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaRepeatIcon} from '@common/icons/media/media-repeat';
|
||||
import {MediaRepeatOnIcon} from '@common/icons/media/media-repeat-on';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ReactElement} from 'react';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
activeColor?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function RepeatButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
activeColor = 'primary',
|
||||
className,
|
||||
}: Props) {
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const repeating = usePlayerStore(s => s.repeat);
|
||||
const player = usePlayerActions();
|
||||
|
||||
let label: ReactElement;
|
||||
if (repeating === 'all') {
|
||||
label = <Trans message="Enable repeat one" />;
|
||||
} else if (repeating === 'one') {
|
||||
label = <Trans message="Disable repeat" />;
|
||||
} else {
|
||||
label = <Trans message="Enable repeat" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={repeating ? activeColor : color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.toggleRepeatMode();
|
||||
}}
|
||||
>
|
||||
{repeating === 'one' ? <MediaRepeatOnIcon /> : <MediaRepeatIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
46
common/resources/client/player/ui/controls/seeking/seek-button.tsx
Executable file
46
common/resources/client/player/ui/controls/seeking/seek-button.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {ReactElement} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {MediaSeekForward15Icon} from '@common/icons/media/media-seek-forward15';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
seconds?: number | string;
|
||||
children?: ReactElement<SvgIconProps>;
|
||||
}
|
||||
export function SeekButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
seconds = '+15',
|
||||
children,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={trans(message('Next'))}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.seek(seconds);
|
||||
}}
|
||||
>
|
||||
{children || <MediaSeekForward15Icon />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
63
common/resources/client/player/ui/controls/seeking/seekbar.tsx
Executable file
63
common/resources/client/player/ui/controls/seeking/seekbar.tsx
Executable file
@@ -0,0 +1,63 @@
|
||||
import {Slider} from '@common/ui/forms/slider/slider';
|
||||
import {UseSliderProps} from '@common/ui/forms/slider/use-slider';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useCurrentTime} from '@common/player/hooks/use-current-time';
|
||||
import {useRef} from 'react';
|
||||
|
||||
interface Props {
|
||||
trackColor?: UseSliderProps['trackColor'];
|
||||
fillColor?: UseSliderProps['fillColor'];
|
||||
className?: string;
|
||||
onPointerMove?: UseSliderProps['onPointerMove'];
|
||||
}
|
||||
export function Seekbar({
|
||||
trackColor,
|
||||
fillColor,
|
||||
className,
|
||||
onPointerMove,
|
||||
}: Props) {
|
||||
const {pause, seek, setIsSeeking, play, getState} = usePlayerActions();
|
||||
const duration = usePlayerStore(s => s.mediaDuration);
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const pauseWhileSeeking = usePlayerStore(s => s.pauseWhileSeeking);
|
||||
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const wasPlayingBeforeDragging = useRef(false);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
fillColor={fillColor}
|
||||
trackColor={trackColor}
|
||||
thumbSize="w-14 h-14"
|
||||
showThumbOnHoverOnly
|
||||
className={className}
|
||||
width="w-auto"
|
||||
isDisabled={!playerReady}
|
||||
value={currentTime}
|
||||
minValue={0}
|
||||
maxValue={duration}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerDown={() => {
|
||||
setIsSeeking(true);
|
||||
if (pauseWhileSeeking) {
|
||||
wasPlayingBeforeDragging.current =
|
||||
getState().isPlaying || getState().isBuffering;
|
||||
pause();
|
||||
}
|
||||
}}
|
||||
onChange={value => {
|
||||
getState().emit('progress', {currentTime: value});
|
||||
seek(value);
|
||||
}}
|
||||
onChangeEnd={() => {
|
||||
setIsSeeking(false);
|
||||
if (pauseWhileSeeking && wasPlayingBeforeDragging.current) {
|
||||
play();
|
||||
wasPlayingBeforeDragging.current = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
common/resources/client/player/ui/controls/shuffle-button.tsx
Executable file
50
common/resources/client/player/ui/controls/shuffle-button.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaShuffleIcon} from '@common/icons/media/media-shuffle';
|
||||
import {MediaShuffleOnIcon} from '@common/icons/media/media-shuffle-on';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
activeColor?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function ShuffleButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
activeColor = 'primary',
|
||||
className,
|
||||
}: Props) {
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const isShuffling = usePlayerStore(s => s.shuffling);
|
||||
const player = usePlayerActions();
|
||||
|
||||
const label = isShuffling ? (
|
||||
<Trans message="Disable shuffle" />
|
||||
) : (
|
||||
<Trans message="Enable shuffle" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
size={size}
|
||||
color={isShuffling ? activeColor : color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.toggleShuffling();
|
||||
}}
|
||||
>
|
||||
{isShuffling ? <MediaShuffleOnIcon /> : <MediaShuffleIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
61
common/resources/client/player/ui/controls/toggle-captions-button.tsx
Executable file
61
common/resources/client/player/ui/controls/toggle-captions-button.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaClosedCaptionsIcon} from '@common/icons/media/media-closed-captions';
|
||||
import {MediaClosedCaptionsOnIcon} from '@common/icons/media/media-closed-captions-on';
|
||||
|
||||
interface Props {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function ToggleCaptionsButton({
|
||||
size = 'md',
|
||||
iconSize,
|
||||
color,
|
||||
className,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
const captionsVisible = usePlayerStore(s => s.textTrackIsVisible);
|
||||
const haveCaptions = usePlayerStore(s => !!s.textTracks.length);
|
||||
|
||||
if (!haveCaptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelMessage = trans(
|
||||
captionsVisible
|
||||
? message('Hide subtitles/captions (c)')
|
||||
: message('Show subtitles/captions (c)')
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={<Trans message={labelMessage} />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
aria-label={labelMessage}
|
||||
size={size}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
player.setTextTrackVisibility(!captionsVisible);
|
||||
}}
|
||||
>
|
||||
{captionsVisible ? (
|
||||
<MediaClosedCaptionsOnIcon />
|
||||
) : (
|
||||
<MediaClosedCaptionsIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
95
common/resources/client/player/ui/controls/volume-controls.tsx
Executable file
95
common/resources/client/player/ui/controls/volume-controls.tsx
Executable file
@@ -0,0 +1,95 @@
|
||||
import {Slider} from '@common/ui/forms/slider/slider';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {BaseSliderProps} from '@common/ui/forms/slider/base-slider';
|
||||
import {ButtonProps} from '@common/ui/buttons/button';
|
||||
import {MediaMuteIcon} from '@common/icons/media/media-mute';
|
||||
import {MediaVolumeLowIcon} from '@common/icons/media/media-volume-low';
|
||||
import {MediaVolumeHighIcon} from '@common/icons/media/media-volume-high';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
trackColor?: BaseSliderProps['trackColor'];
|
||||
fillColor?: BaseSliderProps['fillColor'];
|
||||
buttonColor?: ButtonProps['color'];
|
||||
className?: string;
|
||||
}
|
||||
export function VolumeControls({
|
||||
trackColor,
|
||||
fillColor,
|
||||
buttonColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const volume = usePlayerStore(s => s.volume);
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
return (
|
||||
<div className={clsx('flex w-min items-center gap-4', className)}>
|
||||
<ToggleMuteButton color={buttonColor} />
|
||||
<Slider
|
||||
isDisabled={!playerReady}
|
||||
showThumbOnHoverOnly
|
||||
thumbSize="w-14 h-14"
|
||||
trackColor={trackColor}
|
||||
fillColor={fillColor}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
className="flex-auto"
|
||||
width="w-96"
|
||||
value={volume}
|
||||
onChange={value => {
|
||||
player.setVolume(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleMuteButtonProps {
|
||||
color?: ButtonProps['color'];
|
||||
size?: ButtonProps['size'];
|
||||
iconSize?: ButtonProps['size'];
|
||||
}
|
||||
export function ToggleMuteButton({
|
||||
color,
|
||||
size = 'sm',
|
||||
iconSize = 'md',
|
||||
}: ToggleMuteButtonProps) {
|
||||
const isMuted = usePlayerStore(s => s.muted);
|
||||
const volume = usePlayerStore(s => s.volume);
|
||||
const player = usePlayerActions();
|
||||
const playerReady = usePlayerStore(s => s.providerReady);
|
||||
|
||||
if (isMuted) {
|
||||
return (
|
||||
<Tooltip label={<Trans message="Unmute" />} usePortal={false}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
onClick={() => player.setMuted(false)}
|
||||
>
|
||||
<MediaMuteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={<Trans message="Mute" />}>
|
||||
<IconButton
|
||||
disabled={!playerReady}
|
||||
color={color}
|
||||
size={size}
|
||||
iconSize={iconSize}
|
||||
onClick={() => player.setMuted(true)}
|
||||
>
|
||||
{volume < 40 ? <MediaVolumeLowIcon /> : <MediaVolumeHighIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
57
common/resources/client/player/ui/player-outlet.tsx
Executable file
57
common/resources/client/player/ui/player-outlet.tsx
Executable file
@@ -0,0 +1,57 @@
|
||||
import React, {memo, Suspense, useContext, useEffect} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {YoutubeProvider} from '@common/player/providers/youtube/youtube-provider';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {HtmlVideoProvider} from '@common/player/providers/html-video-provider';
|
||||
import {HtmlAudioProvider} from '@common/player/providers/html-audio-provider';
|
||||
|
||||
const HlsProvider = React.lazy(
|
||||
() => import('@common/player/providers/hls-provider')
|
||||
);
|
||||
const DashProvider = React.lazy(
|
||||
() => import('@common/player/providers/dash-provider')
|
||||
);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export const PlayerOutlet = memo(({className}: Props) => {
|
||||
const {getState} = useContext(PlayerStoreContext);
|
||||
|
||||
useEffect(() => {
|
||||
getState().init();
|
||||
return getState().destroy;
|
||||
}, [getState]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Provider />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Provider() {
|
||||
const provider = usePlayerStore(s => s.providerName);
|
||||
switch (provider) {
|
||||
case 'youtube':
|
||||
return <YoutubeProvider />;
|
||||
case 'htmlVideo':
|
||||
return <HtmlVideoProvider />;
|
||||
case 'htmlAudio':
|
||||
return <HtmlAudioProvider />;
|
||||
case 'hls':
|
||||
return (
|
||||
<Suspense>
|
||||
<HlsProvider />
|
||||
</Suspense>
|
||||
);
|
||||
case 'dash':
|
||||
return (
|
||||
<Suspense>
|
||||
<DashProvider />
|
||||
</Suspense>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
126
common/resources/client/player/ui/video-player/video-player-controls.tsx
Executable file
126
common/resources/client/player/ui/video-player/video-player-controls.tsx
Executable file
@@ -0,0 +1,126 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import clsx from 'clsx';
|
||||
import {Seekbar} from '@common/player/ui/controls/seeking/seekbar';
|
||||
import {PlayButton} from '@common/player/ui/controls/play-button';
|
||||
import {NextButton} from '@common/player/ui/controls/next-button';
|
||||
import {
|
||||
ToggleMuteButton,
|
||||
VolumeControls,
|
||||
} from '@common/player/ui/controls/volume-controls';
|
||||
import {FormattedCurrentTime} from '@common/player/ui/controls/formatted-current-time';
|
||||
import {FormattedPlayerDuration} from '@common/player/ui/controls/formatted-player-duration';
|
||||
import {ToggleCaptionsButton} from '@common/player/ui/controls/toggle-captions-button';
|
||||
import {PlaybackOptionsButton} from '@common/player/ui/controls/playback-options-button';
|
||||
import {FullscreenButton} from '@common/player/ui/controls/fullscreen-button';
|
||||
import {PipButton} from '@common/player/ui/controls/pip-button';
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
interface Props {
|
||||
rightActions?: ReactNode;
|
||||
onPointerEnter?: () => void;
|
||||
onPointerLeave?: () => void;
|
||||
}
|
||||
export function VideoPlayerControls(props: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const controlsVisible = usePlayerStore(s => s.controlsVisible);
|
||||
|
||||
const className = clsx(
|
||||
'player-bottom-text-shadow absolute z-40 text-white/87 transition-opacity duration-300',
|
||||
controlsVisible ? 'opacity-100' : 'opacity-0'
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<MobileControls className={className} {...props} />
|
||||
) : (
|
||||
<DesktopControls className={className} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponsiveControlsProps extends Props {
|
||||
className: string;
|
||||
}
|
||||
function DesktopControls({
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
rightActions,
|
||||
className,
|
||||
}: ResponsiveControlsProps) {
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={clsx('bottom-0 left-0 right-0 p-8', className)}
|
||||
>
|
||||
<Seekbar trackColor="bg-white/40" />
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<PlayButton color="white" />
|
||||
<NextButton color="white" />
|
||||
<VolumeControls
|
||||
className="max-md:hidden"
|
||||
fillColor="bg-white"
|
||||
trackColor="bg-white/20"
|
||||
buttonColor="white"
|
||||
/>
|
||||
<span className="ml-10 text-sm">
|
||||
<FormattedCurrentTime className="min-w-40 text-right" /> /{' '}
|
||||
<FormattedPlayerDuration className="min-w-40 text-right" />
|
||||
</span>
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-4">
|
||||
{rightActions}
|
||||
<ToggleCaptionsButton color="white" />
|
||||
<PlaybackOptionsButton color="white" />
|
||||
<FullscreenButton className="ml-auto" color="white" />
|
||||
<PipButton color="white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileControls({
|
||||
rightActions,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
className,
|
||||
}: ResponsiveControlsProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={clsx('left-0 right-0 top-0 px-6 pt-6 ', className)}
|
||||
>
|
||||
<div className="flex items-end justify-end">
|
||||
{rightActions}
|
||||
<ToggleCaptionsButton color="white" />
|
||||
<PlaybackOptionsButton color="white" />
|
||||
<PipButton color="white" />
|
||||
<ToggleMuteButton color="white" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={clsx('bottom-0 left-0 right-0 px-12', className)}
|
||||
>
|
||||
<div className="flex items-end gap-24">
|
||||
<div className="text-sm">
|
||||
<FormattedCurrentTime className="min-w-40 text-right" /> /{' '}
|
||||
<FormattedPlayerDuration className="min-w-40 text-right" />
|
||||
</div>
|
||||
<FullscreenButton
|
||||
size="sm"
|
||||
iconSize="lg"
|
||||
color="white"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
<Seekbar trackColor="bg-white/40" />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
254
common/resources/client/player/ui/video-player/video-player.tsx
Executable file
254
common/resources/client/player/ui/video-player/video-player.tsx
Executable file
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {PlayerContext} from '@common/player/player-context';
|
||||
import {MediaItem} from '@common/player/media-item';
|
||||
import {PlayerOutlet} from '@common/player/ui/player-outlet';
|
||||
import {
|
||||
PlayerActions,
|
||||
usePlayerActions,
|
||||
} from '@common/player/hooks/use-player-actions';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import clsx from 'clsx';
|
||||
import {PlayerPoster} from '@common/player/ui/controls/player-poster';
|
||||
import {usePlayerClickHandler} from '@common/player/hooks/use-player-click-handler';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {BufferingSpinner} from '@common/player/ui/controls/buffering-spinner';
|
||||
import {guessPlayerProvider} from '@common/player/utils/guess-player-provider';
|
||||
import {usePrevious} from '@common/utils/hooks/use-previous';
|
||||
import {PlayerStoreOptions} from '@common/player/state/player-store-options';
|
||||
import {VideoPlayerControls} from '@common/player/ui/video-player/video-player-controls';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
queue?: MediaItem[];
|
||||
cuedMediaId?: string;
|
||||
autoPlay?: boolean;
|
||||
src?: string;
|
||||
listeners?: PlayerStoreOptions['listeners'];
|
||||
onDestroy?: PlayerStoreOptions['onDestroy'];
|
||||
onBeforePlayNext?: PlayerStoreOptions['onBeforePlayNext'];
|
||||
onBeforePlayPrevious?: PlayerStoreOptions['onBeforePlayPrevious'];
|
||||
apiRef?: MutableRefObject<PlayerActions>;
|
||||
rightActions?: ReactNode;
|
||||
}
|
||||
export function VideoPlayer({
|
||||
id,
|
||||
queue,
|
||||
cuedMediaId,
|
||||
autoPlay,
|
||||
src,
|
||||
listeners,
|
||||
onBeforePlayPrevious,
|
||||
onBeforePlayNext,
|
||||
onDestroy,
|
||||
apiRef,
|
||||
rightActions,
|
||||
}: Props) {
|
||||
return (
|
||||
<PlayerContext
|
||||
id={id}
|
||||
options={{
|
||||
autoPlay,
|
||||
listeners,
|
||||
onDestroy,
|
||||
onBeforePlayNext,
|
||||
onBeforePlayPrevious,
|
||||
initialData: {
|
||||
queue: queue ? queue : [mediaItemFromSrc(src!)],
|
||||
cuedMediaId,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<QueueOverrider src={src} queue={queue} />
|
||||
<PlayerLayout apiRef={apiRef} rightActions={rightActions} />
|
||||
</PlayerContext>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerLayoutProps {
|
||||
apiRef?: MutableRefObject<PlayerActions>;
|
||||
rightActions?: ReactNode;
|
||||
}
|
||||
function PlayerLayout({apiRef, rightActions}: PlayerLayoutProps) {
|
||||
const leaveTimerRef = useRef<number | null>();
|
||||
const inactiveTimerRef = useRef<number | null>();
|
||||
const pointerIsOverControls = useRef(false);
|
||||
const actions = usePlayerActions();
|
||||
const controlsVisible = usePlayerStore(s => s.controlsVisible);
|
||||
const {setControlsVisible, getState} = actions;
|
||||
|
||||
const clickHandler = usePlayerClickHandler();
|
||||
|
||||
const clearTimers = () => {
|
||||
if (leaveTimerRef.current) {
|
||||
clearTimeout(leaveTimerRef.current);
|
||||
leaveTimerRef.current = null;
|
||||
}
|
||||
if (inactiveTimerRef.current) {
|
||||
clearTimeout(inactiveTimerRef.current);
|
||||
inactiveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startInactiveTimer = useCallback(() => {
|
||||
if (getState().isPlaying) {
|
||||
inactiveTimerRef.current = window.setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
}, 3500);
|
||||
}
|
||||
}, [getState, setControlsVisible]);
|
||||
|
||||
// show controls when any key is pressed
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
clearTimers();
|
||||
setControlsVisible(true);
|
||||
};
|
||||
document.addEventListener('keydown', listener);
|
||||
return () => document.removeEventListener('keydown', listener);
|
||||
}, [setControlsVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiRef) {
|
||||
apiRef.current = actions;
|
||||
return actions.subscribe({
|
||||
play: () => startInactiveTimer(),
|
||||
});
|
||||
}
|
||||
}, [apiRef, actions, setControlsVisible, startInactiveTimer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fullscreen-host relative isolate aspect-video bg-black',
|
||||
!controlsVisible && 'cursor-none',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onPointerEnter={() => {
|
||||
setControlsVisible(true);
|
||||
clearTimers();
|
||||
}}
|
||||
onPointerMove={() => {
|
||||
if (pointerIsOverControls.current && controlsVisible) {
|
||||
return;
|
||||
}
|
||||
if (inactiveTimerRef.current) {
|
||||
setControlsVisible(true);
|
||||
}
|
||||
clearTimers();
|
||||
startInactiveTimer();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimers();
|
||||
if (!getState().isPlaying) {
|
||||
return;
|
||||
}
|
||||
leaveTimerRef.current = window.setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
}, 2500);
|
||||
}}
|
||||
>
|
||||
<PlayerOutlet className="z-50 h-full w-full" />
|
||||
<Blocker />
|
||||
<PlayerPoster className="absolute inset-0 z-30" />
|
||||
<OverlayButtons />
|
||||
<BufferingSpinner
|
||||
className="spinner pointer-events-none absolute inset-0 z-40 m-auto h-50 w-50"
|
||||
fillColor="border-white"
|
||||
trackColor="border-white/30"
|
||||
size="w-50 h-50"
|
||||
/>
|
||||
<BottomGradient />
|
||||
<VideoPlayerControls
|
||||
rightActions={rightActions}
|
||||
onPointerEnter={() => {
|
||||
pointerIsOverControls.current = true;
|
||||
setControlsVisible(true);
|
||||
clearTimers();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
pointerIsOverControls.current = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlayButtons() {
|
||||
const showPlayButton = usePlayerStore(s => !s.isPlaying && !s.isSeeking);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute left-0 top-0 z-40 flex h-full w-full items-center justify-center transition-opacity',
|
||||
showPlayButton ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
color="primary"
|
||||
variant="raised"
|
||||
size="lg"
|
||||
radius="rounded-full"
|
||||
>
|
||||
<MediaPlayIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// required in order for "onPointerEnter" to fire consistently when player provider is iframe
|
||||
function Blocker() {
|
||||
return <div className="absolute inset-0 z-20" />;
|
||||
}
|
||||
|
||||
function BottomGradient() {
|
||||
const controlsVisible = usePlayerStore(s => s.controlsVisible);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'player-bottom-gradient pointer-events-none absolute bottom-0 z-30 h-full w-full transition-opacity duration-300',
|
||||
controlsVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaItemFromSrc(src: string): MediaItem {
|
||||
return {
|
||||
id: src,
|
||||
src,
|
||||
provider: guessPlayerProvider(src),
|
||||
};
|
||||
}
|
||||
|
||||
interface QueueOverriderProps {
|
||||
src?: string;
|
||||
queue?: MediaItem[];
|
||||
}
|
||||
function QueueOverrider({src, queue}: QueueOverriderProps) {
|
||||
const {getState, overrideQueue} = usePlayerActions();
|
||||
|
||||
const queueKey = queue?.map(item => item.id).join('-') ?? '';
|
||||
const previousKey = usePrevious(queueKey);
|
||||
|
||||
// override queue when any of specified queue item id or order changes
|
||||
useEffect(() => {
|
||||
if (queue && previousKey && queueKey && previousKey !== queueKey) {
|
||||
overrideQueue(queue);
|
||||
}
|
||||
}, [queueKey, previousKey, queue, overrideQueue]);
|
||||
|
||||
// override queue when src changes
|
||||
useEffect(() => {
|
||||
if (src && getState().cuedMedia?.src !== src) {
|
||||
overrideQueue([mediaItemFromSrc(src)]);
|
||||
}
|
||||
}, [src, getState, overrideQueue]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user