| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,369 @@ | ||
| import { useEffect, useRef, useState, useCallback, useMemo, memo, CSSProperties, RefObject } from 'react'; | ||
| import { Spinner } from 'evergreen-ui'; | ||
| import { useDebounce } from 'use-debounce'; | ||
|
|
||
| import isDev from './isDev'; | ||
| import { ChromiumHTMLVideoElement } from './types'; | ||
| import { FFprobeStream } from '../../../ffprobe'; | ||
|
|
||
| const { compatPlayer: { createMediaSourceStream, readOneJpegFrame } } = window.require('@electron/remote').require('./index.js'); | ||
|
|
||
|
|
||
| async function startPlayback({ path, video, videoStreamIndex, audioStreamIndex, seekTo, signal, playSafe, onCanPlay, getTargetTime, size, fps }: { | ||
| path: string, | ||
| video: ChromiumHTMLVideoElement, | ||
| videoStreamIndex?: number | undefined, | ||
| audioStreamIndex?: number | undefined, | ||
| seekTo: number, | ||
| signal: AbortSignal, | ||
| playSafe: () => void, | ||
| onCanPlay: () => void, | ||
| getTargetTime: () => number, | ||
| size?: number | undefined, | ||
| fps?: number | undefined, | ||
| }) { | ||
| let canPlay = false; | ||
| let bufferEndTime: number | undefined; | ||
| let bufferStartTime = 0; | ||
| let stream; | ||
| let done = false; | ||
| let interval: NodeJS.Timeout | undefined; | ||
| let objectUrl: string | undefined; | ||
| let processChunkTimeout: NodeJS.Timeout; | ||
|
|
||
| function cleanup() { | ||
| console.log('Cleanup'); | ||
| done = true; | ||
| video.pause(); | ||
| if (interval != null) clearInterval(interval); | ||
| if (processChunkTimeout != null) clearInterval(processChunkTimeout); | ||
| stream?.abort(); | ||
| if (objectUrl != null) URL.revokeObjectURL(objectUrl); | ||
| video.removeAttribute('src'); | ||
| } | ||
|
|
||
| signal.addEventListener('abort', cleanup); | ||
|
|
||
| // See chrome://media-internals | ||
|
|
||
| const mediaSource = new MediaSource(); | ||
|
|
||
| let streamTimestamp; | ||
| let lastRemoveTimestamp = 0; | ||
|
|
||
| function setStandardPlaybackRate() { | ||
| // set it a bit faster, so that we don't easily fall behind (better too fast than too slow) | ||
| // eslint-disable-next-line no-param-reassign | ||
| video.playbackRate = 1.05; | ||
| } | ||
|
|
||
| setStandardPlaybackRate(); | ||
|
|
||
| const codecs: string[] = []; | ||
| if (videoStreamIndex != null) codecs.push('avc1.42C01F'); | ||
| if (audioStreamIndex != null) codecs.push('mp4a.40.2'); | ||
| const codecTag = codecs.join(', '); | ||
|
|
||
| const mimeCodec = `video/mp4; codecs="${codecTag}"`; | ||
|
|
||
| // mp4info sample-file.mp4 | grep Codec | ||
| // https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API/Transcoding_assets_for_MSE | ||
| // https://stackoverflow.com/questions/16363167/html5-video-tag-codecs-attribute | ||
| // https://cconcolato.github.io/media-mime-support/ | ||
| // https://github.com/cconcolato/media-mime-support | ||
| // const mimeCodec = 'video/mp4; codecs="avc1.42C01E"'; // Video only | ||
| // const mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; // Video+audio | ||
|
|
||
| if (!MediaSource.isTypeSupported(mimeCodec)) { | ||
| throw new Error(`Unsupported MIME type or codec: ${mimeCodec}`); | ||
| } | ||
|
|
||
| // console.log(mediaSource.readyState); // closed | ||
| objectUrl = URL.createObjectURL(mediaSource); | ||
| // eslint-disable-next-line no-param-reassign | ||
| video.src = objectUrl; | ||
|
|
||
| await new Promise((resolve) => mediaSource.addEventListener('sourceopen', resolve, { once: true })); | ||
| // console.log(mediaSource.readyState); // open | ||
|
|
||
| const sourceBuffer = mediaSource.addSourceBuffer(mimeCodec); | ||
|
|
||
| const getBufferEndTime = () => { | ||
| if (mediaSource.readyState !== 'open') { | ||
| console.log('mediaSource.readyState was not open, but:', mediaSource.readyState); | ||
| // else we will get: Uncaught DOMException: Failed to execute 'end' on 'TimeRanges': The index provided (0) is greater than or equal to the maximum bound (0). | ||
| return undefined; | ||
| } | ||
|
|
||
| if (sourceBuffer.buffered.length === 0) { | ||
| return undefined; | ||
| } | ||
|
|
||
| // https://developer.mozilla.org/en-US/docs/Web/API/TimeRanges/start | ||
| return sourceBuffer.buffered.end(0); | ||
| }; | ||
|
|
||
| sourceBuffer.addEventListener('updateend', () => { | ||
| playSafe(); | ||
| }, { once: true }); | ||
|
|
||
| let firstChunkReceived = false; | ||
|
|
||
| const processChunk = async () => { | ||
| try { | ||
| const chunk = await stream.readChunk(); | ||
| if (chunk == null) { | ||
| console.log('End of stream'); | ||
| return; | ||
| } | ||
| if (done) return; | ||
|
|
||
| if (!firstChunkReceived) { | ||
| firstChunkReceived = true; | ||
| console.log('First chunk received'); | ||
| } | ||
|
|
||
| sourceBuffer.appendBuffer(chunk); | ||
| } catch (err) { | ||
| console.error('processChunk failed', err); | ||
| processChunkTimeout = setTimeout(processChunk, 1000); | ||
| } | ||
| }; | ||
|
|
||
| sourceBuffer.addEventListener('error', (err) => console.error('sourceBuffer error, check DevTools ▶ More Tools ▶ Media', err)); | ||
|
|
||
| // video.addEventListener('loadeddata', () => console.log('loadeddata')); | ||
| // video.addEventListener('play', () => console.log('play')); | ||
| video.addEventListener('canplay', () => { | ||
| console.log('canplay'); | ||
| if (!canPlay) { | ||
| canPlay = true; | ||
| onCanPlay(); | ||
| } | ||
| }, { once: true }); | ||
|
|
||
| sourceBuffer.addEventListener('updateend', ({ timeStamp }) => { | ||
| if (done) return; | ||
|
|
||
| streamTimestamp = timeStamp; // apparently this timestamp cannot be trusted much | ||
|
|
||
| const bufferThrottleSec = isDev ? 5 : 10; // how many seconds ahead of playback we want to buffer | ||
| const bufferMaxSec = bufferThrottleSec + (isDev ? 5 : 60); // how many seconds we want to buffer in total (ahead of playback and behind) | ||
|
|
||
| bufferEndTime = getBufferEndTime(); | ||
|
|
||
| // console.log('updateend', { bufferEndTime }) | ||
| if (bufferEndTime != null) { | ||
| const targetTime = getTargetTime(); | ||
|
|
||
| const bufferedTime = bufferEndTime - lastRemoveTimestamp; | ||
|
|
||
| if (bufferedTime > bufferMaxSec && !sourceBuffer.updating) { | ||
| try { | ||
| lastRemoveTimestamp = bufferEndTime; | ||
| const removeTo = bufferEndTime - bufferMaxSec; | ||
| bufferStartTime = removeTo; | ||
| console.log('sourceBuffer remove', 0, removeTo); | ||
| sourceBuffer.remove(0, removeTo); // updateend will be emitted again when this is done | ||
| return; | ||
| } catch (err) { | ||
| console.error('sourceBuffer remove failed', err); | ||
| } | ||
| } | ||
|
|
||
| const bufferAheadSec = bufferEndTime - targetTime; | ||
| if (bufferAheadSec > bufferThrottleSec) { | ||
| console.debug(`buffer ahead by ${bufferAheadSec}, throttling stream read`); | ||
| processChunkTimeout = setTimeout(processChunk, 1000); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // make sure we always process the next chunk | ||
| processChunk(); | ||
| }); | ||
|
|
||
| stream = createMediaSourceStream({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps }); | ||
|
|
||
| interval = setInterval(() => { | ||
| if (mediaSource.readyState !== 'open') { | ||
| console.warn('mediaSource.readyState was not open, but:', mediaSource.readyState); | ||
| // else we will get: Uncaught DOMException: Failed to execute 'end' on 'TimeRanges': The index provided (0) is greater than or equal to the maximum bound (0). | ||
| return; | ||
| } | ||
|
|
||
| const targetTime = getTargetTime(); | ||
| const playbackDiff = targetTime != null ? targetTime - video.currentTime : undefined; | ||
|
|
||
| const streamTimestampDiff = streamTimestamp != null && bufferEndTime != null ? (streamTimestamp / 1000) - bufferEndTime : undefined; // not really needed, but log for curiosity | ||
| console.debug('bufferStartTime', bufferStartTime, 'bufferEndTime', bufferEndTime, 'targetTime', targetTime, 'playback:', video.currentTime, 'playbackDiff:', playbackDiff, 'streamTimestamp diff:', streamTimestampDiff); | ||
|
|
||
| if (!canPlay || targetTime == null) return; | ||
|
|
||
| if (sourceBuffer.buffered.length !== 1) { | ||
| // not sure why this would happen or how to handle this | ||
| console.warn('sourceBuffer.buffered.length was', sourceBuffer.buffered.length); | ||
| } | ||
|
|
||
| if ((video.paused || video.ended) && !done) { | ||
| console.warn('Resuming unexpectedly paused video'); | ||
| playSafe(); | ||
| } | ||
|
|
||
| // make sure the playback keeps up | ||
| // https://stackoverflow.com/questions/23301496/how-to-keep-a-live-mediasource-video-stream-in-sync | ||
| if (playbackDiff != null && playbackDiff > 1) { | ||
| console.warn(`playback severely behind by ${playbackDiff}s, seeking to desired time`); | ||
| // eslint-disable-next-line no-param-reassign | ||
| video.currentTime = targetTime; | ||
| setStandardPlaybackRate(); | ||
| } else if (playbackDiff != null && playbackDiff > 0.3) { | ||
| console.warn(`playback behind by ${playbackDiff}s, speeding up playback`); | ||
| // eslint-disable-next-line no-param-reassign | ||
| video.playbackRate = 1.5; | ||
| } else { | ||
| setStandardPlaybackRate(); | ||
| } | ||
| }, 200); | ||
|
|
||
| // OK, everything initialized and ready to stream! | ||
| processChunk(); | ||
| } | ||
|
|
||
| function drawJpegFrame(canvas: HTMLCanvasElement | null, jpegImage: Buffer) { | ||
| if (!canvas) return; | ||
|
|
||
| const ctx = canvas.getContext('2d'); | ||
|
|
||
| const img = new Image(); | ||
| if (ctx == null) { | ||
| console.error('Canvas context is null'); | ||
| return; | ||
| } | ||
| // eslint-disable-next-line unicorn/prefer-add-event-listener | ||
| img.onload = () => ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | ||
| // eslint-disable-next-line unicorn/prefer-add-event-listener | ||
| img.onerror = (error) => console.error('Canvas JPEG image error', error); | ||
| img.src = `data:image/jpeg;base64,${jpegImage.toString('base64')}`; | ||
| } | ||
|
|
||
| async function createPauseImage({ path, seekTo, videoStreamIndex, canvas, signal }: { | ||
| path: string, seekTo: number, videoStreamIndex: number, canvas: HTMLCanvasElement | null, signal: AbortSignal, | ||
| }) { | ||
| const { promise, abort } = readOneJpegFrame({ path, seekTo, videoStreamIndex }); | ||
| signal.addEventListener('abort', () => abort()); | ||
| const jpegImage = await promise; | ||
| drawJpegFrame(canvas, jpegImage); | ||
| } | ||
|
|
||
| function MediaSourcePlayer({ rotate, filePath, playerTime, videoStream, audioStream, commandedTime, playing, eventId, masterVideoRef, mediaSourceQuality, playbackVolume }: { | ||
| rotate: number | undefined, filePath: string, playerTime: number, videoStream: FFprobeStream | undefined, audioStream: FFprobeStream | undefined, commandedTime: number, playing: boolean, eventId: number, masterVideoRef: RefObject<HTMLVideoElement>, mediaSourceQuality: number, playbackVolume: number, | ||
| }) { | ||
| const videoRef = useRef<HTMLVideoElement>(null); | ||
| const canvasRef = useRef<HTMLCanvasElement>(null); | ||
| const [loading, setLoading] = useState(true); | ||
|
|
||
| const onVideoError = useCallback((error) => { | ||
| console.error('video error', error); | ||
| }, []); | ||
|
|
||
| const state = useMemo(() => (playing | ||
| ? { startTime: commandedTime, playing, eventId } | ||
| : { startTime: playerTime, playing, eventId } | ||
| ), [commandedTime, eventId, playerTime, playing]); | ||
|
|
||
| const [debouncedState] = useDebounce(state, 300, { | ||
| equalityFn: (a, b) => a.startTime === b.startTime && a.playing === b.playing && a.eventId === b.eventId, | ||
| leading: true, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| // console.log('debouncedState', debouncedState); | ||
| }, [debouncedState]); | ||
|
|
||
| const playSafe = useCallback(async () => { | ||
| try { | ||
| await videoRef.current?.play(); | ||
| } catch (err) { | ||
| console.error('play failed', err); | ||
| } | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| setLoading(true); | ||
|
|
||
| if (debouncedState.startTime == null) { | ||
| return () => undefined; | ||
| } | ||
|
|
||
| const onCanPlay = () => setLoading(false); | ||
| const getTargetTime = () => masterVideoRef.current!.currentTime - debouncedState.startTime; | ||
|
|
||
| const abortController = new AbortController(); | ||
|
|
||
| const video = videoRef.current; | ||
|
|
||
| (async () => { | ||
| try { | ||
| // When playing, we use a secondary video element, but when paused we use a canvas | ||
| if (debouncedState.playing) { | ||
| if (video == null) throw new Error('No video ref'); | ||
|
|
||
| let size: number | undefined; | ||
| if (videoStream != null) { | ||
| if (mediaSourceQuality === 0) size = 800; | ||
| else if (mediaSourceQuality === 1) size = 420; | ||
| } | ||
|
|
||
| let fps: number | undefined; | ||
| if (mediaSourceQuality === 0) fps = 30; | ||
| else if (mediaSourceQuality === 1) fps = 15; | ||
|
|
||
| await startPlayback({ path: filePath, video, videoStreamIndex: videoStream?.index, audioStreamIndex: audioStream?.index, seekTo: debouncedState.startTime, signal: abortController.signal, playSafe, onCanPlay, getTargetTime, size, fps }); | ||
| } else { // paused | ||
| if (videoStream != null) { | ||
| await createPauseImage({ path: filePath, seekTo: debouncedState.startTime, videoStreamIndex: videoStream.index, canvas: canvasRef.current, signal: abortController.signal }); | ||
| } | ||
| setLoading(false); | ||
| } | ||
| } catch (err) { | ||
| console.error('Preview failed', err); | ||
| } | ||
| })(); | ||
|
|
||
| return () => abortController.abort(); | ||
| // Important that we also have eventId in the deps, so that we can restart the preview when the eventId changes | ||
| }, [debouncedState.startTime, debouncedState.eventId, filePath, masterVideoRef, playSafe, debouncedState.playing, videoStream, mediaSourceQuality, audioStream?.index]); | ||
|
|
||
| useEffect(() => { | ||
| if (videoRef.current) videoRef.current.volume = playbackVolume; | ||
| }, [playbackVolume]); | ||
|
|
||
| const onFocus = useCallback((e) => { | ||
| // prevent video element from stealing focus in fullscreen mode https://github.com/mifi/lossless-cut/issues/543#issuecomment-1868167775 | ||
| e.target.blur(); | ||
| }, []); | ||
|
|
||
| const { videoStyle, canvasStyle } = useMemo(() => { | ||
| const sharedStyle: CSSProperties = { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'block', width: '100%', height: '100%', objectFit: 'contain', transform: rotate ? `rotate(${rotate}deg)` : undefined }; | ||
|
|
||
| return { | ||
| videoStyle: { ...sharedStyle, visibility: loading || !debouncedState.playing ? 'hidden' : undefined }, | ||
| canvasStyle: { ...sharedStyle, visibility: loading || debouncedState.playing ? 'hidden' : undefined }, | ||
| } as { videoStyle: CSSProperties, canvasStyle: CSSProperties }; | ||
| }, [loading, debouncedState.playing, rotate]); | ||
|
|
||
| return ( | ||
| <div style={{ width: '100%', height: '100%', left: 0, right: 0, top: 0, bottom: 0, position: 'absolute', overflow: 'hidden', background: 'black', pointerEvents: 'none' }}> | ||
| {/* eslint-disable-next-line jsx-a11y/media-has-caption */} | ||
| <video style={videoStyle} ref={videoRef} playsInline onError={onVideoError} tabIndex={-1} onFocusCapture={onFocus} /> | ||
| {videoStream != null && <canvas width={videoStream.width} height={videoStream.height} ref={canvasRef} style={canvasStyle} tabIndex={-1} onFocusCapture={onFocus} />} | ||
|
|
||
| {loading && ( | ||
| <div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }}><Spinner /></div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(MediaSourcePlayer); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { memo } from 'react'; | ||
|
|
||
| import { useTranslation, Trans } from 'react-i18next'; | ||
|
|
||
| import SetCutpointButton from './components/SetCutpointButton'; | ||
| import SimpleModeButton from './components/SimpleModeButton'; | ||
| import useUserSettings from './hooks/useUserSettings'; | ||
|
|
||
| const electron = window.require('electron'); | ||
|
|
||
| function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode }: { | ||
| mifiLink: unknown, currentCutSeg, onClick: () => void, darkMode?: boolean, | ||
| }) { | ||
| const { t } = useTranslation(); | ||
| const { simpleMode } = useUserSettings(); | ||
|
|
||
| return ( | ||
| <div | ||
| className="no-user-select" | ||
| style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, border: '.7em dashed var(--gray3)', color: 'var(--gray12)', margin: '2em', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', whiteSpace: 'nowrap' }} | ||
| role="button" | ||
| onClick={onClick} | ||
| > | ||
| <div style={{ fontSize: '2em', textTransform: 'uppercase', color: 'var(--gray11)', marginBottom: '.2em' }}>{t('DROP FILE(S)')}</div> | ||
|
|
||
| <div style={{ fontSize: '1.3em', color: 'var(--gray11)', marginBottom: '.1em' }}> | ||
| <Trans>See <b>Help</b> menu for help</Trans> | ||
| </div> | ||
|
|
||
| <div style={{ fontSize: '1.3em', color: 'var(--gray11)' }}> | ||
| <Trans><SetCutpointButton currentCutSeg={currentCutSeg} side="start" style={{ verticalAlign: 'middle' }} /> <SetCutpointButton currentCutSeg={currentCutSeg} side="end" style={{ verticalAlign: 'middle' }} /> or <kbd>I</kbd> <kbd>O</kbd> to set cutpoints</Trans> | ||
| </div> | ||
|
|
||
| <div style={{ fontSize: '1.3em', color: 'var(--gray11)' }} role="button" onClick={(e) => e.stopPropagation()}> | ||
| {simpleMode ? ( | ||
| <Trans><SimpleModeButton style={{ verticalAlign: 'middle' }} size={16} /> to show advanced view</Trans> | ||
| ) : ( | ||
| <Trans><SimpleModeButton style={{ verticalAlign: 'middle' }} size={16} /> to show simple view</Trans> | ||
| )} | ||
| </div> | ||
|
|
||
| {mifiLink && typeof mifiLink === 'object' && 'loadUrl' in mifiLink && typeof mifiLink.loadUrl === 'string' && mifiLink.loadUrl ? ( | ||
| <div style={{ position: 'relative', margin: '.3em', width: '24em', height: '8em' }}> | ||
| <iframe src={`${mifiLink.loadUrl}#dark=${darkMode ? 'true' : 'false'}`} title="iframe" style={{ background: 'rgba(0,0,0,0)', border: 'none', pointerEvents: 'none', width: '100%', height: '100%', position: 'absolute', colorScheme: 'initial' }} /> | ||
| {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} | ||
| <div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={(e) => { e.stopPropagation(); if ('targetUrl' in mifiLink && typeof mifiLink.targetUrl === 'string') electron.shell.openExternal(mifiLink.targetUrl); }} /> | ||
| </div> | ||
| ) : undefined} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(NoFileLoaded); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,8 @@ | ||
| export const saveColor = 'var(--green11)'; | ||
| export const primaryColor = 'var(--cyan9)'; | ||
| export const primaryTextColor = 'var(--cyan11)'; | ||
| 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'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { memo, useEffect, useState, useCallback, useRef, CSSProperties } from 'react'; | ||
| import { Spinner } from 'evergreen-ui'; | ||
|
|
||
| import { ffmpegExtractWindow } from '../util/constants'; | ||
| import { RenderableWaveform } from '../types'; | ||
|
|
||
|
|
||
| function BigWaveform({ waveforms, relevantTime, playing, durationSafe, zoom, seekRel }: { | ||
| waveforms: RenderableWaveform[], relevantTime: number, playing: boolean, durationSafe: number, zoom: number, seekRel: (a: number) => void, | ||
| }) { | ||
| 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 [smoothTimeRaw, setSmoothTime] = useState<number | undefined>(relevantTime); | ||
|
|
||
| const smoothTime = smoothTimeRaw ?? relevantTime; | ||
|
|
||
| const mouseDownRef = useRef<{ relevantTime: number, x }>(); | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| 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(() => { | ||
| const startTime = Date.now(); | ||
|
|
||
| if (playing) { | ||
| let raf; | ||
| // eslint-disable-next-line no-inner-declarations | ||
| function render() { | ||
| raf = window.requestAnimationFrame(() => { | ||
| setSmoothTime(relevantTime + (Date.now() - startTime) / 1000); | ||
| render(); | ||
| }); | ||
| } | ||
|
|
||
| render(); | ||
| return () => window.cancelAnimationFrame(raf); | ||
| } | ||
|
|
||
| setSmoothTime(undefined); | ||
|
|
||
| return undefined; | ||
| }, [relevantTime, playing]); | ||
|
|
||
| return ( | ||
| // eslint-disable-next-line jsx-a11y/no-static-element-interactions | ||
| <div | ||
| ref={containerRef} | ||
| style={{ height: '100%', width: '100%', position: 'relative', cursor: 'grab' }} | ||
| onMouseDown={handleMouseDown} | ||
| onMouseUp={handleMouseUp} | ||
| onMouseLeave={handleMouseUp} | ||
| onMouseMove={handleMouseMove} | ||
| onWheel={handleWheel} | ||
| > | ||
| {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}%`; | ||
|
|
||
| const style: CSSProperties = { | ||
| pointerEvents: 'none', | ||
| backgroundColor: 'var(--gray3)', | ||
| position: 'absolute', | ||
| height: '100%', | ||
| width: widthPercent, | ||
| left: leftPercent, | ||
| borderLeft: waveform.from === 0 ? '1px solid var(--gray11)' : undefined, | ||
| borderRight: waveform.to >= durationSafe ? '1px solid var(--gray11)' : undefined, | ||
| }; | ||
|
|
||
| if (waveform.url == null) { | ||
| return ( | ||
| <div | ||
| key={`${waveform.from}-${waveform.to}`} | ||
| draggable={false} | ||
| style={{ ...style, display: 'flex', alignItems: 'center', justifyContent: 'center' }} | ||
| > | ||
| <Spinner /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <img | ||
| key={`${waveform.from}-${waveform.to}`} | ||
| src={waveform.url} | ||
| draggable={false} | ||
| alt="" | ||
| style={style} | ||
| /> | ||
| ); | ||
| })} | ||
|
|
||
| <div style={{ pointerEvents: 'none', position: 'absolute', height: '100%', backgroundColor: 'var(--red11)', width: 1, left: '50%', top: 0 }} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(BigWaveform); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| .button { | ||
| appearance: none; | ||
| font: inherit; | ||
| line-height: 140%; | ||
| font-size: .8em; | ||
| background-color: var(--gray3); | ||
| color: var(--gray12); | ||
| border-radius: .3em; | ||
| padding: 0 .5em 0 .3em; | ||
| outline: .05em solid var(--gray8); | ||
| border: .05em solid var(--gray7); | ||
| cursor: pointer; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { ButtonHTMLAttributes, memo } from 'react'; | ||
|
|
||
| import styles from './Button.module.css'; | ||
|
|
||
| function Button({ type = 'button', ...props }: ButtonHTMLAttributes<HTMLButtonElement>) { | ||
| return ( | ||
| // eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type | ||
| <button className={styles['button']} type={type} {...props} /> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(Button); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| .CheckboxRoot { | ||
| all: unset | ||
| } | ||
|
|
||
| .CheckboxRoot { | ||
| background-color: var(--gray8); | ||
| width: 1em; | ||
| height: 1em; | ||
| border-radius: .2em; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| box-shadow: 0 2px 10px var(--gray1); | ||
| } | ||
| .CheckboxRoot:hover { | ||
| background-color: var(--gray9); | ||
| } | ||
| .CheckboxRoot:focus { | ||
| box-shadow: 0 0 0 2px var(--gray1); | ||
| } | ||
|
|
||
| .CheckboxIndicator { | ||
| color: var(--gray12); | ||
| } | ||
|
|
||
| .CheckboxRoot[data-disabled]{ | ||
| opacity: .5; | ||
| } | ||
|
|
||
| .Label { | ||
| padding-left: .5em; | ||
| line-height: 1.2; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { useId } from 'react'; | ||
| import { Root, Indicator, CheckboxProps } from '@radix-ui/react-checkbox'; | ||
| import { FaCheck } from 'react-icons/fa'; | ||
|
|
||
| import classes from './Checkbox.module.css'; | ||
|
|
||
|
|
||
| export default function Checkbox({ label, disabled, style, ...props }: CheckboxProps & { label?: string | undefined }) { | ||
| const id = useId(); | ||
| return ( | ||
| <div style={{ display: 'flex', alignItems: 'center', ...style }}> | ||
| {/* eslint-disable-next-line react/jsx-props-no-spreading */} | ||
| <Root className={classes['CheckboxRoot']} disabled={disabled} {...props} id={id}> | ||
| <Indicator className={classes['CheckboxIndicator']}> | ||
| <FaCheck style={{ fontSize: '.7em' }} /> | ||
| </Indicator> | ||
| </Root> | ||
|
|
||
| {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} | ||
| <label className={classes['Label']} htmlFor={id} style={{ opacity: disabled ? 0.5 : undefined }}> | ||
| {label} | ||
| </label> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| import { memo, useState, useCallback, useEffect, useMemo, CSSProperties } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import { IconButton, Checkbox as EvergreenCheckbox, Dialog, Paragraph } from 'evergreen-ui'; | ||
| import { AiOutlineMergeCells } from 'react-icons/ai'; | ||
| import { FaQuestionCircle, FaExclamationTriangle, FaCog } from 'react-icons/fa'; | ||
| import i18n from 'i18next'; | ||
| import invariant from 'tiny-invariant'; | ||
| import Checkbox from './Checkbox'; | ||
|
|
||
| import { ReactSwal } from '../swal'; | ||
| import { readFileMeta, getSmarterOutFormat } from '../ffmpeg'; | ||
| import useFileFormatState from '../hooks/useFileFormatState'; | ||
| import OutputFormatSelect from './OutputFormatSelect'; | ||
| import useUserSettings from '../hooks/useUserSettings'; | ||
| import { isMov } from '../util/streams'; | ||
| import { getOutFileExtension, getSuffixedFileName } from '../util'; | ||
| import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../../ffprobe'; | ||
| import Sheet from './Sheet'; | ||
| import TextInput from './TextInput'; | ||
| import Button from './Button'; | ||
|
|
||
| const { basename } = window.require('path'); | ||
|
|
||
|
|
||
| const rowStyle: CSSProperties = { | ||
| fontSize: '1em', margin: '4px 0px', overflowY: 'auto', whiteSpace: 'nowrap', | ||
| }; | ||
|
|
||
| function Alert({ text }: { text: string }) { | ||
| return ( | ||
| <div style={{ marginBottom: '1em' }}><FaExclamationTriangle style={{ color: 'var(--orange8)', fontSize: '1.3em', verticalAlign: 'middle', marginRight: '.2em' }} /> {text}</div> | ||
| ); | ||
| } | ||
|
|
||
| function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles }: { | ||
| isShown: boolean, onHide: () => void, paths: string[], onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], outFileName: string, fileFormat: string, clearBatchFilesAfterConcat: boolean }) => Promise<void>, alwaysConcatMultipleFiles: boolean, setAlwaysConcatMultipleFiles: (a: boolean) => void, | ||
| }) { | ||
| const { t } = useTranslation(); | ||
| const { preserveMovData, setPreserveMovData, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge } = useUserSettings(); | ||
|
|
||
| const [includeAllStreams, setIncludeAllStreams] = useState(false); | ||
| const [fileMeta, setFileMeta] = useState<{ format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>(); | ||
| const [allFilesMetaCache, setAllFilesMetaCache] = useState<Record<string, {format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>>({}); | ||
| const [clearBatchFilesAfterConcat, setClearBatchFilesAfterConcat] = useState(false); | ||
| const [settingsVisible, setSettingsVisible] = useState(false); | ||
| const [enableReadFileMeta, setEnableReadFileMeta] = useState(false); | ||
| const [outFileName, setOutFileName] = useState<string>(); | ||
| const [uniqueSuffix, setUniqueSuffix] = useState<number>(); | ||
|
|
||
| const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); | ||
|
|
||
| const firstPath = useMemo(() => { | ||
| if (paths.length === 0) return undefined; | ||
| return paths[0]; | ||
| }, [paths]); | ||
|
|
||
| useEffect(() => { | ||
| if (!isShown) return undefined; | ||
|
|
||
| let aborted = false; | ||
|
|
||
| (async () => { | ||
| setFileMeta(undefined); | ||
| setFileFormat(undefined); | ||
| setDetectedFileFormat(undefined); | ||
| setOutFileName(undefined); | ||
| invariant(firstPath != null); | ||
| const fileMetaNew = await readFileMeta(firstPath); | ||
| const fileFormatNew = await getSmarterOutFormat({ filePath: firstPath, fileMeta: fileMetaNew }); | ||
| if (aborted) return; | ||
| setFileMeta(fileMetaNew); | ||
| setFileFormat(fileFormatNew); | ||
| setDetectedFileFormat(fileFormatNew); | ||
| setUniqueSuffix(Date.now()); | ||
| })().catch(console.error); | ||
|
|
||
| return () => { | ||
| aborted = true; | ||
| }; | ||
| }, [firstPath, isShown, setDetectedFileFormat, setFileFormat]); | ||
|
|
||
| useEffect(() => { | ||
| if (fileFormat == null || firstPath == null) { | ||
| setOutFileName(undefined); | ||
| return; | ||
| } | ||
| const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath: firstPath }); | ||
| setOutFileName((existingOutputName) => { | ||
| if (existingOutputName == null) return getSuffixedFileName(firstPath, `merged-${uniqueSuffix}${ext}`); | ||
| return existingOutputName.replace(/(\.[^.]*)?$/, ext); // make sure the last (optional) .* is replaced by .ext` | ||
| }); | ||
| }, [fileFormat, firstPath, isCustomFormatSelected, uniqueSuffix]); | ||
|
|
||
| const allFilesMeta = useMemo(() => { | ||
| if (paths.length === 0) return undefined; | ||
| const filtered = paths.flatMap((path) => (allFilesMetaCache[path] ? [[path, allFilesMetaCache[path]!] as const] : [])); | ||
| return filtered.length === paths.length ? filtered : undefined; | ||
| }, [allFilesMetaCache, paths]); | ||
|
|
||
| const isOutFileNameValid = outFileName != null && outFileName.length > 0; | ||
|
|
||
| const problemsByFile = useMemo(() => { | ||
| if (!allFilesMeta) return {}; | ||
| const allFilesMetaExceptFirstFile = allFilesMeta.slice(1); | ||
| const [, firstFileMeta] = allFilesMeta[0]!; | ||
| const errors: Record<string, string[]> = {}; | ||
|
|
||
| function addError(path: string, error: string) { | ||
| if (!errors[path]) errors[path] = []; | ||
| errors[path]!.push(error); | ||
| } | ||
|
|
||
| allFilesMetaExceptFirstFile.forEach(([path, { streams }]) => { | ||
| streams.forEach((stream, i) => { | ||
| const referenceStream = firstFileMeta.streams[i]; | ||
| if (!referenceStream) { | ||
| addError(path, i18n.t('Extraneous track {{index}}', { index: stream.index + 1 })); | ||
| return; | ||
| } | ||
| // check all these parameters | ||
| ['codec_name', 'width', 'height', 'fps', 'pix_fmt', 'level', 'profile', 'sample_fmt', 'r_frame_rate', 'time_base'].forEach((key) => { | ||
| const val = stream[key]; | ||
| const referenceVal = referenceStream[key]; | ||
| if (val !== referenceVal) { | ||
| addError(path, i18n.t('Track {{index}} mismatch: {{key1}} {{value1}} != {{value2}}', { index: stream.index + 1, key1: key, value1: val || 'none', value2: referenceVal || 'none' })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| return errors; | ||
| }, [allFilesMeta]); | ||
|
|
||
| const onProblemsByFileClick = useCallback((path: string) => { | ||
| ReactSwal.fire({ | ||
| title: i18n.t('Mismatches detected'), | ||
| html: ( | ||
| <ul style={{ margin: '10px 0', textAlign: 'left' }}> | ||
| {(problemsByFile[path] || []).map((problem) => <li key={problem}>{problem}</li>)} | ||
| </ul> | ||
| ), | ||
| }); | ||
| }, [problemsByFile]); | ||
|
|
||
| useEffect(() => { | ||
| if (!isShown || !enableReadFileMeta) return undefined; | ||
|
|
||
| let aborted = false; | ||
|
|
||
| (async () => { | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| for (const path of paths) { | ||
| if (aborted) return; | ||
| if (!allFilesMetaCache[path]) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const fileMetaNew = await readFileMeta(path); | ||
| setAllFilesMetaCache((existing) => ({ ...existing, [path]: fileMetaNew })); | ||
| } | ||
| } | ||
| })().catch(console.error); | ||
|
|
||
| return () => { | ||
| aborted = true; | ||
| }; | ||
| }, [allFilesMetaCache, enableReadFileMeta, isShown, paths]); | ||
|
|
||
| const onOutputFormatUserChange = useCallback((newFormat) => setFileFormat(newFormat), [setFileFormat]); | ||
|
|
||
| const onConcatClick = useCallback(() => { | ||
| if (outFileName == null) throw new Error(); | ||
| if (fileFormat == null) throw new Error(); | ||
| onConcat({ paths, includeAllStreams, streams: fileMeta!.streams, outFileName, fileFormat, clearBatchFilesAfterConcat }); | ||
| }, [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, onConcat, outFileName, paths]); | ||
|
|
||
| return ( | ||
| <> | ||
| <Sheet visible={isShown} onClosePress={onHide} maxWidth="100%" style={{ padding: '0 2em' }}> | ||
| <h2>{t('Merge/concatenate files')}</h2> | ||
|
|
||
| <div style={{ marginBottom: '1em' }}> | ||
| <div style={{ whiteSpace: 'pre-wrap', fontSize: '.9em', marginBottom: '1em' }}> | ||
| {t('This dialog can be used to concatenate files in series, e.g. one after the other:\n[file1][file2][file3]\nIt can NOT be used for merging tracks in parallell (like adding an audio track to a video).\nMake sure all files are of the exact same codecs & codec parameters (fps, resolution etc).')} | ||
| </div> | ||
|
|
||
| <div style={{ backgroundColor: 'var(--gray1)', borderRadius: '.1em' }}> | ||
| {paths.map((path, index) => ( | ||
| <div key={path} style={rowStyle} title={path}> | ||
| <div> | ||
| {index + 1} | ||
| {'. '} | ||
| <span>{basename(path)}</span> | ||
| {!allFilesMetaCache[path] && <FaQuestionCircle style={{ color: 'var(--orange8)', verticalAlign: 'middle', marginLeft: '1em' }} />} | ||
| {problemsByFile[path] && <IconButton appearance="minimal" icon={FaExclamationTriangle} onClick={() => onProblemsByFileClick(path)} title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange8)', marginLeft: '1em' }} />} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', marginBottom: '.5em', gap: '.5em' }}> | ||
| <Checkbox checked={enableReadFileMeta} onCheckedChange={(checked) => setEnableReadFileMeta(!!checked)} label={t('Check compatibility')} /> | ||
|
|
||
| <Button onClick={() => setSettingsVisible(true)} style={{ height: '1.7em' }}><FaCog style={{ fontSize: '1em', verticalAlign: 'middle' }} /> {t('Options')}</Button> | ||
|
|
||
| {fileFormat && detectedFileFormat && ( | ||
| <OutputFormatSelect style={{ height: '1.7em', maxWidth: '20em' }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} /> | ||
| )} | ||
| </div> | ||
|
|
||
| <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end', marginBottom: '1em' }}> | ||
| <div style={{ marginRight: '.5em' }}>{t('Output file name')}:</div> | ||
| <TextInput value={outFileName || ''} onChange={(e) => setOutFileName(e.target.value)} /> | ||
| <Button disabled={detectedFileFormat == null || !isOutFileNameValid} onClick={onConcatClick} style={{ fontSize: '1.3em', padding: '0 .3em', marginLeft: '1em' }}><AiOutlineMergeCells style={{ fontSize: '1.4em', verticalAlign: 'middle' }} /> {t('Merge!')}</Button> | ||
| </div> | ||
|
|
||
| {enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && ( | ||
| <Alert text={t('A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.')} /> | ||
| )} | ||
| {!enableReadFileMeta && ( | ||
| <Alert text={t('File compatibility check is not enabled, so the merge operation might not produce a valid output. Enable "Check compatibility" below to check file compatibility before merging.')} /> | ||
| )} | ||
| </Sheet> | ||
|
|
||
| <Dialog isShown={settingsVisible} onCloseComplete={() => setSettingsVisible(false)} title={t('Merge options')} hasCancel={false} confirmLabel={t('Close')}> | ||
| <EvergreenCheckbox checked={includeAllStreams} onChange={(e) => setIncludeAllStreams(e.target.checked)} label={`${t('Include all tracks?')} ${t('If this is checked, all audio/video/subtitle/data tracks will be included. This may not always work for all file types. If not checked, only default streams will be included.')}`} /> | ||
|
|
||
| <EvergreenCheckbox checked={preserveMetadataOnMerge} onChange={(e) => setPreserveMetadataOnMerge(e.target.checked)} label={t('Preserve original metadata when merging? (slow)')} /> | ||
|
|
||
| {fileFormat != null && isMov(fileFormat) && <EvergreenCheckbox checked={preserveMovData} onChange={(e) => setPreserveMovData(e.target.checked)} label={t('Preserve all MP4/MOV metadata?')} />} | ||
|
|
||
| <EvergreenCheckbox checked={segmentsToChapters} onChange={(e) => setSegmentsToChapters(e.target.checked)} label={t('Create chapters from merged segments? (slow)')} /> | ||
|
|
||
| <EvergreenCheckbox checked={alwaysConcatMultipleFiles} onChange={(e) => setAlwaysConcatMultipleFiles(e.target.checked)} label={t('Always open this dialog when opening multiple files')} /> | ||
|
|
||
| <EvergreenCheckbox checked={clearBatchFilesAfterConcat} onChange={(e) => setClearBatchFilesAfterConcat(e.target.checked)} label={t('Clear batch file list after merge')} /> | ||
|
|
||
| <Paragraph>{t('Note that also other settings from the normal export dialog apply to this merge function. For more information about all options, see the export dialog.')}</Paragraph> | ||
| </Dialog> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(ConcatDialog); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { CSSProperties, HTMLAttributes, memo } from 'react'; | ||
|
|
||
| import { primaryTextColor } from '../colors'; | ||
|
|
||
| export const highlightedTextStyle: CSSProperties = { textDecoration: 'underline', textUnderlineOffset: '.2em', textDecorationColor: primaryTextColor, color: 'var(--gray12)', borderRadius: '.4em' }; | ||
|
|
||
| function HighlightedText({ children, style, ...props }: HTMLAttributes<HTMLSpanElement>) { | ||
| // eslint-disable-next-line react/jsx-props-no-spreading | ||
| return <span {...props} style={{ ...highlightedTextStyle, ...style }}>{children}</span>; | ||
| } | ||
|
|
||
| export default memo(HighlightedText); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { memo } from 'react'; | ||
|
|
||
| import TextInput from './TextInput'; | ||
|
|
||
|
|
||
| function MergedOutFileName({ mergedOutFileName, setMergedOutFileName }: { mergedOutFileName: string | undefined, setMergedOutFileName: (a: string) => void }) { | ||
| return ( | ||
| <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}> | ||
| <TextInput value={mergedOutFileName ?? ''} onChange={(e) => setMergedOutFileName(e.target.value)} style={{ textAlign: 'right' }} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(MergedOutFileName); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,16 @@ | ||
| import { memo } from 'react'; | ||
|
|
||
| import { withBlur } from '../util'; | ||
| import useUserSettings from '../hooks/useUserSettings'; | ||
| import Switch from './Switch'; | ||
|
|
||
|
|
||
| function MovFastStartButton() { | ||
| const { movFastStart, toggleMovFastStart } = useUserSettings(); | ||
|
|
||
| return ( | ||
| <Switch checked={movFastStart} onCheckedChange={withBlur(toggleMovFastStart)} /> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(MovFastStartButton); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'; | ||
| import { useDebounce } from 'use-debounce'; | ||
| import i18n from 'i18next'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import { WarningSignIcon, ErrorIcon, Button, IconButton, TickIcon, ResetIcon } from 'evergreen-ui'; | ||
| import { IoIosHelpCircle } from 'react-icons/io'; | ||
| import { motion, AnimatePresence } from 'framer-motion'; | ||
|
|
||
| import { ReactSwal } from '../swal'; | ||
| import HighlightedText from './HighlightedText'; | ||
| import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, GenerateOutSegFileNames, extVariable, segTagsVariable, segNumIntVariable } from '../util/outputNameTemplate'; | ||
| import useUserSettings from '../hooks/useUserSettings'; | ||
| import Switch from './Switch'; | ||
| import Select from './Select'; | ||
| import TextInput from './TextInput'; | ||
|
|
||
| const electron = window.require('electron'); | ||
|
|
||
| const formatVariable = (variable) => `\${${variable}}`; | ||
|
|
||
| const extVariableFormatted = formatVariable(extVariable); | ||
| const segTagsExample = `${segTagsVariable}.XX`; | ||
|
|
||
| function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: { | ||
| outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: GenerateOutSegFileNames, currentSegIndexSafe: number, | ||
| }) { | ||
| const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings(); | ||
|
|
||
| const [text, setText] = useState(outSegTemplate); | ||
| const [debouncedText] = useDebounce(text, 500); | ||
| const [validText, setValidText] = useState<string>(); | ||
| const [outSegProblems, setOutSegProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false }); | ||
| const [outSegFileNames, setOutSegFileNames] = useState<string[]>(); | ||
| const [shown, setShown] = useState<boolean>(); | ||
| const inputRef = useRef<HTMLInputElement>(null); | ||
|
|
||
| const { t } = useTranslation(); | ||
|
|
||
| const hasTextNumericPaddedValue = useMemo(() => [segNumVariable, segSuffixVariable].some((v) => debouncedText.includes(formatVariable(v))), [debouncedText]); | ||
|
|
||
| useEffect(() => { | ||
| if (debouncedText == null) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const abortController = new AbortController(); | ||
|
|
||
| (async () => { | ||
| try { | ||
| // console.time('generateOutSegFileNames') | ||
| const outSegs = await generateOutSegFileNames({ template: debouncedText }); | ||
| // console.timeEnd('generateOutSegFileNames') | ||
| if (abortController.signal.aborted) return; | ||
| setOutSegFileNames(outSegs.outSegFileNames); | ||
| setOutSegProblems(outSegs.outSegProblems); | ||
| setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined); | ||
| } catch (err) { | ||
| console.error(err); | ||
| setValidText(undefined); | ||
| setOutSegProblems({ error: err instanceof Error ? err.message : String(err) }); | ||
| } | ||
| })(); | ||
|
|
||
| return () => abortController.abort(); | ||
| }, [debouncedText, generateOutSegFileNames, t]); | ||
|
|
||
| // eslint-disable-next-line no-template-curly-in-string | ||
| const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted); | ||
|
|
||
| const onAllSegmentsPreviewPress = useCallback(() => { | ||
| if (outSegFileNames == null) return; | ||
| ReactSwal.fire({ | ||
| title: t('Resulting segment file names', { count: outSegFileNames.length }), | ||
| html: ( | ||
| <div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}> | ||
| {outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)} | ||
| </div> | ||
| ), | ||
| }); | ||
| }, [outSegFileNames, t]); | ||
|
|
||
| useEffect(() => { | ||
| if (validText != null) setOutSegTemplate(validText); | ||
| }, [validText, setOutSegTemplate]); | ||
|
|
||
| const reset = useCallback(() => { | ||
| setOutSegTemplate(defaultOutSegTemplate); | ||
| setText(defaultOutSegTemplate); | ||
| }, [setOutSegTemplate]); | ||
|
|
||
| const onHideClick = useCallback(() => { | ||
| if (outSegProblems.error == null) setShown(false); | ||
| }, [outSegProblems.error]); | ||
|
|
||
| const onShowClick = useCallback(() => { | ||
| if (!shown) setShown(true); | ||
| }, [shown]); | ||
|
|
||
| const onTextChange = useCallback((e) => setText(e.target.value), []); | ||
|
|
||
| const gotImportantMessage = outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning; | ||
| const needToShow = shown || gotImportantMessage; | ||
|
|
||
| const onVariableClick = useCallback((variable: string) => { | ||
| const input = inputRef.current; | ||
| const startPos = input!.selectionStart; | ||
| const endPos = input!.selectionEnd; | ||
| if (startPos == null || endPos == null) return; | ||
|
|
||
| const toInsert = variable === segTagsExample ? `${segTagsExample} ?? ''` : variable; | ||
|
|
||
| const newValue = `${text.slice(0, startPos)}${`${formatVariable(toInsert)}${text.slice(endPos)}`}`; | ||
| setText(newValue); | ||
| }, [text]); | ||
|
|
||
| return ( | ||
| <motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : 0 }}> | ||
| <div>{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}</div> | ||
|
|
||
| {outSegFileNames != null && <HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>{outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'}</HighlightedText>} | ||
|
|
||
| <AnimatePresence> | ||
| {needToShow && ( | ||
| <motion.div | ||
| key="1" | ||
| initial={{ opacity: 0, height: 0, marginTop: 0 }} | ||
| animate={{ opacity: 1, height: 'auto', marginTop: '1em' }} | ||
| exit={{ opacity: 0, height: 0, marginTop: 0 }} | ||
| > | ||
| <div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}> | ||
| <TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" /> | ||
|
|
||
| {outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>} | ||
|
|
||
| <IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" /> | ||
| {!gotImportantMessage && <IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" appearance="primary" />} | ||
| </div> | ||
|
|
||
| <div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}> | ||
| {`${i18n.t('Variables')}:`} | ||
|
|
||
| <IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} /> | ||
| {['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS'].map((variable) => ( | ||
| <span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em', textDecoration: 'underline', textDecorationStyle: 'dashed', fontSize: '.9em' }} onClick={() => onVariableClick(variable)}>{variable}</span> | ||
| ))} | ||
| </div> | ||
|
|
||
| {outSegProblems.error != null && ( | ||
| <div style={{ marginBottom: '1em' }}> | ||
| <ErrorIcon color="var(--red9)" size={14} verticalAlign="baseline" /> {outSegProblems.error} | ||
| </div> | ||
| )} | ||
|
|
||
| {outSegProblems.error == null && outSegProblems.sameAsInputFileNameWarning && ( | ||
| <div style={{ marginBottom: '1em' }}> | ||
| <WarningSignIcon verticalAlign="middle" color="var(--amber9)" />{' '} | ||
| {i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')} | ||
| </div> | ||
| )} | ||
|
|
||
| {isMissingExtension && ( | ||
| <div style={{ marginBottom: '1em' }}> | ||
| <WarningSignIcon verticalAlign="middle" color="var(--amber9)" />{' '} | ||
| {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: extVariableFormatted })} | ||
| </div> | ||
| )} | ||
|
|
||
| {hasTextNumericPaddedValue && ( | ||
| <div style={{ marginBottom: '.3em' }}> | ||
| <Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}> | ||
| {Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)} | ||
| </Select> | ||
| Minimum numeric padded length | ||
| </div> | ||
| )} | ||
|
|
||
| <div title={t('Whether or not to sanitize output file names (sanitizing removes special characters)')} style={{ marginBottom: '.3em' }}> | ||
| <Switch checked={safeOutputFileName} onCheckedChange={toggleSafeOutputFileName} style={{ verticalAlign: 'middle', marginRight: '.5em' }} /> | ||
| <span>{t('Sanitize file names')}</span> | ||
|
|
||
| {!safeOutputFileName && <WarningSignIcon color="var(--amber9)" style={{ marginLeft: '.5em', verticalAlign: 'middle' }} />} | ||
| </div> | ||
| </motion.div> | ||
| )} | ||
| </AnimatePresence> | ||
| </motion.div> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(OutSegTemplateEditor); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { CSSProperties, memo, useMemo } from 'react'; | ||
| import i18n from 'i18next'; | ||
|
|
||
| import allOutFormats from '../outFormats'; | ||
| import { withBlur } from '../util'; | ||
| import Select from './Select'; | ||
|
|
||
| const commonVideoAudioFormats = ['matroska', 'mov', 'mp4', 'mpegts', 'ogv', 'webm']; | ||
| const commonAudioFormats = ['flac', 'ipod', 'mp3', 'oga', 'ogg', 'opus', 'wav']; | ||
| const commonSubtitleFormats = ['ass', 'srt', 'sup', 'webvtt']; | ||
|
|
||
| function renderFormatOptions(formats: string[]) { | ||
| return formats.map((format) => ( | ||
| <option key={format} value={format}>{format} - {allOutFormats[format]}</option> | ||
| )); | ||
| } | ||
|
|
||
| function OutputFormatSelect({ style, detectedFileFormat, fileFormat, onOutputFormatUserChange }: { | ||
| style: CSSProperties, detectedFileFormat?: string | undefined, fileFormat?: string | undefined, onOutputFormatUserChange: (a: string) => void, | ||
| }) { | ||
| const commonVideoAudioFormatsExceptDetectedFormat = useMemo(() => commonVideoAudioFormats.filter((f) => f !== detectedFileFormat), [detectedFileFormat]); | ||
| const commonAudioFormatsExceptDetectedFormat = useMemo(() => commonAudioFormats.filter((f) => f !== detectedFileFormat), [detectedFileFormat]); | ||
| const commonSubtitleFormatsExceptDetectedFormat = useMemo(() => commonSubtitleFormats.filter((f) => f !== detectedFileFormat), [detectedFileFormat]); | ||
| const commonFormatsAndDetectedFormat = useMemo(() => new Set([...commonVideoAudioFormats, ...commonAudioFormats, commonSubtitleFormats, detectedFileFormat]), [detectedFileFormat]); | ||
|
|
||
| const otherFormats = useMemo(() => Object.keys(allOutFormats).filter((format) => !commonFormatsAndDetectedFormat.has(format)), [commonFormatsAndDetectedFormat]); | ||
|
|
||
| return ( | ||
| // eslint-disable-next-line react/jsx-props-no-spreading | ||
| <Select style={style} value={fileFormat || ''} title={i18n.t('Output container format:')} onChange={withBlur((e) => onOutputFormatUserChange(e.target.value))}> | ||
| <option key="disabled1" value="" disabled>{i18n.t('Output container format:')}</option> | ||
|
|
||
| {detectedFileFormat && ( | ||
| <option key={detectedFileFormat} value={detectedFileFormat}> | ||
| {detectedFileFormat} - {allOutFormats[detectedFileFormat]} {i18n.t('(detected)')} | ||
| </option> | ||
| )} | ||
|
|
||
| <option key="disabled2" value="" disabled>--- {i18n.t('Common video/audio formats:')} ---</option> | ||
| {renderFormatOptions(commonVideoAudioFormatsExceptDetectedFormat)} | ||
|
|
||
| <option key="disabled3" value="" disabled>--- {i18n.t('Common audio formats:')} ---</option> | ||
| {renderFormatOptions(commonAudioFormatsExceptDetectedFormat)} | ||
|
|
||
| <option key="disabled4" value="" disabled>--- {i18n.t('Common subtitle formats:')} ---</option> | ||
| {renderFormatOptions(commonSubtitleFormatsExceptDetectedFormat)} | ||
|
|
||
| <option key="disabled5" value="" disabled>--- {i18n.t('All other formats:')} ---</option> | ||
| {renderFormatOptions(otherFormats)} | ||
| </Select> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(OutputFormatSelect); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { memo, useState, useCallback, useRef, useEffect } from 'react'; | ||
| import { MdSubtitles } from 'react-icons/md'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import Select from './Select'; | ||
|
|
||
| function PlaybackStreamSelector({ | ||
| subtitleStreams, | ||
| videoStreams, | ||
| audioStreams, | ||
| activeSubtitleStreamIndex, | ||
| activeVideoStreamIndex, | ||
| activeAudioStreamIndex, | ||
| onActiveSubtitleChange, | ||
| onActiveVideoStreamChange, | ||
| onActiveAudioStreamChange, | ||
| }: { | ||
| subtitleStreams, | ||
| videoStreams, | ||
| audioStreams, | ||
| activeSubtitleStreamIndex?: number | undefined, | ||
| activeVideoStreamIndex?: number | undefined, | ||
| activeAudioStreamIndex?: number | undefined, | ||
| onActiveSubtitleChange: (a?: number | undefined) => void, | ||
| onActiveVideoStreamChange: (a?: number | undefined) => void, | ||
| onActiveAudioStreamChange: (a?: number | undefined) => void, | ||
| }) { | ||
| const [controlVisible, setControlVisible] = useState(false); | ||
| const timeoutRef = useRef<number>(); | ||
|
|
||
| const { t } = useTranslation(); | ||
|
|
||
| const resetTimer = useCallback(() => { | ||
| clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = window.setTimeout(() => setControlVisible(false), 7000); | ||
| }, []); | ||
|
|
||
| const onChange = useCallback((e, fn) => { | ||
| resetTimer(); | ||
| const index = e.target.value ? parseInt(e.target.value, 10) : undefined; | ||
| fn(index); | ||
| e.target.blur(); | ||
| }, [resetTimer]); | ||
|
|
||
| const onActiveSubtitleChange2 = useCallback((e) => onChange(e, onActiveSubtitleChange), [onActiveSubtitleChange, onChange]); | ||
| const onActiveVideoStreamChange2 = useCallback((e) => onChange(e, onActiveVideoStreamChange), [onActiveVideoStreamChange, onChange]); | ||
| const onActiveAudioStreamChange2 = useCallback((e) => onChange(e, onActiveAudioStreamChange), [onActiveAudioStreamChange, onChange]); | ||
|
|
||
| const onIconClick = useCallback(() => { | ||
| resetTimer(); | ||
| setControlVisible((v) => !v); | ||
| }, [resetTimer]); | ||
|
|
||
| useEffect(() => () => clearTimeout(timeoutRef.current), []); | ||
|
|
||
| return ( | ||
| <> | ||
| {controlVisible && ( | ||
| <> | ||
| {subtitleStreams.length > 0 && ( | ||
| <Select | ||
| value={activeSubtitleStreamIndex ?? ''} | ||
| onChange={onActiveSubtitleChange2} | ||
| onMouseMove={resetTimer} | ||
| > | ||
| <option value="">{t('Subtitle')}</option> | ||
| {subtitleStreams.map((stream, i) => ( | ||
| <option key={stream.index} value={stream.index}>#{i + 1} (id {stream.index}) {stream.tags?.language}</option> | ||
| ))} | ||
| </Select> | ||
| )} | ||
|
|
||
| {videoStreams.length > 1 && ( | ||
| <Select | ||
| value={activeVideoStreamIndex ?? ''} | ||
| onChange={onActiveVideoStreamChange2} | ||
| onMouseMove={resetTimer} | ||
| > | ||
| <option value="">{t('Video track')}</option> | ||
| {videoStreams.map((stream, i) => ( | ||
| <option key={stream.index} value={stream.index}>#{i + 1} (id {stream.index + 1}) {stream.codec_name}</option> | ||
| ))} | ||
| </Select> | ||
| )} | ||
|
|
||
| {audioStreams.length > 1 && ( | ||
| <Select | ||
| value={activeAudioStreamIndex ?? ''} | ||
| onChange={onActiveAudioStreamChange2} | ||
| onMouseMove={resetTimer} | ||
| > | ||
| <option value="">{t('Audio track')}</option> | ||
| {audioStreams.map((stream, i) => ( | ||
| <option key={stream.index} value={stream.index}>#{i + 1} (id {stream.index + 1}) {stream.codec_name} - {stream.tags?.language}</option> | ||
| ))} | ||
| </Select> | ||
| )} | ||
| </> | ||
| )} | ||
|
|
||
| <MdSubtitles | ||
| size={30} | ||
| role="button" | ||
| style={{ margin: '0 7px', color: 'var(--gray12)', opacity: 0.7 }} | ||
| onClick={onIconClick} | ||
| /> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(PlaybackStreamSelector); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,16 @@ | ||
| import { memo } from 'react'; | ||
|
|
||
| import { withBlur } from '../util'; | ||
| import useUserSettings from '../hooks/useUserSettings'; | ||
| import Switch from './Switch'; | ||
|
|
||
|
|
||
| function PreserveMovDataButton() { | ||
| const { preserveMovData, togglePreserveMovData } = useUserSettings(); | ||
|
|
||
| return ( | ||
| <Switch checked={preserveMovData} onCheckedChange={withBlur(togglePreserveMovData)} /> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(PreserveMovDataButton); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { CSSProperties, useMemo } from 'react'; | ||
|
|
||
| import { useSegColors } from '../contexts'; | ||
| import useUserSettings from '../hooks/useUserSettings'; | ||
| import { SegmentBase, SegmentColorIndex } from '../types'; | ||
|
|
||
| const SegmentCutpointButton = ({ currentCutSeg, side, Icon, onClick, title, style }: { | ||
| currentCutSeg: SegmentBase & SegmentColorIndex, side: 'start' | 'end', Icon, onClick?: (() => void) | undefined, title?: string | undefined, style?: CSSProperties | undefined | ||
| }) => { | ||
| const { darkMode } = useUserSettings(); | ||
| const { getSegColor } = useSegColors(); | ||
| const segColor = useMemo(() => getSegColor(currentCutSeg), [currentCutSeg, getSegColor]); | ||
|
|
||
| const start = side === 'start'; | ||
| const border = `3px solid ${segColor.desaturate(0.6).lightness(darkMode ? 45 : 35).string()}`; | ||
| const backgroundColor = segColor.desaturate(0.6).lightness(darkMode ? 35 : 55).string(); | ||
|
|
||
| return ( | ||
| <Icon | ||
| size={13} | ||
| title={title} | ||
| role="button" | ||
| style={{ flexShrink: 0, color: 'white', padding: start ? '4px 4px 4px 2px' : '4px 2px 4px 4px', borderLeft: start && border, borderRight: !start && border, background: backgroundColor, borderRadius: 6, ...style }} | ||
| onClick={onClick} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default SegmentCutpointButton; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| .select { | ||
| appearance: none; | ||
| font: inherit; | ||
| line-height: 120%; | ||
| font-size: .8em; | ||
| background-color: var(--gray3); | ||
| color: var(--gray12); | ||
| border-radius: .3em; | ||
| padding: 0 1.2em 0 .3em; | ||
| outline: .05em solid var(--gray8); | ||
| border: .05em solid var(--gray7); | ||
|
|
||
| background-image: url("data:image/svg+xml;utf8,<svg fill='rgba(0,0,0,0.6)' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>"); | ||
| 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,<svg fill='rgba(255,255,255,0.6)' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>"); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { SelectHTMLAttributes, memo } from 'react'; | ||
|
|
||
| import styles from './Select.module.css'; | ||
|
|
||
|
|
||
| function Select(props: SelectHTMLAttributes<HTMLSelectElement>) { | ||
| return ( | ||
| // eslint-disable-next-line react/jsx-props-no-spreading | ||
| <select className={styles['select']} {...props} /> | ||
| ); | ||
| } | ||
|
|
||
| export default memo(Select); |