From 6cc18f1d8e4d439a6b4088ef27dca91123b17980 Mon Sep 17 00:00:00 2001 From: Matt Klaber Date: Sun, 14 Nov 2021 16:11:43 +0000 Subject: [PATCH] Change playback speed exponentially (#840) Co-authored-by: Mikael Finstad --- src/App.jsx | 11 +++++--- src/HelpSheet.jsx | 2 ++ src/util/rate-calculator.js | 37 +++++++++++++++++++++++++++ src/util/rate-calculator.test.js | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/util/rate-calculator.js create mode 100644 src/util/rate-calculator.test.js diff --git a/src/App.jsx b/src/App.jsx index 685c6e152cc..935a80911dd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,7 +15,6 @@ import Mousetrap from 'mousetrap'; import JSON5 from 'json5'; import fromPairs from 'lodash/fromPairs'; -import clamp from 'lodash/clamp'; import sortBy from 'lodash/sortBy'; import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; @@ -62,6 +61,7 @@ import { hasDuplicates, havePermissionToReadFile, isMac, resolvePathIfNeeded, pathExists, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, } from './util'; import { formatDuration } from './util/duration'; +import { adjustRate } from './util/rate-calculator'; import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMultipleFilesDialog, showOpenAndMergeDialog, openAbout, showEditableJsonDialog } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; @@ -1232,7 +1232,7 @@ const App = memo(() => { } }, [filePath, captureFormat, customOutDir, previewFilePath, outputDir, enableTransferTimestamps, hideAllNotifications]); - const changePlaybackRate = useCallback((dir) => { + const changePlaybackRate = useCallback((dir, rateMultiplier) => { if (canvasPlayerEnabled) { toast.fire({ title: i18n.t('Unable to change playback rate right now'), timer: 1000 }); return; @@ -1242,8 +1242,7 @@ const App = memo(() => { if (!playing) { video.play(); } else { - // https://github.com/mifi/lossless-cut/issues/447#issuecomment-766339083 - const newRate = clamp(Math.round((video.playbackRate + (dir * 0.15)) * 100) / 100, 0.1, 16); + const newRate = adjustRate(video.playbackRate, dir, rateMultiplier); toast.fire({ title: `${i18n.t('Playback rate:')} ${Math.round(newRate * 100)}%`, timer: 1000 }); video.playbackRate = newRate; } @@ -1674,7 +1673,9 @@ const App = memo(() => { const togglePlayNoReset = () => togglePlay(); const togglePlayReset = () => togglePlay(true); const reducePlaybackRate = () => changePlaybackRate(-1); + const reducePlaybackRateMore = () => changePlaybackRate(-1, 2.0); const increasePlaybackRate = () => changePlaybackRate(1); + const increasePlaybackRateMore = () => changePlaybackRate(1, 2.0); function seekBackwards() { seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current * -1); seekAccelerationRef.current *= keyboardSeekAccFactor; @@ -1707,7 +1708,9 @@ const App = memo(() => { mousetrap.bind('space', () => togglePlayReset()); mousetrap.bind('k', () => togglePlayNoReset()); mousetrap.bind('j', () => reducePlaybackRate()); + mousetrap.bind('shift+j', () => reducePlaybackRateMore()); mousetrap.bind('l', () => increasePlaybackRate()); + mousetrap.bind('shift+l', () => increasePlaybackRateMore()); mousetrap.bind('z', () => toggleComfortZoom()); mousetrap.bind(',', () => seekBackwardsShort()); mousetrap.bind('.', () => seekForwardsShort()); diff --git a/src/HelpSheet.jsx b/src/HelpSheet.jsx index 58577c36c7a..33ceba82dfd 100644 --- a/src/HelpSheet.jsx +++ b/src/HelpSheet.jsx @@ -44,6 +44,8 @@ const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSe
SPACE, k {t('Play/pause')}
J {t('Slow down playback')}
L {t('Speed up playback')}
+
SHIFT + J {t('Slow down playback more')}
+
SHIFT + L {t('Speed up playback more')}

{t('Seeking')}

diff --git a/src/util/rate-calculator.js b/src/util/rate-calculator.js new file mode 100644 index 00000000000..508aaa80e74 --- /dev/null +++ b/src/util/rate-calculator.js @@ -0,0 +1,37 @@ +import clamp from 'lodash/clamp'; + +/** + * @constant {number} + * @default + * The default playback rate multiplier is used to adjust the current playback + * rate when no additional modifiers are applied. This is set to ∛2 so that striking + * the fast forward key (`l`) three times speeds playback up to twice the speed. + */ +export const DEFAULT_PLAYBACK_RATE = (2 ** (1 / 3)); + +/** + * Adjusts the current playback rate up or down + * @param {number} playbackRate current playback rate + * @param {number} direction positive for forward, negative for reverse + * @param {number} [multiplier] rate multiplier, defaults to {@link DEFAULT_PLAYBACK_RATE} + * @returns a new playback rate + */ +export function adjustRate(playbackRate, direction, multiplier) { + const m = multiplier || DEFAULT_PLAYBACK_RATE; + const factor = direction > 0 ? m : (1 / m); + let newRate = playbackRate * factor; + // If the multiplier causes us to go faster than real time or slower than real time, + // stop along the way at 1.0. This could happen if the current playbackRate was reached + // using a different multiplier (e.g., holding the shift key). + // https://github.com/mifi/lossless-cut/issues/447#issuecomment-766339083 + if ((newRate > 1.0 && playbackRate < 1.0) || (newRate < 1.0 && playbackRate > 1.0)) { + newRate = 1.0; + } + // And, clean up any rounding errors that get us to almost 1.0 (e.g., treat 1.00001 as 1) + if ((newRate > (m ** (-1 / 2))) && (newRate < (m ** (1 / 2)))) { + newRate = 1.0; + } + return clamp(newRate, 0.1, 16); +} + +export default adjustRate; diff --git a/src/util/rate-calculator.test.js b/src/util/rate-calculator.test.js new file mode 100644 index 00000000000..d9fc71f6486 --- /dev/null +++ b/src/util/rate-calculator.test.js @@ -0,0 +1,44 @@ +import { adjustRate, DEFAULT_PLAYBACK_RATE } from './rate-calculator'; + +it('inverts for reverse direction', () => { + const r = adjustRate(1, -1, 2); + expect(r).toBeLessThan(1); +}); + +it('uses default rate', () => { + const r = adjustRate(1, 1); + expect(r).toBe(1 * DEFAULT_PLAYBACK_RATE); +}); + +it('allows multiplier override', () => { + const r = adjustRate(1, 1, Math.PI); + expect(r).toBe(1 * Math.PI); +}); + +describe('speeding up', () => { + it('sets rate to 1 if close to 1', () => { + expect(adjustRate(1 / DEFAULT_PLAYBACK_RATE + 0.01, 1)).toBe(1); + }); + + it('sets rate to 1 if passing 1 ', () => { + expect(adjustRate(0.5, 1, 2)).toBe(1); + }); + + it('will not play faster than 16', () => { + expect(adjustRate(15.999999, 1, 2)).toBe(16); + }); +}); + +describe('slowing down', () => { + it('sets rate to 1 if close to 1', () => { + expect(adjustRate(DEFAULT_PLAYBACK_RATE + 0.01, -1)).toBe(1); + }); + + it('sets rate to 1 if passing 1', () => { + expect(adjustRate(1.1, -1, 2)).toBe(1); + }); + + it('will not play slower than 0.1', () => { + expect(adjustRate(0.1111, -1, 2)).toBe(0.1); + }); +});