From 251027a931e4298bfde594768ae52505de244c32 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Mon, 27 Oct 2025 15:09:56 +0800 Subject: [PATCH] introduce reduce motion setting also improve reduce motion to include more animations/transitions closes #2580 related #1410 #880 #371 --- src/main/configStore.ts | 1 + src/renderer/src/App.tsx | 17 ++++++++---- src/renderer/src/BetweenSegments.tsx | 7 +++-- src/renderer/src/SegmentList.tsx | 5 ++-- src/renderer/src/Timeline.tsx | 4 +-- src/renderer/src/TimelineSeg.tsx | 13 +++++---- src/renderer/src/animations.ts | 8 ++++++ .../src/components/BatchFilesList.tsx | 5 ++-- src/renderer/src/components/Settings.tsx | 15 +++++++++-- src/renderer/src/hooks/useUserSettingsRoot.ts | 27 ++++++++++++++++++- src/renderer/src/index.tsx | 5 +--- src/renderer/src/main.css | 7 ++++- src/renderer/src/swal.ts | 8 +++--- src/renderer/src/util.ts | 3 +++ types.ts | 1 + 15 files changed, 90 insertions(+), 36 deletions(-) diff --git a/src/main/configStore.ts b/src/main/configStore.ts index 21396448..0165a378 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -163,6 +163,7 @@ const defaults: Config = { waveformMode: undefined, thumbnailsEnabled: false, keyframesEnabled: true, + reducedMotion: 'user', }; const configFileName = 'config.json'; // note: this is also hard-coded inside electron-store diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1da84c27..dbd6e531 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,7 +1,7 @@ import { memo, useEffect, useState, useCallback, useRef, useMemo, CSSProperties, ReactEventHandler, FocusEventHandler, DragEventHandler } from 'react'; import { FaAngleLeft, FaRegTimesCircle } from 'react-icons/fa'; import { MdRotate90DegreesCcw } from 'react-icons/md'; -import { AnimatePresence } from 'framer-motion'; +import { AnimatePresence, MotionConfig } from 'framer-motion'; import { ThemeProvider } from 'evergreen-ui'; import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; @@ -176,7 +176,7 @@ function App() { const allUserSettings = useUserSettingsRoot(); const { - captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMetadata, preserveChapters, preserveMovData, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, preserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, mergedFileTemplate, setMergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, toggleDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, setWaveformMode, thumbnailsEnabled, setThumbnailsEnabled, keyframesEnabled, setKeyframesEnabled, + captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMetadata, preserveChapters, preserveMovData, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, preserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, mergedFileTemplate, setMergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, toggleDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, setWaveformMode, thumbnailsEnabled, setThumbnailsEnabled, keyframesEnabled, setKeyframesEnabled, prefersReducedMotion, reducedMotion, } = allUserSettings; // Note that each action may be multiple key bindings and this will only be the first binding for each action @@ -2513,13 +2513,20 @@ function App() { const baseColorStyle = useMemo(() => ({ color: 'var(--gray-12)', background: 'var(--gray-1)', colorScheme: darkMode ? 'only dark' : 'only light' }), [darkMode]); + const rootClass = useMemo(() => [ + ...(darkMode ? ['dark-theme'] : []), + ...(prefersReducedMotion ? ['no-animations'] : []), + ].join(' '), [darkMode, prefersReducedMotion]); + + const rootStyle = useMemo(() => ({ ...baseColorStyle, display: 'flex', flexDirection: 'column', height: '100vh', transition: darkModeTransition }), [baseColorStyle]); + return ( - <> + -
+
- + ); } diff --git a/src/renderer/src/BetweenSegments.tsx b/src/renderer/src/BetweenSegments.tsx index 583f17e5..f71c3a99 100644 --- a/src/renderer/src/BetweenSegments.tsx +++ b/src/renderer/src/BetweenSegments.tsx @@ -2,7 +2,6 @@ import { memo } from 'react'; import { motion } from 'framer-motion'; import { FaTrashAlt, FaSave } from 'react-icons/fa'; -import { mySpring } from './animations'; import { saveColor } from './colors'; import useUserSettings from './hooks/useUserSettings'; @@ -15,7 +14,7 @@ function BetweenSegments({ start, end, fileDurationNonZero, invertCutSegments }: }) { const left = `${(start / fileDurationNonZero) * 100}%`; - const { effectiveExportMode } = useUserSettings(); + const { effectiveExportMode, prefersReducedMotion, springAnimation } = useUserSettings(); return (
{/* https://github.com/mifi/lossless-cut/issues/2157 */} diff --git a/src/renderer/src/SegmentList.tsx b/src/renderer/src/SegmentList.tsx index 6949efc1..9b87efb8 100644 --- a/src/renderer/src/SegmentList.tsx +++ b/src/renderer/src/SegmentList.tsx @@ -15,7 +15,6 @@ import useContextMenu from './hooks/useContextMenu'; import useUserSettings from './hooks/useUserSettings'; import { saveColor, controlsBackground, primaryTextColor, darkModeTransition } from './colors'; import { useSegColors } from './contexts'; -import { mySpring } from './animations'; import { getSegmentTags } from './segments'; import TagEditor from './components/TagEditor'; import { ContextMenuTemplate, DefiniteSegmentBase, FormatTimecode, GetFrameCount, InverseCutSegment, SegmentBase, SegmentTags, StateSegment } from './types'; @@ -356,7 +355,7 @@ function SegmentList({ const { getSegColor } = useSegColors(); const [draggingId, setDraggingId] = useState(); - const { invertCutSegments, simpleMode, darkMode } = useUserSettings(); + const { invertCutSegments, simpleMode, darkMode, springAnimation } = useUserSettings(); const getButtonColor = useCallback((seg: StateSegment | undefined, next?: boolean) => getSegColor(seg ? { segColorIndex: next ? seg.segColorIndex + 1 : seg.segColorIndex } : undefined).desaturate(0.3).lightness(darkMode ? 45 : 55).string(), [darkMode, getSegColor]); const currentSegColor = useMemo(() => getButtonColor(currentCutSeg), [currentCutSeg, getButtonColor]); @@ -586,7 +585,7 @@ function SegmentList({ initial={{ x: width }} animate={{ x: 0 }} exit={{ x: width }} - transition={mySpring} + transition={springAnimation} >
(null); const timelineScrollerSkipEventRef = useRef(false); @@ -427,7 +427,7 @@ function Timeline({ ))} {currentTimePercent !== undefined && ( - + )} {commandedTimePercent !== undefined && ( diff --git a/src/renderer/src/TimelineSeg.tsx b/src/renderer/src/TimelineSeg.tsx index 52c94855..f3234a30 100644 --- a/src/renderer/src/TimelineSeg.tsx +++ b/src/renderer/src/TimelineSeg.tsx @@ -3,7 +3,6 @@ import { motion, AnimatePresence, MotionStyle } from 'framer-motion'; import { FaTrashAlt } from 'react-icons/fa'; import Color from 'color'; -import { mySpring } from './animations'; import useUserSettings from './hooks/useUserSettings'; import { useSegColors } from './contexts'; import { FormatTimecode, StateSegment } from './types'; @@ -23,7 +22,7 @@ function Marker({ getTimePercent: (a: number) => string, formatTimecode: FormatTimecode, }) { - const { darkMode } = useUserSettings(); + const { darkMode, prefersReducedMotion, springAnimation } = useUserSettings(); const pinColor = darkMode ? color.saturate(0.2).lightness(40).string() : color.desaturate(0.2).lightness(50).string(); @@ -59,8 +58,8 @@ function Marker({ return ( { @@ -164,8 +163,8 @@ function Segment({ return ( prefersReducedMotionValue; diff --git a/src/renderer/src/components/BatchFilesList.tsx b/src/renderer/src/components/BatchFilesList.tsx index 6f90d458..77ec3e76 100644 --- a/src/renderer/src/components/BatchFilesList.tsx +++ b/src/renderer/src/components/BatchFilesList.tsx @@ -10,8 +10,8 @@ import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import BatchFile from './BatchFile'; import { controlsBackground, darkModeTransition, primaryColor } from '../colors'; -import { mySpring } from '../animations'; import { BatchFile as BatchFileType } from '../types'; +import useUserSettings from '../hooks/useUserSettings'; const iconStyle = { @@ -37,6 +37,7 @@ function BatchFilesList({ selectedBatchFiles, filePath, width, batchFiles, setBa onDrop: DragEventHandler, }) { const { t } = useTranslation(); + const { springAnimation } = useUserSettings(); const [sortDesc, setSortDesc] = useState(); const [draggingId, setDraggingId] = useState(); @@ -86,7 +87,7 @@ function BatchFilesList({ selectedBatchFiles, filePath, width, batchFiles, setBa initial={{ x: -width }} animate={{ x: 0 }} exit={{ x: -width }} - transition={mySpring} + transition={springAnimation} onDrop={onDrop} >
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 26601963..3d9c48fb 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -11,7 +11,7 @@ import Switch from './Switch'; import useUserSettings from '../hooks/useUserSettings'; import { askForFfPath } from '../dialogs'; import { isMasBuild, isStoreBuild } from '../util'; -import { LanguageKey, ModifierKey, TimecodeFormat, langNames } from '../../../../types'; +import { Config, LanguageKey, ModifierKey, TimecodeFormat, langNames } from '../../../../types'; import styles from './Settings.module.css'; import Select from './Select'; @@ -74,7 +74,7 @@ function Settings({ const { t } = useTranslation(); const [showAdvanced, setShowAdvanced] = useState(!simpleMode); - const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds } = useUserSettings(); + const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion } = useUserSettings(); const onLangChange = useCallback>((e) => { const { value } = e.target; @@ -482,6 +482,17 @@ function Settings({ + + {t('Reduce motion in user interface')} + + + + + {t('In timecode show')} diff --git a/src/renderer/src/hooks/useUserSettingsRoot.ts b/src/renderer/src/hooks/useUserSettingsRoot.ts index 19bb2579..f846a845 100644 --- a/src/renderer/src/hooks/useUserSettingsRoot.ts +++ b/src/renderer/src/hooks/useUserSettingsRoot.ts @@ -1,11 +1,17 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import i18n from 'i18next'; +import { Transition } from 'framer-motion'; + import { Config } from '../../../../types'; import { errorToast } from '../swal'; import isDev from '../isDev'; +import { mySpring, setPrefersReducedMotion } from '../animations'; const { configStore } = window.require('@electron/remote').require('./index.js'); +const { systemPreferences } = window.require('@electron/remote'); + +const animationSettings = systemPreferences.getAnimationSettings(); export default () => { const firstUpdateRef = useRef(true); @@ -175,6 +181,8 @@ export default () => { useEffect(() => safeSetConfig({ thumbnailsEnabled }), [thumbnailsEnabled]); const [keyframesEnabled, setKeyframesEnabled] = useState(safeGetConfigInitial('keyframesEnabled')); useEffect(() => safeSetConfig({ keyframesEnabled }), [keyframesEnabled]); + const [reducedMotion, setReducedMotion] = useState(safeGetConfigInitial('reducedMotion')); + useEffect(() => safeSetConfig({ reducedMotion }), [reducedMotion]); const resetKeyBindings = useCallback(() => { @@ -192,6 +200,19 @@ export default () => { const toggleDarkMode = useCallback(() => setDarkMode((v) => !v), []); + const prefersReducedMotion = useMemo(() => { + if (reducedMotion !== 'user') return reducedMotion === 'always'; + // fallback to electron detected system setting + // note: user has to restart app for changes here to be detected + return animationSettings.prefersReducedMotion; + }, [reducedMotion]); + + useEffect(() => { + setPrefersReducedMotion(prefersReducedMotion); + }, [prefersReducedMotion]); + + const springAnimation = useMemo(() => (prefersReducedMotion ? { duration: 0 } : mySpring), [prefersReducedMotion]); + return { captureFormat, setCaptureFormat, @@ -326,5 +347,9 @@ export default () => { setThumbnailsEnabled, keyframesEnabled, setKeyframesEnabled, + reducedMotion, + prefersReducedMotion, + setReducedMotion, + springAnimation, }; }; diff --git a/src/renderer/src/index.tsx b/src/renderer/src/index.tsx index d0141b3e..1b11cf7b 100644 --- a/src/renderer/src/index.tsx +++ b/src/renderer/src/index.tsx @@ -1,6 +1,5 @@ import { Suspense, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { MotionConfig } from 'framer-motion'; import { enableMapSet } from 'immer'; import * as Electron from 'electron'; import Remote from '@electron/remote'; @@ -77,9 +76,7 @@ root.render( }> - - - + , diff --git a/src/renderer/src/main.css b/src/renderer/src/main.css index cf2c260a..375e5926 100644 --- a/src/renderer/src/main.css +++ b/src/renderer/src/main.css @@ -125,5 +125,10 @@ input:focus-visible, select:focus-visible { /* because some people have a hard time finding the button */ .export-animation { - animation: pulse 2s linear infinite + animation: pulse 2s linear infinite; +} + +.no-animations * { + transition: none !important; + animation: none !important; } diff --git a/src/renderer/src/swal.ts b/src/renderer/src/swal.ts index 479e9493..4defade5 100644 --- a/src/renderer/src/swal.ts +++ b/src/renderer/src/swal.ts @@ -1,18 +1,16 @@ import SwalRaw from 'sweetalert2/dist/sweetalert2.js'; import type { SweetAlertOptions } from 'sweetalert2'; import withReactContent from 'sweetalert2-react-content'; -import i18n from './i18n'; - -const { systemPreferences } = window.require('@electron/remote'); +import i18n from './i18n'; +import { prefersReducedMotion } from './animations'; -const animationSettings = systemPreferences.getAnimationSettings(); let commonSwalOptions: SweetAlertOptions = { target: '#swal2-container-wrapper', }; -if (animationSettings.prefersReducedMotion) { +if (prefersReducedMotion()) { commonSwalOptions = { ...commonSwalOptions, showClass: { diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 91482173..ec621a19 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -13,6 +13,7 @@ import { ffmpegExtractWindow } from './util/constants'; import { appName } from '../../main/common'; import { DirectoryAccessDeclinedError, UnsupportedFileError } from '../errors'; import { Html5ifyMode } from '../../../types'; +import { prefersReducedMotion } from './animations'; const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename } = window.require('path'); const fsExtra = window.require('fs-extra'); @@ -531,6 +532,8 @@ export const mediaSourceQualities = ['HD', 'SD', 'OG']; // OG is original export const splitKeyboardKeys = (keys: string) => keys.split('+'); export function shootConfetti(options?: confetti.Options) { + if (prefersReducedMotion()) return; + confetti({ particleCount: 30, angle: 110, diff --git a/types.ts b/types.ts index e0a9a2a3..95583e87 100644 --- a/types.ts +++ b/types.ts @@ -120,6 +120,7 @@ export interface Config { waveformMode: WaveformMode | undefined, thumbnailsEnabled: boolean, keyframesEnabled: boolean, + reducedMotion: 'always' | 'never' | 'user', } export interface Waveform {