refactor progress
This commit is contained in:
328
fluxer_app/src/components/modals/MobileVideoViewer.tsx
Normal file
328
fluxer_app/src/components/modals/MobileVideoViewer.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user