Files
fluxer/fluxer_app/src/components/modals/MobileVideoViewer.tsx
2026-02-17 12:22:36 +00:00

329 lines
9.2 KiB
TypeScript

/*
* 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 styles from '@app/components/modals/MobileVideoViewer.module.css';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import VideoVolumeStore from '@app/stores/VideoVolumeStore';
import {useLingui} from '@lingui/react/macro';
import {DotsThreeIcon, PauseIcon, PlayIcon, SpeakerHighIcon, SpeakerXIcon, XIcon} from '@phosphor-icons/react';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {type ReactZoomPanPinchRef, TransformComponent, TransformWrapper} from 'react-zoom-pan-pinch';
interface MobileVideoViewerProps {
src: string;
initialTime?: number;
loop?: boolean;
onClose: () => void;
onMenuOpen?: () => void;
}
function formatTime(time: number): string {
if (!Number.isFinite(time)) return '0:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export const MobileVideoViewer = observer(function MobileVideoViewer({
src,
initialTime,
loop = true,
onClose,
onMenuOpen,
}: MobileVideoViewerProps) {
const {t} = useLingui();
const videoRef = useRef<HTMLVideoElement>(null);
const transformRef = useRef<ReactZoomPanPinchRef>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [zoomScale, setZoomScale] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [hudVisible, setHudVisible] = useState(true);
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const progress = duration > 0 ? currentTime / duration : 0;
const scheduleHudHide = useCallback(() => {
if (hudTimerRef.current) {
clearTimeout(hudTimerRef.current);
}
hudTimerRef.current = setTimeout(() => {
if (isPlaying) {
setHudVisible(false);
}
hudTimerRef.current = null;
}, 3000);
}, [isPlaying]);
const handleTapSurface = useCallback(() => {
setHudVisible((prev) => !prev);
if (!hudVisible) {
scheduleHudHide();
}
}, [hudVisible, scheduleHudHide]);
const handleZoomChange = useCallback((_ref: ReactZoomPanPinchRef, state: {scale: number}) => {
setZoomScale(state.scale);
}, []);
const handlePlayPause = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
transformRef.current?.resetTransform();
} else {
video.pause();
}
}, []);
const handleToggleMute = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
VideoVolumeStore.toggleMute();
}, []);
const handleProgressClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const video = videoRef.current;
if (!video || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
video.currentTime = percentage * duration;
},
[duration],
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
setIsPlaying(true);
scheduleHudHide();
};
const handlePause = () => {
setIsPlaying(false);
setHudVisible(true);
if (hudTimerRef.current) {
clearTimeout(hudTimerRef.current);
hudTimerRef.current = null;
}
};
const handleTimeUpdate = () => setCurrentTime(video.currentTime);
const handleLoadedMetadata = () => {
setDuration(video.duration);
if (initialTime && !hasInitialized) {
video.currentTime = initialTime;
setHasInitialized(true);
video.play();
}
};
const handleDurationChange = () => setDuration(video.duration);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('durationchange', handleDurationChange);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('durationchange', handleDurationChange);
};
}, [initialTime, hasInitialized, scheduleHudHide]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.muted = VideoVolumeStore.isMuted;
}, []);
useEffect(() => {
return () => {
if (hudTimerRef.current) {
clearTimeout(hudTimerRef.current);
}
};
}, []);
const handleClose = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClose();
},
[onClose],
);
const handleMenuOpen = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onMenuOpen?.();
},
[onMenuOpen],
);
return (
<div className={styles.container}>
<TransformWrapper
ref={transformRef}
initialScale={1}
minScale={1}
maxScale={5}
pinch={{step: 5}}
doubleClick={{disabled: true}}
panning={{disabled: isPlaying}}
disabled={isPlaying}
onTransformed={handleZoomChange}
centerOnInit
centerZoomedOut
>
<TransformComponent
wrapperStyle={{width: '100%', height: '100%'}}
contentStyle={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<video
ref={videoRef}
className={styles.video}
src={src}
autoPlay={!initialTime}
playsInline
loop={loop}
muted={VideoVolumeStore.isMuted}
/>
</TransformComponent>
</TransformWrapper>
<div
className={styles.tapSurface}
onClick={handleTapSurface}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleTapSurface();
}
}}
role="button"
tabIndex={0}
aria-label={t`Toggle controls`}
/>
<AnimatePresence>
{hudVisible && zoomScale <= 1 && (
<motion.div
className={styles.hudOverlay}
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.15}}
>
<div className={styles.topBar}>
<button type="button" className={styles.topBarButton} onClick={handleClose} aria-label={t`Close`}>
<XIcon size={20} weight="bold" />
</button>
{onMenuOpen && (
<button
type="button"
className={styles.topBarButton}
onClick={handleMenuOpen}
aria-label={t`More options`}
>
<DotsThreeIcon size={20} weight="bold" />
</button>
)}
</div>
<div className={styles.bottomArea}>
<button
type="button"
className={styles.muteButton}
onClick={handleToggleMute}
aria-label={VideoVolumeStore.isMuted ? t`Unmute` : t`Mute`}
>
{VideoVolumeStore.isMuted ? (
<SpeakerXIcon size={18} weight="fill" />
) : (
<SpeakerHighIcon size={18} weight="fill" />
)}
</button>
<div className={styles.controlsBar}>
<button
type="button"
className={styles.playPauseButton}
onClick={handlePlayPause}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{isPlaying ? <PauseIcon size={20} weight="fill" /> : <PlayIcon size={20} weight="fill" />}
</button>
<div
className={styles.progressBarWrapper}
onClick={handleProgressClick}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const video = videoRef.current;
if (!video) return;
const step = duration * 0.05;
video.currentTime = Math.max(
0,
Math.min(duration, video.currentTime + (e.key === 'ArrowRight' ? step : -step)),
);
}
}}
role="slider"
tabIndex={0}
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t`Video progress`}
>
<div className={styles.progressTrack}>
<div className={styles.progressFill} style={{width: `${progress * 100}%`}} />
</div>
</div>
<span className={styles.timeDisplay}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});