diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f8a84317..47714891 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -493,106 +493,114 @@ "viewQueue": "view queue" }, "setting": { - "accentColor": "accent color", "accentColor_description": "sets the accent color for the application", - "albumBackground": "album background image", + "accentColor": "accent color", "albumBackground_description": "adds a background image for album pages containing the album art", - "albumBackgroundBlur": "album background image blur size", + "albumBackground": "album background image", "albumBackgroundBlur_description": "adjusts the amount of blur applied to the album background image", - "applicationHotkeys": "application hotkeys", + "albumBackgroundBlur": "album background image blur size", "applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)", + "applicationHotkeys": "application hotkeys", "artistBackground": "artist background image", "artistBackground_description": "adds a background image for artist pages containing the artist art", "artistBackgroundBlur": "artist background image blur size", "artistBackgroundBlur_description": "adjusts the amount of blur applied to the artist background image", "artistConfiguration": "album artist page configuration", "artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page", - "audioDevice": "audio device", + "artistConfiguration": "album artist page configuration", "audioDevice_description": "select the audio device to use for playback (web player only)", - "audioExclusiveMode": "audio exclusive mode", + "audioDevice": "audio device", "audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio", - "audioPlayer": "audio player", + "audioExclusiveMode": "audio exclusive mode", "audioPlayer_description": "select the audio player to use for playback", - "buttonSize": "player bar button size", + "audioPlayer": "audio player", "buttonSize_description": "the size of the player bar buttons", - "clearCache": "clear browser cache", + "buttonSize": "player bar button size", "clearCache_description": "a 'hard clear' of feishin. in addition to clearing feishin's cache, empty the browser cache (saved images and other assets). server credentials and settings are preserved", - "clearQueryCache": "clear feishin cache", - "clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved", + "clearCache": "clear browser cache", "clearCacheSuccess": "cache cleared successfully", - "contextMenu": "context menu (right click) configuration", + "clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved", + "clearQueryCache": "clear feishin cache", "contextMenu_description": "allows you to hide items that are shown in the menu when you right click on an item. items that are unchecked will be hidden", - "crossfadeDuration": "crossfade duration", + "contextMenu": "context menu (right click) configuration", "crossfadeDuration_description": "sets the duration of the crossfade effect", - "crossfadeStyle": "crossfade style", + "crossfadeDuration": "crossfade duration", "crossfadeStyle_description": "select the crossfade style to use for the audio player", - "customCssEnable": "enable custom css", - "customCssEnable_description": "allow for writing custom css", - "customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom css can still pose risks by changing the interface", - "customCss": "custom css", "customCss_description": "custom css content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization", - "customFontPath": "custom font path", + "customCss": "custom css", + "customCssEnable_description": "allow for writing custom css", + "customCssEnable": "enable custom css", + "customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom css can still pose risks by changing the interface", "customFontPath_description": "sets the path to the custom font to use for the application", + "customFontPath": "custom font path", "disableAutomaticUpdates": "disable automatic updates", - "releaseChannel_optionLatest": "stable", "releaseChannel_optionBeta": "beta", + "releaseChannel_optionLatest": "latest", "releaseChannel": "release channel", "releaseChannel_description": "choose between stable releases or beta releases for automatic updates", "disableLibraryUpdateOnStartup": "disable checking for new versions on startup", - "discordApplicationId": "{{discord}} application id", "discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})", - "discordPausedStatus": "show rich presence when paused", - "discordPausedStatus_description": "when enabled, status will show when player is paused", - "discordIdleStatus": "show rich presence idle status", + "discordApplicationId": "{{discord}} application id", + "discordDisplayType_artistname": "artist name(s)", + "discordDisplayType_description": "changes what you are listening to in your status", + "discordDisplayType_songname": "song name", + "discordDisplayType": "{{discord}} presence display type", "discordIdleStatus_description": "when enabled, update status while player is idle", - "discordListening": "show status as listening", + "discordIdleStatus": "show rich presence idle status", + "discordLinkType_description": "adds external links to {{lastfm}} or {{musicbrainz}} to the song and artist fields in {{discord}} rich presence. {{musicbrainz}} is the most accurate but requires tags and doesn't provide artist links while {{lastfm}} should always provide a link. makes no extra network requests", + "discordLinkType_mbz_lastfm": "{{musicbrainz}} with {{lastfm}} fallback", + "discordLinkType_none": "$t(common.none)", + "discordLinkType": "{{discord}} presence links", "discordListening_description": "show status as listening instead of playing", - "discordRichPresence": "{{discord}} rich presence", + "discordListening": "show status as listening", + "discordPausedStatus_description": "when enabled, status will show when player is paused", + "discordPausedStatus": "show rich presence when paused", "discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}", "discordServeImage": "serve {{discord}} images from server", "discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for Jellyfin and Navidrome. {{discord}} uses a bot to fetch images, so your server must be reachable from the public internet", "discordUpdateInterval": "{{discord}} rich presence update interval", "discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)", - "discordDisplayType": "{{discord}} presence display type", - "discordDisplayType_description": "changes what you are listening to in your status", - "discordDisplayType_songname": "song name", - "discordDisplayType_artistname": "artist name(s)", - "discordLinkType": "{{discord}} presence links", - "discordLinkType_description": "adds external links to {{lastfm}} or {{musicbrainz}} to the song and artist fields in {{discord}} rich presence. {{musicbrainz}} is the most accurate but requires tags and doesn't provide artist links while {{lastfm}} should always provide a link. makes no extra network requests", - "discordLinkType_none": "$t(common.none)", - "discordLinkType_mbz_lastfm": "{{musicbrainz}} with {{lastfm}} fallback", - "doubleClickBehavior": "queue all searched tracks when double clicking", + "discordUpdateInterval": "{{discord}} rich presence update interval", "doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued", - "enableRemote": "enable remote control server", + "doubleClickBehavior": "queue all searched tracks when double clicking", "enableRemote_description": "enables the remote control server to allow other devices to control the application", - "externalLinks": "show external links", - "externalLinks_description": "enables showing external links (Last.fm, MusicBrainz) on artist/album pages", - "exitToTray": "exit to tray", + "enableRemote": "enable remote control server", "exitToTray_description": "exit the application to the system tray", - "floatingQueueArea": "show floating queue hover area", + "exitToTray": "exit to tray", + "exportImportSettings_control_description": "Export and Import settings via JSON", + "exportImportSettings_control_exportText": "Export Settings", + "exportImportSettings_control_importText": "Import Settings", + "exportImportSettings_control_title": "Import / Export Settings", + "exportImportSettings_destructiveWarning": "Importing settings is destructive, please review the above before clicking \"Import\" below!", + "exportImportSettings_importBtn": "Import Settings", + "exportImportSettings_importModalTitle": "Import Feishin Settings", + "exportImportSettings_importSuccess": "Settings have been imported successfully!", + "exportImportSettings_notValidJSON": "The file passed is not valid JSON", + "exportImportSettings_offendingKeyError": "\"{{offendingKey}}\" is incorrect - {{reason}}", + "externalLinks_description": "enables showing external links (Last.fm, MusicBrainz) on artist/album pages", + "externalLinks": "show external links", "floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue", - "followLyric": "follow current lyric", + "floatingQueueArea": "show floating queue hover area", "followLyric_description": "scroll the lyric to the current playing position", - "preferLocalLyrics": "prefer local lyrics", - "preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available", - "font": "font", + "followLyric": "follow current lyric", "font_description": "sets the font to use for the application", - "fontType": "font type", - "fontType_description": "built-in font selects one of the fonts provided by feishin. system font allows you to select any font provided by your operating system. custom allows you to provide your own font", + "font": "font", + "fontType_description": "built-in font selects one of the fonts provided by Feishin. system font allows you to select any font provided by your operating system. custom allows you to provide your own font", "fontType_optionBuiltIn": "built-in font", "fontType_optionCustom": "custom font", "fontType_optionSystem": "system font", - "gaplessAudio": "gapless audio", + "fontType": "font type", "gaplessAudio_description": "sets the gapless audio setting for mpv", "gaplessAudio_optionWeak": "weak (recommended)", - "genreBehavior": "genre page default behavior", + "gaplessAudio": "gapless audio", "genreBehavior_description": "determines whether clicking on a genre opens by default in track or album list", - "globalMediaHotkeys": "global media hotkeys", + "genreBehavior": "genre page default behavior", "globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback", - "homeConfiguration": "home page configuration", + "globalMediaHotkeys": "global media hotkeys", "homeConfiguration_description": "configure what items are shown, and in what order, on the home page", - "homeFeature": "home featured carousel", + "homeConfiguration": "home page configuration", "homeFeature_description": "controls whether to show the large featured carousel on the home page", + "homeFeature": "home featured carousel", "hotkey_browserBack": "browser back", "hotkey_browserForward": "browser forward", "hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)", @@ -627,134 +635,130 @@ "hotkey_volumeUp": "volume up", "hotkey_zoomIn": "zoom in", "hotkey_zoomOut": "zoom out", - "imageAspectRatio": "use native cover art aspect ratio", "imageAspectRatio_description": "if enabled, cover art will be shown using their native aspect ratio. for art that is not 1:1, the remaining space will be empty", - "language": "language", + "imageAspectRatio": "use native cover art aspect ratio", "language_description": "sets the language for the application ($t(common.restartRequired))", - "lastfm": "show last.fm links", "lastfm_description": "show links to Last.fm on artist/album pages", - "lastfmApiKey": "{{lastfm}} API key", + "lastfm": "show last.fm links", "lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art", - "lyricFetch": "fetch lyrics from the internet", + "lastfmApiKey": "{{lastfm}} API key", "lyricFetch_description": "fetch lyrics from various internet sources", - "lyricFetchProvider": "providers to fetch lyrics from", + "lyricFetch": "fetch lyrics from the internet", "lyricFetchProvider_description": "select the providers to fetch lyrics from. the order of the providers is the order in which they will be queried", - "lyricOffset": "lyric offset (ms)", + "lyricFetchProvider": "providers to fetch lyrics from", "lyricOffset_description": "offset the lyric by the specified amount of milliseconds", - "notify": "enable song notifications", - "notify_description": "show notifications when changing the current song", - "minimizeToTray": "minimize to tray", + "lyricOffset": "lyric offset (ms)", "minimizeToTray_description": "minimize the application to the system tray", - "minimumScrobblePercentage": "minimum scrobble duration (percentage)", + "minimizeToTray": "minimize to tray", "minimumScrobblePercentage_description": "the minimum percentage of the song that must be played before it is scrobbled", - "minimumScrobbleSeconds": "minimum scrobble (seconds)", + "minimumScrobblePercentage": "minimum scrobble duration (percentage)", "minimumScrobbleSeconds_description": "the minimum duration in seconds of the song that must be played before it is scrobbled", - "mpvExecutablePath": "mpv executable path", + "minimumScrobbleSeconds": "minimum scrobble (seconds)", "mpvExecutablePath_description": "sets the path to the mpv executable. if left empty, the default path will be used", - "mpvExtraParameters": "mpv parameters", + "mpvExecutablePath": "mpv executable path", "mpvExtraParameters_help": "one per line", - "musicbrainz": "show MusicBrainz links", "musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists", - "neteaseTranslation": "Enable NetEase translations", + "musicbrainz": "show MusicBrainz links", "neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available", - "passwordStore": "passwords/secret store", + "neteaseTranslation": "Enable NetEase translations", "passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords", - "playbackStyle": "playback style", + "passwordStore": "passwords/secret store", "playbackStyle_description": "select the playback style to use for the audio player", "playbackStyle_optionCrossFade": "crossfade", "playbackStyle_optionNormal": "normal", - "playButtonBehavior": "play button behavior", + "playbackStyle": "playback style", "playButtonBehavior_description": "sets the default behavior of the play button when adding songs to the queue", "playButtonBehavior_optionAddLast": "$t(player.addLast)", "playButtonBehavior_optionAddNext": "$t(player.addNext)", "playButtonBehavior_optionPlay": "$t(player.play)", "playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)", - "playerAlbumArtResolution": "player album art resolution", + "playButtonBehavior": "play button behavior", "playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto", - "playerbarOpenDrawer": "playerbar fullscreen toggle", + "playerAlbumArtResolution": "player album art resolution", "playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player", - "remotePassword": "remote control server password", + "playerbarOpenDrawer": "playerbar fullscreen toggle", + "preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available", + "preferLocalLyrics": "prefer local lyrics", + "preservePitch_description": "preserves pitch when modifying playback speed", + "preservePitch": "preserve pitch", + "preventSleepOnPlayback_description": "prevent the display from sleeping while music is playing", + "preventSleepOnPlayback": "prevent sleep on playback", "remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about", - "remotePort": "remote control server port", + "remotePassword": "remote control server password", "remotePort_description": "sets the port for the remote control server", - "remoteUsername": "remote control server username", + "remotePort": "remote control server port", "remoteUsername_description": "sets the username for the remote control server. if both username and password are empty, authentication will be disabled", - "replayGainClipping": "{{ReplayGain}} clipping", + "remoteUsername": "remote control server username", "replayGainClipping_description": "Prevent clipping caused by {{ReplayGain}} by automatically lowering the gain", - "replayGainFallback": "{{ReplayGain}} fallback", + "replayGainClipping": "{{ReplayGain}} clipping", "replayGainFallback_description": "gain in db to apply if the file has no {{ReplayGain}} tags", - "replayGainMode": "{{ReplayGain}} mode", + "replayGainFallback": "{{ReplayGain}} fallback", "replayGainMode_description": "adjust volume gain according to {{ReplayGain}} values stored in the file metadata", "replayGainMode_optionAlbum": "$t(entity.album_one)", "replayGainMode_optionNone": "$t(common.none)", "replayGainMode_optionTrack": "$t(entity.track_one)", - "replayGainPreamp": "{{ReplayGain}} preamp (dB)", + "replayGainMode": "{{ReplayGain}} mode", "replayGainPreamp_description": "adjust the preamp gain applied to the {{ReplayGain}} values", - "sampleRate": "sample rate", + "replayGainPreamp": "{{ReplayGain}} preamp (dB)", "sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media. a value less than 8000 will use the default frequency", - "savePlayQueue": "save play queue", + "sampleRate": "sample rate", "savePlayQueue_description": "save the play queue when the application is closed and restore it when the application is opened", - "scrobble": "scrobble", + "savePlayQueue": "save play queue", "scrobble_description": "scrobble plays to your media server", - "showSkipButton": "show skip buttons", + "scrobble": "scrobble", "showSkipButton_description": "show or hide the skip buttons on the player bar", - "showSkipButtons": "show skip buttons", + "showSkipButton": "show skip buttons", "showSkipButtons_description": "show or hide the skip buttons on the player bar", - "sidebarCollapsedNavigation": "sidebar (collapsed) navigation", + "showSkipButtons": "show skip buttons", "sidebarCollapsedNavigation_description": "show or hide the navigation in the collapsed sidebar", - "sidebarConfiguration": "sidebar configuration", + "sidebarCollapsedNavigation": "sidebar (collapsed) navigation", "sidebarConfiguration_description": "select the items and order in which they appear in the sidebar", - "sidebarPlaylistList": "sidebar playlist list", + "sidebarConfiguration": "sidebar configuration", "sidebarPlaylistList_description": "show or hide the playlist list in the sidebar", - "sidePlayQueueStyle": "side play queue style", + "sidebarPlaylistList": "sidebar playlist list", "sidePlayQueueStyle_description": "sets the style of the side play queue", "sidePlayQueueStyle_optionAttached": "attached", "sidePlayQueueStyle_optionDetached": "detached", - "skipDuration": "skip duration", - "skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar", - "skipPlaylistPage": "skip playlist page", - "skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page", - "startMinimized": "start minimized", - "startMinimized_description": "start the application in system tray", - "preventSleepOnPlayback": "prevent sleep on playback", - "preventSleepOnPlayback_description": "prevent the display from sleeping while music is playing", - "theme": "theme", - "theme_description": "sets the theme to use for the application", - "themeDark": "theme (dark)", - "themeDark_description": "sets the dark theme to use for the application", - "themeLight": "theme (light)", - "themeLight_description": "sets the light theme to use for the application", - "transcodeNote": "takes effect after 1 (web) - 2 (mpv) songs", - "transcode": "enable transcoding", - "transcode_description": "enables transcoding to different formats", - "transcodeBitrate": "bitrate to transcode", - "transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick", - "transcodeFormat": "format to transcode", - "transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide", - "mediaSession": "enable media session", "mediaSession_description": "enables Windows Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen (Windows only)", - "translationApiProvider": "translation api provider", - "translationApiProvider_description": "api provider for translation", - "translationApiKey": "translation api key", + "mediaSession": "enable media session", + "sidePlayQueueStyle": "side play queue style", + "skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar", + "skipDuration": "skip duration", + "skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page", + "skipPlaylistPage": "skip playlist page", + "startMinimized_description": "start the application in system tray", + "startMinimized": "start minimized", + "theme_description": "sets the theme to use for the application", + "theme": "theme", + "themeDark_description": "sets the dark theme to use for the application", + "themeDark": "theme (dark)", + "themeLight_description": "sets the light theme to use for the application", + "themeLight": "theme (light)", + "transcode_description": "enables transcoding to different formats", + "transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick", + "transcodeBitrate": "bitrate to transcode", + "transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide", + "transcodeFormat": "format to transcode", "translationApiKey_description": "api key for translation (global service endpoint only)", - "translationTargetLanguage": "translation target language", + "translationApiKey": "translation api key", + "translationApiProvider_description": "api provider for translation", + "translationApiProvider": "translation api provider", "translationTargetLanguage_description": "target language for translation", - "trayEnabled": "show tray", + "translationTargetLanguage": "translation target language", "trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray", - "useSystemTheme": "use system theme", + "trayEnabled": "show tray", "useSystemTheme_description": "follow the system-defined light or dark preference", - "volumeWheelStep": "volume wheel step", + "useSystemTheme": "use system theme", "volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider", - "volumeWidth": "volume slider width", + "volumeWheelStep": "volume wheel step", "volumeWidth_description": "the width of the volume slider", - "webAudio": "use web audio", + "volumeWidth": "volume slider width", "webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise", - "preservePitch": "preserve pitch", - "preservePitch_description": "preserves pitch when modifying playback speed", - "windowBarStyle": "window bar style", + "webAudio": "use web audio", "windowBarStyle_description": "select the style of the window bar", - "zoom": "zoom percentage", - "zoom_description": "sets the zoom percentage for the application" + "windowBarStyle": "window bar style", + "zoom_description": "sets the zoom percentage for the application", + "zoom": "zoom percentage" }, "table": { "column": { @@ -832,5 +836,10 @@ "table": "table" } } + }, + "dragDropZone": { + "error_oneFileOnly": "Please only select 1 file", + "error_readingFile": "There has been an issue reading the file: {{errorMessage}}", + "mainText": "Drop a file here" } } diff --git a/src/renderer/components/export-import-settings-modal/export-import-settings-modal.tsx b/src/renderer/components/export-import-settings-modal/export-import-settings-modal.tsx new file mode 100644 index 00000000..acfd9ed5 --- /dev/null +++ b/src/renderer/components/export-import-settings-modal/export-import-settings-modal.tsx @@ -0,0 +1,121 @@ +import { t } from 'i18next'; +import { useCallback, useState } from 'react'; +import { ZodError } from 'zod'; + +import { DiffVisualiser } from '/@/renderer/components/settings-diff-visualiser/settings-diff-visualiser'; +import { + migrateSettings, + type SettingsState, + useSettingsForExport, + useSettingsStoreActions, + ValidationSettingsStateSchema, + VersionedSettings, +} from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; + +enum SCREENS { + FILE_PICKER, + DIFF_VISUALS, + IMPORT_COMPLETE, +} + +export const ExportImportSettingsModal = () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Version needs to be omitted from the settings object + const { version, ...settings } = useSettingsForExport(); + const { setSettings } = useSettingsStoreActions(); + + const [currentScreen, setCurrentScreen] = useState(SCREENS.FILE_PICKER); + const [selectedSettingsFile, setSettingsFile] = useState(); + + const onItemSelected = useCallback((itemContents: string) => { + const settingsFile = JSON.parse(itemContents) as VersionedSettings; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Version needs to be omitted from the settings object + const { version, ...settings } = settingsFile; + const parsedResult = settings as SettingsState; + setSettingsFile(parsedResult); + setCurrentScreen(SCREENS.DIFF_VISUALS); + }, []); + + const validateItemSelected = useCallback( + (itemContents: string): { error?: string; isValid: boolean } => { + try { + JSON.parse(itemContents); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- "err" is not useful and the catch cannot be empty + } catch (err) { + return { + error: t('setting.exportImportSettings_notValidJSON'), + isValid: false, + }; + } + + const content = JSON.parse(itemContents); + + const migratedSettings = migrateSettings(content, content?.version || 0); + const validationRes = ValidationSettingsStateSchema.safeParse(migratedSettings); + + if (!validationRes.success) { + const error = validationRes.error as ZodError; + const firstError = error.errors.pop(); + + const dotPath = firstError?.path.join('.'); + const reason = firstError?.message; + + return { + error: t('setting.exportImportSettings_offendingKeyError', { + offendingKey: dotPath, + reason, + }), + isValid: false, + }; + } + + return { + isValid: true, + }; + }, + [], + ); + + const onImportClick = useCallback(() => { + if (selectedSettingsFile) { + setSettings(selectedSettingsFile); + setCurrentScreen(SCREENS.IMPORT_COMPLETE); + } + }, [selectedSettingsFile, setSettings]); + + return ( + <> + {currentScreen === SCREENS.FILE_PICKER ? ( + + + + ) : null} + {currentScreen === SCREENS.DIFF_VISUALS ? ( + + + + {t('setting.exportImportSettings_destructiveWarning').toString()} + + + + ) : null} + {currentScreen === SCREENS.IMPORT_COMPLETE ? ( + + {t('setting.exportImportSettings_importSuccess').toString()} + + ) : null} + + ); +}; diff --git a/src/renderer/components/settings-diff-visualiser/settings-diff-visualiser.tsx b/src/renderer/components/settings-diff-visualiser/settings-diff-visualiser.tsx new file mode 100644 index 00000000..cb4aa8de --- /dev/null +++ b/src/renderer/components/settings-diff-visualiser/settings-diff-visualiser.tsx @@ -0,0 +1,58 @@ +import { SettingsState } from '/@/renderer/store'; +import { Box } from '/@/shared/components/box/box'; +import { Text } from '/@/shared/components/text/text'; + +interface DiffVisualiserProps { + newSettings: Omit; + originalSettings: Omit; +} + +const diff = (newSettings: SettingsState, originalSettings: SettingsState) => { + const diffs: string[] = []; + + const newSettingsString = JSON.stringify(newSettings, null, 2); + const originalSettingsString = JSON.stringify(originalSettings, null, 2); + + const newSettingsLines = newSettingsString.split('\n'); + const originalSettingsLines = originalSettingsString.split('\n'); + + originalSettingsLines.forEach((line, index) => { + if (line !== newSettingsLines[index]) { + diffs.push(`- ${line}`); + if (newSettingsLines[index] !== undefined) { + diffs.push(`+ ${newSettingsLines[index]}`); + } + } else { + diffs.push(` ${line}`); + } + }); + + return diffs; +}; + +export const DiffVisualiser = ({ newSettings, originalSettings }: DiffVisualiserProps) => { + const differences = diff(newSettings, originalSettings); + + return ( + + {differences.map((line, index) => ( + + {line} + + ))} + + ); +}; diff --git a/src/renderer/features/settings/components/advanced/advanced-tab.tsx b/src/renderer/features/settings/components/advanced/advanced-tab.tsx index d4016b28..090fc1da 100644 --- a/src/renderer/features/settings/components/advanced/advanced-tab.tsx +++ b/src/renderer/features/settings/components/advanced/advanced-tab.tsx @@ -1,3 +1,4 @@ +import { ExportImportSettings } from '/@/renderer/features/settings/components/advanced/export-import-settings'; import { StylesSettings } from '/@/renderer/features/settings/components/advanced/styles-settings'; import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings'; import { Stack } from '/@/shared/components/stack/stack'; @@ -7,6 +8,7 @@ export const AdvancedTab = () => { + ); }; diff --git a/src/renderer/features/settings/components/advanced/export-import-settings.tsx b/src/renderer/features/settings/components/advanced/export-import-settings.tsx new file mode 100644 index 00000000..f22941c4 --- /dev/null +++ b/src/renderer/features/settings/components/advanced/export-import-settings.tsx @@ -0,0 +1,53 @@ +import { openModal } from '@mantine/modals'; +import { t } from 'i18next'; +import { useCallback } from 'react'; + +import { ExportImportSettingsModal } from '/@/renderer/components/export-import-settings-modal/export-import-settings-modal'; +import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; +import { useSettingsForExport } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; + +export const ExportImportSettings = () => { + const settingForExport = useSettingsForExport(); + + const onExportSettings = useCallback(() => { + const settingsFile = new File([JSON.stringify(settingForExport)], 'feishin-settings.json', { + type: 'application/json', + }); + + const settingsFileLink = document.createElement('a'); + const settingsFilesUrl = URL.createObjectURL(settingsFile); + settingsFileLink.href = settingsFilesUrl; + settingsFileLink.download = settingsFile.name; + settingsFileLink.click(); + + URL.revokeObjectURL(settingsFilesUrl); + }, [settingForExport]); + + const openImportModal = () => { + openModal({ + children: , + size: 'lg', + title: t('setting.exportImportSettings_importModalTitle').toString(), + }); + }; + + return ( + <> + + + + + } + description={t('setting.exportImportSettings_control_description').toString()} + title={t('setting.exportImportSettings_control_title').toString()} + /> + + ); +}; diff --git a/src/renderer/features/settings/components/advanced/styles-settings.tsx b/src/renderer/features/settings/components/advanced/styles-settings.tsx index f9bdc383..2ab62716 100644 --- a/src/renderer/features/settings/components/advanced/styles-settings.tsx +++ b/src/renderer/features/settings/components/advanced/styles-settings.tsx @@ -1,5 +1,5 @@ import { closeAllModals, openModal } from '@mantine/modals'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option'; @@ -40,6 +40,13 @@ export const StylesSettings = () => { closeAllModals(); }; + useEffect(() => { + if (content !== css) { + setCss(content); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Reason: This is to only fire if an external source updates the stores css.content + }, [content]); + const openConfirmModal = () => { openModal({ children: ( diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index 20039d1d..80d0f34b 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -14,6 +14,7 @@ import { useGeneralSettings, useSettingsStoreActions, } from '/@/renderer/store/settings.store'; +import { type Font, FONT_OPTIONS } from '/@/renderer/types/fonts'; import { FileInput } from '/@/shared/components/file-input/file-input'; import { NumberInput } from '/@/shared/components/number-input/number-input'; import { Select } from '/@/shared/components/select/select'; @@ -25,23 +26,6 @@ const ipc = isElectron() ? window.api.ipc : null; // Electron 32+ removed file.path, use this which is exposed in preload to get real path const webUtils = isElectron() ? window.electron.webUtils : null; -type Font = { - label: string; - value: string; -}; - -const FONT_OPTIONS: Font[] = [ - { label: 'Archivo', value: 'Archivo' }, - { label: 'Fredoka', value: 'Fredoka' }, - { label: 'Inter', value: 'Inter' }, - { label: 'League Spartan', value: 'League Spartan' }, - { label: 'Lexend', value: 'Lexend' }, - { label: 'Poppins', value: 'Poppins' }, - { label: 'Raleway', value: 'Raleway' }, - { label: 'Sora', value: 'Sora' }, - { label: 'Work Sans', value: 'Work Sans' }, -]; - const FONT_TYPES: Font[] = [ { label: i18n.t('setting.fontType', { diff --git a/src/renderer/features/settings/components/general/artist-settings.tsx b/src/renderer/features/settings/components/general/artist-settings.tsx index 9a22ca7d..4df64418 100644 --- a/src/renderer/features/settings/components/general/artist-settings.tsx +++ b/src/renderer/features/settings/components/general/artist-settings.tsx @@ -13,12 +13,17 @@ export const ArtistSettings = () => { const { artistItems } = useGeneralSettings(); const { setArtistItems } = useSettingsStoreActions(); + const mappedArtistItems = artistItems.map((item) => ({ + ...item, + id: item.id as ArtistItem, + })); + return ( ); diff --git a/src/renderer/features/settings/components/general/home-settings.tsx b/src/renderer/features/settings/components/general/home-settings.tsx index 28c98f8f..299e770f 100644 --- a/src/renderer/features/settings/components/general/home-settings.tsx +++ b/src/renderer/features/settings/components/general/home-settings.tsx @@ -13,12 +13,17 @@ export const HomeSettings = () => { const { homeItems } = useGeneralSettings(); const { setHomeItems } = useSettingsStoreActions(); + const mappedHomeItems = homeItems.map((item) => ({ + ...item, + id: item.id as HomeItem, + })); + return ( ); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index c1528407..af485cb7 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -1,8 +1,8 @@ import type { ContextMenuItemType } from '/@/renderer/features/context-menu'; -import { ColDef } from '@ag-grid-community/core'; import isElectron from 'is-electron'; import { generatePath } from 'react-router'; +import { z } from 'zod'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { shallow } from 'zustand/shallow'; @@ -12,7 +12,9 @@ import i18n from '/@/i18n/i18n'; import { AppRoute } from '/@/renderer/router/routes'; import { usePlayerStore } from '/@/renderer/store/player.store'; import { mergeOverridingColumns } from '/@/renderer/store/utils'; +import { FontValueSchema } from '/@/renderer/types/fonts'; import { randomString } from '/@/renderer/utils'; +import { sanitizeCss } from '/@/renderer/utils/sanitize'; import { AppTheme } from '/@/shared/themes/app-theme-types'; import { LibraryItem, LyricSource } from '/@/shared/types/domain-types'; import { @@ -26,13 +28,409 @@ import { TableType, } from '/@/shared/types/types'; -export type SidebarItemType = { +const HomeItemSchema = z.enum([ + 'mostPlayed', + 'random', + 'recentlyAdded', + 'recentlyPlayed', + 'recentlyReleased', +]); + +const ArtistItemSchema = z.enum([ + 'biography', + 'compilations', + 'recentAlbums', + 'similarArtists', + 'topSongs', +]); + +const BindingActionsSchema = z.enum([ + 'browserBack', + 'browserForward', + 'favoriteCurrentAdd', + 'favoriteCurrentRemove', + 'favoriteCurrentToggle', + 'favoritePreviousAdd', + 'favoritePreviousRemove', + 'favoritePreviousToggle', + 'globalSearch', + 'localSearch', + 'volumeMute', + 'navigateHome', + 'next', + 'pause', + 'play', + 'playPause', + 'previous', + 'rate0', + 'rate1', + 'rate2', + 'rate3', + 'rate4', + 'rate5', + 'toggleShuffle', + 'skipBackward', + 'skipForward', + 'stop', + 'toggleFullscreenPlayer', + 'toggleQueue', + 'toggleRepeat', + 'volumeDown', + 'volumeUp', + 'zoomIn', + 'zoomOut', +]); + +const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']); + +const DiscordLinkTypeSchema = z.enum(['last_fm', 'musicbrainz', 'musicbrainz_last_fm', 'none']); + +const GenreTargetSchema = z.enum(['album', 'track']); + +const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']); + +const SidebarItemTypeSchema = z.object({ + disabled: z.boolean(), + id: z.string(), + label: z.string(), + route: z.union([z.nativeEnum(AppRoute), z.string()]), +}); + +const SortableItemSchema = (itemSchema: T) => + z.object({ + disabled: z.boolean(), + id: itemSchema, + }); + +const PersistedTableColumnSchema = z.object({ + column: z.nativeEnum(TableColumn), + extraProps: z.record(z.any()).optional(), + width: z.number(), +}); + +const DataTablePropsSchema = z.object({ + autoFit: z.boolean(), + columns: z.array(PersistedTableColumnSchema), + followCurrentSong: z.boolean().optional(), + rowHeight: z.number(), +}); + +const TranscodingConfigSchema = z.object({ + bitrate: z.number().optional(), + enabled: z.boolean(), + format: z.string().optional(), +}); + +const MpvSettingsSchema = z.object({ + audioExclusiveMode: z.enum(['no', 'yes']), + audioFormat: z.enum(['float', 's16', 's32']).optional(), + audioSampleRateHz: z.number().optional(), + gaplessAudio: z.enum(['no', 'weak', 'yes']), + replayGainClip: z.boolean(), + replayGainFallbackDB: z.number().optional(), + replayGainMode: z.enum(['album', 'no', 'track']), + replayGainPreampDB: z.number().optional(), +}); + +const CssSettingsSchema = z.object({ + content: z.string().transform((val) => sanitizeCss(`