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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user