From 05b61e08a528aa32dc88cea46d668fe9cfbe68a3 Mon Sep 17 00:00:00 2001 From: R-J Lim Date: Sun, 16 Jun 2024 15:04:05 -0700 Subject: [PATCH] Subtitle track-specific settings --- common/app/components/SettingsDialog.tsx | 1 + common/app/components/VideoPlayer.tsx | 131 +--- common/app/components/video-player.css | 8 + common/app/hooks/use-subtitle-styles.ts | 24 + common/app/services/chrome-extension.ts | 4 + common/app/services/video-channel.ts | 6 +- common/components/SettingsForm.tsx | 706 ++++++++++-------- .../components/SettingsProfileSelectMenu.tsx | 1 + .../SubtitleAppearanceTrackSelector.tsx | 44 ++ common/components/SubtitlePreview.tsx | 171 +++++ common/locales/de.json | 5 + common/locales/en.json | 5 + common/locales/es.json | 5 + common/locales/ja.json | 5 + common/locales/pl.json | 5 + common/locales/pt_BR.json | 5 + common/locales/ru.json | 5 + common/locales/zh_CN.json | 5 + .../settings/settings-import-export.test.ts | 20 +- common/settings/settings-import-export.ts | 78 +- common/settings/settings-provider.test.ts | 129 ++++ common/settings/settings-provider.ts | 145 +++- common/settings/settings.ts | 51 +- .../src/controllers/subtitle-controller.ts | 83 +- .../handlers/video/blur-subtitles-handler.ts | 19 +- extension/src/services/binding.ts | 3 - extension/src/ui/components/Popup.tsx | 1 + extension/src/ui/components/SettingsUi.tsx | 1 + 28 files changed, 1165 insertions(+), 501 deletions(-) create mode 100644 common/app/hooks/use-subtitle-styles.ts create mode 100644 common/components/SubtitleAppearanceTrackSelector.tsx create mode 100644 common/components/SubtitlePreview.tsx diff --git a/common/app/components/SettingsDialog.tsx b/common/app/components/SettingsDialog.tsx index 75801489..f4d591ee 100755 --- a/common/app/components/SettingsDialog.tsx +++ b/common/app/components/SettingsDialog.tsx @@ -80,6 +80,7 @@ export default function SettingsDialog({ extensionSupportsOverlay={extension.supportsStreamingVideoOverlay} extensionSupportsSidePanel={extension.supportsSidePanel} extensionSupportsOrderableAnkiFields={extension.supportsOrderableAnkiFields} + extensionSupportsTrackSpecificSettings={extension.supportsTrackSpecificSettings} insideApp chromeKeyBinds={extension.extensionCommands} onOpenChromeExtensionShortcuts={extension.openShortcuts} diff --git a/common/app/components/VideoPlayer.tsx b/common/app/components/VideoPlayer.tsx index 98bd72b2..91cb2e4a 100755 --- a/common/app/components/VideoPlayer.tsx +++ b/common/app/components/VideoPlayer.tsx @@ -20,13 +20,10 @@ import { AnkiSettings, AsbplayerSettings, SubtitleAlignment, + changeForTextSubtitleSetting, + textSubtitleSettingsForTrack, } from '@project/common/settings'; -import { - surroundingSubtitles, - mockSurroundingSubtitles, - computeStyles, - computeStyleString, -} from '@project/common/util'; +import { surroundingSubtitles, mockSurroundingSubtitles } from '@project/common/util'; import { SubtitleCollection } from '@project/common/subtitle-collection'; import SubtitleTextImage from '@project/common/components/SubtitleTextImage'; import Clock from '../services/clock'; @@ -43,6 +40,7 @@ import i18n from 'i18next'; import { adjacentSubtitle } from '../../key-binder'; import { usePlaybackPreferences } from '../hooks/use-playback-preferences'; import { MiningContext } from '../services/mining-context'; +import { useSubtitleStyles } from '../hooks/use-subtitle-styles'; interface ExperimentalHTMLVideoElement extends HTMLVideoElement { readonly audioTracks: any; @@ -73,12 +71,6 @@ const useStyles = makeStyles({ whiteSpace: 'pre-wrap', lineHeight: 'normal', }, - subtitleEntryBlurred: { - filter: 'blur(10px)', - '&:hover': { - filter: 'none', - }, - }, }); function notifyReady( @@ -152,6 +144,7 @@ const showingSubtitleHtml = ( subtitle: IndexedSubtitleModel, videoRef: MutableRefObject, subtitleStyles: string, + subtitleClasses: string, imageBasedSubtitleScaleFactor: number ) => { if (subtitle.textImage) { @@ -166,12 +159,13 @@ const showingSubtitleHtml = ( style="width:100%;" alt="subtitle" src="${subtitle.textImage.dataUrl}" + class="${subtitleClasses}" /> `; } - return `${subtitle.text}`; + return `${subtitle.text}`; }; interface ShowingSubtitleProps { @@ -824,11 +818,9 @@ export default function VideoPlayer({ return keyBinder.bindToggleBlurTrack( (event, track) => { event.preventDefault(); - - const subtitleTracks = subtitleSettings.subtitleTracks; - subtitleTracks[track].blur = !subtitleTracks[track].blur; - - setSubtitleSettings({ ...subtitleSettings, subtitleTracks }); + const originalValue = textSubtitleSettingsForTrack(subtitleSettings, track).subtitleBlur!; + const change = changeForTextSubtitleSetting({ subtitleBlur: !originalValue }, track); + setSubtitleSettings({ ...subtitleSettings, ...change }); }, () => false ); @@ -1242,80 +1234,6 @@ export default function VideoPlayer({ const handleDoubleClick = useCallback(() => handleFullscreenToggle(), [handleFullscreenToggle]); - const { - subtitleSize, - subtitleColor, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundColor, - subtitleBackgroundOpacity, - subtitleFontFamily, - subtitleCustomStyles, - imageBasedSubtitleScaleFactor, - } = subtitleSettings; - const subtitleStyles = useMemo( - () => - computeStyles({ - subtitleSize, - subtitleColor, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundColor, - subtitleBackgroundOpacity, - subtitleFontFamily, - subtitleCustomStyles, - }), - [ - subtitleSize, - subtitleColor, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundColor, - subtitleBackgroundOpacity, - subtitleFontFamily, - subtitleCustomStyles, - ] - ); - - const subtitleStylesString = useMemo( - () => - computeStyleString({ - subtitleSize, - subtitleColor, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundColor, - subtitleBackgroundOpacity, - subtitleFontFamily, - subtitleCustomStyles, - }), - [ - subtitleSize, - subtitleColor, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundColor, - subtitleBackgroundOpacity, - subtitleFontFamily, - subtitleCustomStyles, - ] - ); - useEffect(() => { const interval = setInterval(() => { if (Date.now() - lastMouseMovementTimestamp.current > 300) { @@ -1331,11 +1249,19 @@ export default function VideoPlayer({ }, [showCursor]); const handleAlertClosed = useCallback(() => setAlertOpen(false), []); + const trackStyles = useSubtitleStyles(subtitleSettings); const { getSubtitleDomCache } = useSubtitleDomCache( subtitles, useCallback( - (subtitle) => showingSubtitleHtml(subtitle, videoRef, subtitleStylesString, imageBasedSubtitleScaleFactor), - [subtitleStylesString, imageBasedSubtitleScaleFactor] + (subtitle) => + showingSubtitleHtml( + subtitle, + videoRef, + trackStyles[subtitle.track]?.styleString ?? trackStyles[0].styleString, + trackStyles[subtitle.track]?.classes ?? trackStyles[0].classes, + subtitleSettings.imageBasedSubtitleScaleFactor + ), + [trackStyles, subtitleSettings.imageBasedSubtitleScaleFactor] ) ); @@ -1392,28 +1318,19 @@ export default function VideoPlayer({ className={classes.subtitleContainer} > {showSubtitles.map((subtitle, index) => { - const trackSetting = subtitleSettings.subtitleTracks[index]; - if (miscSettings.preCacheSubtitleDom) { const domCache = getSubtitleDomCache(); - return ( - - ); + return ; } return ( ); })} diff --git a/common/app/components/video-player.css b/common/app/components/video-player.css index fc1f134a..9124b98b 100644 --- a/common/app/components/video-player.css +++ b/common/app/components/video-player.css @@ -3,3 +3,11 @@ left: 100% !important; top: 100% !important; } + +.asbplayer-subtitles-blurred { + filter: blur(10px); +} + +.asbplayer-subtitles-blurred:hover { + filter: none; +} diff --git a/common/app/hooks/use-subtitle-styles.ts b/common/app/hooks/use-subtitle-styles.ts new file mode 100644 index 00000000..97d5a5ec --- /dev/null +++ b/common/app/hooks/use-subtitle-styles.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import { SubtitleSettings, TextSubtitleSettings, textSubtitleSettingsForTrack } from '../../settings'; +import { computeStyleString, computeStyles } from '../../util'; + +interface TrackStyles { + styles: { [key: string]: any }; + styleString: string; + classes: string; +} + +export const useSubtitleStyles = (settings: SubtitleSettings) => { + return useMemo(() => { + const tracks: TrackStyles[] = []; + for (let track = 0; track <= settings.subtitleTracksV2.length; ++track) { + const s = textSubtitleSettingsForTrack(settings, track) as TextSubtitleSettings; + tracks.push({ + styles: computeStyles(s), + styleString: computeStyleString(s), + classes: s.subtitleBlur ? 'asbplayer-subtitles-blurred' : '', + }); + } + return tracks; + }, [settings]); +}; diff --git a/common/app/services/chrome-extension.ts b/common/app/services/chrome-extension.ts index 836f1de3..0bdc6c76 100755 --- a/common/app/services/chrome-extension.ts +++ b/common/app/services/chrome-extension.ts @@ -116,6 +116,10 @@ export default class ChromeExtension { return this.installed && gte(this.version, '1.3.0'); } + get supportsTrackSpecificSettings() { + return this.installed && gte(this.version, '1.3.0'); + } + get supportsSettingsProfiles() { return this.installed && gte(this.version, '1.3.0'); } diff --git a/common/app/services/video-channel.ts b/common/app/services/video-channel.ts index 33538251..5b66f3a2 100755 --- a/common/app/services/video-channel.ts +++ b/common/app/services/video-channel.ts @@ -407,9 +407,10 @@ export default class VideoChannel { subtitleBackgroundColor, subtitleFontFamily, subtitleCustomStyles, + subtitleBlur, imageBasedSubtitleScaleFactor, subtitleAlignment, - subtitleTracks, + subtitleTracksV2, subtitlePositionOffset, } = settings; const message: SubtitleSettingsToVideoMessage = { @@ -426,9 +427,10 @@ export default class VideoChannel { subtitleBackgroundColor, subtitleFontFamily, subtitleCustomStyles, + subtitleBlur, imageBasedSubtitleScaleFactor, subtitleAlignment, - subtitleTracks, + subtitleTracksV2, subtitlePositionOffset, }, }; diff --git a/common/components/SettingsForm.tsx b/common/components/SettingsForm.tsx index 155dc7f4..879ea45c 100644 --- a/common/components/SettingsForm.tsx +++ b/common/components/SettingsForm.tsx @@ -13,7 +13,7 @@ import Grid from '@material-ui/core/Grid'; import InputAdornment from '@material-ui/core/InputAdornment'; import IconButton from '@material-ui/core/IconButton'; import InputLabel from '@material-ui/core/InputLabel'; -import LabelWithHoverEffect from '@project/common/components/LabelWithHoverEffect'; +import LabelWithHoverEffect from './LabelWithHoverEffect'; import MenuItem from '@material-ui/core/MenuItem'; import DeleteIcon from '@material-ui/icons/Delete'; import MoreVertIcon from '@material-ui/icons/MoreVert'; @@ -34,17 +34,20 @@ import { CustomAnkiFieldSettings, KeyBindName, SubtitleListPreference, + TextSubtitleSettings, + changeForTextSubtitleSetting, sortedAnkiFieldModels, + textSubtitleSettingsAreDirty, + textSubtitleSettingsForTrack, } from '@project/common/settings'; import { computeStyles, download, isNumeric } from '@project/common/util'; import { CustomStyle, validateSettings } from '@project/common/settings'; import { useOutsideClickListener } from '@project/common/hooks'; -import TagsTextField from '@project/common/components/TagsTextField'; +import TagsTextField from './TagsTextField'; import hotkeys from 'hotkeys-js'; import Typography from '@material-ui/core/Typography'; import { isMacOs } from 'react-device-detect'; import Switch from '@material-ui/core/Switch'; -// import Checkbox from '@material-ui/core/Checkbox'; import RadioGroup from '@material-ui/core/RadioGroup'; import Tooltip from '@material-ui/core/Tooltip'; import Autocomplete from '@material-ui/lab/Autocomplete'; @@ -61,10 +64,14 @@ import { Anki } from '../anki'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import { WebSocketClient } from '../web-socket-client/web-socket-client'; import { isFirefox } from '@project/common/browser-detection'; +import SubtitleAppearanceTrackSelector from './SubtitleAppearanceTrackSelector'; +import SubtitlePreview from './SubtitlePreview'; +import UndoIcon from '@material-ui/icons/Undo'; interface StylesProps { smallScreen: boolean; } + const useStyles = makeStyles((theme) => ({ root: ({ smallScreen }) => { let styles: any = { @@ -156,9 +163,6 @@ const useSelectableSettingStyles = makeStyles((theme) => ({ hidden: { opacity: 0.5, }, - checkbox: { - padding: 0, - }, })); function regexIsValid(regex: string) { @@ -630,6 +634,7 @@ interface Props { extensionSupportsOverlay: boolean; extensionSupportsSidePanel: boolean; extensionSupportsOrderableAnkiFields: boolean; + extensionSupportsTrackSpecificSettings: boolean; insideApp?: boolean; settings: AsbplayerSettings; scrollToId?: string; @@ -655,6 +660,7 @@ export default function SettingsForm({ extensionSupportsOverlay, extensionSupportsSidePanel, extensionSupportsOrderableAnkiFields, + extensionSupportsTrackSpecificSettings, insideApp, scrollToId, chromeKeyBinds, @@ -777,20 +783,9 @@ export default function SettingsForm({ track2Field, track3Field, ankiFieldSettings, - subtitleSize, - subtitleColor, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundColor, - subtitleBackgroundOpacity, - subtitleFontFamily, subtitlePreview, subtitlePositionOffset, subtitleAlignment, - subtitleTracks, audioPaddingStart, audioPaddingEnd, maxImageWidth, @@ -820,7 +815,6 @@ export default function SettingsForm({ tags, imageBasedSubtitleScaleFactor, streamingAppUrl, - subtitleCustomStyles, streamingDisplaySubtitles, streamingRecordMedia, streamingTakeScreenshot, @@ -835,6 +829,23 @@ export default function SettingsForm({ webSocketClientEnabled, webSocketServerUrl, } = settings; + + const [selectedSubtitleAppearanceTrack, setSelectedSubtitleAppearanceTrack] = useState(); + const { + subtitleSize, + subtitleColor, + subtitleThickness, + subtitleOutlineThickness, + subtitleOutlineColor, + subtitleShadowThickness, + subtitleShadowColor, + subtitleBackgroundColor, + subtitleBackgroundOpacity, + subtitleFontFamily, + subtitleCustomStyles, + subtitleBlur, + } = textSubtitleSettingsForTrack(settings, selectedSubtitleAppearanceTrack); + const handleAddCustomField = useCallback( (customFieldName: string) => { handleSettingChanged('customAnkiFields', { ...settings.customAnkiFields, [customFieldName]: '' }); @@ -862,36 +873,6 @@ export default function SettingsForm({ [settings.keyBindSet, handleSettingChanged] ); - const subtitlePreviewStyles = useMemo( - () => - computeStyles({ - subtitleColor, - subtitleSize, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundOpacity, - subtitleBackgroundColor, - subtitleFontFamily, - subtitleCustomStyles, - }), - [ - subtitleColor, - subtitleSize, - subtitleThickness, - subtitleOutlineThickness, - subtitleOutlineColor, - subtitleShadowThickness, - subtitleShadowColor, - subtitleBackgroundOpacity, - subtitleBackgroundColor, - subtitleFontFamily, - subtitleCustomStyles, - ] - ); - const [deckNames, setDeckNames] = useState(); const [modelNames, setModelNames] = useState(); const [ankiConnectUrlError, setAnkiConnectUrlError] = useState(); @@ -1132,6 +1113,28 @@ export default function SettingsForm({ [customAnkiFieldSettings, ankiFieldSettings, onSettingsChanged] ); + const handleSubtitleTextSettingChanged = useCallback( + (key: K, value: TextSubtitleSettings[K]) => { + // See settings.ts for more info about how/why subtitle settings are interpreted + const diff = changeForTextSubtitleSetting({ [key]: value }, settings, selectedSubtitleAppearanceTrack); + onSettingsChanged(diff); + }, + [selectedSubtitleAppearanceTrack, settings, onSettingsChanged] + ); + + const handleResetSubtitleTrack = useCallback(() => { + const diff = changeForTextSubtitleSetting( + textSubtitleSettingsForTrack(settings, 0), + settings, + selectedSubtitleAppearanceTrack + ); + onSettingsChanged(diff); + }, [settings, selectedSubtitleAppearanceTrack, onSettingsChanged]); + + const selectedSubtitleAppearanceTrackIsDirty = + selectedSubtitleAppearanceTrack !== undefined && + textSubtitleSettingsAreDirty(settings, selectedSubtitleAppearanceTrack); + return (
-
- handleSettingChanged('subtitleColor', event.target.value)} - /> -
-
- handleSettingChanged('subtitleSize', Number(event.target.value))} - inputProps={{ - min: 1, - step: 1, - }} - /> -
-
- - {t('settings.subtitleThickness')} - - handleSettingChanged('subtitleThickness', value as number)} - min={100} - max={900} - step={100} - marks - valueLabelDisplay="auto" - /> -
-
- handleSettingChanged('subtitleOutlineColor', event.target.value)} - /> -
-
- - handleSettingChanged('subtitleOutlineThickness', Number(event.target.value)) - } - inputProps={{ - min: 0, - step: 0.1, - }} - color="secondary" - /> -
-
- handleSettingChanged('subtitleShadowColor', event.target.value)} - /> -
-
- - handleSettingChanged('subtitleShadowThickness', Number(event.target.value)) - } - inputProps={{ - min: 0, - step: 0.1, - }} - color="secondary" - /> -
-
- - handleSettingChanged('subtitleBackgroundColor', event.target.value) - } - /> -
-
- - handleSettingChanged('subtitleBackgroundOpacity', Number(event.target.value)) - } - /> -
-
- 0} - label={t('settings.subtitleFontFamily')} - fullWidth - value={subtitleFontFamily} - color="secondary" - onChange={(event) => handleSettingChanged('subtitleFontFamily', event.target.value)} - InputProps={{ - endAdornment: - localFontFamilies.length === 0 && - localFontsAvailable && - localFontsPermission === 'prompt' ? ( - - - - - - ) : null, - }} - > - {localFontFamilies.length > 0 - ? localFontFamilies.map((f) => ( - - {f} - - )) - : null} - -
- {subtitleCustomStyles.map((customStyle, index) => { - return ( - { - const newValue = [...settings.subtitleCustomStyles]; - newValue[index] = { ...newCustomStyle }; - handleSettingChanged('subtitleCustomStyles', newValue); + {(!extensionInstalled || extensionSupportsTrackSpecificSettings) && ( + <> + + setSelectedSubtitleAppearanceTrack(t === 'all' ? undefined : t) + } + /> + {selectedSubtitleAppearanceTrack !== undefined && ( + + )} + + )} + handleSettingChanged('subtitlePreview', text)} + /> + {subtitleColor !== undefined && ( +
+ + handleSubtitleTextSettingChanged('subtitleColor', event.target.value) + } + /> +
+ )} + {subtitleSize !== undefined && ( +
+ + handleSubtitleTextSettingChanged('subtitleSize', Number(event.target.value)) + } + inputProps={{ + min: 1, + step: 1, }} - onDelete={() => { - const newValue: CustomStyle[] = []; - for (let j = 0; j < settings.subtitleCustomStyles.length; ++j) { - if (j !== index) { - newValue.push(settings.subtitleCustomStyles[j]); - } - } - handleSettingChanged('subtitleCustomStyles', newValue); + /> +
+ )} + {subtitleThickness !== undefined && ( +
+ + {t('settings.subtitleThickness')} + + + handleSubtitleTextSettingChanged('subtitleThickness', value as number) + } + min={100} + max={900} + step={100} + marks + valueLabelDisplay="auto" + /> +
+ )} + {subtitleOutlineColor !== undefined && ( +
+ + handleSubtitleTextSettingChanged('subtitleOutlineColor', event.target.value) + } + /> +
+ )} + {subtitleOutlineThickness !== undefined && ( +
+ + handleSubtitleTextSettingChanged( + 'subtitleOutlineThickness', + Number(event.target.value) + ) + } + inputProps={{ + min: 0, + step: 0.1, }} + color="secondary" /> - ); - })} - - handleSettingChanged('subtitleCustomStyles', [ - ...settings.subtitleCustomStyles, - { key: styleKey, value: '' }, - ]) - } - /> -
- handleSettingChanged('subtitlePreview', event.target.value)} - style={subtitlePreviewStyles} - /> -
-
- - handleSettingChanged('imageBasedSubtitleScaleFactor', Number(event.target.value)) - } - /> -
- -
- {t('settings.subtitleAlignment')} - - - event.target.checked && - handleSettingChanged('subtitleAlignment', 'bottom') - } +
+ )} + {subtitleShadowColor !== undefined && ( +
+ + handleSubtitleTextSettingChanged('subtitleShadowColor', event.target.value) + } + /> +
+ )} + {subtitleShadowThickness !== undefined && ( +
+ + handleSubtitleTextSettingChanged( + 'subtitleShadowThickness', + Number(event.target.value) + ) + } + inputProps={{ + min: 0, + step: 0.1, + }} + color="secondary" + /> +
+ )} + {subtitleBackgroundColor !== undefined && ( +
+ + handleSubtitleTextSettingChanged('subtitleBackgroundColor', event.target.value) + } + /> +
+ )} + {subtitleBackgroundOpacity !== undefined && ( +
+ + handleSubtitleTextSettingChanged( + 'subtitleBackgroundOpacity', + Number(event.target.value) + ) + } + /> +
+ )} + {subtitleFontFamily !== undefined && ( +
+ 0} + label={t('settings.subtitleFontFamily')} + fullWidth + value={subtitleFontFamily} + color="secondary" + onChange={(event) => + handleSubtitleTextSettingChanged('subtitleFontFamily', event.target.value) + } + InputProps={{ + endAdornment: + localFontFamilies.length === 0 && + localFontsAvailable && + localFontsPermission === 'prompt' ? ( + + + + + + ) : null, + }} + > + {localFontFamilies.length > 0 + ? localFontFamilies.map((f) => ( + + {f} + + )) + : null} + +
+ )} + {subtitleCustomStyles !== undefined && ( + <> + {subtitleCustomStyles.map((customStyle, index) => { + return ( + { + const newValue = [...settings.subtitleCustomStyles]; + newValue[index] = { ...newCustomStyle }; + handleSubtitleTextSettingChanged('subtitleCustomStyles', newValue); + }} + onDelete={() => { + const newValue: CustomStyle[] = []; + for (let j = 0; j < settings.subtitleCustomStyles.length; ++j) { + if (j !== index) { + newValue.push(settings.subtitleCustomStyles[j]); + } + } + handleSubtitleTextSettingChanged('subtitleCustomStyles', newValue); + }} /> + ); + })} + + handleSubtitleTextSettingChanged('subtitleCustomStyles', [ + ...settings.subtitleCustomStyles, + { key: styleKey, value: '' }, + ]) } - label={t('settings.subtitleAlignmentBottom')} /> + + )} + + {subtitleBlur !== undefined && ( + - event.target.checked && handleSettingChanged('subtitleAlignment', 'top') - } + { + handleSubtitleTextSettingChanged('subtitleBlur', e.target.checked); + }} /> } - label={t('settings.subtitleAlignmentTop')} + label={t('settings.subtitleBlur')} + labelPlacement="start" + className={classes.switchLabel} /> - -
-
- handleSettingChanged('subtitlePositionOffset', Number(e.target.value))} - /> -
- - {t('settings.subtitleBlur')} - - - {subtitleTracks.map((trackData, trackIndex) => { - return ( - + + )} + {selectedSubtitleAppearanceTrack === undefined && ( + <> +
+ + handleSettingChanged( + 'imageBasedSubtitleScaleFactor', + Number(event.target.value) + ) + } + /> +
+
+ {t('settings.subtitleAlignment')} + { - handleSettingChanged( - 'subtitleTracks', - subtitleTracks.map((entry, entryIndex) => { - return entryIndex === trackIndex - ? { ...entry, blur: e.target.checked } - : entry; - }) - ); - }} + + event.target.checked && + handleSettingChanged('subtitleAlignment', 'bottom') + } /> } - label={t('settings.subtitleBlurEntryLabel', { - trackNumber: trackIndex + 1, - })} - labelPlacement="start" - className={classes.switchLabel} + label={t('settings.subtitleAlignmentBottom')} /> - - ); - })} - - {t('settings.subtitleBlurDescription')} + + event.target.checked && + handleSettingChanged('subtitleAlignment', 'top') + } + /> + } + label={t('settings.subtitleAlignmentTop')} + /> + +
+
+ + handleSettingChanged('subtitlePositionOffset', Number(e.target.value)) + } + /> +
+ + )}
diff --git a/common/components/SettingsProfileSelectMenu.tsx b/common/components/SettingsProfileSelectMenu.tsx index 2bf6372f..44166ddc 100644 --- a/common/components/SettingsProfileSelectMenu.tsx +++ b/common/components/SettingsProfileSelectMenu.tsx @@ -87,6 +87,7 @@ function renderMenuItem({ {profile !== undefined && ( e.stopPropagation()} onClick={(e) => { onRemoveProfile(profile.name); }} diff --git a/common/components/SubtitleAppearanceTrackSelector.tsx b/common/components/SubtitleAppearanceTrackSelector.tsx new file mode 100644 index 00000000..b8504340 --- /dev/null +++ b/common/components/SubtitleAppearanceTrackSelector.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import MenuItem from '@material-ui/core/MenuItem'; +import TextField from '@material-ui/core/TextField'; +import { useTranslation } from 'react-i18next'; + +const maxTracks = 3; + +interface Props { + track: Track; + onTrackSelected: (track: Track) => void; +} + +type Track = number | 'all'; + +export default function SubtitleAppearanceTrackSelector({ track, onTrackSelected }: Props) { + const { t } = useTranslation(); + + return ( + <> + + e.target.value === 'all' ? onTrackSelected('all') : onTrackSelected(Number(e.target.value) as Track) + } + > + {t('settings.allSubtitleTracks')} + {[...Array(maxTracks).keys()].map((i) => { + return ( + + {t('settings.subtitleTrackChoice', { trackNumber: i + 1 })} + + ); + })} + + + ); +} diff --git a/common/components/SubtitlePreview.tsx b/common/components/SubtitlePreview.tsx new file mode 100644 index 00000000..fc3504cd --- /dev/null +++ b/common/components/SubtitlePreview.tsx @@ -0,0 +1,171 @@ +import React, { useMemo } from 'react'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import { SubtitleSettings, TextSubtitleSettings, textSubtitleSettingsForTrack } from '../settings'; +import { computeStyles } from '../util'; + +interface Props { + subtitleSettings: SubtitleSettings; + text: string; + track?: number; + onTextChanged: (text: string) => void; +} + +const useStyles = makeStyles((theme) => ({ + subtitlePreview: { + backgroundImage: `linear-gradient(45deg, ${theme.palette.action.disabledBackground} 25%, transparent 25%), linear-gradient(-45deg, ${theme.palette.action.disabledBackground} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${theme.palette.action.disabledBackground} 75%), linear-gradient(-45deg, transparent 75%,${theme.palette.action.disabledBackground} 75%)`, + backgroundSize: '20px 20px', + backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px', + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + maxWidth: '100%', + padding: 10, + }, + subtitlePreviewInput: { + border: 'none', + width: '100%', + textAlign: 'center', + backgroundColor: 'rgba(0,0,0,0)', + '&:focus': { + outline: 'none', + }, + }, + blurred: { + filter: 'blur(10px)', + '&:hover': { + filter: 'none', + }, + }, +})); + +interface InputProps { + text: string; + className: string; + onTextChanged: (text: string) => void; + textSubtitleSettings: TextSubtitleSettings; +} + +const SubtitlePreviewInput = ({ text, className, textSubtitleSettings, onTextChanged }: InputProps) => { + const { + subtitleSize, + subtitleColor, + subtitleThickness, + subtitleOutlineThickness, + subtitleOutlineColor, + subtitleShadowThickness, + subtitleShadowColor, + subtitleBackgroundColor, + subtitleBackgroundOpacity, + subtitleFontFamily, + subtitleCustomStyles, + subtitleBlur, + } = textSubtitleSettings; + + const subtitlePreviewStyles = useMemo( + () => + computeStyles({ + subtitleColor, + subtitleSize, + subtitleThickness, + subtitleOutlineThickness, + subtitleOutlineColor, + subtitleShadowThickness, + subtitleShadowColor, + subtitleBackgroundOpacity, + subtitleBackgroundColor, + subtitleFontFamily, + subtitleCustomStyles, + subtitleBlur, + }), + [ + subtitleColor, + subtitleSize, + subtitleThickness, + subtitleOutlineThickness, + subtitleOutlineColor, + subtitleShadowThickness, + subtitleShadowColor, + subtitleBackgroundOpacity, + subtitleBackgroundColor, + subtitleFontFamily, + subtitleCustomStyles, + subtitleBlur, + ] + ); + + return ( + onTextChanged(event.target.value)} + style={subtitlePreviewStyles} + /> + ); +}; + +export default function SubtitlePreview({ subtitleSettings, text, track, onTextChanged }: Props) { + const classes = useStyles(); + const inputClassName = (s: TextSubtitleSettings) => + s.subtitleBlur ? `${classes.subtitlePreviewInput} ${classes.blurred}` : classes.subtitlePreviewInput; + const textSubtitleSettings = textSubtitleSettingsForTrack(subtitleSettings, track); + const { + subtitleSize, + subtitleColor, + subtitleThickness, + subtitleOutlineThickness, + subtitleOutlineColor, + subtitleShadowThickness, + subtitleShadowColor, + subtitleBackgroundColor, + subtitleBackgroundOpacity, + subtitleFontFamily, + subtitleCustomStyles, + subtitleBlur, + } = textSubtitleSettings; + + if ( + subtitleSize === undefined || + subtitleColor === undefined || + subtitleThickness === undefined || + subtitleOutlineThickness === undefined || + subtitleOutlineColor === undefined || + subtitleShadowThickness === undefined || + subtitleShadowColor === undefined || + subtitleBackgroundColor === undefined || + subtitleBackgroundOpacity === undefined || + subtitleFontFamily === undefined || + subtitleCustomStyles === undefined || + subtitleBlur === undefined + ) { + return ( +
+ {[...Array(subtitleSettings.subtitleTracksV2.length + 1).keys()].map((track) => { + const textSubtitleSettings = textSubtitleSettingsForTrack( + subtitleSettings, + track + ) as TextSubtitleSettings; + + return ( + + ); + })} +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/common/locales/de.json b/common/locales/de.json index 94673fa1..30cc3134 100644 --- a/common/locales/de.json +++ b/common/locales/de.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "Untertiteldarstellung", "subtitleBackgroundColor": "Untertitelhintergrundfarbe", "subtitleBackgroundOpacity": "Untertitelhintergrunddeckung", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "Unten", "subtitleAlignmentTop": "Oben", "subtitleBlur": "Subtitle blur", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Track {{trackNumber}}", "subtitleBlurDescription": "Hides selected subtitle tracks by blurring them. Can be un-blurred on mouse hover.", "tabName": "Reiterbezeichnung", diff --git a/common/locales/en.json b/common/locales/en.json index b1e0f382..42343471 100644 --- a/common/locales/en.json +++ b/common/locales/en.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "Subtitle Appearance", "subtitleBackgroundColor": "Subtitle Background Color", "subtitleBackgroundOpacity": "Subtitle Background Opacity", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "Bottom", "subtitleAlignmentTop": "Top", "subtitleBlur": "Subtitle blur", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Track {{trackNumber}}", "subtitleBlurDescription": "Hides selected subtitle tracks by blurring them. Can be un-blurred on mouse hover.", "tabName": "Name of the tab", diff --git a/common/locales/es.json b/common/locales/es.json index d8b438da..7a957b76 100644 --- a/common/locales/es.json +++ b/common/locales/es.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "Apariencia de Subtítulos", "subtitleBackgroundColor": "Color del Fondo de los Subtítulos", "subtitleBackgroundOpacity": "Opacidad del Fondo de los Subtítulos", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "Inferior", "subtitleAlignmentTop": "Superior", "subtitleBlur": "Subtitle blur", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Track {{trackNumber}}", "subtitleBlurDescription": "Hides selected subtitle tracks by blurring them. Can be un-blurred on mouse hover.", "tabName": "Nombre de la pestaña", diff --git a/common/locales/ja.json b/common/locales/ja.json index ad63bb80..d53bcdff 100644 --- a/common/locales/ja.json +++ b/common/locales/ja.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "字幕の表示設定", "subtitleBackgroundColor": "字幕の背景色", "subtitleBackgroundOpacity": "字幕の背景の透明度", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "下部", "subtitleAlignmentTop": "上部", "subtitleBlur": "字幕のぼかし", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "トラック {{trackNumber}}", "subtitleBlurDescription": "字幕トラックをぼかします。マウスホバーでぼかしを解除できます。", "tabName": "タブ名", diff --git a/common/locales/pl.json b/common/locales/pl.json index 8913abc8..fc595865 100644 --- a/common/locales/pl.json +++ b/common/locales/pl.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "Wygląd napisów", "subtitleBackgroundColor": "Kolor tła napisów", "subtitleBackgroundOpacity": "Krycie tła napisów", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "Dół", "subtitleAlignmentTop": "Góra", "subtitleBlur": "Rozmycie napisów", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Ścieka {{trackNumber}}", "subtitleBlurDescription": "Ukrywa wybrane ściezki napisów rozmywając je. Napisy można odkryć poprzez najechanie myszką.", "tabName": "Nazwa karty", diff --git a/common/locales/pt_BR.json b/common/locales/pt_BR.json index d3341e4a..a093cea7 100644 --- a/common/locales/pt_BR.json +++ b/common/locales/pt_BR.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "Aparência da legenda", "subtitleBackgroundColor": "Cor de fundo da legenda", "subtitleBackgroundOpacity": "Opacidade do fundo da legenda", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "Inferior", "subtitleAlignmentTop": "Superior", "subtitleBlur": "Subtitle blur", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Track {{trackNumber}}", "subtitleBlurDescription": "Hides selected subtitle tracks by blurring them. Can be un-blurred on mouse hover.", "tabName": "Nome da guia", diff --git a/common/locales/ru.json b/common/locales/ru.json index 671b519a..e0c60519 100644 --- a/common/locales/ru.json +++ b/common/locales/ru.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "Внешний вид субтитров", "subtitleBackgroundColor": "Цвет фона субтитров", "subtitleBackgroundOpacity": "Прозрачность фона субтитров", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "По нижнему краю", "subtitleAlignmentTop": "По верхнему краю", "subtitleBlur": "Subtitle blur", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Track {{trackNumber}}", "subtitleBlurDescription": "Hides selected subtitle tracks by blurring them. Can be un-blurred on mouse hover.", "tabName": "название вкладки", diff --git a/common/locales/zh_CN.json b/common/locales/zh_CN.json index 7a70c27e..d8f87db1 100644 --- a/common/locales/zh_CN.json +++ b/common/locales/zh_CN.json @@ -296,6 +296,7 @@ "track1Field": "Subtitle Track 1 Field", "track2Field": "Subtitle Track 2 Field", "track3Field": "Subtitle Track 3 Field", + "reset": "Reset", "subtitleAppearance": "字幕外观", "subtitleBackgroundColor": "字幕背景颜色", "subtitleBackgroundOpacity": "字幕背景不透明度", @@ -311,6 +312,10 @@ "subtitleAlignmentBottom": "底部", "subtitleAlignmentTop": "顶部", "subtitleBlur": "Subtitle blur", + "subtitleTrack": "Subtitle Track", + "allSubtitleTracks": "All", + "subtitleTrackChoice": "Track {{trackNumber}}", + "subtitleTrackSelectorHelper": "Changes settings for ALL tracks. Settings that already have track-specific values are hidden.", "subtitleBlurEntryLabel": "Track {{trackNumber}}", "subtitleBlurDescription": "Hides selected subtitle tracks by blurring them. Can be un-blurred on mouse hover.", "tabName": "选项卡名称", diff --git a/common/settings/settings-import-export.test.ts b/common/settings/settings-import-export.test.ts index 72fa00fc..09b7e31e 100644 --- a/common/settings/settings-import-export.test.ts +++ b/common/settings/settings-import-export.test.ts @@ -41,10 +41,27 @@ it('validates exported settings', () => { subtitleBackgroundColor: '#000000', subtitleBackgroundOpacity: 0, subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: false, + subtitleCustomStyles: [], + subtitleTracksV2: [ + { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: true, + subtitleCustomStyles: [], + }, + ], subtitlePreview: 'アあ安Aa', subtitlePositionOffset: 71, subtitleAlignment: 'bottom', - subtitleTracks: [{ blur: true }, { blur: false }, { blur: false }], audioPaddingStart: 0, audioPaddingEnd: 500, maxImageWidth: 480, @@ -103,7 +120,6 @@ it('validates exported settings', () => { customAnkiFields: {}, tags: [], imageBasedSubtitleScaleFactor: 1, - subtitleCustomStyles: [], streamingAppUrl: 'http://localhost:3000/asbplayer', streamingDisplaySubtitles: false, streamingRecordMedia: true, diff --git a/common/settings/settings-import-export.ts b/common/settings/settings-import-export.ts index e66fcf91..05d89b8d 100644 --- a/common/settings/settings-import-export.ts +++ b/common/settings/settings-import-export.ts @@ -8,6 +8,7 @@ const keyBindSchema = { type: 'string', }, }, + required: ['keys'], }; const ankiFieldSchema = { id: '/AnkiField', @@ -20,6 +21,66 @@ const ankiFieldSchema = { type: 'boolean', }, }, + required: ['order', 'display'], +}; +const textSubtitleSettingsSchema = { + id: '/TextSubtitleSettings', + type: 'object', + properties: { + subtitleColor: { + type: 'string', + }, + subtitleSize: { + type: 'number', + }, + subtitleThickness: { + type: 'number', + }, + subtitleOutlineThickness: { + type: 'number', + }, + subtitleShadowColor: { + type: 'string', + }, + subtitleBackgroundOpacity: { + type: 'number', + }, + subtitleBackgroundColor: { + type: 'string', + }, + subtitleFontFamily: { + type: 'string', + }, + subtitleCustomStyles: { + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'string', + }, + value: { + type: 'string', + }, + }, + }, + }, + subtitleBlur: { + type: 'boolean', + }, + }, + required: [ + 'subtitleColor', + 'subtitleSize', + 'subtitleThickness', + 'subtitleOutlineThickness', + 'subtitleShadowColor', + 'subtitleBackgroundOpacity', + 'subtitleBackgroundColor', + 'subtitleFontFamily', + 'subtitleCustomStyles', + 'subtitleBlur', + ], }; const settingsSchema = { id: '/Settings', @@ -115,6 +176,9 @@ const settingsSchema = { subtitleFontFamily: { type: 'string', }, + subtitleBlur: { + type: 'boolean', + }, subtitlePreview: { type: 'string', }, @@ -124,15 +188,10 @@ const settingsSchema = { subtitleAlignment: { type: 'string', }, - subtitleTracks: { + subtitleTracksV2: { type: 'array', items: { - type: 'object', - properties: { - blur: { - type: 'boolean', - }, - }, + $ref: '/TextSubtitleSettings', }, }, audioPaddingStart: { @@ -334,6 +393,7 @@ export const validateSettings = (settings: any) => { const validator = new Validator(); validator.addSchema(keyBindSchema); validator.addSchema(ankiFieldSchema); + validator.addSchema(textSubtitleSettingsSchema); const result = validator.validate(settings, settingsSchema); validateAllKnownKeys(settings, []); @@ -386,5 +446,9 @@ const schemaForRef = (ref: string) => { return ankiFieldSchema; } + if (ref === '/TextSubtitleSettings') { + return textSubtitleSettingsSchema; + } + return undefined; }; diff --git a/common/settings/settings-provider.test.ts b/common/settings/settings-provider.test.ts index 43dec82d..25d5c120 100644 --- a/common/settings/settings-provider.test.ts +++ b/common/settings/settings-provider.test.ts @@ -4,8 +4,11 @@ import { Profile, SettingsProvider, SettingsStorage, + SubtitleAlignment, + changeForTextSubtitleSetting, defaultSettings, prefixedSettings, + textSubtitleSettingsForTrack, unprefixedSettings, } from '@project/common/settings'; @@ -150,3 +153,129 @@ it('removes corresponding field settings when custom anki fields are removed', a customAnkiFieldSettings: { foo: { order: 1, display: true } }, }); }); + +const subtitleSettings = { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: false, + subtitleCustomStyles: [], + imageBasedSubtitleScaleFactor: 1, + subtitlePositionOffset: 70, + subtitleAlignment: 'top' as SubtitleAlignment, + subtitleTracksV2: [ + { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: true, + subtitleCustomStyles: [], + }, + ], +}; + +it('calculates diff for text subtitle settings', () => { + expect( + changeForTextSubtitleSetting({ subtitleCustomStyles: [{ key: 'opacity', value: '0.5' }] }, subtitleSettings, 2) + ).toEqual({ + subtitleTracksV2: [ + { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: true, + subtitleCustomStyles: [], + }, + { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: false, + subtitleCustomStyles: [{ key: 'opacity', value: '0.5' }], + }, + ], + }); + expect(changeForTextSubtitleSetting({ subtitleBlur: false }, subtitleSettings, 0)).toEqual({ + subtitleBlur: false, + }); + expect(changeForTextSubtitleSetting({ subtitleOutlineColor: '#ccc' }, subtitleSettings, 1)).toEqual({ + subtitleTracksV2: [ + { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#ccc', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: true, + subtitleCustomStyles: [], + }, + ], + }); + expect(changeForTextSubtitleSetting({ subtitleBlur: false }, subtitleSettings, 1)).toEqual({ + subtitleTracksV2: [], + }); +}); + +it('targets correct values for text subtitle ', () => { + expect(textSubtitleSettingsForTrack(subtitleSettings, 0)).toEqual({ + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: false, + subtitleCustomStyles: [], + }); + expect(textSubtitleSettingsForTrack(subtitleSettings, 1)).toEqual({ + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 2, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: 'ToppanBunkyuMidashiGothicStdN-ExtraBold', + subtitleBlur: true, + subtitleCustomStyles: [], + }); +}); diff --git a/common/settings/settings-provider.ts b/common/settings/settings-provider.ts index fc9a602a..47cdbcec 100755 --- a/common/settings/settings-provider.ts +++ b/common/settings/settings-provider.ts @@ -6,12 +6,31 @@ import { CustomAnkiFieldSettings, KeyBindName, SubtitleListPreference, + SubtitleSettings, + TextSubtitleSettings, + textSubtitleSettingsKeys, } from '.'; import { AutoPausePreference, PostMineAction, PostMinePlayback } from '..'; // @ts-ignore const isMacOs = (navigator.userAgentData?.platform ?? navigator.platform)?.toUpperCase()?.indexOf('MAC') > -1; +const defaultSubtitleTextSettings = { + subtitleSize: 36, + subtitleColor: '#ffffff', + subtitleThickness: 700, + subtitleOutlineThickness: 0, + subtitleOutlineColor: '#000000', + subtitleShadowThickness: 3, + subtitleShadowColor: '#000000', + subtitleBackgroundColor: '#000000', + subtitleBackgroundOpacity: 0, + subtitleFontFamily: '', + subtitlePreview: 'アあ安Aa', + subtitleCustomStyles: [], + subtitleBlur: false, +}; + export const defaultSettings: AsbplayerSettings = { ankiConnectUrl: 'http://127.0.0.1:8765', deck: '', @@ -39,20 +58,10 @@ export const defaultSettings: AsbplayerSettings = { track3: { order: 10, display: false }, }, customAnkiFieldSettings: {}, - subtitleSize: 36, - subtitleColor: '#ffffff', - subtitleThickness: 700, - subtitleOutlineThickness: 0, - subtitleOutlineColor: '#000000', - subtitleShadowThickness: 3, - subtitleShadowColor: '#000000', - subtitleBackgroundColor: '#000000', - subtitleBackgroundOpacity: 0, - subtitleFontFamily: '', - subtitlePreview: 'アあ安Aa', + ...defaultSubtitleTextSettings, subtitlePositionOffset: 75, subtitleAlignment: 'bottom', - subtitleTracks: [{ blur: false }, { blur: false }, { blur: false }], + subtitleTracksV2: [], audioPaddingStart: 0, audioPaddingEnd: 500, maxImageWidth: 0, @@ -114,7 +123,6 @@ export const defaultSettings: AsbplayerSettings = { customAnkiFields: {}, tags: [], imageBasedSubtitleScaleFactor: 1, - subtitleCustomStyles: [], streamingAppUrl: 'https://killergerbah.github.io/asbplayer', streamingDisplaySubtitles: true, streamingRecordMedia: true, @@ -184,6 +192,116 @@ export const sortedAnkiFieldModels = (settings: AnkiSettings): AnkiFieldUiModel[ ]); }; +export const allTextSubtitleSettings = (subtitleSettings: SubtitleSettings) => { + const textSettings: TextSubtitleSettings[] = []; + + for (let i = 0; i <= subtitleSettings.subtitleTracksV2.length; ++i) { + textSettings.push(textSubtitleSettingsForTrack(subtitleSettings, i) as TextSubtitleSettings); + } + + return textSettings; +}; + +export const textSubtitleSettingsForTrack = ( + subtitleSettings: SubtitleSettings, + track?: number +): Partial | TextSubtitleSettings => { + if (track === undefined) { + const valuesAllSame = (k: keyof TextSubtitleSettings) => { + const val = subtitleSettings[k]; + + for (const track of subtitleSettings.subtitleTracksV2) { + if (!deepEquals(track[k], val)) { + return false; + } + } + + return true; + }; + + let mergedSettings: any = {}; + + for (const key of textSubtitleSettingsKeys) { + if (valuesAllSame(key)) { + mergedSettings[key] = subtitleSettings[key]; + } else { + mergedSettings[key] = undefined; + } + } + + return mergedSettings as Partial; + } + + if (track === 0 || track > subtitleSettings.subtitleTracksV2.length) { + return Object.fromEntries( + textSubtitleSettingsKeys.map((k) => [k, subtitleSettings[k]]) + ) as unknown as TextSubtitleSettings; + } + + return subtitleSettings.subtitleTracksV2[track - 1] as TextSubtitleSettings; +}; + +export const changeForTextSubtitleSetting = ( + updates: Partial, + subtitleSettings: SubtitleSettings, + track?: number +) => { + if (track === undefined) { + // Change settings for all tracks + const newSubtitleTracks = []; + + for (let i = 0; i < subtitleSettings.subtitleTracksV2.length; ++i) { + newSubtitleTracks.push({ ...subtitleSettings.subtitleTracksV2[i], ...updates }); + } + + return { + ...updates, + subtitleTracksV2: newSubtitleTracks, + }; + } + + if (track === 0) { + // Change setting for track 0 (top-level settings object) + return { ...updates }; + } + + // Change setting for track >= 1 (nested subtitleTracks setting) + const newSubtitleTracks = [...subtitleSettings.subtitleTracksV2]; + const firstTrack = textSubtitleSettingsForTrack(subtitleSettings, 0) as TextSubtitleSettings; + + while (newSubtitleTracks.length < track) { + newSubtitleTracks.push(firstTrack); + } + + newSubtitleTracks[track - 1] = { + ...newSubtitleTracks[track - 1], + ...updates, + }; + + let removeFromEnd = 0; + + for (let i = newSubtitleTracks.length - 1; i >= 0; --i) { + if (!deepEquals(firstTrack, newSubtitleTracks[i])) { + break; + } + + ++removeFromEnd; + } + + if (removeFromEnd > 0) { + newSubtitleTracks.splice(newSubtitleTracks.length - removeFromEnd); + } + + return { subtitleTracksV2: newSubtitleTracks }; +}; + +export const textSubtitleSettingsAreDirty = (settings: SubtitleSettings, track: number) => { + return ( + track !== 0 && + !deepEquals(textSubtitleSettingsForTrack(settings, 0), textSubtitleSettingsForTrack(settings, track)) + ); +}; + type SettingsDeserializers = { [key in keyof AsbplayerSettings]: (serialized: string) => any }; export const settingsDeserializers: SettingsDeserializers = Object.fromEntries( Object.entries(defaultSettings).map(([key, value]) => { @@ -345,7 +463,6 @@ export class SettingsProvider { if (settings.customAnkiFields === undefined) { return settings; } - const customAnkiFieldSettings = settings.customAnkiFieldSettings ?? (( diff --git a/common/settings/settings.ts b/common/settings/settings.ts index 23b9cc58..0a2a1933 100755 --- a/common/settings/settings.ts +++ b/common/settings/settings.ts @@ -97,6 +97,48 @@ const ankiSettingsKeysObject: { [key in keyof AnkiSettings]: boolean } = { export const ankiSettingsKeys: (keyof AnkiSettings)[] = Object.keys(ankiSettingsKeysObject) as (keyof AnkiSettings)[]; +const textSubtitleSettingsKeysObject: { [key in keyof TextSubtitleSettings]: boolean } = { + subtitleColor: true, + subtitleSize: true, + subtitleThickness: true, + subtitleOutlineThickness: true, + subtitleOutlineColor: true, + subtitleShadowThickness: true, + subtitleShadowColor: true, + subtitleBackgroundOpacity: true, + subtitleBackgroundColor: true, + subtitleFontFamily: true, + subtitleCustomStyles: true, + subtitleBlur: true, +}; + +export const textSubtitleSettingsKeys: (keyof TextSubtitleSettings)[] = Object.keys( + textSubtitleSettingsKeysObject +) as (keyof TextSubtitleSettings)[]; + +const subtitleSettingsKeysObject: { [key in keyof SubtitleSettings]: boolean } = { + subtitleColor: true, + subtitleSize: true, + subtitleThickness: true, + subtitleOutlineThickness: true, + subtitleOutlineColor: true, + subtitleShadowThickness: true, + subtitleShadowColor: true, + subtitleBackgroundOpacity: true, + subtitleBackgroundColor: true, + subtitleFontFamily: true, + subtitleCustomStyles: true, + subtitleBlur: true, + imageBasedSubtitleScaleFactor: true, + subtitlePositionOffset: true, + subtitleAlignment: true, + subtitleTracksV2: true, +}; + +export const subtitleSettingsKeys: (keyof SubtitleSettings)[] = Object.keys( + subtitleSettingsKeysObject +) as (keyof SubtitleSettings)[]; + export const extractAnkiSettings = (settings: T): AnkiSettings => { return Object.fromEntries(ankiSettingsKeys.map((k) => [k, settings[k]])) as unknown as AnkiSettings; }; @@ -118,15 +160,18 @@ export interface TextSubtitleSettings { readonly subtitleBackgroundColor: string; readonly subtitleFontFamily: string; readonly subtitleCustomStyles: CustomStyle[]; + readonly subtitleBlur: boolean; } export interface SubtitleSettings extends TextSubtitleSettings { readonly imageBasedSubtitleScaleFactor: number; readonly subtitlePositionOffset: number; readonly subtitleAlignment: SubtitleAlignment; - readonly subtitleTracks: { - blur: boolean; - }[]; + + // Settings for (0-based) tracks 1, 2,... + // We don't configure track 0 here to avoid having to migrate old settings into this new data structure. + // Track 0 continues to be configured from the top-level settings object. + readonly subtitleTracksV2: TextSubtitleSettings[]; } export interface KeyBind { diff --git a/extension/src/controllers/subtitle-controller.ts b/extension/src/controllers/subtitle-controller.ts index ed3ffd2f..31d4c6a8 100755 --- a/extension/src/controllers/subtitle-controller.ts +++ b/extension/src/controllers/subtitle-controller.ts @@ -5,7 +5,13 @@ import { SubtitleModel, VideoToExtensionCommand, } from '@project/common'; -import { SettingsProvider, SubtitleAlignment, SubtitleSettings } from '@project/common/settings'; +import { + SettingsProvider, + SubtitleAlignment, + SubtitleSettings, + TextSubtitleSettings, + allTextSubtitleSettings, +} from '@project/common/settings'; import { SubtitleCollection, SubtitleSlice } from '@project/common/subtitle-collection'; import { computeStyleString, surroundingSubtitles } from '@project/common/util'; import i18n from 'i18next'; @@ -33,14 +39,14 @@ export default class SubtitleController { private subtitlesInterval?: NodeJS.Timer; private showingLoadedMessage: boolean; private subtitleSettings?: SubtitleSettings; - private subtitleStyles?: string; + private subtitleStyles?: string[]; + private subtitleClasses?: string[]; private notificationElementOverlayHideTimeout?: NodeJS.Timer; private _subtitles: SubtitleModelWithIndex[]; private subtitleCollection: SubtitleCollection; private subtitlesElementOverlay: ElementOverlay; private notificationElementOverlay: ElementOverlay; disabledSubtitleTracks: { [key: number]: boolean | undefined }; - blurredSubtitleTracks: { [key: number]: boolean | undefined }; subtitleFileNames?: string[]; _forceHideSubtitles: boolean; _displaySubtitles: boolean; @@ -71,7 +77,6 @@ export default class SubtitleController { this.subtitleCollection = new SubtitleCollection([]); this.showingSubtitles = []; this.disabledSubtitleTracks = {}; - this.blurredSubtitleTracks = {}; this._forceHideSubtitles = false; this._displaySubtitles = true; this.lastLoadedMessageTimestamp = 0; @@ -127,23 +132,35 @@ export default class SubtitleController { } setSubtitleSettings(newSubtitleSettings: SubtitleSettings) { - const styles = computeStyleString(newSubtitleSettings); - - const blurSettingsChanged = - newSubtitleSettings.subtitleTracks.find((entry, index) => { - const oldBlur = this.subtitleSettings?.subtitleTracks[index].blur; - return entry.blur !== oldBlur; - }) !== undefined; - const shouldForceRerender = blurSettingsChanged; - - if (styles !== this.subtitleStyles || shouldForceRerender) { + const styles = this._computeStyles(newSubtitleSettings); + const classes = this._computeClasses(newSubtitleSettings); + + if ( + this.subtitleStyles === undefined || + !this._arrayEquals(styles, this.subtitleStyles, (a, b) => a === b) || + this.subtitleClasses === undefined || + !this._arrayEquals(classes, this.subtitleClasses, (a, b) => a === b) + ) { this.subtitleStyles = styles; + this.subtitleClasses = classes; this.cacheHtml(); } this.subtitleSettings = newSubtitleSettings; } + private _computeStyles(settings: SubtitleSettings) { + return allTextSubtitleSettings(settings).map((s) => computeStyleString(s)); + } + + private _computeClasses(settings: SubtitleSettings) { + return allTextSubtitleSettings(settings).map((s) => this._computeClassesForTrack(s)); + } + + private _computeClassesForTrack(settings: TextSubtitleSettings) { + return settings.subtitleBlur ? 'asbplayer-subtitles-blurred' : ''; + } + set subtitleAlignment(value: SubtitleAlignment) { if (this._subtitleAlignment === value) { return; @@ -161,14 +178,6 @@ export default class SubtitleController { return this._subtitleAlignment; } - setBlurredSubtitleTrack(trackIndex: number, value: boolean) { - this.blurredSubtitleTracks[trackIndex] = value; - } - - getBlurredSubtitleTrack(trackIndex: number) { - return this.blurredSubtitleTracks[trackIndex] || false; - } - private _applyElementOverlayParams(overlay: ElementOverlay, params: ElementOverlayParams) { overlay.offsetAnchor = params.offsetAnchor; overlay.fullscreenContainerClassName = params.fullscreenContainerClassName; @@ -362,11 +371,8 @@ export default class SubtitleController { return subtitles.map((subtitle) => { return { html: () => { - const blurClass = this.getBlurredSubtitleTrack(subtitle.track) - ? 'asbplayer-subtitles-blurred' - : undefined; - if (subtitle.textImage) { + const className = this.subtitleClasses?.[subtitle.track] ?? ''; const imageScale = ((this.subtitleSettings?.imageBasedSubtitleScaleFactor ?? 1) * this.video.getBoundingClientRect().width) / @@ -374,7 +380,7 @@ export default class SubtitleController { const width = imageScale * subtitle.textImage.image.width; return ` -
+
subtitle `; } else { - return this._buildTextHtml(subtitle.text, blurClass); + return this._buildTextHtml(subtitle.text, subtitle.track); } }, key: String(subtitle.index), @@ -391,8 +397,8 @@ export default class SubtitleController { }); } - private _buildTextHtml(text: string, className?: string) { - return `${text}`; + private _buildTextHtml(text: string, track?: number) { + return `${text}`; } unbind() { @@ -570,17 +576,20 @@ export default class SubtitleController { this.subtitlesElementOverlay.appendHtml(html); } - private _subtitleStyles() { - if (this.subtitleStyles) { - return this.subtitleStyles; + private _subtitleClasses(track?: number) { + if (track === undefined || this.subtitleClasses === undefined) { + return ''; } - if (this.subtitleSettings) { - this.subtitleStyles = computeStyleString(this.subtitleSettings); - return this.subtitleStyles; + return track < this.subtitleClasses.length ? this.subtitleClasses[track] : this.subtitleClasses[0]; + } + + private _subtitleStyles(track?: number) { + if (track === undefined || this.subtitleStyles === undefined) { + return ''; } - return ''; + return track < this.subtitleStyles.length ? this.subtitleStyles[track] : this.subtitleStyles[0]; } private _arrayEquals(a: T[], b: T[], equals: (lhs: T, rhs: T) => boolean): boolean { diff --git a/extension/src/handlers/video/blur-subtitles-handler.ts b/extension/src/handlers/video/blur-subtitles-handler.ts index 8f5917f9..2e99acab 100755 --- a/extension/src/handlers/video/blur-subtitles-handler.ts +++ b/extension/src/handlers/video/blur-subtitles-handler.ts @@ -5,7 +5,13 @@ import { Message, SettingsUpdatedMessage, } from '@project/common'; -import { SettingsProvider } from '@project/common/settings'; +import { + SettingsProvider, + SubtitleSettings, + changeForTextSubtitleSetting, + subtitleSettingsKeys, + textSubtitleSettingsForTrack, +} from '@project/common/settings'; import TabRegistry from '../../services/tab-registry'; export default class BlurSubtitlesHandler { @@ -27,9 +33,14 @@ export default class BlurSubtitlesHandler { async handle(command: Command, sender: chrome.runtime.MessageSender) { const message = command.message as BlurSubtitlesMessage; - const trackSettings = await this.settings.getSingle('subtitleTracks'); - trackSettings[message.track].blur = !trackSettings[message.track].blur; - await this.settings.set({ subtitleTracks: trackSettings }); + const subtitleSettings: SubtitleSettings = await this.settings.get(subtitleSettingsKeys); + const oldValue = textSubtitleSettingsForTrack(subtitleSettings, message.track); + const change = changeForTextSubtitleSetting( + { subtitleBlur: !oldValue.subtitleBlur }, + subtitleSettings, + message.track + ); + await this.settings.set(change); this.tabRegistry.publishCommandToVideoElements((videoElement) => { const settingsUpdatedCommand: ExtensionToVideoCommand = { diff --git a/extension/src/services/binding.ts b/extension/src/services/binding.ts index 7e068263..29bf0139 100755 --- a/extension/src/services/binding.ts +++ b/extension/src/services/binding.ts @@ -795,9 +795,6 @@ export default class Binding { this.subtitleController.displaySubtitles = currentSettings.streamingDisplaySubtitles; this.subtitleController.subtitlePositionOffset = currentSettings.subtitlePositionOffset; this.subtitleController.subtitleAlignment = currentSettings.subtitleAlignment; - currentSettings.subtitleTracks.forEach((entry, trackIndex) => { - this.subtitleController.setBlurredSubtitleTrack(trackIndex, entry.blur); - }); this.subtitleController.surroundingSubtitlesCountRadius = currentSettings.surroundingSubtitlesCountRadius; this.subtitleController.surroundingSubtitlesTimeRadius = currentSettings.surroundingSubtitlesTimeRadius; this.subtitleController.autoCopyCurrentSubtitle = currentSettings.autoCopyCurrentSubtitle; diff --git a/extension/src/ui/components/Popup.tsx b/extension/src/ui/components/Popup.tsx index 959de1eb..62a094f8 100644 --- a/extension/src/ui/components/Popup.tsx +++ b/extension/src/ui/components/Popup.tsx @@ -115,6 +115,7 @@ const Popup = ({ extensionSupportsOverlay extensionSupportsSidePanel={!isFirefoxBuild} extensionSupportsOrderableAnkiFields + extensionSupportsTrackSpecificSettings forceVerticalTabs={false} anki={anki} chromeKeyBinds={chromeCommandBindsToKeyBinds(commands)} diff --git a/extension/src/ui/components/SettingsUi.tsx b/extension/src/ui/components/SettingsUi.tsx index 82821138..bbabeb0b 100644 --- a/extension/src/ui/components/SettingsUi.tsx +++ b/extension/src/ui/components/SettingsUi.tsx @@ -91,6 +91,7 @@ const SettingsUi = () => { extensionSupportsOverlay extensionSupportsSidePanel={!isFirefoxBuild} extensionSupportsOrderableAnkiFields + extensionSupportsTrackSpecificSettings chromeKeyBinds={commands} onOpenChromeExtensionShortcuts={handleOpenExtensionShortcuts} onSettingsChanged={onSettingsChanged}