import React, { memo, useRef, useMemo, useCallback, useEffect, useState } from 'react'; import { motion, useMotionValue, useSpring } from 'framer-motion'; import Hammer from 'react-hammerjs'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; import TimelineSeg from './TimelineSeg'; import BetweenSegments from './BetweenSegments'; import useContextMenu from './hooks/useContextMenu'; import useUserSettings from './hooks/useUserSettings'; import { timelineBackground } from './colors'; import { getSegColor } from './util/colors'; const currentTimeWidth = 1; const hammerOptions = { recognizers: {} }; const Waveform = memo(({ waveform, calculateTimelinePercent, durationSafe }) => { const [style, setStyle] = useState({ display: 'none' }); const leftPos = calculateTimelinePercent(waveform.from); const toTruncated = Math.min(waveform.to, durationSafe); // Prevents flash function onLoad() { setStyle({ position: 'absolute', height: '100%', left: leftPos, width: `${((toTruncated - waveform.from) / durationSafe) * 100}%`, }); } return ( ); }); const Waveforms = memo(({ calculateTimelinePercent, durationSafe, waveforms, zoom, timelineHeight }) => (
{waveforms.map((waveform) => ( ))}
)); const CommandedTime = memo(({ commandedTimePercent }) => { const color = 'white'; const commonStyle = { left: commandedTimePercent, position: 'absolute', zIndex: 4, pointerEvents: 'none' }; return ( <>
); }); const Timeline = memo(({ durationSafe, getCurrentTime, startTimeOffset, playerTime, commandedTime, zoom, neighbouringKeyFrames, seekAbs, apparentCutSegments, setCurrentSegIndex, currentSegIndexSafe, inverseCutSegments, formatTimecode, waveforms, shouldShowWaveform, shouldShowKeyframes, timelineHeight = 36, thumbnails, onZoomWindowStartTimeChange, waveformEnabled, thumbnailsEnabled, playing, isFileOpened, onWheel, commandedTimeRef, goToTimecode, }) => { const { t } = useTranslation(); const { invertCutSegments } = useUserSettings(); const timelineScrollerRef = useRef(); const timelineScrollerSkipEventRef = useRef(); const timelineScrollerSkipEventDebounce = useRef(); const timelineWrapperRef = useRef(); const [hoveringTime, setHoveringTime] = useState(); const currentTime = getCurrentTime() || 0; const displayTime = (hoveringTime != null && isFileOpened && !playing ? hoveringTime : currentTime) + startTimeOffset; const displayTimePercent = useMemo(() => `${Math.round((displayTime / durationSafe) * 100)}%`, [displayTime, durationSafe]); const isZoomed = zoom > 1; // Don't show keyframes if too packed together (at current zoom) // See https://github.com/mifi/lossless-cut/issues/259 // todo // const areKeyframesTooClose = keyframes.length > zoom * 200; const areKeyframesTooClose = false; const calculateTimelinePos = useCallback((time) => (time !== undefined ? Math.min(time / durationSafe, 1) : undefined), [durationSafe]); const calculateTimelinePercent = useCallback((time) => { const pos = calculateTimelinePos(time); return pos !== undefined ? `${pos * 100}%` : undefined; }, [calculateTimelinePos]); const currentTimePercent = useMemo(() => calculateTimelinePercent(playerTime), [calculateTimelinePercent, playerTime]); const commandedTimePercent = useMemo(() => calculateTimelinePercent(commandedTime), [calculateTimelinePercent, commandedTime]); const timeOfInterestPosPixels = useMemo(() => { // https://github.com/mifi/lossless-cut/issues/676 const pos = calculateTimelinePos(playerTime); if (pos != null && timelineScrollerRef.current) return pos * zoom * timelineScrollerRef.current.offsetWidth; return undefined; }, [calculateTimelinePos, playerTime, zoom]); const calcZoomWindowStartTime = useCallback(() => (timelineScrollerRef.current ? (timelineScrollerRef.current.scrollLeft / (timelineScrollerRef.current.offsetWidth * zoom)) * durationSafe : 0), [durationSafe, zoom]); // const zoomWindowStartTime = calcZoomWindowStartTime(duration, zoom); useEffect(() => { timelineScrollerSkipEventDebounce.current = debounce(() => { timelineScrollerSkipEventRef.current = false; }, 1000); }, []); function suppressScrollerEvents() { timelineScrollerSkipEventRef.current = true; timelineScrollerSkipEventDebounce.current(); } const scrollLeftMotion = useMotionValue(0); const spring = useSpring(scrollLeftMotion, { damping: 100, stiffness: 1000 }); useEffect(() => { spring.onChange(value => { if (timelineScrollerSkipEventRef.current) return; // Don't animate while zooming timelineScrollerRef.current.scrollLeft = value; }); }, [spring]); // Pan timeline when cursor moves out of timeline window useEffect(() => { if (timeOfInterestPosPixels == null || timelineScrollerSkipEventRef.current) return; if (timeOfInterestPosPixels > timelineScrollerRef.current.scrollLeft + timelineScrollerRef.current.offsetWidth) { const timelineWidth = timelineWrapperRef.current.offsetWidth; const scrollLeft = timeOfInterestPosPixels - (timelineScrollerRef.current.offsetWidth * 0.1); scrollLeftMotion.set(Math.min(scrollLeft, timelineWidth - timelineScrollerRef.current.offsetWidth)); } else if (timeOfInterestPosPixels < timelineScrollerRef.current.scrollLeft) { const scrollLeft = timeOfInterestPosPixels - (timelineScrollerRef.current.offsetWidth * 0.9); scrollLeftMotion.set(Math.max(scrollLeft, 0)); } }, [timeOfInterestPosPixels, scrollLeftMotion]); // Keep cursor in middle while zooming useEffect(() => { suppressScrollerEvents(); if (isZoomed) { const zoomedTargetWidth = timelineScrollerRef.current.offsetWidth * zoom; const scrollLeft = Math.max((commandedTimeRef.current / durationSafe) * zoomedTargetWidth - timelineScrollerRef.current.offsetWidth / 2, 0); scrollLeftMotion.set(scrollLeft); timelineScrollerRef.current.scrollLeft = scrollLeft; } }, [zoom, durationSafe, commandedTimeRef, scrollLeftMotion, isZoomed]); useEffect(() => { const cancelWheel = (event) => event.preventDefault(); const scroller = timelineScrollerRef.current; scroller.addEventListener('wheel', cancelWheel, { passive: false }); return () => { scroller.removeEventListener('wheel', cancelWheel); }; }, []); const onTimelineScroll = useCallback(() => { onZoomWindowStartTimeChange(calcZoomWindowStartTime()); }, [calcZoomWindowStartTime, onZoomWindowStartTimeChange]); // Keep cursor in middle while scrolling /* const onTimelineScroll = useCallback((e) => { onZoomWindowStartTimeChange(zoomWindowStartTime); if (!zoomed || timelineScrollerSkipEventRef.current) return; seekAbs((((e.target.scrollLeft + (timelineScrollerRef.current.offsetWidth * 0.5)) / (timelineScrollerRef.current.offsetWidth * zoom)) * duration)); }, [duration, seekAbs, zoomed, zoom, zoomWindowStartTime, onZoomWindowStartTimeChange]); */ const getMouseTimelinePos = useCallback((e) => { const target = timelineWrapperRef.current; const rect = target.getBoundingClientRect(); const relX = e.pageX - (rect.left + document.body.scrollLeft); return (relX / target.offsetWidth) * durationSafe; }, [durationSafe]); const handleTap = useCallback((e) => { seekAbs((getMouseTimelinePos(e.srcEvent))); }, [seekAbs, getMouseTimelinePos]); useEffect(() => { setHoveringTime(); }, [playerTime, commandedTime]); const onMouseMove = useCallback((e) => setHoveringTime(getMouseTimelinePos(e.nativeEvent)), [getMouseTimelinePos]); const onMouseOut = useCallback(() => setHoveringTime(), []); const contextMenuTemplate = useMemo(() => [ { label: t('Seek to timecode'), click: goToTimecode }, ], [goToTimecode, t]); useContextMenu(timelineScrollerRef, contextMenuTemplate); return ( // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
{waveformEnabled && shouldShowWaveform && waveforms && ( )} {thumbnailsEnabled && (
{thumbnails.map((thumbnail, i) => { const leftPercent = (thumbnail.time / durationSafe) * 100; const nextThumbnail = thumbnails[i + 1]; const nextThumbTime = nextThumbnail ? nextThumbnail.time : durationSafe; const maxWidthPercent = ((nextThumbTime - thumbnail.time) / durationSafe) * 100 * 0.9; return ( ); })}
)}
{currentTimePercent !== undefined && ( )} {commandedTimePercent !== undefined && ( )} {apparentCutSegments.map((seg, i) => { const segColor = getSegColor(seg); if (seg.start === 0 && seg.end === 0) return null; // No video loaded return ( ); })} {inverseCutSegments.map((seg) => ( ))} {shouldShowKeyframes && !areKeyframesTooClose && neighbouringKeyFrames.map((f) => (
))}
{(waveformEnabled && !thumbnailsEnabled && !shouldShowWaveform) && (
{t('Zoom in more to view waveform')}
)}
{formatTimecode({ seconds: displayTime })}{isZoomed ? ` ${displayTimePercent}` : ''}
); }); export default Timeline;