first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
}}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}