2,821 changes: 2,821 additions & 0 deletions src/renderer/src/App.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { memo } from 'react';
import { memo } from 'react';
import { motion } from 'framer-motion';
import { FaTrashAlt, FaSave } from 'react-icons/fa';

import { mySpring } from './animations';
import { saveColor } from './colors';

const BetweenSegments = memo(({ start, end, duration, invertCutSegments }) => {

function BetweenSegments({ start, end, duration, invertCutSegments }: { start: number, end: number, duration: number, invertCutSegments: boolean }) {
const left = `${(start / duration) * 100}%`;

return (
Expand Down Expand Up @@ -38,6 +39,6 @@ const BetweenSegments = memo(({ start, end, duration, invertCutSegments }) => {
<div style={{ flexGrow: 1, borderBottom: '1px dashed var(--gray10)', marginLeft: 5, marginRight: 5 }} />
</motion.div>
);
});
}

export default BetweenSegments;
export default memo(BetweenSegments);
233 changes: 162 additions & 71 deletions src/BottomBar.jsx → src/renderer/src/BottomBar.tsx

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions src/ErrorBoundary.jsx → src/renderer/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React, { Component } from 'react';
import { Component, ErrorInfo, ReactNode } from 'react';
import { Trans } from 'react-i18next';

import { openSendReportDialog } from './reporting';

class ErrorBoundary extends Component {
constructor(props) {

class ErrorBoundary extends Component<{ children: ReactNode }> {
// eslint-disable-next-line react/state-in-constructor
override state: { error: { message: string } | undefined };

constructor(props: { children: ReactNode }) {
super(props);
this.state = { error: undefined };
}
Expand All @@ -13,11 +17,11 @@ class ErrorBoundary extends Component {
return { error };
}

componentDidCatch(error, errorInfo) {
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('componentDidCatch', error, errorInfo);
}

render() {
override render() {
const { error } = this.state;
if (error) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import React, { memo } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';

import CopyClipboardButton from './components/CopyClipboardButton';
import Sheet from './components/Sheet';
import { FfmpegCommandLog } from './types';

const LastCommandsSheet = memo(({ visible, onTogglePress, ffmpegCommandLog }) => {
function LastCommandsSheet({ visible, onTogglePress, ffmpegCommandLog }: {
visible: boolean, onTogglePress: () => void, ffmpegCommandLog: FfmpegCommandLog,
}) {
const { t } = useTranslation();

return (
<Sheet visible={visible} onClosePress={onTogglePress} style={{ paddingTop: '2em' }}>
<Sheet visible={visible} onClosePress={onTogglePress} style={{ padding: '0 1em' }}>
<h2>{t('Last ffmpeg commands')}</h2>

{ffmpegCommandLog.length > 0 ? (
Expand All @@ -25,6 +28,6 @@ const LastCommandsSheet = memo(({ visible, onTogglePress, ffmpegCommandLog }) =>
)}
</Sheet>
);
});
}

export default LastCommandsSheet;
export default memo(LastCommandsSheet);
369 changes: 369 additions & 0 deletions src/renderer/src/MediaSourcePlayer.tsx
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);
53 changes: 53 additions & 0 deletions src/renderer/src/NoFileLoaded.tsx
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);
508 changes: 508 additions & 0 deletions src/renderer/src/SegmentList.tsx

Large diffs are not rendered by default.

512 changes: 512 additions & 0 deletions src/renderer/src/StreamsSelector.tsx

Large diffs are not rendered by default.

186 changes: 127 additions & 59 deletions src/Timeline.jsx → src/renderer/src/Timeline.tsx

Large diffs are not rendered by default.

48 changes: 32 additions & 16 deletions src/TimelineSeg.jsx → src/renderer/src/TimelineSeg.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
import React, { memo, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { memo, useMemo } from 'react';
import { motion, AnimatePresence, MotionStyle } from 'framer-motion';
import { FaTrashAlt } from 'react-icons/fa';

import { mySpring } from './animations';
import useUserSettings from './hooks/useUserSettings';
import { useSegColors } from './contexts';
import { ApparentCutSegment, FormatTimecode } from './types';


const TimelineSeg = memo(({
duration, cutStart, cutEnd, isActive, segNum, name,
onSegClick, invertCutSegments, segColor, formatTimecode, selected,
}) => {
function TimelineSeg({
seg, duration, isActive, segNum, onSegClick, invertCutSegments, formatTimecode, selected,
} : {
seg: ApparentCutSegment, duration: number, isActive: boolean, segNum: number, onSegClick: (a: number) => void, invertCutSegments: boolean, formatTimecode: FormatTimecode, selected: boolean,
}) {
const { darkMode } = useUserSettings();
const { getSegColor } = useSegColors();

const segColor = useMemo(() => getSegColor(seg), [getSegColor, seg]);

const { name, start: cutStart, end: cutEnd } = seg;

const cutSectionWidth = `${((cutEnd - cutStart) / duration) * 100}%`;

const startTimePos = `${(cutStart / duration) * 100}%`;

const markerBorder = useMemo(() => `2px solid ${isActive ? segColor.lighten(0.2).string() : 'transparent'}`, [isActive, segColor]);
const markerBorder = useMemo(() => {
if (!isActive) return '2px solid transparent';
return `1.5px solid ${darkMode ? segColor.desaturate(0.1).lightness(70).string() : segColor.desaturate(0.2).lightness(40).string()}`;
}, [darkMode, isActive, segColor]);

const backgroundColor = useMemo(() => {
if (invertCutSegments || !selected) return segColor.alpha(0.4).string();
if (isActive) return segColor.alpha(0.7).string();
return segColor.alpha(0.6).string();
}, [invertCutSegments, isActive, segColor, selected]);
// we use both transparency and lightness, so that segments can be visible when overlapping
if (invertCutSegments || !selected) return darkMode ? segColor.desaturate(0.3).lightness(30).alpha(0.5).string() : segColor.desaturate(0.3).lightness(70).alpha(0.5).string();
if (isActive) return darkMode ? segColor.saturate(0.2).lightness(60).alpha(0.7).string() : segColor.saturate(0.2).lightness(40).alpha(0.8).string();
return darkMode ? segColor.desaturate(0.2).lightness(50).alpha(0.7).string() : segColor.lightness(35).alpha(0.6).string();
}, [darkMode, invertCutSegments, isActive, segColor, selected]);
const markerBorderRadius = 5;

const wrapperStyle = {
const wrapperStyle: MotionStyle = {
position: 'absolute',
top: 0,
bottom: 0,
Expand All @@ -34,6 +49,7 @@ const TimelineSeg = memo(({
originX: 0,
boxSizing: 'border-box',
color: 'white',
overflow: 'hidden',

borderLeft: markerBorder,
borderTopLeftRadius: markerBorderRadius,
Expand All @@ -46,7 +62,7 @@ const TimelineSeg = memo(({

const onThisSegClick = () => onSegClick(segNum);

const title = [];
const title: string[] = [];
if (cutEnd > cutStart) title.push(`${formatTimecode({ seconds: cutEnd - cutStart, shorten: true })}`);
if (name) title.push(name);

Expand All @@ -62,7 +78,7 @@ const TimelineSeg = memo(({
onClick={onThisSegClick}
title={title.join(' ')}
>
<div style={{ alignSelf: 'flex-start', flexShrink: 1, fontSize: 10, minWidth: 0, overflow: 'hidden' }}>{segNum + 1}</div>
<div style={{ alignSelf: 'flex-start', flexShrink: 0, fontSize: 10, minWidth: 0, letterSpacing: '-.1em' }}>{segNum + 1}</div>

<AnimatePresence>
{invertCutSegments && (
Expand All @@ -87,6 +103,6 @@ const TimelineSeg = memo(({
<div style={{ flexGrow: 1 }} />
</motion.div>
);
});
}

export default TimelineSeg;
export default memo(TimelineSeg);
77 changes: 53 additions & 24 deletions src/TopMenu.jsx → src/renderer/src/TopMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,48 @@
import React, { memo, useCallback } from 'react';
import { CSSProperties, ReactNode, memo, useCallback } from 'react';
import { IoIosSettings } from 'react-icons/io';
import { FaLock, FaUnlock } from 'react-icons/fa';
import { IconButton, Button, CrossIcon, ListIcon, VolumeUpIcon, VolumeOffIcon } from 'evergreen-ui';
import { CrossIcon, ListIcon, VolumeUpIcon, VolumeOffIcon } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import Button from './components/Button';

import ExportModeButton from './components/ExportModeButton';

import { withBlur } from './util';
import { primaryTextColor, controlsBackground, darkModeTransition } from './colors';
import useUserSettings from './hooks/useUserSettings';


const TopMenu = memo(({
filePath, fileFormat, copyAnyAudioTrack, toggleStripAudio,
renderOutFmt, numStreamsToCopy, numStreamsTotal, setStreamsSelectorShown, toggleSettings,
selectedSegments, isCustomFormatSelected, clearOutDir,
}) => {
import { InverseCutSegment } from './types';


const outFmtStyle = { height: 20, maxWidth: 100 };
const exportModeStyle = { flexGrow: 0, flexBasis: 140 };

function TopMenu({
filePath,
fileFormat,
copyAnyAudioTrack,
toggleStripAudio,
renderOutFmt,
numStreamsToCopy,
numStreamsTotal,
setStreamsSelectorShown,
toggleSettings,
selectedSegments,
isCustomFormatSelected,
clearOutDir,
}: {
filePath: string | undefined,
fileFormat: string | undefined,
copyAnyAudioTrack: boolean,
toggleStripAudio: () => void,
renderOutFmt: (style: CSSProperties) => ReactNode,
numStreamsToCopy: number,
numStreamsTotal: number,
setStreamsSelectorShown: (v: boolean) => void,
toggleSettings: () => void,
selectedSegments: InverseCutSegment[],
isCustomFormatSelected: boolean,
clearOutDir: () => void,
}) {
const { t } = useTranslation();
const { customOutDir, changeOutDir, simpleMode, outFormatLocked, setOutFormatLocked } = useUserSettings();

Expand All @@ -35,55 +62,57 @@ const TopMenu = memo(({
>
{filePath && (
<>
<Button height={20} iconBefore={ListIcon} onClick={withBlur(() => setStreamsSelectorShown(true))}>
<Button onClick={withBlur(() => setStreamsSelectorShown(true))}>
<ListIcon size={'1em' as unknown as number} verticalAlign="middle" marginRight=".3em" />
{t('Tracks')} ({numStreamsToCopy}/{numStreamsTotal})
</Button>

<Button
iconBefore={copyAnyAudioTrack ? VolumeUpIcon : VolumeOffIcon}
height={20}
title={copyAnyAudioTrack ? t('Keep audio tracks') : t('Discard audio tracks')}
onClick={withBlur(toggleStripAudio)}
>
{copyAnyAudioTrack ? t('Keep audio') : t('Discard audio')}
{copyAnyAudioTrack ? (
<><VolumeUpIcon size={'1em' as unknown as number} verticalAlign="middle" marginRight=".3em" />{t('Keep audio')}</>
) : (
<><VolumeOffIcon size={'1em' as unknown as number} verticalAlign="middle" marginRight=".3em" />{t('Discard audio')}</>
)}
</Button>
</>
)}

<div style={{ flexGrow: 1 }} />

{showClearWorkingDirButton && (
<IconButton
intent="danger"
icon={CrossIcon}
height={20}
<CrossIcon
role="button"
tabIndex={0}
style={{ width: 20 }}
onClick={withBlur(clearOutDir)}
title={t('Clear working directory')}
/>
)}

<Button
height={20}
onClick={withBlur(changeOutDir)}
title={customOutDir}
paddingLeft={showClearWorkingDirButton ? 4 : undefined}
style={{ paddingLeft: showClearWorkingDirButton ? 4 : undefined }}
>
{customOutDir ? t('Working dir set') : t('Working dir unset')}
</Button>

{filePath && (
<>
{renderOutFmt({ height: 20, maxWidth: 100 })}
{renderOutFmt(outFmtStyle)}

{!simpleMode && (isCustomFormatSelected || outFormatLocked) && renderFormatLock()}

<ExportModeButton selectedSegments={selectedSegments} style={{ flexGrow: 0, flexBasis: 140 }} />
<ExportModeButton selectedSegments={selectedSegments} style={exportModeStyle} />
</>
)}

<IoIosSettings size={24} role="button" onClick={toggleSettings} style={{ verticalAlign: 'middle', marginLeft: 5 }} />
<IoIosSettings size={24} role="button" onClick={toggleSettings} style={{ marginLeft: 5 }} />
</div>
);
});
}

export default TopMenu;
export default memo(TopMenu);
Original file line number Diff line number Diff line change
@@ -1,4 +1,237 @@
// Vitest Snapshot v1
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`format srt 1`] = `
"1
00:00:02,000 --> 00:00:06,000
First subtitle
2
00:00:28,967 --> 01:30:30,950
Subtitle 2 line 1
Subtitle 2 line 2
"
`;

exports[`parses DV Analyzer Summary.txt 1`] = `
[
{
"end": 60.4,
"name": "XXXX-XX-XX 00:00:00.000",
"start": 0,
"tags": {
"recordedEnd": "XXXX-XX-XX XX:XX:XX:XX",
"recordedStart": "XXXX-XX-XX 00:00:00.000",
},
},
{
"end": 485.08,
"name": "XXXX-XX-XX XX:XX:XX:XX",
"start": 60.4,
"tags": {
"recordedEnd": "2001-12-31 23:22:09",
"recordedStart": "XXXX-XX-XX XX:XX:XX:XX",
},
},
{
"end": 1010.68,
"name": "2001-12-31 23:28:13",
"start": 485.08,
"tags": {
"recordedEnd": "2002-01-01 19:34:38",
"recordedStart": "2001-12-31 23:28:13",
},
},
{
"end": 1235.32,
"name": "2002-01-01 13:31:24",
"start": 1010.68,
"tags": {
"recordedEnd": "2002-01-01 22:03:01",
"recordedStart": "2002-01-01 13:31:24",
},
},
{
"end": 1575.04,
"name": "2002-01-02 14:27:10",
"start": 1235.32,
"tags": {
"recordedEnd": "2002-01-02 15:48:55",
"recordedStart": "2002-01-02 14:27:10",
},
},
{
"end": 1575.08,
"name": "2002-01-02 22:30:22",
"start": 1575.04,
"tags": {
"recordedEnd": "2002-01-02 22:30:22",
"recordedStart": "2002-01-02 22:30:22",
},
},
{
"end": 1575.16,
"name": "2002-01-02 22:30:22",
"start": 1575.08,
"tags": {
"recordedEnd": "2002-01-02 22:30:22",
"recordedStart": "2002-01-02 22:30:22",
},
},
{
"end": 1575.24,
"name": "2002-01-02 22:30:22",
"start": 1575.16,
"tags": {
"recordedEnd": "2002-01-05 10:57:51",
"recordedStart": "2002-01-02 22:30:22",
},
},
{
"end": 1919.44,
"name": "2002-01-05 10:57:51",
"start": 1575.24,
"tags": {
"recordedEnd": "2002-01-05 11:36:20",
"recordedStart": "2002-01-05 10:57:51",
},
},
{
"end": 2075.88,
"name": "2002-01-05 13:18:43",
"start": 1919.44,
"tags": {
"recordedEnd": "2002-01-05 14:04:19",
"recordedStart": "2002-01-05 13:18:43",
},
},
{
"end": 2138.2,
"name": "2002-01-05 16:39:22",
"start": 2075.88,
"tags": {
"recordedEnd": "2002-01-05 22:51:40",
"recordedStart": "2002-01-05 16:39:22",
},
},
{
"end": 2138.76,
"name": "2002-01-05 16:40:24",
"start": 2138.2,
"tags": {
"recordedEnd": "2002-01-05 16:40:25",
"recordedStart": "2002-01-05 16:40:24",
},
},
{
"end": 2217.32,
"name": "2002-01-05 22:53:17",
"start": 2138.76,
"tags": {
"recordedEnd": "2002-01-05 22:55:08",
"recordedStart": "2002-01-05 22:53:17",
},
},
{
"end": 2269.96,
"name": "2002-01-16 21:17:04",
"start": 2217.32,
"tags": {
"recordedEnd": "2002-01-16 21:18:01",
"recordedStart": "2002-01-16 21:17:04",
},
},
{
"end": 2332.64,
"name": "2002-01-20 20:06:37",
"start": 2269.96,
"tags": {
"recordedEnd": "2002-01-20 20:07:48",
"recordedStart": "2002-01-20 20:06:37",
},
},
{
"end": 3009.12,
"name": "2002-01-30 18:34:52",
"start": 2332.64,
"tags": {
"recordedEnd": "2002-03-12 00:46:51",
"recordedStart": "2002-01-30 18:34:52",
},
},
{
"end": 3596.48,
"name": "2002-03-14 20:27:57",
"start": 3009.12,
"tags": {
"recordedEnd": "2002-04-12 21:06:54",
"recordedStart": "2002-03-14 20:27:57",
},
},
{
"end": 3622.08,
"name": "2002-04-12 21:06:56",
"start": 3596.48,
"tags": {
"recordedEnd": "2002-04-12 21:07:22",
"recordedStart": "2002-04-12 21:06:56",
},
},
{
"end": 4305,
"name": "2002-04-12 21:11:47",
"start": 3622.08,
"tags": {
"recordedEnd": "2002-04-27 00:05:36",
"recordedStart": "2002-04-12 21:11:47",
},
},
{
"end": 4307.12,
"name": "2002-04-25 22:59:57",
"start": 4305,
"tags": {
"recordedEnd": "2002-04-25 22:59:59",
"recordedStart": "2002-04-25 22:59:57",
},
},
{
"end": 4357.68,
"name": "2002-04-25 23:00:00",
"start": 4307.12,
"tags": {
"recordedEnd": "2002-05-02 13:33:33",
"recordedStart": "2002-04-25 23:00:00",
},
},
{
"end": 4359.72,
"name": "2002-04-25 23:00:50",
"start": 4357.68,
"tags": {
"recordedEnd": "2002-04-25 23:00:52",
"recordedStart": "2002-04-25 23:00:50",
},
},
{
"end": 4660.44,
"name": "2002-05-02 13:40:27",
"start": 4359.72,
"tags": {
"recordedEnd": "2002-05-02 13:53:34",
"recordedStart": "2002-05-02 13:40:27",
},
},
{
"end": undefined,
"name": "2002-05-02 13:54:14",
"start": 4660.44,
"tags": {
"recordedEnd": "2002-05-02 13:59:29",
"recordedStart": "2002-05-02 13:54:14",
},
},
]
`;

exports[`parses fcpxml 1.9 1`] = `
[
Expand Down Expand Up @@ -792,6 +1025,28 @@ exports[`parses pbf 4`] = `
]
`;

exports[`parses srt 1`] = `
[
{
"end": 6,
"name": "First subtitle",
"start": 2,
"tags": {
"index": "1",
},
},
{
"end": 5430.95,
"name": "Subtitle 2 line 1
Subtitle 2 line 2",
"start": 28.967,
"tags": {
"index": "2",
},
},
]
`;

exports[`parses xmeml - with multiple tracks 1`] = `
[
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Vitest Snapshot v1
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`converts segments to chapters with gaps 1`] = `
[
Expand Down
File renamed without changes.
4 changes: 2 additions & 2 deletions src/colors.js → src/renderer/src/colors.ts
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)';
// 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';
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { memo } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ForkIcon, DisableIcon } from 'evergreen-ui';

import useUserSettings from '../hooks/useUserSettings';

const AutoExportToggler = memo(() => {
function AutoExportToggler() {
const { t } = useTranslation();
const { autoExportExtraStreams, setAutoExportExtraStreams } = useUserSettings();

Expand All @@ -13,6 +13,6 @@ const AutoExportToggler = memo(() => {
{autoExportExtraStreams ? t('Extract') : t('Discard')}
</Button>
);
});
}

export default AutoExportToggler;
export default memo(AutoExportToggler);
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, { memo, useRef, useMemo } from 'react';
import { memo, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaAngleRight, FaFile } from 'react-icons/fa';

import useContextMenu from '../hooks/useContextMenu';
import { primaryTextColor } from '../colors';

const BatchFile = memo(({ path, isOpen, isSelected, name, onSelect, onDelete }) => {
const ref = useRef();
function BatchFile({ path, isOpen, isSelected, name, onSelect, onDelete }: {
path: string, isOpen: boolean, isSelected: boolean, name: string, onSelect: (a: string) => void, onDelete: (a: string) => void
}) {
const ref = useRef<HTMLDivElement>(null);

const { t } = useTranslation();
const contextMenuTemplate = useMemo(() => [
Expand All @@ -24,6 +26,6 @@ const BatchFile = memo(({ path, isOpen, isSelected, name, onSelect, onDelete })
{isOpen && <FaAngleRight size={14} style={{ color: 'var(--gray9)', marginRight: -5, flexShrink: 0 }} />}
</div>
);
});
}

export default BatchFile;
export default memo(BatchFile);
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo, useCallback, useState } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { FaTimes, FaHatWizard } from 'react-icons/fa';
Expand All @@ -20,10 +20,10 @@ const iconStyle = {
padding: '3px 5px',
};

const BatchFilesList = memo(({ selectedBatchFiles, filePath, width, batchFiles, setBatchFiles, onBatchFileSelect, batchListRemoveFile, closeBatch, onMergeFilesClick, onBatchConvertToSupportedFormatClick }) => {
function BatchFilesList({ selectedBatchFiles, filePath, width, batchFiles, setBatchFiles, onBatchFileSelect, batchListRemoveFile, closeBatch, onMergeFilesClick, onBatchConvertToSupportedFormatClick }) {
const { t } = useTranslation();

const [sortDesc, setSortDesc] = useState();
const [sortDesc, setSortDesc] = useState<boolean>();

const sortableList = batchFiles.map((batchFile) => ({ id: batchFile.path, batchFile }));

Expand All @@ -46,7 +46,7 @@ const BatchFilesList = memo(({ selectedBatchFiles, filePath, width, batchFiles,
return (
<motion.div
className="no-user-select"
style={{ width, background: controlsBackground, color: 'var(--gray12)', transition: darkModeTransition, display: 'flex', flexDirection: 'column', overflowY: 'hidden', overflowX: 'hidden', resize: 'horizontal' }}
style={{ width, background: controlsBackground, color: 'var(--gray12)', borderRight: '1px solid var(--gray7)', transition: darkModeTransition, display: 'flex', flexDirection: 'column', overflowY: 'hidden', overflowX: 'hidden', resize: 'horizontal' }}
initial={{ x: -width }}
animate={{ x: 0 }}
exit={{ x: -width }}
Expand All @@ -70,6 +70,6 @@ const BatchFilesList = memo(({ selectedBatchFiles, filePath, width, batchFiles,
</div>
</motion.div>
);
});
}

export default BatchFilesList;
export default memo(BatchFilesList);
134 changes: 134 additions & 0 deletions src/renderer/src/components/BigWaveform.tsx
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);
13 changes: 13 additions & 0 deletions src/renderer/src/components/Button.module.css
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;
}
12 changes: 12 additions & 0 deletions src/renderer/src/components/Button.tsx
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
@@ -1,17 +1,18 @@
import React, { memo } from 'react';
import { Button } from 'evergreen-ui';
import { memo } from 'react';
import { Button, ButtonProps } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';

import useUserSettings from '../hooks/useUserSettings';
import { withBlur } from '../util';

const CaptureFormatButton = memo(({ showIcon = false, ...props }) => {

function CaptureFormatButton({ showIcon = false, ...props }: { showIcon?: boolean } & ButtonProps) {
const { t } = useTranslation();
const { captureFormat, toggleCaptureFormat } = useUserSettings();
return (
<Button
iconBefore={showIcon ? <FaImage /> : undefined}
iconBefore={showIcon ? <FaImage /> : null}
title={t('Capture frame format')}
onClick={withBlur(toggleCaptureFormat)}
// eslint-disable-next-line react/jsx-props-no-spreading
Expand All @@ -20,6 +21,6 @@ const CaptureFormatButton = memo(({ showIcon = false, ...props }) => {
{captureFormat}
</Button>
);
});
}

export default CaptureFormatButton;
export default memo(CaptureFormatButton);
33 changes: 33 additions & 0 deletions src/renderer/src/components/Checkbox.module.css
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;
}
25 changes: 25 additions & 0 deletions src/renderer/src/components/Checkbox.tsx
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>
);
}
242 changes: 242 additions & 0 deletions src/renderer/src/components/ConcatDialog.tsx
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
@@ -1,12 +1,12 @@
import React, { memo, useCallback } from 'react';
import { memo, useCallback } from 'react';
import { FaClipboard } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { motion, useAnimation } from 'framer-motion';
import { MotionStyle, motion, useAnimation } from 'framer-motion';

const electron = window.require('electron');
const { clipboard } = electron;

const CopyClipboardButton = memo(({ text, style }) => {
function CopyClipboardButton({ text, style }: { text: string, style?: MotionStyle }) {
const { t } = useTranslation();

const animation = useAnimation();
Expand All @@ -23,8 +23,7 @@ const CopyClipboardButton = memo(({ text, style }) => {
<motion.span animate={animation} style={{ display: 'inline-block', cursor: 'pointer', ...style }}>
<FaClipboard title={t('Copy to clipboard')} onClick={onClick} />
</motion.span>

);
});
}

export default CopyClipboardButton;
export default memo(CopyClipboardButton);
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, { memo } from 'react';
import { memo } from 'react';
import { FiScissors } from 'react-icons/fi';
import { FaFileExport } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';

import { primaryColor } from '../colors';
import useUserSettings from '../hooks/useUserSettings';
import { SegmentToExport } from '../types';


const ExportButton = memo(({ segmentsToExport, areWeCutting, onClick, size = 1 }) => {
function ExportButton({ segmentsToExport, areWeCutting, onClick, size = 1 }: {
segmentsToExport: SegmentToExport[], areWeCutting: boolean, onClick: () => void, size?: number | undefined,
}) {
const CutIcon = areWeCutting ? FiScissors : FaFileExport;

const { t } = useTranslation();
Expand Down Expand Up @@ -37,6 +40,6 @@ const ExportButton = memo(({ segmentsToExport, areWeCutting, onClick, size = 1 }
{text}
</div>
);
});
}

export default ExportButton;
export default memo(ExportButton);
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
right: 0;
top: 0;
bottom: 0;
z-index: 10;
background: var(--whiteA11);
color: var(--gray12);
backdrop-filter: blur(30px);
Expand All @@ -28,14 +27,15 @@ table.options {
width: 100%;
}

table.options td:last-child {
text-align: right;
width: 3em;
}
table.options td:nth-child(2) {
text-align: right;
}

table.options td:last-child {
text-align: center;
width: 1.7em;
}

table.options td {
vertical-align: top;
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import React, { memo, useMemo } from 'react';
import { CSSProperties, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { withBlur } from '../util';
import useUserSettings from '../hooks/useUserSettings';
import Select from './Select';
import { ExportMode } from '../types';

const ExportModeButton = memo(({ selectedSegments, style }) => {

function ExportModeButton({ selectedSegments, style }: { selectedSegments: unknown[], style?: CSSProperties }) {
const { t } = useTranslation();

const { effectiveExportMode, setAutoMerge, setAutoDeleteMergedSegments, setSegmentsToChaptersOnly } = useUserSettings();

function onChange(newMode) {
function onChange(newMode: ExportMode) {
switch (newMode) {
case 'sesgments_to_chapters': {
case 'segments_to_chapters': {
setAutoMerge(false);
setAutoDeleteMergedSegments(false);
setSegmentsToChaptersOnly(true);
Expand Down Expand Up @@ -41,10 +43,10 @@ const ExportModeButton = memo(({ selectedSegments, style }) => {
}

const selectableModes = useMemo(() => [
'separate',
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge' ? ['merge'] : []),
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge+separate' ? ['merge+separate'] : []),
'sesgments_to_chapters',
'separate' as const,
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge' ? ['merge'] as const : []),
...(selectedSegments.length >= 2 || effectiveExportMode === 'merge+separate' ? ['merge+separate'] as const : []),
'segments_to_chapters' as const,
], [effectiveExportMode, selectedSegments.length]);

return (
Expand All @@ -54,9 +56,11 @@ const ExportModeButton = memo(({ selectedSegments, style }) => {
value={effectiveExportMode}
onChange={withBlur((e) => onChange(e.target.value))}
>
<option key="disabled" value="" disabled>{t('Export mode')}</option>

{selectableModes.map((mode) => {
const titles = {
sesgments_to_chapters: t('Chapters only'),
segments_to_chapters: t('Segments to chapters'),
merge: t('Merge cuts'),
'merge+separate': t('Merge & Separate'),
separate: t('Separate files'),
Expand All @@ -71,6 +75,6 @@ const ExportModeButton = memo(({ selectedSegments, style }) => {
})}
</Select>
);
});
}

export default ExportModeButton;
export default memo(ExportModeButton);
12 changes: 12 additions & 0 deletions src/renderer/src/components/HighlightedText.tsx
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);
803 changes: 803 additions & 0 deletions src/renderer/src/components/KeyboardShortcuts.tsx

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/renderer/src/components/MergedOutFileName.tsx
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 React, { memo } from 'react';
import { memo } from 'react';

import { withBlur } from '../util';
import useUserSettings from '../hooks/useUserSettings';
import Switch from './Switch';


const MovFastStartButton = memo(() => {
function MovFastStartButton() {
const { movFastStart, toggleMovFastStart } = useUserSettings();

return (
<Switch checked={movFastStart} onCheckedChange={withBlur(toggleMovFastStart)} />
);
});
}

export default MovFastStartButton;
export default memo(MovFastStartButton);
190 changes: 190 additions & 0 deletions src/renderer/src/components/OutSegTemplateEditor.tsx
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);
54 changes: 54 additions & 0 deletions src/renderer/src/components/OutputFormatSelect.tsx
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);
110 changes: 110 additions & 0 deletions src/renderer/src/components/PlaybackStreamSelector.tsx
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 React, { memo } from 'react';
import { memo } from 'react';

import { withBlur } from '../util';
import useUserSettings from '../hooks/useUserSettings';
import Switch from './Switch';


const PreserveMovDataButton = memo(() => {
function PreserveMovDataButton() {
const { preserveMovData, togglePreserveMovData } = useUserSettings();

return (
<Switch checked={preserveMovData} onCheckedChange={withBlur(togglePreserveMovData)} />
);
});
}

export default PreserveMovDataButton;
export default memo(PreserveMovDataButton);
29 changes: 29 additions & 0 deletions src/renderer/src/components/SegmentCutpointButton.tsx
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;
22 changes: 22 additions & 0 deletions src/renderer/src/components/Select.module.css
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>");
}
13 changes: 13 additions & 0 deletions src/renderer/src/components/Select.tsx
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);
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React from 'react';
import { CSSProperties } from 'react';
import { FaHandPointUp } from 'react-icons/fa';

import SegmentCutpointButton from './SegmentCutpointButton';
import { mirrorTransform } from '../util';
import { SegmentBase, SegmentColorIndex } from '../types';

// constant side because we are mirroring
const SetCutpointButton = ({ currentCutSeg, side, title, onClick, style }) => (
const SetCutpointButton = ({ currentCutSeg, side, title, onClick, style }: {
currentCutSeg: SegmentBase & SegmentColorIndex, side: 'start' | 'end', title?: string, onClick?: () => void, style?: CSSProperties
}) => (
<SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaHandPointUp} onClick={onClick} title={title} style={{ transform: side === 'start' ? mirrorTransform : undefined, ...style }} />
);

Expand Down
File renamed without changes.
Loading