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

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

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

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

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