initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -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';