From 574abcb19a6bead679f71ac4125ced584a1f7a2e Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 12 Mar 2023 16:51:15 +0800 Subject: [PATCH] improvements - implement full screen waveform #260 - make thumbnails not overlap timeline #483 - allow thumbnails at the same time as waveform #260 - fix waveform color in light mode - fix waveform window overlaps - make tracks screen dark mode too - add more borders and dark mode fixes --- src/App.jsx | 57 +++++++----- src/BottomBar.jsx | 13 +-- src/NoFileLoaded.jsx | 10 +- src/SegmentList.jsx | 2 +- src/StreamsSelector.jsx | 115 +++++++++++------------ src/Timeline.jsx | 32 ++++--- src/colors.js | 3 +- src/components/BatchFilesList.jsx | 2 +- src/components/BigWaveform.jsx | 116 ++++++++++++++++++++++++ src/components/OutSegTemplateEditor.jsx | 8 +- src/components/Select.module.css | 6 +- src/components/Sheet.jsx | 2 +- src/ffmpeg.js | 23 +++-- src/hooks/useWaveform.js | 32 +++++-- src/main.css | 2 + src/theme.js | 27 +++++- 16 files changed, 310 insertions(+), 140 deletions(-) create mode 100644 src/components/BigWaveform.jsx diff --git a/src/App.jsx b/src/App.jsx index 7fccd94e..f12f20ab 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,7 @@ import React, { memo, useEffect, useState, useCallback, useRef, useMemo } from ' import { FaAngleLeft, FaWindowClose } from 'react-icons/fa'; import { MdRotate90DegreesCcw } from 'react-icons/md'; import { AnimatePresence } from 'framer-motion'; -import { SideSheet, Position, ThemeProvider } from 'evergreen-ui'; +import { ThemeProvider } from 'evergreen-ui'; import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this import { useDebounce } from 'use-debounce'; import i18n from 'i18next'; @@ -79,6 +79,7 @@ import { fallbackLng } from './i18n'; import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment } from './segments'; import { getOutSegError as getOutSegErrorRaw } from './util/outputNameTemplate'; import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; +import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; @@ -142,7 +143,8 @@ const App = memo(() => { const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); // State per application launch - const [timelineMode, setTimelineMode] = useState(); + const [waveformMode, setWaveformMode] = useState(); + const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false); const [keyframesEnabled, setKeyframesEnabled] = useState(true); const [showRightBar, setShowRightBar] = useState(true); const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); @@ -200,13 +202,17 @@ const App = memo(() => { } }, [detectedFileFormat, outFormatLocked, setFileFormat, setOutFormatLocked]); - const toggleTimelineMode = useCallback((newMode) => { - if (newMode === timelineMode) { - setTimelineMode(); + const toggleWaveformMode = useCallback((newMode) => { + if (waveformMode === 'waveform') { + setWaveformMode('big-waveform'); + } else if (waveformMode === 'big-waveform') { + setWaveformMode(); } else { - setTimelineMode(newMode); + setWaveformMode(newMode); } - }, [timelineMode]); + }, [waveformMode]); + + const toggleEnableThumbnails = useCallback(() => setThumbnailsEnabled((v) => !v), []); const toggleExportConfirmEnabled = useCallback(() => setExportConfirmEnabled((v) => { const newVal = !v; @@ -589,12 +595,13 @@ const App = memo(() => { const hasAudio = !!mainAudioStream; const hasVideo = !!mainVideoStream; - const waveformEnabled = timelineMode === 'waveform' && hasAudio; - const thumbnailsEnabled = timelineMode === 'thumbnails' && hasVideo; + const waveformEnabled = hasAudio && ['waveform', 'big-waveform'].includes(waveformMode); + const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform'; + const showThumbnails = thumbnailsEnabled && hasVideo; const [, cancelRenderThumbnails] = useDebounceOld(() => { async function renderThumbnails() { - if (!thumbnailsEnabled || thumnailsRenderingPromiseRef.current) return; + if (!showThumbnails || thumnailsRenderingPromiseRef.current) return; try { setThumbnails([]); @@ -609,7 +616,7 @@ const App = memo(() => { } if (isDurationValid(zoomedDuration)) renderThumbnails(); - }, 500, [zoomedDuration, filePath, zoomWindowStartTime, thumbnailsEnabled]); + }, 500, [zoomedDuration, filePath, zoomWindowStartTime, showThumbnails]); // Cleanup removed thumbnails useEffect(() => { @@ -628,11 +635,11 @@ const App = memo(() => { subtitlesByStreamIdRef.current = subtitlesByStreamId; }, [subtitlesByStreamId]); - const shouldShowKeyframes = keyframesEnabled && !!mainVideoStream && calcShouldShowKeyframes(zoomedDuration); + const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration); const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration); const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, mainVideoStream, detectedFps, ffmpegExtractWindow }); - const { waveforms } = useWaveform({ filePath, commandedTime, zoomedDuration, waveformEnabled, mainAudioStream, shouldShowWaveform, ffmpegExtractWindow }); + const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, mainAudioStream, shouldShowWaveform, ffmpegExtractWindow, durationSafe }); const resetState = useCallback(() => { console.log('State reset'); @@ -1420,6 +1427,7 @@ const App = memo(() => { if (timecode) setStartTimeOffset(timecode); setDetectedFps(haveVideoStream ? getStreamFps(videoStream) : undefined); + if (!haveVideoStream) setWaveformMode('big-waveform'); setMainFileMeta({ streams: fileMeta.streams, formatData: fileMeta.format, chapters: fileMeta.chapters }); setMainVideoStream(videoStream); setMainAudioStream(audioStream); @@ -1916,6 +1924,7 @@ const App = memo(() => { closeExportConfirm(); setLastCommandsVisible(false); setSettingsVisible(false); + setStreamsSelectorShown(false); return false; } @@ -2205,7 +2214,7 @@ const App = memo(() => {
{!isFileOpened && } -
+
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
+ {bigWaveformEnabled && } + {isRotationSet && !hideCanvasPreview && (
@@ -2301,7 +2312,7 @@ const App = memo(() => { waveforms={waveforms} shouldShowWaveform={shouldShowWaveform} waveformEnabled={waveformEnabled} - thumbnailsEnabled={thumbnailsEnabled} + showThumbnails={showThumbnails} neighbouringKeyFrames={neighbouringKeyFrames} thumbnails={thumbnailsSorted} playerTime={playerTime} @@ -2358,8 +2369,10 @@ const App = memo(() => { shortStep={shortStep} seekClosestKeyframe={seekClosestKeyframe} togglePlay={togglePlay} - toggleTimelineMode={toggleTimelineMode} - timelineMode={timelineMode} + showThumbnails={showThumbnails} + toggleEnableThumbnails={toggleEnableThumbnails} + toggleWaveformMode={toggleWaveformMode} + waveformMode={waveformMode} hasAudio={hasAudio} keyframesEnabled={keyframesEnabled} toggleKeyframesEnabled={toggleKeyframesEnabled} @@ -2371,13 +2384,7 @@ const App = memo(() => { />
- setStreamsSelectorShown(false)} - > + setStreamsSelectorShown(false)} style={{ padding: '1em 0' }}> {mainStreams && ( { setDispositionByStreamId={setDispositionByStreamId} /> )} - + diff --git a/src/BottomBar.jsx b/src/BottomBar.jsx index 3861753a..4c0045bc 100644 --- a/src/BottomBar.jsx +++ b/src/BottomBar.jsx @@ -135,9 +135,10 @@ const BottomBar = memo(({ seekAbs, currentSegIndexSafe, cutSegments, currentCutSeg, setCutStart, setCutEnd, setCurrentSegIndex, jumpTimelineStart, jumpTimelineEnd, jumpCutEnd, jumpCutStart, startTimeOffset, setCutTime, currentApparentCutSeg, - playing, shortStep, togglePlay, toggleLoopSelectedSegments, toggleTimelineMode, hasAudio, timelineMode, + playing, shortStep, togglePlay, toggleLoopSelectedSegments, hasAudio, keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe, detectedFps, isFileOpened, selectedSegments, darkMode, setDarkMode, + toggleEnableThumbnails, toggleWaveformMode, waveformMode, showThumbnails, }) => { const { t } = useTranslation(); @@ -194,7 +195,7 @@ const BottomBar = memo(({ const text = seg ? `${newIndex + 1}` : '-'; const wide = text.length > 1; const segButtonStyle = { - backgroundColor, opacity, padding: `6px ${wide ? 4 : 6}px`, borderRadius: 10, color: 'white', fontSize: wide ? 12 : 14, width: 20, boxSizing: 'border-box', letterSpacing: -1, lineHeight: '10px', fontWeight: 'bold', margin: '0 6px', + backgroundColor, opacity, padding: `6px ${wide ? 4 : 6}px`, borderRadius: 10, color: seg ? 'white' : undefined, fontSize: wide ? 12 : 14, width: 20, boxSizing: 'border-box', letterSpacing: -1, lineHeight: '10px', fontWeight: 'bold', margin: '0 6px', }; return ( @@ -222,20 +223,20 @@ const BottomBar = memo(({ {hasAudio && ( toggleTimelineMode('waveform')} + onClick={() => toggleWaveformMode('waveform')} /> )} {hasVideo && ( <> toggleTimelineMode('thumbnails')} + onClick={toggleEnableThumbnails} /> { const { simpleMode } = useUserSettings(); return ( -
-
{t('DROP FILE(S)')}
+
+
{t('DROP FILE(S)')}
-
+
See Help menu for help
-
+
or I O to set cutpoints
-
+
{simpleMode ? ( to show advanced view ) : ( diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index 0b288d19..6d6cd0e0 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -258,7 +258,7 @@ const SegmentList = memo(({ return ( - } onClick={onClick} /> +
{stream.index + 1}
{stream.codec_name} {codecTag} @@ -255,7 +255,7 @@ const Stream = memo(({ dispositionByStreamId, setDispositionByStreamId, filePath {language} {stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`} - @@ -266,9 +266,10 @@ const Stream = memo(({ dispositionByStreamId, setDispositionByStreamId, filePath - + + onInfoClick(stream, t('Track {{num}} info', { num: stream.index + 1 }))} appearance="minimal" iconSize={18} /> - } onClick={onExtractStreamPress} appearance="minimal" iconSize={18} /> + {onExtractStreamPress && } - {path.replace(/.*\/([^/]+)$/, '$1')} +
+
{path.replace(/.*\/([^/]+)$/, '$1')}
@@ -311,9 +312,9 @@ const FileHeading = ({ path, formatData, chapters, onTrashClick, onEditClick, se {chapters && chapters.length > 0 && onInfoClick(chapters, t('Chapters'))} appearance="minimal" iconSize={18} />} {onEditClick && } {onTrashClick && } - } onClick={() => setCopyAllStreams(true)} appearance="minimal" /> - } onClick={() => setCopyAllStreams(false)} appearance="minimal" /> - {onExtractAllStreamsPress && } onClick={onExtractAllStreamsPress} appearance="minimal" />} + setCopyAllStreams(true)} appearance="minimal" /> + setCopyAllStreams(false)} appearance="minimal" /> + {onExtractAllStreamsPress && }
); }; @@ -321,7 +322,7 @@ const FileHeading = ({ path, formatData, chapters, onTrashClick, onEditClick, se const Thead = () => { const { t } = useTranslation(); return ( - + {t('Keep?')} {t('Codec')} @@ -337,7 +338,7 @@ const Thead = () => { }; const tableStyle = { fontSize: 14, width: '100%' }; -const fileStyle = { marginBottom: 20, padding: 5, minWidth: '100%', overflowX: 'auto' }; +const fileStyle = { margin: '1.5em 1em 1.5em 1em', padding: 5, overflowX: 'auto' }; const StreamsSelector = memo(({ mainFilePath, mainFileFormatData, mainFileStreams, mainFileChapters, isCopyingStreamId, toggleCopyStreamId, @@ -383,79 +384,79 @@ const StreamsSelector = memo(({ return ( <> -
- {t('Click to select which tracks to keep when exporting:')} +

{t('Click to select which tracks to keep when exporting:')}

+ +
+ {/* We only support editing main file metadata for now */} + setEditingFile(mainFilePath)} setCopyAllStreams={(enabled) => setCopyAllStreamsForPath(mainFilePath, enabled)} onExtractAllStreamsPress={onExtractAllStreamsPress} /> + + + + + {mainFileStreams.map((stream) => ( + toggleCopyStreamId(mainFilePath, streamId)} + batchSetCopyStreamIds={(filter, enabled) => batchSetCopyStreamIdsForPath(mainFilePath, mainFileStreams, filter, enabled)} + setEditingStream={setEditingStream} + fileDuration={getFormatDuration(mainFileFormatData)} + onExtractStreamPress={() => onExtractStreamPress(stream.index)} + /> + ))} + +
+
+ + {externalFilesEntries.map(([path, { streams, formatData }]) => ( +
+ removeFile(path)} setCopyAllStreams={(enabled) => setCopyAllStreamsForPath(path, enabled)} /> - - {/* We only support editing main file metadata for now */} - setEditingFile(mainFilePath)} setCopyAllStreams={(enabled) => setCopyAllStreamsForPath(mainFilePath, enabled)} onExtractAllStreamsPress={onExtractAllStreamsPress} /> - - {mainFileStreams.map((stream) => ( + {streams.map((stream) => ( toggleCopyStreamId(mainFilePath, streamId)} - batchSetCopyStreamIds={(filter, enabled) => batchSetCopyStreamIdsForPath(mainFilePath, mainFileStreams, filter, enabled)} + copyStream={isCopyingStreamId(path, stream.index)} + onToggle={(streamId) => toggleCopyStreamId(path, streamId)} + batchSetCopyStreamIds={(filter, enabled) => batchSetCopyStreamIdsForPath(path, streams, filter, enabled)} setEditingStream={setEditingStream} - fileDuration={getFormatDuration(mainFileFormatData)} - onExtractStreamPress={() => onExtractStreamPress(stream.index)} + fileDuration={getFormatDuration(formatData)} /> ))}
-
- - {externalFilesEntries.map(([path, { streams, formatData }]) => ( - - removeFile(path)} setCopyAllStreams={(enabled) => setCopyAllStreamsForPath(path, enabled)} /> - - - - - {streams.map((stream) => ( - toggleCopyStreamId(path, streamId)} - batchSetCopyStreamIds={(filter, enabled) => batchSetCopyStreamIdsForPath(path, streams, filter, enabled)} - setEditingStream={setEditingStream} - fileDuration={getFormatDuration(formatData)} - /> - ))} - -
-
- ))} +
+ ))} +
{externalFilesEntries.length > 0 && ( - {t('Note: Cutting and including external tracks at the same time does not yet work. If you want to do both, it must be done as separate operations. See github issue #896.')} +
{t('Note: Cutting and including external tracks at the same time does not yet work. If you want to do both, it must be done as separate operations. See github issue #896.')}
)} - {nonCopiedExtraStreams.length > 0 && ( -
+
{t('Discard or extract unprocessable tracks to separate files?')}
)} {externalFilesEntries.length > 0 && ( -
- {t('When tracks have different lengths, do you want to make the output file as long as the longest or the shortest track?')} +
+
{t('When tracks have different lengths, do you want to make the output file as long as the longest or the shortest track?')}
diff --git a/src/Timeline.jsx b/src/Timeline.jsx index e94ba475..142684a2 100644 --- a/src/Timeline.jsx +++ b/src/Timeline.jsx @@ -34,8 +34,8 @@ const Waveform = memo(({ waveform, calculateTimelinePercent, durationSafe }) => ); }); -const Waveforms = memo(({ calculateTimelinePercent, durationSafe, waveforms, zoom, timelineHeight }) => ( -
+const Waveforms = memo(({ calculateTimelinePercent, durationSafe, waveforms, zoom, height }) => ( +
{waveforms.map((waveform) => ( ))} @@ -58,12 +58,14 @@ const Timeline = memo(({ durationSafe, startTimeOffset, playerTime, commandedTime, relevantTime, zoom, neighbouringKeyFrames, seekAbs, apparentCutSegments, setCurrentSegIndex, currentSegIndexSafe, inverseCutSegments, formatTimecode, - waveforms, shouldShowWaveform, shouldShowKeyframes, timelineHeight = 36, thumbnails, - onZoomWindowStartTimeChange, waveformEnabled, thumbnailsEnabled, + waveforms, shouldShowWaveform, shouldShowKeyframes, thumbnails, + onZoomWindowStartTimeChange, waveformEnabled, showThumbnails, playing, isFileOpened, onWheel, commandedTimeRef, goToTimecode, isSegmentSelected, }) => { const { t } = useTranslation(); + const timelineHeight = 36; + const { invertCutSegments, darkMode } = useUserSettings(); const timelineScrollerRef = useRef(); @@ -239,11 +241,17 @@ const Timeline = memo(({ return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/mouse-events-have-key-events
+ {(waveformEnabled && !shouldShowWaveform) && ( +
+ {t('Zoom in more to view waveform')} +
+ )} +
)} - {thumbnailsEnabled && ( -
+ {showThumbnails && ( +
{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 ( - + ); })}
@@ -327,12 +335,6 @@ const Timeline = memo(({
- {(waveformEnabled && !thumbnailsEnabled && !shouldShowWaveform) && ( -
- {t('Zoom in more to view waveform')} -
- )} -
{formatTimecode({ seconds: displayTime })}{isZoomed ? ` ${displayTimePercent}` : ''} diff --git a/src/colors.js b/src/colors.js index 872920c2..88e2c705 100644 --- a/src/colors.js +++ b/src/colors.js @@ -2,7 +2,8 @@ export const saveColor = 'var(--green11)'; export const primaryColor = 'var(--cyan9)'; export const primaryTextColor = 'var(--cyan11)'; // todo darkMode: -export const waveformColor = '#ffffff'; // Must be hex because used by ffmpeg +export const waveformColorLight = '#000000'; // Must be hex because used by ffmpeg +export const waveformColorDark = '#ffffff'; // Must be hex because used by ffmpeg export const controlsBackground = 'var(--gray4)'; export const timelineBackground = 'var(--gray2)'; export const darkModeTransition = 'background .5s'; diff --git a/src/components/BatchFilesList.jsx b/src/components/BatchFilesList.jsx index 196e9e79..075a239c 100644 --- a/src/components/BatchFilesList.jsx +++ b/src/components/BatchFilesList.jsx @@ -46,7 +46,7 @@ const BatchFilesList = memo(({ selectedBatchFiles, filePath, width, batchFiles, return ( { + const windowSize = ffmpegExtractWindow * 2; + const windowStart = Math.max(0, relevantTime - windowSize); + const windowEnd = relevantTime + windowSize; + const filtered = waveforms.filter((waveform) => waveform.from >= windowStart && waveform.to <= windowEnd); + + const scaleFactor = zoom; + + const [smoothTime, setSmoothTime] = useState(relevantTime); + + const mouseDownRef = useRef(); + const containerRef = useRef(); + + const getRect = useCallback(() => containerRef.current.getBoundingClientRect(), []); + + const handleMouseDown = useCallback((e) => { + const rect = e.target.getBoundingClientRect(); + const x = e.clientX - rect.left; + + mouseDownRef.current = { relevantTime, x }; + e.preventDefault(); + }, [relevantTime]); + + const scaleToTime = useCallback((v) => (((v) / getRect().width) * windowSize) / zoom, [getRect, windowSize, zoom]); + + const handleMouseMove = useCallback((e) => { + if (mouseDownRef.current == null) return; + + seekRel(-scaleToTime(e.movementX)); + + e.preventDefault(); + }, [scaleToTime, seekRel]); + + const handleWheel = useCallback((e) => { + seekRel(scaleToTime(e.deltaX)); + }, [scaleToTime, seekRel]); + + const handleMouseUp = useCallback((e) => { + if (!mouseDownRef.current) return; + mouseDownRef.current = undefined; + e.preventDefault(); + }, []); + + + useEffect(() => { + let time = relevantTime; + setSmoothTime(time); + const startTime = new Date().getTime(); + + if (playing) { + let raf; + // eslint-disable-next-line no-inner-declarations + function render() { + raf = window.requestAnimationFrame(() => { + time = new Date().getTime() / 1000; + setSmoothTime(relevantTime + (new Date().getTime() - startTime) / 1000); + render(); + }); + } + + render(); + return () => window.cancelAnimationFrame(raf); + } + + return undefined; + }, [relevantTime, playing]); + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ {filtered.map((waveform) => { + const left = 0.5 + ((waveform.from - smoothTime) / windowSize) * scaleFactor; + const width = ((waveform.to - waveform.from) / windowSize) * scaleFactor; + const leftPercent = `${left * 100}%`; + const widthPercent = `${width * 100}%`; + + return ( + + = durationSafe ? '1px solid var(--gray11)' : undefined, + }} + /> +
+ + ); + })} + +
+
+ ); +}); + +export default BigWaveform; diff --git a/src/components/OutSegTemplateEditor.jsx b/src/components/OutSegTemplateEditor.jsx index 1ef0615e..4317ed0b 100644 --- a/src/components/OutSegTemplateEditor.jsx +++ b/src/components/OutSegTemplateEditor.jsx @@ -2,7 +2,7 @@ import React, { memo, useState, useEffect, useCallback } from 'react'; import { useDebounce } from 'use-debounce'; import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; -import { Text, Button, Alert, IconButton, TickIcon, ResetIcon, Heading } from 'evergreen-ui'; +import { WarningSignIcon, ErrorIcon, Button, Alert, IconButton, TickIcon, ResetIcon } from 'evergreen-ui'; import withReactContent from 'sweetalert2-react-content'; import Swal from '../swal'; @@ -93,7 +93,7 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate {needToShow && ( <> -
+
{outSegFileNames && } @@ -102,8 +102,8 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
- {error != null && {i18n.t('There is an error in the file name template:')}{error}} - {isMissingExtension && {i18n.t('The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.', { ext: extVar })}} + {error != null &&
{i18n.t('There is an error in the file name template:')} {error}
} + {isMissingExtension &&
{i18n.t('The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.', { ext: extVar })}
}
{`${i18n.t('Variables')}`}{': '} {['FILENAME', 'CUT_FROM', 'CUT_TO', 'SEG_NUM', 'SEG_LABEL', 'SEG_SUFFIX', 'EXT', 'SEG_TAGS.XX'].map((variable) => setText((oldText) => `${oldText}\${${variable}}`)}>{variable})} diff --git a/src/components/Select.module.css b/src/components/Select.module.css index 56002829..d7f4788d 100644 --- a/src/components/Select.module.css +++ b/src/components/Select.module.css @@ -10,9 +10,13 @@ outline: .05em solid var(--gray8); border: .05em solid var(--gray7); - background-image: url("data:image/svg+xml;utf8,"); + background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; background-position-x: 100%; background-position-y: 0; background-size: auto 100%; +} + +:global(.dark-theme) .select { + background-image: url("data:image/svg+xml;utf8,"); } \ No newline at end of file diff --git a/src/components/Sheet.jsx b/src/components/Sheet.jsx index 721a43d7..057c57a8 100644 --- a/src/components/Sheet.jsx +++ b/src/components/Sheet.jsx @@ -14,7 +14,7 @@ const Sheet = memo(({ visible, onClosePress, style, children }) => ( style={style} className={styles.sheet} > - +
{children} diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 5bd3bbfc..d91b58d9 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -596,14 +596,12 @@ export async function renderThumbnails({ filePath, from, duration, onThumbnail } } -export async function renderWaveformPng({ filePath, aroundTime, window, color }) { - const { from, to } = getIntervalAroundTime(aroundTime, window); - +export async function renderWaveformPng({ filePath, start, duration, color }) { const args1 = [ '-hide_banner', '-i', filePath, - '-ss', from, - '-t', to - from, + '-ss', start, + '-t', duration, '-c', 'copy', '-vn', '-map', 'a:0', @@ -614,7 +612,7 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color }) const args2 = [ '-hide_banner', '-i', '-', - '-filter_complex', `aformat=channel_layouts=mono,showwavespic=s=640x120:scale=sqrt:colors=${color}`, + '-filter_complex', `showwavespic=s=2000x300:scale=lin:filter=peak:split_channels=1:colors=${color}`, '-frames:v', '1', '-vcodec', 'png', '-f', 'image2', @@ -622,18 +620,19 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color }) ]; console.log(getFfCommandLine('ffmpeg1', args1)); - console.log(getFfCommandLine('ffmpeg2', args2)); + console.log('|', getFfCommandLine('ffmpeg2', args2)); let ps1; let ps2; try { - ps1 = runFfmpeg(args1, { encoding: null, buffer: false }); - ps2 = runFfmpeg(args2, { encoding: null }); + ps1 = runFfmpeg(args1, { encoding: null, buffer: false }, { logCli: false }); + ps2 = runFfmpeg(args2, { encoding: null }, { logCli: false }); ps1.stdout.pipe(ps2.stdin); const timer = setTimeout(() => { ps1.kill(); ps2.kill(); + console.warn('ffmpeg timed out'); }, 10000); let stdout; @@ -647,9 +646,9 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color }) return { url: URL.createObjectURL(blob), - from, - aroundTime, - to, + from: start, + to: start + duration, + duration, createdAt: new Date(), }; } catch (err) { diff --git a/src/hooks/useWaveform.js b/src/hooks/useWaveform.js index f256c174..6cb4018b 100644 --- a/src/hooks/useWaveform.js +++ b/src/hooks/useWaveform.js @@ -1,27 +1,39 @@ import { useState, useRef, useEffect } from 'react'; import sortBy from 'lodash/sortBy'; -import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this -import { waveformColor } from '../colors'; +import useThrottle from 'react-use/lib/useThrottle'; +import { waveformColorDark, waveformColorLight } from '../colors'; import { renderWaveformPng } from '../ffmpeg'; const maxWaveforms = 100; // const maxWaveforms = 3; // testing -export default ({ filePath, commandedTime, zoomedDuration, waveformEnabled, mainAudioStream, shouldShowWaveform, ffmpegExtractWindow }) => { +export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnabled, mainAudioStream, shouldShowWaveform, ffmpegExtractWindow }) => { const creatingWaveformPromise = useRef(); const [waveforms, setWaveforms] = useState([]); + const waveformsRef = useRef(); - useDebounceOld(() => { + useEffect(() => { + waveformsRef.current = waveforms; + }, [waveforms]); + + const waveformColor = darkMode ? waveformColorDark : waveformColorLight; + + const timeThrottled = useThrottle(relevantTime, 1000); + + useEffect(() => { let aborted = false; (async () => { - const alreadyHaveWaveformAtCommandedTime = waveforms.some((waveform) => waveform.from <= commandedTime && waveform.to >= commandedTime); - const shouldRun = filePath && mainAudioStream && commandedTime != null && shouldShowWaveform && waveformEnabled && !alreadyHaveWaveformAtCommandedTime && !creatingWaveformPromise.current; + const waveformStartTime = Math.floor(timeThrottled / ffmpegExtractWindow) * ffmpegExtractWindow; + + const alreadyHaveWaveformAtTime = (waveformsRef.current || []).some((waveform) => waveform.from === waveformStartTime); + const shouldRun = filePath && mainAudioStream && timeThrottled != null && shouldShowWaveform && waveformEnabled && !alreadyHaveWaveformAtTime && !creatingWaveformPromise.current; if (!shouldRun) return; try { - const promise = renderWaveformPng({ filePath, aroundTime: commandedTime, window: ffmpegExtractWindow, color: waveformColor }); + const safeExtractDuration = Math.min(waveformStartTime + ffmpegExtractWindow, durationSafe) - waveformStartTime; + const promise = renderWaveformPng({ filePath, start: waveformStartTime, duration: safeExtractDuration, color: waveformColor }); creatingWaveformPromise.current = promise; const newWaveform = await promise; if (aborted) return; @@ -43,7 +55,7 @@ export default ({ filePath, commandedTime, zoomedDuration, waveformEnabled, main return () => { aborted = true; }; - }, 500, [filePath, commandedTime, zoomedDuration, waveformEnabled, mainAudioStream, shouldShowWaveform, waveforms, ffmpegExtractWindow]); + }, [filePath, timeThrottled, waveformEnabled, mainAudioStream, shouldShowWaveform, ffmpegExtractWindow, durationSafe, waveformColor, setWaveforms]); const lastWaveformsRef = useRef([]); useEffect(() => { @@ -54,8 +66,8 @@ export default ({ filePath, commandedTime, zoomedDuration, waveformEnabled, main lastWaveformsRef.current = waveforms; }, [waveforms]); - useEffect(() => setWaveforms([]), [filePath]); - useEffect(() => () => setWaveforms([]), []); + useEffect(() => setWaveforms([]), [filePath, setWaveforms]); + useEffect(() => () => setWaveforms([]), [setWaveforms]); return { waveforms }; }; diff --git a/src/main.css b/src/main.css index 86599ad3..fc5aa7ec 100644 --- a/src/main.css +++ b/src/main.css @@ -4,6 +4,8 @@ https://www.radix-ui.com/docs/colors/palette-composition/understanding-the-scale */ @import '@radix-ui/colors/red.css'; @import '@radix-ui/colors/redDark.css'; +@import '@radix-ui/colors/amber.css'; +@import '@radix-ui/colors/amberDark.css'; @import '@radix-ui/colors/green.css'; @import '@radix-ui/colors/greenDark.css'; @import '@radix-ui/colors/cyan.css'; diff --git a/src/theme.js b/src/theme.js index 058eb76b..69130abc 100644 --- a/src/theme.js +++ b/src/theme.js @@ -10,11 +10,20 @@ function colorKeyForIntent(intent) { function borderColorForIntent(intent, isHover) { if (intent === 'danger') return isHover ? 'var(--red8)' : 'var(--red7)'; if (intent === 'success') return isHover ? 'var(--green8)' : 'var(--green7)'; - return 'var(--gray7)'; + return 'var(--gray8)'; } export default { ...defaultTheme, + colors: { + ...defaultTheme.colors, + icon: { + default: 'var(--gray12)', + muted: 'var(--gray11)', + disabled: 'var(--gray8)', + selected: 'var(--gray12)', + }, + }, components: { ...defaultTheme.components, Button: { @@ -43,6 +52,22 @@ export default { opacity: 0.5, }, }, + minimal: { + ...defaultTheme.components.Button.appearances.minimal, + + // https://github.com/segmentio/evergreen/blob/master/src/themes/default/components/button.js + color: (theme, props) => props.color || colorKeyForIntent(props.intent), + + _hover: { + backgroundColor: 'var(--gray4)', + }, + _active: { + backgroundColor: 'var(--gray5)', + }, + disabled: { + opacity: 0.5, + }, + }, }, }, },