initial commit
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 672px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
}
|
||||
|
||||
.fileName {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.progressSection {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeDisplay {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mainControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.playButton:hover {
|
||||
background-color: var(--brand-primary-light);
|
||||
}
|
||||
|
||||
.playButton:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.playButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.seekButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
|
||||
.seekButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.seekButton:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.seekButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.secondaryControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.volumeControl {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playbackRate {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mobile .container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.mobile .playButton {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.mobile .seekButton {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ClockClockwiseIcon, ClockCounterClockwiseIcon, PauseIcon, PlayIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import AudioVolumeStore from '~/stores/AudioVolumeStore';
|
||||
import {useMediaPlayer} from '../hooks/useMediaPlayer';
|
||||
import {useMediaProgress} from '../hooks/useMediaProgress';
|
||||
import {formatTime} from '../utils/formatTime';
|
||||
import {AUDIO_PLAYBACK_RATES, DEFAULT_SEEK_AMOUNT} from '../utils/mediaConstants';
|
||||
import styles from './AudioPlayer.module.css';
|
||||
import {MediaPlaybackRate} from './MediaPlaybackRate';
|
||||
import {MediaProgressBar} from './MediaProgressBar';
|
||||
import {MediaVolumeControl} from './MediaVolumeControl';
|
||||
|
||||
export interface AudioPlayerProps {
|
||||
src: string;
|
||||
title?: string;
|
||||
duration?: number;
|
||||
autoPlay?: boolean;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AudioPlayer({
|
||||
src,
|
||||
title,
|
||||
duration: initialDuration,
|
||||
autoPlay = false,
|
||||
isMobile = false,
|
||||
className,
|
||||
}: AudioPlayerProps) {
|
||||
const {t} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hasStarted, setHasStarted] = useState(autoPlay);
|
||||
|
||||
const [volume, setVolumeState] = useState(AudioVolumeStore.volume);
|
||||
const [isMuted, setIsMutedState] = useState(AudioVolumeStore.isMuted);
|
||||
|
||||
const {mediaRef, state, play, toggle, seekRelative, setPlaybackRate} = useMediaPlayer({
|
||||
autoPlay,
|
||||
persistVolume: false,
|
||||
persistPlaybackRate: true,
|
||||
});
|
||||
|
||||
const {currentTime, duration, progress, buffered, seekToPercentage, startSeeking, endSeeking} = useMediaProgress({
|
||||
mediaRef,
|
||||
initialDuration,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.volume = volume;
|
||||
media.muted = isMuted;
|
||||
}, [mediaRef, volume, isMuted]);
|
||||
|
||||
const setVolume = useCallback(
|
||||
(newVolume: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, newVolume));
|
||||
setVolumeState(clamped);
|
||||
AudioVolumeStore.setVolume(clamped);
|
||||
if (isMuted && clamped > 0) {
|
||||
setIsMutedState(false);
|
||||
}
|
||||
},
|
||||
[isMuted],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setIsMutedState((prev) => !prev);
|
||||
AudioVolumeStore.toggleMute();
|
||||
}, []);
|
||||
|
||||
const hasAutoPlayedRef = useRef(autoPlay);
|
||||
useEffect(() => {
|
||||
if (hasStarted && !hasAutoPlayedRef.current) {
|
||||
hasAutoPlayedRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
play();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [hasStarted, play]);
|
||||
|
||||
const handlePlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
[hasStarted, toggle],
|
||||
);
|
||||
|
||||
const handleSeekBackward = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekRelative(-DEFAULT_SEEK_AMOUNT);
|
||||
},
|
||||
[seekRelative],
|
||||
);
|
||||
|
||||
const handleSeekForward = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekRelative(DEFAULT_SEEK_AMOUNT);
|
||||
},
|
||||
[seekRelative],
|
||||
);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(percentage: number) => {
|
||||
seekToPercentage(percentage);
|
||||
},
|
||||
[seekToPercentage],
|
||||
);
|
||||
|
||||
const playButtonSize = isMobile ? 28 : 24;
|
||||
const seekButtonSize = isMobile ? 24 : 20;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx(styles.container, isMobile && styles.mobile, className)}>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: Audio player doesn't require captions */}
|
||||
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={hasStarted ? src : undefined} preload="none" />
|
||||
|
||||
{title && <h3 className={styles.fileName}>{title}</h3>}
|
||||
|
||||
<div className={styles.progressSection}>
|
||||
<MediaProgressBar
|
||||
progress={progress}
|
||||
buffered={buffered}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={handleSeek}
|
||||
onSeekStart={startSeeking}
|
||||
onSeekEnd={endSeeking}
|
||||
className={styles.progressBar}
|
||||
/>
|
||||
<div className={styles.timeDisplay}>
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.mainControls}>
|
||||
<Tooltip text={t`Rewind ${DEFAULT_SEEK_AMOUNT} seconds`} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSeekBackward}
|
||||
className={styles.seekButton}
|
||||
aria-label={t`Rewind ${DEFAULT_SEEK_AMOUNT} seconds`}
|
||||
>
|
||||
<ClockCounterClockwiseIcon size={seekButtonSize} weight="bold" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlayClick}
|
||||
className={styles.playButton}
|
||||
aria-label={state.isPlaying ? t`Pause` : t`Play`}
|
||||
>
|
||||
{state.isPlaying ? (
|
||||
<PauseIcon size={playButtonSize} weight="fill" />
|
||||
) : (
|
||||
<PlayIcon size={playButtonSize} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Tooltip text={t`Forward ${DEFAULT_SEEK_AMOUNT} seconds`} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSeekForward}
|
||||
className={styles.seekButton}
|
||||
aria-label={t`Forward ${DEFAULT_SEEK_AMOUNT} seconds`}
|
||||
>
|
||||
<ClockClockwiseIcon size={seekButtonSize} weight="bold" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.secondaryControls}>
|
||||
<MediaVolumeControl
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
onVolumeChange={setVolume}
|
||||
onToggleMute={toggleMute}
|
||||
iconSize={18}
|
||||
className={styles.volumeControl}
|
||||
/>
|
||||
|
||||
<MediaPlaybackRate
|
||||
rate={state.playbackRate}
|
||||
onRateChange={setPlaybackRate}
|
||||
rates={AUDIO_PLAYBACK_RATES}
|
||||
isAudio
|
||||
className={styles.playbackRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--background-tertiary);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fileNameTruncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileExtension {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.playButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.playButton:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.playButton:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.playButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.progressSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
min-width: 70px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.volumeControl {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.volumeControl button {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.volumeControl button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.actionButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {DownloadSimpleIcon, FileAudioIcon, PauseIcon, PlayIcon, StarIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {useMediaPlayer} from '../hooks/useMediaPlayer';
|
||||
import {useMediaProgress} from '../hooks/useMediaProgress';
|
||||
import {useMediaVolume} from '../hooks/useMediaVolume';
|
||||
import {formatTime} from '../utils/formatTime';
|
||||
import styles from './InlineAudioPlayer.module.css';
|
||||
import {MediaProgressBar} from './MediaProgressBar';
|
||||
import {MediaVolumeControl} from './MediaVolumeControl';
|
||||
|
||||
export interface InlineAudioPlayerProps {
|
||||
src: string;
|
||||
title?: string;
|
||||
fileSize?: number;
|
||||
duration?: number;
|
||||
extension?: string;
|
||||
isFavorited?: boolean;
|
||||
canFavorite?: boolean;
|
||||
onFavoriteClick?: (e: React.MouseEvent) => void;
|
||||
onDownloadClick?: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function splitFilename(filename: string): {name: string; extension: string} {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot === -1 || lastDot === 0) {
|
||||
return {name: filename, extension: ''};
|
||||
}
|
||||
return {
|
||||
name: filename.substring(0, lastDot),
|
||||
extension: filename.substring(lastDot),
|
||||
};
|
||||
}
|
||||
|
||||
export function InlineAudioPlayer({
|
||||
src,
|
||||
title = 'Audio',
|
||||
fileSize,
|
||||
duration: initialDuration,
|
||||
extension,
|
||||
isFavorited = false,
|
||||
canFavorite = false,
|
||||
onFavoriteClick,
|
||||
onDownloadClick,
|
||||
onContextMenu,
|
||||
className,
|
||||
}: InlineAudioPlayerProps) {
|
||||
const {t} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
const {mediaRef, state, play, toggle} = useMediaPlayer({
|
||||
persistVolume: true,
|
||||
});
|
||||
|
||||
const {currentTime, duration, progress, buffered, seekToPercentage, startSeeking, endSeeking} = useMediaProgress({
|
||||
mediaRef,
|
||||
initialDuration,
|
||||
});
|
||||
|
||||
const {volume, isMuted, setVolume, toggleMute} = useMediaVolume({
|
||||
mediaRef,
|
||||
});
|
||||
|
||||
const hasAutoPlayedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasStarted && !hasAutoPlayedRef.current) {
|
||||
hasAutoPlayedRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
play();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [hasStarted, play]);
|
||||
|
||||
const displayDuration = initialDuration || duration;
|
||||
|
||||
const {name: fileName, extension: fileExtension} = extension
|
||||
? {name: title, extension: `.${extension}`}
|
||||
: splitFilename(title);
|
||||
|
||||
const metaString = fileSize
|
||||
? `${formatFileSize(fileSize)}${displayDuration ? ` • ${formatTime(displayDuration)}` : ''}`
|
||||
: displayDuration
|
||||
? formatTime(displayDuration)
|
||||
: '';
|
||||
|
||||
const handlePlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
[hasStarted, toggle],
|
||||
);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(percentage: number) => {
|
||||
seekToPercentage(percentage);
|
||||
},
|
||||
[seekToPercentage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.container, className)}
|
||||
onContextMenu={onContextMenu}
|
||||
role="group"
|
||||
aria-label={t`Audio Player`}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: Audio player doesn't require captions */}
|
||||
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={hasStarted ? src : undefined} preload="none" />
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.iconContainer}>
|
||||
<FileAudioIcon size={24} weight="regular" />
|
||||
</div>
|
||||
|
||||
<div className={styles.fileInfo}>
|
||||
<p className={styles.fileName}>
|
||||
<span className={styles.fileNameTruncate}>{fileName}</span>
|
||||
<span className={styles.fileExtension}>{fileExtension}</span>
|
||||
</p>
|
||||
{metaString && <p className={styles.fileMeta}>{metaString}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlayClick}
|
||||
className={styles.playButton}
|
||||
aria-label={state.isPlaying ? t`Pause` : t`Play`}
|
||||
>
|
||||
{state.isPlaying ? <PauseIcon size={20} weight="fill" /> : <PlayIcon size={20} weight="fill" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.progressSection}>
|
||||
<MediaProgressBar
|
||||
progress={progress}
|
||||
buffered={buffered}
|
||||
currentTime={currentTime}
|
||||
duration={displayDuration}
|
||||
onSeek={handleSeek}
|
||||
onSeekStart={startSeeking}
|
||||
onSeekEnd={endSeeking}
|
||||
compact
|
||||
className={styles.progressBar}
|
||||
/>
|
||||
<span className={styles.time}>
|
||||
{formatTime(currentTime)} / {formatTime(displayDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.controlsLeft}>
|
||||
<MediaVolumeControl
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
onVolumeChange={setVolume}
|
||||
onToggleMute={toggleMute}
|
||||
compact
|
||||
iconSize={18}
|
||||
className={styles.volumeControl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.controlsRight}>
|
||||
{canFavorite && onFavoriteClick && (
|
||||
<Tooltip text={isFavorited ? t`Remove from favorites` : t`Add to favorites`} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onFavoriteClick(e)}
|
||||
className={styles.actionButton}
|
||||
aria-label={isFavorited ? t`Remove from favorites` : t`Add to favorites`}
|
||||
>
|
||||
<StarIcon size={18} weight={isFavorited ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onDownloadClick && (
|
||||
<Tooltip text={t`Download`} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onDownloadClick(e)}
|
||||
className={styles.actionButton}
|
||||
aria-label={t`Download`}
|
||||
>
|
||||
<DownloadSimpleIcon size={18} weight="bold" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CornersInIcon, CornersOutIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './MediaPlayButton.module.css';
|
||||
|
||||
export interface MediaFullscreenButtonProps {
|
||||
isFullscreen: boolean;
|
||||
supportsFullscreen?: boolean;
|
||||
onToggle: () => void;
|
||||
iconSize?: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showTooltip?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MediaFullscreenButton({
|
||||
isFullscreen,
|
||||
supportsFullscreen = true,
|
||||
onToggle,
|
||||
iconSize = 20,
|
||||
size = 'medium',
|
||||
showTooltip = true,
|
||||
className,
|
||||
}: MediaFullscreenButtonProps) {
|
||||
const {t} = useLingui();
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
},
|
||||
[onToggle],
|
||||
);
|
||||
|
||||
if (!supportsFullscreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = isFullscreen ? t`Exit fullscreen` : t`Enter fullscreen`;
|
||||
const Icon = isFullscreen ? CornersInIcon : CornersOutIcon;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsx(styles.button, styles[size], className)}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={iconSize} weight="bold" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (showTooltip) {
|
||||
return (
|
||||
<Tooltip text={label} position="top">
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {PictureInPictureIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './MediaPlayButton.module.css';
|
||||
|
||||
export interface MediaPipButtonProps {
|
||||
isPiP: boolean;
|
||||
supportsPiP?: boolean;
|
||||
onToggle: () => void;
|
||||
iconSize?: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showTooltip?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MediaPipButton({
|
||||
isPiP,
|
||||
supportsPiP = true,
|
||||
onToggle,
|
||||
iconSize = 20,
|
||||
size = 'medium',
|
||||
showTooltip = true,
|
||||
className,
|
||||
}: MediaPipButtonProps) {
|
||||
const {t} = useLingui();
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
},
|
||||
[onToggle],
|
||||
);
|
||||
|
||||
if (!supportsPiP) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = isPiP ? t`Exit picture-in-picture` : t`Enter picture-in-picture`;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsx(styles.button, styles[size], className)}
|
||||
aria-label={label}
|
||||
>
|
||||
<PictureInPictureIcon size={iconSize} weight={isPiP ? 'fill' : 'bold'} />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (showTooltip) {
|
||||
return (
|
||||
<Tooltip text={label} position="top">
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
opacity 150ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--brand-primary, #5865f2);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.large {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.xlarge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
padding: 16px;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.overlay:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {PauseIcon, PlayIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './MediaPlayButton.module.css';
|
||||
|
||||
export interface MediaPlayButtonProps {
|
||||
isPlaying: boolean;
|
||||
onToggle: () => void;
|
||||
size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
iconSize?: number;
|
||||
showTooltip?: boolean;
|
||||
className?: string;
|
||||
overlay?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SIZE_MAP = {
|
||||
small: 16,
|
||||
medium: 20,
|
||||
large: 24,
|
||||
xlarge: 32,
|
||||
};
|
||||
|
||||
export function MediaPlayButton({
|
||||
isPlaying,
|
||||
onToggle,
|
||||
size = 'medium',
|
||||
iconSize,
|
||||
showTooltip = true,
|
||||
className,
|
||||
overlay = false,
|
||||
disabled = false,
|
||||
}: MediaPlayButtonProps) {
|
||||
const {t} = useLingui();
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
onToggle();
|
||||
}
|
||||
},
|
||||
[onToggle, disabled],
|
||||
);
|
||||
|
||||
const actualIconSize = iconSize ?? SIZE_MAP[size];
|
||||
const label = isPlaying ? t`Pause` : t`Play`;
|
||||
|
||||
const Icon = isPlaying ? PauseIcon : PlayIcon;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsx(styles.button, styles[size], overlay && styles.overlay, className)}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size={actualIconSize} weight="fill" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (showTooltip && !overlay) {
|
||||
return (
|
||||
<Tooltip text={label} position="top">
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
background-color 150ms ease;
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono, monospace);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--brand-primary, #5865f2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--brand-primary-light, #5865f2);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {AUDIO_PLAYBACK_RATES, VIDEO_PLAYBACK_RATES} from '../utils/mediaConstants';
|
||||
import styles from './MediaPlaybackRate.module.css';
|
||||
|
||||
export interface MediaPlaybackRateProps {
|
||||
rate: number;
|
||||
onRateChange: (rate: number) => void;
|
||||
rates?: ReadonlyArray<number>;
|
||||
isAudio?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showTooltip?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatRate(rate: number): string {
|
||||
if (rate === 1) return '1x';
|
||||
if (Number.isInteger(rate)) return `${rate}x`;
|
||||
return `${rate}x`;
|
||||
}
|
||||
|
||||
export function MediaPlaybackRate({
|
||||
rate,
|
||||
onRateChange,
|
||||
rates,
|
||||
isAudio = false,
|
||||
size = 'medium',
|
||||
showTooltip = true,
|
||||
className,
|
||||
}: MediaPlaybackRateProps) {
|
||||
const {t} = useLingui();
|
||||
const availableRates = useMemo(() => {
|
||||
if (rates) return rates;
|
||||
return isAudio ? AUDIO_PLAYBACK_RATES : VIDEO_PLAYBACK_RATES;
|
||||
}, [rates, isAudio]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentIndex = availableRates.indexOf(rate);
|
||||
const nextIndex = (currentIndex + 1) % availableRates.length;
|
||||
onRateChange(availableRates[nextIndex]);
|
||||
},
|
||||
[rate, availableRates, onRateChange],
|
||||
);
|
||||
|
||||
const isActive = rate !== 1;
|
||||
const formattedRate = formatRate(rate);
|
||||
const label = t`Playback speed: ${formattedRate}`;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsx(styles.button, styles[size], isActive && styles.active, className)}
|
||||
aria-label={label}
|
||||
>
|
||||
{formatRate(rate)}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (showTooltip) {
|
||||
return (
|
||||
<Tooltip text={t`Playback speed`} position="top">
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.buffered {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 150ms ease;
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: var(--brand-primary-light, #5865f2);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.container:hover .thumb,
|
||||
.container:focus-within .thumb,
|
||||
.isDragging .thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.container:hover .thumb:hover,
|
||||
.isDragging .thumb {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-primary, #fff);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
z-index: var(--z-index-tooltip);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tooltipArrow {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid var(--background-primary);
|
||||
}
|
||||
|
||||
.compact {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.compact .track {
|
||||
height: 3px;
|
||||
top: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.compact .thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: -4px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FloatingPortal} from '@floating-ui/react';
|
||||
import {computePosition, offset} from '@floating-ui/react-dom';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
|
||||
import {formatTime} from '../utils/formatTime';
|
||||
import styles from './MediaProgressBar.module.css';
|
||||
|
||||
export interface MediaProgressBarProps {
|
||||
progress: number;
|
||||
buffered?: number;
|
||||
currentTime?: number;
|
||||
duration?: number;
|
||||
isSeeking?: boolean;
|
||||
onSeek?: (percentage: number) => void;
|
||||
onSeekStart?: () => void;
|
||||
onSeekEnd?: () => void;
|
||||
showPreview?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function MediaProgressBar({
|
||||
progress,
|
||||
buffered = 0,
|
||||
currentTime = 0,
|
||||
duration = 0,
|
||||
onSeek,
|
||||
onSeekStart,
|
||||
onSeekEnd,
|
||||
showPreview = true,
|
||||
className,
|
||||
compact = false,
|
||||
ariaLabel,
|
||||
}: MediaProgressBarProps) {
|
||||
const {t} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipPortalRoot = useTooltipPortalRoot();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
|
||||
const [hoverTime, setHoverTime] = useState<string>('0:00');
|
||||
const [tooltipPosition, setTooltipPosition] = useState({x: 0, y: 0, isReady: false});
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const getPercentageFromEvent = useCallback((clientX: number): number => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return 0;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
return Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
}, []);
|
||||
|
||||
const updateTooltipPosition = useCallback(async () => {
|
||||
if (hoverPosition === null || !containerRef.current || !tooltipRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const tooltip = tooltipRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () => ({
|
||||
x: rect.left + (hoverPosition / 100) * rect.width,
|
||||
y: rect.top,
|
||||
top: rect.top,
|
||||
left: rect.left + (hoverPosition / 100) * rect.width,
|
||||
bottom: rect.bottom,
|
||||
right: rect.left + (hoverPosition / 100) * rect.width,
|
||||
width: 0,
|
||||
height: rect.height,
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const {x, y} = await computePosition(virtualElement, tooltip, {
|
||||
placement: 'top',
|
||||
middleware: [offset(8)],
|
||||
});
|
||||
|
||||
setTooltipPosition({x, y, isReady: true});
|
||||
} catch (error) {
|
||||
console.error('Error positioning tooltip:', error);
|
||||
}
|
||||
}, [hoverPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoverPosition !== null && tooltipRef.current) {
|
||||
updateTooltipPosition();
|
||||
}
|
||||
}, [hoverPosition, updateTooltipPosition]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
const percentage = getPercentageFromEvent(e.clientX);
|
||||
setHoverPosition(percentage);
|
||||
|
||||
if (duration > 0) {
|
||||
const time = (percentage / 100) * duration;
|
||||
setHoverTime(formatTime(time));
|
||||
}
|
||||
|
||||
if (isDragging && onSeek) {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
onSeek(percentage);
|
||||
rafRef.current = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
[getPercentageFromEvent, duration, isDragging, onSeek],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const percentage = getPercentageFromEvent(e.clientX);
|
||||
|
||||
setIsDragging(true);
|
||||
onSeekStart?.();
|
||||
onSeek?.(percentage);
|
||||
},
|
||||
[getPercentageFromEvent, onSeekStart, onSeek],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging) {
|
||||
setIsDragging(false);
|
||||
onSeekEnd?.();
|
||||
}
|
||||
}, [isDragging, onSeekEnd]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!isDragging) {
|
||||
setHoverPosition(null);
|
||||
setTooltipPosition((prev) => ({...prev, isReady: false}));
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
const percentage = getPercentageFromEvent(touch.clientX);
|
||||
|
||||
setIsDragging(true);
|
||||
onSeekStart?.();
|
||||
onSeek?.(percentage);
|
||||
},
|
||||
[getPercentageFromEvent, onSeekStart, onSeek],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
const percentage = getPercentageFromEvent(touch.clientX);
|
||||
|
||||
setHoverPosition(percentage);
|
||||
onSeek?.(percentage);
|
||||
},
|
||||
[getPercentageFromEvent, onSeek],
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setHoverPosition(null);
|
||||
onSeekEnd?.();
|
||||
}, [onSeekEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => handleMouseMove(e);
|
||||
const handleGlobalMouseUp = () => handleMouseUp();
|
||||
const handleGlobalTouchMove = (e: TouchEvent) => handleTouchMove(e);
|
||||
const handleGlobalTouchEnd = () => handleTouchEnd();
|
||||
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
document.addEventListener('touchmove', handleGlobalTouchMove);
|
||||
document.addEventListener('touchend', handleGlobalTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
document.removeEventListener('touchmove', handleGlobalTouchMove);
|
||||
document.removeEventListener('touchend', handleGlobalTouchEnd);
|
||||
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!duration) return;
|
||||
|
||||
let newPercentage = progress;
|
||||
const step = 5;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
newPercentage = Math.max(0, progress - step);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
newPercentage = Math.min(100, progress + step);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
newPercentage = 0;
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
newPercentage = 100;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
onSeek?.(newPercentage);
|
||||
},
|
||||
[progress, duration, onSeek],
|
||||
);
|
||||
|
||||
const displayProgress = isDragging && hoverPosition !== null ? hoverPosition : progress;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.container, compact && styles.compact, isDragging && styles.isDragging, className)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={(e) => handleMouseMove(e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleTouchStart}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="slider"
|
||||
aria-label={ariaLabel || t`Media progress`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuetext={formatTime(currentTime)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.track}>
|
||||
<div className={styles.buffered} style={{width: `${buffered}%`}} />
|
||||
<div className={styles.fill} style={{width: `${displayProgress}%`}} />
|
||||
</div>
|
||||
<div ref={thumbRef} className={styles.thumb} style={{left: `${displayProgress}%`}} />
|
||||
|
||||
{showPreview && hoverPosition !== null && (
|
||||
<FloatingPortal root={tooltipPortalRoot}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="progress-tooltip"
|
||||
ref={tooltipRef}
|
||||
className={styles.tooltip}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPosition.x,
|
||||
top: tooltipPosition.y,
|
||||
visibility: tooltipPosition.isReady ? 'visible' : 'hidden',
|
||||
}}
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{
|
||||
opacity: {duration: 0.1},
|
||||
}}
|
||||
>
|
||||
{hoverTime}
|
||||
<div className={styles.tooltipArrow} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.time {
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.separator {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.small .time {
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.large .time {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.compact .separator,
|
||||
.compact .duration {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {formatTime} from '../utils/formatTime';
|
||||
import styles from './MediaTimeDisplay.module.css';
|
||||
|
||||
export interface MediaTimeDisplayProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MediaTimeDisplay({
|
||||
currentTime,
|
||||
duration,
|
||||
size = 'medium',
|
||||
compact = false,
|
||||
className,
|
||||
}: MediaTimeDisplayProps) {
|
||||
const {t} = useLingui();
|
||||
const currentFormatted = formatTime(currentTime);
|
||||
const durationFormatted = formatTime(duration);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, styles[size], compact && styles.compact, className)}
|
||||
aria-label={t`Time: ${currentFormatted} of ${durationFormatted}`}
|
||||
role="group"
|
||||
>
|
||||
<span className={styles.time}>{currentFormatted}</span>
|
||||
{!compact && (
|
||||
<>
|
||||
<span className={styles.separator}>/</span>
|
||||
<span className={clsx(styles.time, styles.duration)}>{durationFormatted}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.muteButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
background-color 150ms ease;
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.muteButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.muteButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary, #5865f2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sliderWrapper {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.sliderTrack {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sliderFill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.sliderThumb {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.container:hover .sliderThumb,
|
||||
.slider:focus-within .sliderThumb,
|
||||
.isDragging .sliderThumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.isDragging .sliderThumb {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.compact {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.compact .muteButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.compact .slider {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.compact .sliderTrack {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.compact .sliderThumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {SpeakerHighIcon, SpeakerLowIcon, SpeakerNoneIcon, SpeakerXIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import KeyboardModeStore from '~/stores/KeyboardModeStore';
|
||||
import styles from './MediaVolumeControl.module.css';
|
||||
|
||||
export interface MediaVolumeControlProps {
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onToggleMute: () => void;
|
||||
expandable?: boolean;
|
||||
iconSize?: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getVolumeIcon(volume: number, isMuted: boolean) {
|
||||
if (isMuted || volume === 0) {
|
||||
return SpeakerXIcon;
|
||||
}
|
||||
if (volume < 0.33) {
|
||||
return SpeakerNoneIcon;
|
||||
}
|
||||
if (volume < 0.67) {
|
||||
return SpeakerLowIcon;
|
||||
}
|
||||
return SpeakerHighIcon;
|
||||
}
|
||||
|
||||
export const MediaVolumeControl = observer(function MediaVolumeControl({
|
||||
volume,
|
||||
isMuted,
|
||||
onVolumeChange,
|
||||
onToggleMute,
|
||||
expandable = false,
|
||||
iconSize = 20,
|
||||
compact = false,
|
||||
className,
|
||||
}: MediaVolumeControlProps) {
|
||||
const {t} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const Icon = getVolumeIcon(volume, isMuted);
|
||||
const displayVolume = isMuted ? 0 : volume;
|
||||
|
||||
const getVolumeFromEvent = useCallback((clientX: number): number => {
|
||||
const slider = sliderRef.current;
|
||||
if (!slider) return 0;
|
||||
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
return Math.max(0, Math.min(1, x / rect.width));
|
||||
}, []);
|
||||
|
||||
const handleMuteClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleMute();
|
||||
},
|
||||
[onToggleMute],
|
||||
);
|
||||
|
||||
const handleSliderMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newVolume = getVolumeFromEvent(e.clientX);
|
||||
setIsDragging(true);
|
||||
onVolumeChange(newVolume);
|
||||
},
|
||||
[getVolumeFromEvent, onVolumeChange],
|
||||
);
|
||||
|
||||
const handleSliderMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const newVolume = getVolumeFromEvent(e.clientX);
|
||||
onVolumeChange(newVolume);
|
||||
rafRef.current = null;
|
||||
});
|
||||
},
|
||||
[isDragging, getVolumeFromEvent, onVolumeChange],
|
||||
);
|
||||
|
||||
const handleSliderMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleSliderTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
e.stopPropagation();
|
||||
const touch = e.touches[0];
|
||||
const newVolume = getVolumeFromEvent(touch.clientX);
|
||||
setIsDragging(true);
|
||||
onVolumeChange(newVolume);
|
||||
},
|
||||
[getVolumeFromEvent, onVolumeChange],
|
||||
);
|
||||
|
||||
const handleSliderTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (!isDragging) return;
|
||||
const touch = e.touches[0];
|
||||
const newVolume = getVolumeFromEvent(touch.clientX);
|
||||
onVolumeChange(newVolume);
|
||||
},
|
||||
[isDragging, getVolumeFromEvent, onVolumeChange],
|
||||
);
|
||||
|
||||
const handleSliderTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleSliderKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
let newVolume = volume;
|
||||
const step = 0.1;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
newVolume = Math.max(0, volume - step);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
newVolume = Math.min(1, volume + step);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
newVolume = 0;
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
newVolume = 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
onVolumeChange(newVolume);
|
||||
},
|
||||
[volume, onVolumeChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleSliderMouseMove);
|
||||
document.addEventListener('mouseup', handleSliderMouseUp);
|
||||
document.addEventListener('touchmove', handleSliderTouchMove);
|
||||
document.addEventListener('touchend', handleSliderTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleSliderMouseMove);
|
||||
document.removeEventListener('mouseup', handleSliderMouseUp);
|
||||
document.removeEventListener('touchmove', handleSliderTouchMove);
|
||||
document.removeEventListener('touchend', handleSliderTouchEnd);
|
||||
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [isDragging, handleSliderMouseMove, handleSliderMouseUp, handleSliderTouchMove, handleSliderTouchEnd]);
|
||||
|
||||
const muteLabel = isMuted ? t`Unmute` : t`Mute`;
|
||||
const isKeyboardMode = KeyboardModeStore.keyboardModeEnabled;
|
||||
const isExpanded = !expandable || isHovered || isDragging || (isFocused && isKeyboardMode);
|
||||
const sliderWidth = compact ? 62 : 72;
|
||||
|
||||
const handleButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const step = 0.1;
|
||||
let newVolume = volume;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
newVolume = Math.max(0, volume - step);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
newVolume = Math.min(1, volume + step);
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleMute();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
onVolumeChange(newVolume);
|
||||
},
|
||||
[volume, onVolumeChange, onToggleMute],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
||||
|
||||
const sliderElement = (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={clsx(styles.slider, isDragging && styles.isDragging)}
|
||||
onMouseDown={handleSliderMouseDown}
|
||||
onTouchStart={handleSliderTouchStart}
|
||||
onKeyDown={handleSliderKeyDown}
|
||||
role="slider"
|
||||
aria-label={t`Volume`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(displayVolume * 100)}
|
||||
aria-valuetext={`${Math.round(displayVolume * 100)}%`}
|
||||
aria-orientation="horizontal"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.sliderTrack}>
|
||||
<div className={styles.sliderFill} style={{width: `${displayVolume * 100}%`}} />
|
||||
</div>
|
||||
<div
|
||||
className={styles.sliderThumb}
|
||||
style={{left: `calc(6px + ${displayVolume * 100}% - ${displayVolume * 12}px)`}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.container, compact && styles.compact, className)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => !isDragging && setIsHovered(false)}
|
||||
role="group"
|
||||
aria-label={t`Volume Control`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMuteClick}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={styles.muteButton}
|
||||
aria-label={muteLabel}
|
||||
>
|
||||
<Icon size={iconSize} weight="fill" />
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip text={muteLabel} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMuteClick}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={styles.muteButton}
|
||||
aria-label={muteLabel}
|
||||
>
|
||||
<Icon size={iconSize} weight="fill" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{expandable ? (
|
||||
<motion.div
|
||||
className={styles.sliderWrapper}
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isExpanded ? sliderWidth : 0,
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
{sliderElement}
|
||||
</motion.div>
|
||||
) : (
|
||||
sliderElement
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.container:focus-visible {
|
||||
outline: 2px solid var(--brand-primary, #5865f2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.videoHidden {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.posterOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.posterOverlay:hover {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.posterImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbHashPlaceholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.playOverlayButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.playOverlayButton:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.playOverlayButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.controlsOverlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 0 4px 0;
|
||||
background-color: var(--background-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.controlButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.controlButton:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.timeDisplay {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playPauseIndicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fillContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.fillContainer .video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-index-modal);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.loadingOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.controlsRow {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.timeDisplay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 240px) {
|
||||
.controlsLeft > *:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controlsRight > *:not(:last-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CircleNotchIcon, PauseIcon, PlayIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {thumbHashToDataURL} from 'thumbhash';
|
||||
import VideoVolumeStore from '~/stores/VideoVolumeStore';
|
||||
import * as ImageCacheUtils from '~/utils/ImageCacheUtils';
|
||||
import {useControlsVisibility} from '../hooks/useControlsVisibility';
|
||||
import {useMediaFullscreen} from '../hooks/useMediaFullscreen';
|
||||
import {useMediaKeyboard} from '../hooks/useMediaKeyboard';
|
||||
import {useMediaPiP} from '../hooks/useMediaPiP';
|
||||
import {useMediaPlayer} from '../hooks/useMediaPlayer';
|
||||
import {useMediaProgress} from '../hooks/useMediaProgress';
|
||||
import {VIDEO_BREAKPOINTS, VIDEO_PLAYBACK_RATES} from '../utils/mediaConstants';
|
||||
import {MediaFullscreenButton} from './MediaFullscreenButton';
|
||||
import {MediaPipButton} from './MediaPipButton';
|
||||
import {MediaPlayButton} from './MediaPlayButton';
|
||||
import {MediaPlaybackRate} from './MediaPlaybackRate';
|
||||
import {MediaProgressBar} from './MediaProgressBar';
|
||||
import {MediaTimeDisplay} from './MediaTimeDisplay';
|
||||
import {MediaVolumeControl} from './MediaVolumeControl';
|
||||
import styles from './VideoPlayer.module.css';
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
placeholder?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
fillContainer?: boolean;
|
||||
isMobile?: boolean;
|
||||
onInitialPlay?: () => void;
|
||||
onEnded?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const VideoPlayer = observer(function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
placeholder,
|
||||
duration: initialDuration,
|
||||
width,
|
||||
height,
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
fillContainer = false,
|
||||
isMobile = false,
|
||||
onInitialPlay,
|
||||
onEnded,
|
||||
className,
|
||||
style,
|
||||
}: VideoPlayerProps) {
|
||||
const {t} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hasPlayed, setHasPlayed] = useState(autoPlay);
|
||||
const [containerWidth, setContainerWidth] = useState(width || VIDEO_BREAKPOINTS.LARGE + 1);
|
||||
const [isInteracting, setIsInteracting] = useState(false);
|
||||
const [showPlayPauseIndicator, setShowPlayPauseIndicator] = useState<'play' | 'pause' | null>(null);
|
||||
const prevPlayingRef = useRef<boolean | null>(null);
|
||||
|
||||
const [posterLoaded, setPosterLoaded] = useState(poster ? ImageCacheUtils.hasImage(poster) : false);
|
||||
|
||||
const thumbHashURL = useMemo(() => {
|
||||
if (!placeholder) return undefined;
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(placeholder), (c) => c.charCodeAt(0));
|
||||
return thumbHashToDataURL(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [placeholder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!poster) return;
|
||||
if (ImageCacheUtils.hasImage(poster)) {
|
||||
setPosterLoaded(true);
|
||||
return;
|
||||
}
|
||||
ImageCacheUtils.loadImage(
|
||||
poster,
|
||||
() => setPosterLoaded(true),
|
||||
() => setPosterLoaded(false),
|
||||
);
|
||||
}, [poster]);
|
||||
|
||||
const [volume, setVolumeState] = useState(VideoVolumeStore.volume);
|
||||
const [isMuted, setIsMutedState] = useState(VideoVolumeStore.isMuted);
|
||||
|
||||
const {mediaRef, state, play, toggle, seekRelative, setPlaybackRate} = useMediaPlayer({
|
||||
autoPlay,
|
||||
loop,
|
||||
persistVolume: false,
|
||||
persistPlaybackRate: true,
|
||||
onEnded,
|
||||
});
|
||||
|
||||
const {currentTime, duration, progress, buffered, seekToPercentage, startSeeking, endSeeking} = useMediaProgress({
|
||||
mediaRef,
|
||||
initialDuration,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.volume = volume;
|
||||
media.muted = isMuted;
|
||||
}, [mediaRef, volume, isMuted]);
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(newVolume: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, newVolume));
|
||||
setVolumeState(clamped);
|
||||
VideoVolumeStore.setVolume(clamped);
|
||||
if (isMuted && clamped > 0) {
|
||||
setIsMutedState(false);
|
||||
}
|
||||
},
|
||||
[isMuted],
|
||||
);
|
||||
|
||||
const handleToggleMute = useCallback(() => {
|
||||
setIsMutedState((prev) => !prev);
|
||||
VideoVolumeStore.toggleMute();
|
||||
}, []);
|
||||
|
||||
const {isFullscreen, supportsFullscreen, toggleFullscreen} = useMediaFullscreen({
|
||||
containerRef,
|
||||
videoRef: mediaRef as React.RefObject<HTMLVideoElement | null>,
|
||||
});
|
||||
|
||||
const {isPiP, supportsPiP, togglePiP} = useMediaPiP({
|
||||
videoRef: mediaRef as React.RefObject<HTMLVideoElement | null>,
|
||||
});
|
||||
|
||||
const {controlsVisible, showControls, containerProps} = useControlsVisibility({
|
||||
isPlaying: state.isPlaying,
|
||||
isInteracting,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (prevPlayingRef.current !== null && prevPlayingRef.current !== state.isPlaying && hasPlayed) {
|
||||
setShowPlayPauseIndicator(state.isPlaying ? 'play' : 'pause');
|
||||
const timer = setTimeout(() => setShowPlayPauseIndicator(null), 500);
|
||||
prevPlayingRef.current = state.isPlaying;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
prevPlayingRef.current = state.isPlaying;
|
||||
return undefined;
|
||||
}, [state.isPlaying, hasPlayed]);
|
||||
|
||||
useMediaKeyboard({
|
||||
containerRef,
|
||||
enabled: true,
|
||||
onTogglePlay: toggle,
|
||||
onSeekBackward: (amount) => seekRelative(-amount),
|
||||
onSeekForward: (amount) => seekRelative(amount),
|
||||
onVolumeUp: () => handleVolumeChange(volume + 0.1),
|
||||
onVolumeDown: () => handleVolumeChange(volume - 0.1),
|
||||
onToggleMute: handleToggleMute,
|
||||
onToggleFullscreen: toggleFullscreen,
|
||||
onSeekPercentage: seekToPercentage,
|
||||
});
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
setContainerWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [handleResize]);
|
||||
|
||||
const hasAutoPlayedRef = useRef(autoPlay);
|
||||
useEffect(() => {
|
||||
if (hasPlayed && !hasAutoPlayedRef.current) {
|
||||
hasAutoPlayedRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
play();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [hasPlayed, play]);
|
||||
|
||||
const handlePosterClick = useCallback(() => {
|
||||
setHasPlayed(true);
|
||||
onInitialPlay?.();
|
||||
}, [onInitialPlay]);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(percentage: number) => {
|
||||
seekToPercentage(percentage);
|
||||
},
|
||||
[seekToPercentage],
|
||||
);
|
||||
|
||||
const handleSeekStart = useCallback(() => {
|
||||
setIsInteracting(true);
|
||||
startSeeking();
|
||||
}, [startSeeking]);
|
||||
|
||||
const handleSeekEnd = useCallback(() => {
|
||||
setIsInteracting(false);
|
||||
endSeeking();
|
||||
}, [endSeeking]);
|
||||
|
||||
const handleVideoClick = useCallback(() => {
|
||||
if (isMobile) {
|
||||
showControls();
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
}, [isMobile, showControls, toggle]);
|
||||
|
||||
const isSmall = containerWidth < VIDEO_BREAKPOINTS.SMALL;
|
||||
const isMedium = containerWidth < VIDEO_BREAKPOINTS.MEDIUM;
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
...style,
|
||||
...(width && height && !fillContainer ? {aspectRatio: `${width} / ${height}`} : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
styles.container,
|
||||
fillContainer && styles.fillContainer,
|
||||
isFullscreen && styles.fullscreen,
|
||||
className,
|
||||
)}
|
||||
style={containerStyle}
|
||||
{...containerProps}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: Video player doesn't require captions for now */}
|
||||
<video
|
||||
ref={mediaRef as React.RefObject<HTMLVideoElement>}
|
||||
className={clsx(styles.video, !hasPlayed && styles.videoHidden)}
|
||||
src={hasPlayed ? src : undefined}
|
||||
preload="none"
|
||||
playsInline
|
||||
onClick={handleVideoClick}
|
||||
aria-label={t`Video`}
|
||||
/>
|
||||
|
||||
{!hasPlayed && !autoPlay && (
|
||||
<div
|
||||
className={styles.posterOverlay}
|
||||
onClick={handlePosterClick}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handlePosterClick()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{thumbHashURL && !posterLoaded && (
|
||||
<motion.img
|
||||
key="thumbhash"
|
||||
initial={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
src={thumbHashURL}
|
||||
alt=""
|
||||
className={styles.thumbHashPlaceholder}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{poster && posterLoaded && <img src={poster} alt="" className={styles.posterImage} />}
|
||||
|
||||
<button type="button" className={styles.playOverlayButton} aria-label={t`Play video`}>
|
||||
<PlayIcon size={24} weight="fill" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.isBuffering && hasPlayed && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<CircleNotchIcon size={48} weight="bold" className={styles.spinner} color="#fff" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showPlayPauseIndicator && (
|
||||
<motion.div
|
||||
className={styles.playPauseIndicator}
|
||||
initial={{opacity: 0, scale: 0.5, x: '-50%', y: '-50%'}}
|
||||
animate={{opacity: 1, scale: 1, x: '-50%', y: '-50%'}}
|
||||
exit={{opacity: 0, scale: 1.2, x: '-50%', y: '-50%'}}
|
||||
transition={{duration: 0.2}}
|
||||
>
|
||||
{showPlayPauseIndicator === 'play' ? (
|
||||
<PlayIcon size={24} weight="fill" />
|
||||
) : (
|
||||
<PauseIcon size={24} weight="fill" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{(hasPlayed || autoPlay) && (
|
||||
<motion.div
|
||||
className={styles.controlsOverlay}
|
||||
initial={{y: '100%'}}
|
||||
animate={{y: controlsVisible ? 0 : '100%'}}
|
||||
exit={{y: '100%'}}
|
||||
transition={{duration: 0.2, ease: 'easeOut'}}
|
||||
>
|
||||
<MediaProgressBar
|
||||
progress={progress}
|
||||
buffered={buffered}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={handleSeek}
|
||||
onSeekStart={handleSeekStart}
|
||||
onSeekEnd={handleSeekEnd}
|
||||
className={styles.progressBar}
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className={styles.controlsRow}>
|
||||
<div className={styles.controlsLeft}>
|
||||
<MediaPlayButton isPlaying={state.isPlaying} onToggle={toggle} size="small" />
|
||||
{!isSmall && (
|
||||
<MediaVolumeControl
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onToggleMute={handleToggleMute}
|
||||
expandable
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{!isSmall && <MediaTimeDisplay currentTime={currentTime} duration={duration} size="small" />}
|
||||
</div>
|
||||
|
||||
<div className={styles.controlsCenter} />
|
||||
|
||||
<div className={styles.controlsRight}>
|
||||
{!isMedium && (
|
||||
<MediaPlaybackRate
|
||||
rate={state.playbackRate}
|
||||
onRateChange={setPlaybackRate}
|
||||
rates={VIDEO_PLAYBACK_RATES}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{!isMedium && supportsPiP && (
|
||||
<MediaPipButton
|
||||
isPiP={isPiP}
|
||||
supportsPiP={supportsPiP}
|
||||
onToggle={togglePiP}
|
||||
iconSize={18}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{supportsFullscreen && (
|
||||
<MediaFullscreenButton
|
||||
isFullscreen={isFullscreen}
|
||||
supportsFullscreen={supportsFullscreen}
|
||||
onToggle={toggleFullscreen}
|
||||
iconSize={18}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useRef, useState} from 'react';
|
||||
|
||||
export interface UseControlsVisibilityOptions {
|
||||
autohideDelay?: number;
|
||||
disabled?: boolean;
|
||||
isPlaying?: boolean;
|
||||
isInteracting?: boolean;
|
||||
}
|
||||
|
||||
export interface UseControlsVisibilityReturn {
|
||||
controlsVisible: boolean;
|
||||
showControls: () => void;
|
||||
hideControls: () => void;
|
||||
containerProps: {
|
||||
onMouseMove: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onTouchStart: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useControlsVisibility(options: UseControlsVisibilityOptions = {}): UseControlsVisibilityReturn {
|
||||
const {disabled = false, isPlaying = false, isInteracting = false} = options;
|
||||
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const isHoveredRef = useRef(false);
|
||||
|
||||
const shouldShowControls = disabled || !isPlaying || isHoveredRef.current || isInteracting;
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
}, []);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
setControlsVisible(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (!controlsVisible) {
|
||||
setControlsVisible(true);
|
||||
}
|
||||
}, [controlsVisible]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
isHoveredRef.current = true;
|
||||
setControlsVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
isHoveredRef.current = false;
|
||||
if (isPlaying && !isInteracting) {
|
||||
setControlsVisible(false);
|
||||
}
|
||||
}, [isPlaying, isInteracting]);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
if (isPlaying && !isInteracting) {
|
||||
setControlsVisible((prev) => !prev);
|
||||
} else {
|
||||
setControlsVisible(true);
|
||||
}
|
||||
}, [isPlaying, isInteracting]);
|
||||
|
||||
const finalVisible = shouldShowControls || controlsVisible;
|
||||
|
||||
return {
|
||||
controlsVisible: finalVisible,
|
||||
showControls,
|
||||
hideControls,
|
||||
containerProps: {
|
||||
onMouseMove: handleMouseMove,
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
onTouchStart: handleTouchStart,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import type {ExtendedHTMLElement, ExtendedHTMLVideoElement} from '~/types/browser';
|
||||
import {
|
||||
getExtendedDocument,
|
||||
supportsMozRequestFullScreen,
|
||||
supportsMsRequestFullscreen,
|
||||
supportsWebkitRequestFullscreen,
|
||||
} from '~/types/browser';
|
||||
|
||||
export interface UseMediaFullscreenOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null> | React.RefObject<HTMLElement>;
|
||||
videoRef?: React.RefObject<HTMLVideoElement | null>;
|
||||
onFullscreenChange?: (isFullscreen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseMediaFullscreenReturn {
|
||||
isFullscreen: boolean;
|
||||
supportsFullscreen: boolean;
|
||||
enterFullscreen: () => Promise<void>;
|
||||
exitFullscreen: () => Promise<void>;
|
||||
toggleFullscreen: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getFullscreenElement(): Element | null {
|
||||
const doc = getExtendedDocument();
|
||||
return (
|
||||
document.fullscreenElement ||
|
||||
doc.webkitFullscreenElement ||
|
||||
doc.mozFullScreenElement ||
|
||||
doc.msFullscreenElement ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function supportsContainerFullscreenAPI(): boolean {
|
||||
const doc = getExtendedDocument();
|
||||
return !!(
|
||||
document.fullscreenEnabled ||
|
||||
doc.webkitFullscreenEnabled ||
|
||||
doc.mozFullScreenEnabled ||
|
||||
doc.msFullscreenEnabled
|
||||
);
|
||||
}
|
||||
|
||||
function supportsIOSVideoFullscreen(videoElement: HTMLVideoElement | null): boolean {
|
||||
if (!videoElement) return false;
|
||||
const extendedVideo = videoElement as ExtendedHTMLVideoElement;
|
||||
return !!(extendedVideo.webkitSupportsFullscreen || extendedVideo.webkitEnterFullscreen);
|
||||
}
|
||||
|
||||
async function requestFullscreen(element: HTMLElement): Promise<void> {
|
||||
if (element.requestFullscreen) {
|
||||
await element.requestFullscreen();
|
||||
} else if (supportsWebkitRequestFullscreen(element)) {
|
||||
const extendedElement = element as ExtendedHTMLElement;
|
||||
await extendedElement.webkitRequestFullscreen!();
|
||||
} else if (supportsMozRequestFullScreen(element)) {
|
||||
const extendedElement = element as ExtendedHTMLElement;
|
||||
await extendedElement.mozRequestFullScreen!();
|
||||
} else if (supportsMsRequestFullscreen(element)) {
|
||||
const extendedElement = element as ExtendedHTMLElement;
|
||||
await extendedElement.msRequestFullscreen!();
|
||||
}
|
||||
}
|
||||
|
||||
async function exitFullscreenAPI(): Promise<void> {
|
||||
const doc = getExtendedDocument();
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if (doc.webkitExitFullscreen) {
|
||||
await doc.webkitExitFullscreen();
|
||||
} else if (doc.mozCancelFullScreen) {
|
||||
await doc.mozCancelFullScreen();
|
||||
} else if (doc.msExitFullscreen) {
|
||||
await doc.msExitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
export function useMediaFullscreen(options: UseMediaFullscreenOptions): UseMediaFullscreenReturn {
|
||||
const {containerRef, videoRef, onFullscreenChange} = options;
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [supportsFullscreen, setSupportsFullscreen] = useState(() => supportsContainerFullscreenAPI());
|
||||
const [useIOSFullscreen, setUseIOSFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasContainerSupport = supportsContainerFullscreenAPI();
|
||||
const hasIOSSupport = supportsIOSVideoFullscreen(videoRef?.current ?? null);
|
||||
|
||||
setSupportsFullscreen(hasContainerSupport || hasIOSSupport);
|
||||
setUseIOSFullscreen(!hasContainerSupport && hasIOSSupport);
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const fullscreenElement = getFullscreenElement();
|
||||
const isNowFullscreen = fullscreenElement === containerRef.current;
|
||||
setIsFullscreen(isNowFullscreen);
|
||||
onFullscreenChange?.(isNowFullscreen);
|
||||
};
|
||||
|
||||
const handleIOSFullscreenChange = () => {
|
||||
const video = videoRef?.current as ExtendedHTMLVideoElement | null;
|
||||
if (video) {
|
||||
const isNowFullscreen = video.webkitDisplayingFullscreen ?? false;
|
||||
setIsFullscreen(isNowFullscreen);
|
||||
onFullscreenChange?.(isNowFullscreen);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||
|
||||
const video = videoRef?.current;
|
||||
if (video) {
|
||||
video.addEventListener('webkitbeginfullscreen', handleIOSFullscreenChange);
|
||||
video.addEventListener('webkitendfullscreen', handleIOSFullscreenChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||
|
||||
if (video) {
|
||||
video.removeEventListener('webkitbeginfullscreen', handleIOSFullscreenChange);
|
||||
video.removeEventListener('webkitendfullscreen', handleIOSFullscreenChange);
|
||||
}
|
||||
};
|
||||
}, [containerRef, videoRef, onFullscreenChange]);
|
||||
|
||||
const enterFullscreen = useCallback(async () => {
|
||||
if (useIOSFullscreen && videoRef?.current) {
|
||||
try {
|
||||
const video = videoRef.current as ExtendedHTMLVideoElement;
|
||||
if (video.webkitEnterFullscreen) {
|
||||
await video.webkitEnterFullscreen();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enter iOS fullscreen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container || !supportsContainerFullscreenAPI()) return;
|
||||
|
||||
try {
|
||||
await requestFullscreen(container);
|
||||
} catch (error) {
|
||||
console.error('Failed to enter fullscreen:', error);
|
||||
}
|
||||
}, [containerRef, videoRef, useIOSFullscreen]);
|
||||
|
||||
const exitFullscreen = useCallback(async () => {
|
||||
if (useIOSFullscreen && videoRef?.current) {
|
||||
try {
|
||||
const video = videoRef.current as ExtendedHTMLVideoElement;
|
||||
if (video.webkitExitFullscreen && video.webkitDisplayingFullscreen) {
|
||||
await video.webkitExitFullscreen();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to exit iOS fullscreen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!getFullscreenElement()) return;
|
||||
|
||||
try {
|
||||
await exitFullscreenAPI();
|
||||
} catch (error) {
|
||||
console.error('Failed to exit fullscreen:', error);
|
||||
}
|
||||
}, [videoRef, useIOSFullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
if (isFullscreen) {
|
||||
await exitFullscreen();
|
||||
} else {
|
||||
await enterFullscreen();
|
||||
}
|
||||
}, [isFullscreen, enterFullscreen, exitFullscreen]);
|
||||
|
||||
return {
|
||||
isFullscreen,
|
||||
supportsFullscreen,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
};
|
||||
}
|
||||
144
fluxer_app/src/components/media-player/hooks/useMediaKeyboard.ts
Normal file
144
fluxer_app/src/components/media-player/hooks/useMediaKeyboard.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {SEEK_STEP, VOLUME_STEP} from '../utils/mediaConstants';
|
||||
|
||||
export interface UseMediaKeyboardOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
enabled?: boolean;
|
||||
onTogglePlay?: () => void;
|
||||
onSeekBackward?: (amount: number) => void;
|
||||
onSeekForward?: (amount: number) => void;
|
||||
onVolumeUp?: (step: number) => void;
|
||||
onVolumeDown?: (step: number) => void;
|
||||
onToggleMute?: () => void;
|
||||
onToggleFullscreen?: () => void;
|
||||
onSeekPercentage?: (percentage: number) => void;
|
||||
seekAmount?: number;
|
||||
volumeStep?: number;
|
||||
}
|
||||
|
||||
export interface UseMediaKeyboardReturn {
|
||||
handleKeyDown: (event: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
function keyMatches(key: string, keys: ReadonlyArray<string>): boolean {
|
||||
return keys.includes(key);
|
||||
}
|
||||
|
||||
const PLAY_PAUSE_KEYS = ['Space', 'k', 'K'] as const;
|
||||
const SEEK_BACKWARD_KEYS = ['ArrowLeft', 'j', 'J'] as const;
|
||||
const SEEK_FORWARD_KEYS = ['ArrowRight', 'l', 'L'] as const;
|
||||
const VOLUME_UP_KEYS = ['ArrowUp'] as const;
|
||||
const VOLUME_DOWN_KEYS = ['ArrowDown'] as const;
|
||||
const MUTE_KEYS = ['m', 'M'] as const;
|
||||
const FULLSCREEN_KEYS = ['f', 'F'] as const;
|
||||
const SEEK_PERCENTAGE_KEYS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] as const;
|
||||
|
||||
export function useMediaKeyboard(options: UseMediaKeyboardOptions): UseMediaKeyboardReturn {
|
||||
const {
|
||||
containerRef,
|
||||
enabled = true,
|
||||
onTogglePlay,
|
||||
onSeekBackward,
|
||||
onSeekForward,
|
||||
onVolumeUp,
|
||||
onVolumeDown,
|
||||
onToggleMute,
|
||||
onToggleFullscreen,
|
||||
onSeekPercentage,
|
||||
seekAmount = SEEK_STEP,
|
||||
volumeStep = VOLUME_STEP,
|
||||
} = options;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {key} = event;
|
||||
|
||||
if (keyMatches(key, PLAY_PAUSE_KEYS)) {
|
||||
event.preventDefault();
|
||||
onTogglePlay?.();
|
||||
} else if (keyMatches(key, SEEK_BACKWARD_KEYS)) {
|
||||
event.preventDefault();
|
||||
onSeekBackward?.(seekAmount);
|
||||
} else if (keyMatches(key, SEEK_FORWARD_KEYS)) {
|
||||
event.preventDefault();
|
||||
onSeekForward?.(seekAmount);
|
||||
} else if (keyMatches(key, VOLUME_UP_KEYS)) {
|
||||
event.preventDefault();
|
||||
onVolumeUp?.(volumeStep);
|
||||
} else if (keyMatches(key, VOLUME_DOWN_KEYS)) {
|
||||
event.preventDefault();
|
||||
onVolumeDown?.(volumeStep);
|
||||
} else if (keyMatches(key, MUTE_KEYS)) {
|
||||
event.preventDefault();
|
||||
onToggleMute?.();
|
||||
} else if (keyMatches(key, FULLSCREEN_KEYS)) {
|
||||
event.preventDefault();
|
||||
onToggleFullscreen?.();
|
||||
} else if (keyMatches(key, SEEK_PERCENTAGE_KEYS)) {
|
||||
event.preventDefault();
|
||||
const percentage = parseInt(key, 10) * 10;
|
||||
onSeekPercentage?.(percentage);
|
||||
}
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
onTogglePlay,
|
||||
onSeekBackward,
|
||||
onSeekForward,
|
||||
onVolumeUp,
|
||||
onVolumeDown,
|
||||
onToggleMute,
|
||||
onToggleFullscreen,
|
||||
onSeekPercentage,
|
||||
seekAmount,
|
||||
volumeStep,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !enabled) return;
|
||||
|
||||
const handleContainerKeyDown = (event: KeyboardEvent) => {
|
||||
if (container.contains(document.activeElement)) {
|
||||
handleKeyDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', handleContainerKeyDown);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleContainerKeyDown);
|
||||
};
|
||||
}, [containerRef, enabled, handleKeyDown]);
|
||||
|
||||
return {
|
||||
handleKeyDown: handleKeyDown as (event: React.KeyboardEvent) => void,
|
||||
};
|
||||
}
|
||||
110
fluxer_app/src/components/media-player/hooks/useMediaPiP.ts
Normal file
110
fluxer_app/src/components/media-player/hooks/useMediaPiP.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {supportsDisablePictureInPicture} from '~/types/browser';
|
||||
|
||||
export interface UseMediaPiPOptions {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null> | React.RefObject<HTMLVideoElement>;
|
||||
onPiPChange?: (isPiP: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseMediaPiPReturn {
|
||||
isPiP: boolean;
|
||||
supportsPiP: boolean;
|
||||
enterPiP: () => Promise<void>;
|
||||
exitPiP: () => Promise<void>;
|
||||
togglePiP: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useMediaPiP(options: UseMediaPiPOptions): UseMediaPiPReturn {
|
||||
const {videoRef, onPiPChange} = options;
|
||||
|
||||
const [isPiP, setIsPiP] = useState(false);
|
||||
const [supportsPiP] = useState(() => {
|
||||
if (!document.pictureInPictureEnabled) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleEnterPiP = () => {
|
||||
setIsPiP(true);
|
||||
onPiPChange?.(true);
|
||||
};
|
||||
|
||||
const handleLeavePiP = () => {
|
||||
setIsPiP(false);
|
||||
onPiPChange?.(false);
|
||||
};
|
||||
|
||||
video.addEventListener('enterpictureinpicture', handleEnterPiP);
|
||||
video.addEventListener('leavepictureinpicture', handleLeavePiP);
|
||||
|
||||
if (document.pictureInPictureElement === video) {
|
||||
setIsPiP(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('enterpictureinpicture', handleEnterPiP);
|
||||
video.removeEventListener('leavepictureinpicture', handleLeavePiP);
|
||||
};
|
||||
}, [videoRef, onPiPChange]);
|
||||
|
||||
const enterPiP = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !supportsPiP) return;
|
||||
|
||||
if (supportsDisablePictureInPicture(video) && video.disablePictureInPicture) return;
|
||||
|
||||
try {
|
||||
await video.requestPictureInPicture();
|
||||
} catch (error) {
|
||||
console.error('Failed to enter PiP:', error);
|
||||
}
|
||||
}, [videoRef, supportsPiP]);
|
||||
|
||||
const exitPiP = useCallback(async () => {
|
||||
if (!document.pictureInPictureElement) return;
|
||||
|
||||
try {
|
||||
await document.exitPictureInPicture();
|
||||
} catch (error) {
|
||||
console.error('Failed to exit PiP:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePiP = useCallback(async () => {
|
||||
if (isPiP) {
|
||||
await exitPiP();
|
||||
} else {
|
||||
await enterPiP();
|
||||
}
|
||||
}, [isPiP, enterPiP, exitPiP]);
|
||||
|
||||
return {
|
||||
isPiP,
|
||||
supportsPiP,
|
||||
enterPiP,
|
||||
exitPiP,
|
||||
togglePiP,
|
||||
};
|
||||
}
|
||||
440
fluxer_app/src/components/media-player/hooks/useMediaPlayer.ts
Normal file
440
fluxer_app/src/components/media-player/hooks/useMediaPlayer.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {DEFAULT_VOLUME, MUTE_STORAGE_KEY, PLAYBACK_RATE_STORAGE_KEY, VOLUME_STORAGE_KEY} from '../utils/mediaConstants';
|
||||
|
||||
export interface MediaPlayerState {
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
isEnded: boolean;
|
||||
isBuffering: boolean;
|
||||
isSeeking: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
bufferedRanges: TimeRanges | null;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
playbackRate: number;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface UseMediaPlayerOptions {
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
initialVolume?: number;
|
||||
initialPlaybackRate?: number;
|
||||
persistVolume?: boolean;
|
||||
persistPlaybackRate?: boolean;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
onLoadedMetadata?: (duration: number) => void;
|
||||
}
|
||||
|
||||
export interface UseMediaPlayerReturn {
|
||||
mediaRef: React.RefObject<HTMLMediaElement | null>;
|
||||
state: MediaPlayerState;
|
||||
play: () => Promise<void>;
|
||||
pause: () => void;
|
||||
toggle: () => Promise<void>;
|
||||
seek: (time: number) => void;
|
||||
seekRelative: (delta: number) => void;
|
||||
seekPercentage: (percentage: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
toggleMute: () => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
}
|
||||
|
||||
function getStoredVolume(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(VOLUME_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
const value = parseFloat(stored);
|
||||
if (Number.isFinite(value) && value >= 0 && value <= 1) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
function getStoredMuted(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(MUTE_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredPlaybackRate(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_RATE_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
const value = parseFloat(stored);
|
||||
if (Number.isFinite(value) && value >= 0.25 && value <= 4) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function storeVolume(volume: number): void {
|
||||
try {
|
||||
localStorage.setItem(VOLUME_STORAGE_KEY, volume.toString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function storeMuted(muted: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(MUTE_STORAGE_KEY, muted.toString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function storePlaybackRate(rate: number): void {
|
||||
try {
|
||||
localStorage.setItem(PLAYBACK_RATE_STORAGE_KEY, rate.toString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPlayerReturn {
|
||||
const {
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
muted: initialMuted,
|
||||
initialVolume,
|
||||
initialPlaybackRate,
|
||||
persistVolume = true,
|
||||
persistPlaybackRate = true,
|
||||
onEnded,
|
||||
onError,
|
||||
onPlay,
|
||||
onPause,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
} = options;
|
||||
|
||||
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
||||
const previousVolumeRef = useRef<number>(DEFAULT_VOLUME);
|
||||
|
||||
const callbacksRef = useRef({onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata});
|
||||
callbacksRef.current = {onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata};
|
||||
|
||||
const [state, setState] = useState<MediaPlayerState>(() => ({
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
isEnded: false,
|
||||
isBuffering: false,
|
||||
isSeeking: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
bufferedRanges: null,
|
||||
volume: initialVolume ?? (persistVolume ? getStoredVolume() : DEFAULT_VOLUME),
|
||||
isMuted: initialMuted ?? (persistVolume ? getStoredMuted() : false),
|
||||
playbackRate: initialPlaybackRate ?? (persistPlaybackRate ? getStoredPlaybackRate() : 1),
|
||||
error: null,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
media.volume = state.volume;
|
||||
media.muted = state.isMuted;
|
||||
media.playbackRate = state.playbackRate;
|
||||
media.loop = loop;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const handlePlay = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: true,
|
||||
isPaused: false,
|
||||
isEnded: false,
|
||||
}));
|
||||
callbacksRef.current.onPlay?.();
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
}));
|
||||
callbacksRef.current.onPause?.();
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
isEnded: true,
|
||||
}));
|
||||
callbacksRef.current.onEnded?.();
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const currentTime = media.currentTime;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentTime,
|
||||
bufferedRanges: media.buffered,
|
||||
}));
|
||||
callbacksRef.current.onTimeUpdate?.(currentTime);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const duration = media.duration;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
duration: Number.isFinite(duration) ? duration : 0,
|
||||
}));
|
||||
callbacksRef.current.onLoadedMetadata?.(duration);
|
||||
};
|
||||
|
||||
const handleDurationChange = () => {
|
||||
const duration = media.duration;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
duration: Number.isFinite(duration) ? duration : 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
setState((prev) => ({...prev, isBuffering: true}));
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setState((prev) => ({...prev, isBuffering: false}));
|
||||
};
|
||||
|
||||
const handleSeeking = () => {
|
||||
setState((prev) => ({...prev, isSeeking: true}));
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
setState((prev) => ({...prev, isSeeking: false}));
|
||||
};
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
volume: media.volume,
|
||||
isMuted: media.muted,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRateChange = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
playbackRate: media.playbackRate,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const error = media.error;
|
||||
const errorMessage = error
|
||||
? new Error(error.message || 'Media playback error')
|
||||
: new Error('Unknown media error');
|
||||
setState((prev) => ({...prev, error: errorMessage}));
|
||||
callbacksRef.current.onError?.(errorMessage);
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
bufferedRanges: media.buffered,
|
||||
}));
|
||||
};
|
||||
|
||||
media.addEventListener('play', handlePlay);
|
||||
media.addEventListener('pause', handlePause);
|
||||
media.addEventListener('ended', handleEnded);
|
||||
media.addEventListener('timeupdate', handleTimeUpdate);
|
||||
media.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
media.addEventListener('durationchange', handleDurationChange);
|
||||
media.addEventListener('waiting', handleWaiting);
|
||||
media.addEventListener('canplay', handleCanPlay);
|
||||
media.addEventListener('seeking', handleSeeking);
|
||||
media.addEventListener('seeked', handleSeeked);
|
||||
media.addEventListener('volumechange', handleVolumeChange);
|
||||
media.addEventListener('ratechange', handleRateChange);
|
||||
media.addEventListener('error', handleError);
|
||||
media.addEventListener('progress', handleProgress);
|
||||
|
||||
if (media.readyState >= 1) {
|
||||
handleLoadedMetadata();
|
||||
}
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('play', handlePlay);
|
||||
media.removeEventListener('pause', handlePause);
|
||||
media.removeEventListener('ended', handleEnded);
|
||||
media.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
media.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
media.removeEventListener('durationchange', handleDurationChange);
|
||||
media.removeEventListener('waiting', handleWaiting);
|
||||
media.removeEventListener('canplay', handleCanPlay);
|
||||
media.removeEventListener('seeking', handleSeeking);
|
||||
media.removeEventListener('seeked', handleSeeked);
|
||||
media.removeEventListener('volumechange', handleVolumeChange);
|
||||
media.removeEventListener('ratechange', handleRateChange);
|
||||
media.removeEventListener('error', handleError);
|
||||
media.removeEventListener('progress', handleProgress);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media || !autoPlay) return;
|
||||
|
||||
media.play().catch((error) => {
|
||||
console.debug('Autoplay prevented:', error);
|
||||
});
|
||||
}, [autoPlay]);
|
||||
|
||||
const play = useCallback(async () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
try {
|
||||
await media.play();
|
||||
} catch (error) {
|
||||
console.error('Play failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.pause();
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
if (media.paused) {
|
||||
await play();
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}, [play, pause]);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, media.duration || 0));
|
||||
media.currentTime = clampedTime;
|
||||
}, []);
|
||||
|
||||
const seekRelative = useCallback(
|
||||
(delta: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
seek(media.currentTime + delta);
|
||||
},
|
||||
[seek],
|
||||
);
|
||||
|
||||
const seekPercentage = useCallback((percentage: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media || !Number.isFinite(media.duration)) return;
|
||||
|
||||
const clampedPercentage = Math.max(0, Math.min(100, percentage));
|
||||
const time = (clampedPercentage / 100) * media.duration;
|
||||
media.currentTime = time;
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback(
|
||||
(volume: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
media.volume = clampedVolume;
|
||||
|
||||
if (clampedVolume > 0) {
|
||||
previousVolumeRef.current = clampedVolume;
|
||||
}
|
||||
|
||||
if (persistVolume) {
|
||||
storeVolume(clampedVolume);
|
||||
}
|
||||
},
|
||||
[persistVolume],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const newMuted = !media.muted;
|
||||
media.muted = newMuted;
|
||||
|
||||
if (!newMuted && media.volume === 0) {
|
||||
media.volume = previousVolumeRef.current || DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
if (persistVolume) {
|
||||
storeMuted(newMuted);
|
||||
}
|
||||
}, [persistVolume]);
|
||||
|
||||
const setPlaybackRate = useCallback(
|
||||
(rate: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const clampedRate = Math.max(0.25, Math.min(4, rate));
|
||||
media.playbackRate = clampedRate;
|
||||
|
||||
if (persistPlaybackRate) {
|
||||
storePlaybackRate(clampedRate);
|
||||
}
|
||||
},
|
||||
[persistPlaybackRate],
|
||||
);
|
||||
|
||||
return {
|
||||
mediaRef,
|
||||
state,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seek,
|
||||
seekRelative,
|
||||
seekPercentage,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
setPlaybackRate,
|
||||
};
|
||||
}
|
||||
207
fluxer_app/src/components/media-player/hooks/useMediaProgress.ts
Normal file
207
fluxer_app/src/components/media-player/hooks/useMediaProgress.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
export interface UseMediaProgressOptions {
|
||||
mediaRef: React.RefObject<HTMLMediaElement | null>;
|
||||
initialDuration?: number;
|
||||
updateInterval?: number;
|
||||
useRAF?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMediaProgressReturn {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
progress: number;
|
||||
buffered: number;
|
||||
isSeeking: boolean;
|
||||
seekToPercentage: (percentage: number) => void;
|
||||
seekToTime: (time: number) => void;
|
||||
startSeeking: () => void;
|
||||
endSeeking: () => void;
|
||||
}
|
||||
|
||||
function getBufferedPercentage(media: HTMLMediaElement): number {
|
||||
if (!media.buffered.length || !Number.isFinite(media.duration) || media.duration === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const currentTime = media.currentTime;
|
||||
let bufferedEnd = 0;
|
||||
|
||||
for (let i = 0; i < media.buffered.length; i++) {
|
||||
const start = media.buffered.start(i);
|
||||
const end = media.buffered.end(i);
|
||||
|
||||
if (currentTime >= start && currentTime <= end) {
|
||||
bufferedEnd = end;
|
||||
break;
|
||||
}
|
||||
|
||||
if (end > bufferedEnd) {
|
||||
bufferedEnd = end;
|
||||
}
|
||||
}
|
||||
|
||||
return (bufferedEnd / media.duration) * 100;
|
||||
}
|
||||
|
||||
export function useMediaProgress(options: UseMediaProgressOptions): UseMediaProgressReturn {
|
||||
const {mediaRef, initialDuration, updateInterval = 100, useRAF = true} = options;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(initialDuration ?? 0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const isSeekingRef = useRef(false);
|
||||
|
||||
const updateProgress = useCallback(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media || isSeekingRef.current) return;
|
||||
|
||||
const newCurrentTime = media.currentTime;
|
||||
const newDuration = Number.isFinite(media.duration) ? media.duration : 0;
|
||||
const newBuffered = getBufferedPercentage(media);
|
||||
|
||||
setCurrentTime(newCurrentTime);
|
||||
setDuration(newDuration);
|
||||
setBuffered(newBuffered);
|
||||
}, [mediaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
updateProgress();
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(Number.isFinite(media.duration) ? media.duration : 0);
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
setBuffered(getBufferedPercentage(media));
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isSeekingRef.current) {
|
||||
setCurrentTime(media.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeeking = () => {
|
||||
if (!isSeekingRef.current) {
|
||||
setIsSeeking(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
if (!isSeekingRef.current) {
|
||||
setIsSeeking(false);
|
||||
}
|
||||
setCurrentTime(media.currentTime);
|
||||
};
|
||||
|
||||
media.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
media.addEventListener('progress', handleProgress);
|
||||
media.addEventListener('timeupdate', handleTimeUpdate);
|
||||
media.addEventListener('seeking', handleSeeking);
|
||||
media.addEventListener('seeked', handleSeeked);
|
||||
|
||||
if (useRAF) {
|
||||
const tick = () => {
|
||||
updateProgress();
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
intervalRef.current = window.setInterval(updateProgress, updateInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
media.removeEventListener('progress', handleProgress);
|
||||
media.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
media.removeEventListener('seeking', handleSeeking);
|
||||
media.removeEventListener('seeked', handleSeeked);
|
||||
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [mediaRef, updateProgress, updateInterval, useRAF]);
|
||||
|
||||
const seekToPercentage = useCallback(
|
||||
(percentage: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media || !Number.isFinite(media.duration)) return;
|
||||
|
||||
const clampedPercentage = Math.max(0, Math.min(100, percentage));
|
||||
const time = (clampedPercentage / 100) * media.duration;
|
||||
media.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
},
|
||||
[mediaRef],
|
||||
);
|
||||
|
||||
const seekToTime = useCallback(
|
||||
(time: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity));
|
||||
media.currentTime = clampedTime;
|
||||
setCurrentTime(clampedTime);
|
||||
},
|
||||
[mediaRef],
|
||||
);
|
||||
|
||||
const startSeeking = useCallback(() => {
|
||||
isSeekingRef.current = true;
|
||||
setIsSeeking(true);
|
||||
}, []);
|
||||
|
||||
const endSeeking = useCallback(() => {
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
}, []);
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
buffered,
|
||||
isSeeking,
|
||||
seekToPercentage,
|
||||
seekToTime,
|
||||
startSeeking,
|
||||
endSeeking,
|
||||
};
|
||||
}
|
||||
185
fluxer_app/src/components/media-player/hooks/useMediaVolume.ts
Normal file
185
fluxer_app/src/components/media-player/hooks/useMediaVolume.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {DEFAULT_VOLUME, MUTE_STORAGE_KEY, VOLUME_STORAGE_KEY} from '../utils/mediaConstants';
|
||||
|
||||
export interface UseMediaVolumeOptions {
|
||||
mediaRef: React.RefObject<HTMLMediaElement | null>;
|
||||
initialVolume?: number;
|
||||
initialMuted?: boolean;
|
||||
persist?: boolean;
|
||||
onVolumeChange?: (volume: number) => void;
|
||||
onMuteChange?: (muted: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseMediaVolumeReturn {
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
previousVolume: number;
|
||||
setVolume: (volume: number) => void;
|
||||
toggleMute: () => void;
|
||||
setMuted: (muted: boolean) => void;
|
||||
increaseVolume: (step?: number) => void;
|
||||
decreaseVolume: (step?: number) => void;
|
||||
}
|
||||
|
||||
function getStoredVolume(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(VOLUME_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
const value = parseFloat(stored);
|
||||
if (Number.isFinite(value) && value >= 0 && value <= 1) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
function getStoredMuted(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(MUTE_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storeVolume(volume: number): void {
|
||||
try {
|
||||
localStorage.setItem(VOLUME_STORAGE_KEY, volume.toString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function storeMuted(muted: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(MUTE_STORAGE_KEY, muted.toString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function useMediaVolume(options: UseMediaVolumeOptions): UseMediaVolumeReturn {
|
||||
const {mediaRef, initialVolume, initialMuted, persist = true, onVolumeChange, onMuteChange} = options;
|
||||
|
||||
const [volume, setVolumeState] = useState(() => initialVolume ?? (persist ? getStoredVolume() : DEFAULT_VOLUME));
|
||||
const [isMuted, setIsMutedState] = useState(() => initialMuted ?? (persist ? getStoredMuted() : false));
|
||||
const previousVolumeRef = useRef(volume > 0 ? volume : DEFAULT_VOLUME);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
media.volume = volume;
|
||||
media.muted = isMuted;
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
const newVolume = media.volume;
|
||||
const newMuted = media.muted;
|
||||
|
||||
setVolumeState(newVolume);
|
||||
setIsMutedState(newMuted);
|
||||
|
||||
if (newVolume > 0) {
|
||||
previousVolumeRef.current = newVolume;
|
||||
}
|
||||
};
|
||||
|
||||
media.addEventListener('volumechange', handleVolumeChange);
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('volumechange', handleVolumeChange);
|
||||
};
|
||||
}, [mediaRef, volume, isMuted]);
|
||||
|
||||
const setVolume = useCallback(
|
||||
(newVolume: number) => {
|
||||
const media = mediaRef.current;
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
|
||||
if (media) {
|
||||
media.volume = clampedVolume;
|
||||
}
|
||||
|
||||
setVolumeState(clampedVolume);
|
||||
|
||||
if (clampedVolume > 0) {
|
||||
previousVolumeRef.current = clampedVolume;
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
storeVolume(clampedVolume);
|
||||
}
|
||||
|
||||
onVolumeChange?.(clampedVolume);
|
||||
},
|
||||
[mediaRef, persist, onVolumeChange],
|
||||
);
|
||||
|
||||
const setMuted = useCallback(
|
||||
(muted: boolean) => {
|
||||
const media = mediaRef.current;
|
||||
|
||||
if (media) {
|
||||
media.muted = muted;
|
||||
|
||||
if (!muted && media.volume === 0) {
|
||||
media.volume = previousVolumeRef.current;
|
||||
setVolumeState(previousVolumeRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
setIsMutedState(muted);
|
||||
|
||||
if (persist) {
|
||||
storeMuted(muted);
|
||||
}
|
||||
|
||||
onMuteChange?.(muted);
|
||||
},
|
||||
[mediaRef, persist, onMuteChange],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setMuted(!isMuted);
|
||||
}, [isMuted, setMuted]);
|
||||
|
||||
const increaseVolume = useCallback(
|
||||
(step = 0.1) => {
|
||||
setVolume(Math.min(1, volume + step));
|
||||
},
|
||||
[volume, setVolume],
|
||||
);
|
||||
|
||||
const decreaseVolume = useCallback(
|
||||
(step = 0.1) => {
|
||||
setVolume(Math.max(0, volume - step));
|
||||
},
|
||||
[volume, setVolume],
|
||||
);
|
||||
|
||||
return {
|
||||
volume,
|
||||
isMuted,
|
||||
previousVolume: previousVolumeRef.current,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
setMuted,
|
||||
increaseVolume,
|
||||
decreaseVolume,
|
||||
};
|
||||
}
|
||||
34
fluxer_app/src/components/media-player/utils/formatTime.ts
Normal file
34
fluxer_app/src/components/media-player/utils/formatTime.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const AUDIO_PLAYBACK_RATES = [0.5, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
export const VIDEO_PLAYBACK_RATES = [0.5, 1, 1.5, 2] as const;
|
||||
|
||||
export const DEFAULT_SEEK_AMOUNT = 10;
|
||||
|
||||
export const DEFAULT_VOLUME = 1;
|
||||
|
||||
export const VIDEO_BREAKPOINTS = {
|
||||
SMALL: 240,
|
||||
MEDIUM: 320,
|
||||
LARGE: 400,
|
||||
} as const;
|
||||
|
||||
export const VOLUME_STEP = 0.1;
|
||||
|
||||
export const SEEK_STEP = 10;
|
||||
|
||||
export const VOLUME_STORAGE_KEY = 'fluxer:media-player:volume';
|
||||
|
||||
export const MUTE_STORAGE_KEY = 'fluxer:media-player:muted';
|
||||
|
||||
export const PLAYBACK_RATE_STORAGE_KEY = 'fluxer:media-player:playback-rate';
|
||||
Reference in New Issue
Block a user