mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 10:03:33 +00:00
Import / Export Feishin Settings (#1163)
* Create a shared DragDrop Zone - This zone allows the dropping of files - The zone allows validation by parent - The zone allows customisation like icon shown * Import Settings - Ability to import settings from a JSON file - Validation to ensure file compatibility - Visualiser for viewing string differences * i18n - Moved all hardcoded values to be en localised * Zod / Validation This commit contains the code to move settings to using ZOD, the reason for this is so that we can validate the settings schema that is being imported. This commit also adds various validation and transforms to ensure the settings being reimported match values we expect. I also removed the original crude validation and replaced it with the new ZOD parser that will handle this for us. Finally the "styles-settings" component will listen to any external content updates and update its value, the reasoning is the external import wouldn't update the existing value. - Split Settings schema into two parts, schema that is validated on import and schema that is not - Schemas are merged to make the full SettingsStateSchema * Migrate during validation - Migration is done as part of validation - Updated the store version to v10 as there has been changes to the settings - Migrate will now add the fields from v9 to v10 - the build was failing due to ids not being mapped to their enum values --------- Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
This commit is contained in:
parent
645d260407
commit
a9f2b083fa
13 changed files with 980 additions and 411 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(SCREENS.FILE_PICKER);
|
||||
const [selectedSettingsFile, setSettingsFile] = useState<SettingsState>();
|
||||
|
||||
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 ? (
|
||||
<Stack>
|
||||
<DragDropZone
|
||||
icon="fileJson"
|
||||
onItemSelected={onItemSelected}
|
||||
validateItem={validateItemSelected}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
{currentScreen === SCREENS.DIFF_VISUALS ? (
|
||||
<Stack>
|
||||
<DiffVisualiser
|
||||
newSettings={selectedSettingsFile!}
|
||||
originalSettings={settings}
|
||||
/>
|
||||
<Text size="sm" ta="center">
|
||||
{t('setting.exportImportSettings_destructiveWarning').toString()}
|
||||
</Text>
|
||||
<Button onClick={onImportClick} variant="state-info">
|
||||
{t('setting.exportImportSettings_importBtn').toString()}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
{currentScreen === SCREENS.IMPORT_COMPLETE ? (
|
||||
<Text py="md" ta="center">
|
||||
{t('setting.exportImportSettings_importSuccess').toString()}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<SettingsState, 'actions'>;
|
||||
originalSettings: Omit<SettingsState, 'actions'>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
mah="400px"
|
||||
p="md"
|
||||
style={{ fontFamily: 'monospace', overflow: 'auto', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{differences.map((line, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
style={{
|
||||
color: line.startsWith('+')
|
||||
? 'green'
|
||||
: line.startsWith('-')
|
||||
? 'red'
|
||||
: 'white',
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 = () => {
|
|||
<Stack gap="md">
|
||||
<UpdateSettings />
|
||||
<StylesSettings />
|
||||
<ExportImportSettings />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: <ExportImportSettingsModal />,
|
||||
size: 'lg',
|
||||
title: t('setting.exportImportSettings_importModalTitle').toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsOptions
|
||||
control={
|
||||
<>
|
||||
<Button onClick={onExportSettings}>
|
||||
{t('setting.exportImportSettings_control_exportText').toString()}
|
||||
</Button>
|
||||
<Button onClick={openImportModal}>
|
||||
{t('setting.exportImportSettings_control_importText').toString()}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description={t('setting.exportImportSettings_control_description').toString()}
|
||||
title={t('setting.exportImportSettings_control_title').toString()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DraggableItems
|
||||
description="setting.artistConfiguration"
|
||||
itemLabels={ARTIST_ITEMS}
|
||||
setItems={setArtistItems}
|
||||
settings={artistItems}
|
||||
settings={mappedArtistItems}
|
||||
title="setting.artistConfiguration"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DraggableItems
|
||||
description="setting.homeConfiguration"
|
||||
itemLabels={HOME_ITEMS}
|
||||
setItems={setHomeItems}
|
||||
settings={homeItems}
|
||||
settings={mappedHomeItems}
|
||||
title="setting.homeConfiguration"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = <T extends z.ZodTypeAny>(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(`<style>${val}`)),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
const DiscordSettingsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
displayType: DiscordDisplayTypeSchema,
|
||||
enabled: z.boolean(),
|
||||
linkType: DiscordLinkTypeSchema,
|
||||
showAsListening: z.boolean(),
|
||||
showPaused: z.boolean(),
|
||||
showServerImage: z.boolean(),
|
||||
});
|
||||
|
||||
const FontSettingsSchema = z.object({
|
||||
builtIn: FontValueSchema,
|
||||
custom: z.string().nullable(),
|
||||
system: z.string().nullable(),
|
||||
type: z.nativeEnum(FontType),
|
||||
});
|
||||
|
||||
const SkipButtonsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
skipBackwardSeconds: z.number(),
|
||||
skipForwardSeconds: z.number(),
|
||||
});
|
||||
|
||||
const GeneralSettingsSchema = z.object({
|
||||
accent: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => /^rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)$/.test(val),
|
||||
{
|
||||
message: 'Accent must be a valid rgb() color string',
|
||||
},
|
||||
),
|
||||
albumArtRes: z.number().nullable().optional(),
|
||||
albumBackground: z.boolean(),
|
||||
albumBackgroundBlur: z.number(),
|
||||
artistBackground: z.boolean(),
|
||||
artistBackgroundBlur: z.number(),
|
||||
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
|
||||
buttonSize: z.number(),
|
||||
disabledContextMenu: z.record(z.boolean()),
|
||||
doubleClickQueueAll: z.boolean(),
|
||||
externalLinks: z.boolean(),
|
||||
followSystemTheme: z.boolean(),
|
||||
genreTarget: GenreTargetSchema,
|
||||
homeFeature: z.boolean(),
|
||||
homeItems: z.array(SortableItemSchema(HomeItemSchema)),
|
||||
language: z.string(),
|
||||
lastFM: z.boolean(),
|
||||
lastfmApiKey: z.string(),
|
||||
musicBrainz: z.boolean(),
|
||||
nativeAspectRatio: z.boolean(),
|
||||
passwordStore: z.string().optional(),
|
||||
playButtonBehavior: z.nativeEnum(Play),
|
||||
playerbarOpenDrawer: z.boolean(),
|
||||
resume: z.boolean(),
|
||||
showQueueDrawerButton: z.boolean(),
|
||||
sidebarCollapsedNavigation: z.boolean(),
|
||||
sidebarCollapseShared: z.boolean(),
|
||||
sidebarItems: z.array(SidebarItemTypeSchema),
|
||||
sidebarPlaylistList: z.boolean(),
|
||||
sideQueueType: SideQueueTypeSchema,
|
||||
skipButtons: SkipButtonsSchema,
|
||||
theme: z.nativeEnum(AppTheme),
|
||||
themeDark: z.nativeEnum(AppTheme),
|
||||
themeLight: z.nativeEnum(AppTheme),
|
||||
volumeWheelStep: z.number(),
|
||||
volumeWidth: z.number(),
|
||||
zoomFactor: z.number(),
|
||||
});
|
||||
|
||||
const HotkeyBindingSchema = z.object({
|
||||
allowGlobal: z.boolean(),
|
||||
hotkey: z.string(),
|
||||
isGlobal: z.boolean(),
|
||||
});
|
||||
|
||||
const HotkeysSettingsSchema = z.object({
|
||||
bindings: z
|
||||
.record(BindingActionsSchema, HotkeyBindingSchema)
|
||||
.refine((obj): obj is Required<typeof obj> =>
|
||||
BindingActionsSchema.options.every((key) => obj[key] != null),
|
||||
),
|
||||
globalMediaHotkeys: z.boolean(),
|
||||
});
|
||||
|
||||
const LyricsSettingsSchema = z.object({
|
||||
alignment: z.enum(['center', 'left', 'right']),
|
||||
delayMs: z.number(),
|
||||
enableNeteaseTranslation: z.boolean(),
|
||||
fetch: z.boolean(),
|
||||
follow: z.boolean(),
|
||||
fontSize: z.number(),
|
||||
fontSizeUnsync: z.number(),
|
||||
gap: z.number(),
|
||||
gapUnsync: z.number(),
|
||||
preferLocalLyrics: z.boolean(),
|
||||
showMatch: z.boolean(),
|
||||
showProvider: z.boolean(),
|
||||
sources: z.array(z.nativeEnum(LyricSource)),
|
||||
translationApiKey: z.string(),
|
||||
translationApiProvider: z.string().nullable(),
|
||||
translationTargetLanguage: z.string().nullable(),
|
||||
});
|
||||
|
||||
const ScrobbleSettingsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
notify: z.boolean(),
|
||||
scrobbleAtDuration: z.number(),
|
||||
scrobbleAtPercentage: z.number(),
|
||||
});
|
||||
|
||||
const PlaybackSettingsSchema = z.object({
|
||||
audioDeviceId: z.string().nullable().optional(),
|
||||
crossfadeDuration: z.number(),
|
||||
crossfadeStyle: z.nativeEnum(CrossfadeStyle),
|
||||
mediaSession: z.boolean(),
|
||||
mpvExtraParameters: z.array(z.string()),
|
||||
mpvProperties: MpvSettingsSchema,
|
||||
muted: z.boolean(),
|
||||
preservePitch: z.boolean(),
|
||||
scrobble: ScrobbleSettingsSchema,
|
||||
style: z.nativeEnum(PlaybackStyle),
|
||||
transcode: TranscodingConfigSchema,
|
||||
type: z.nativeEnum(PlaybackType),
|
||||
webAudio: z.boolean(),
|
||||
});
|
||||
|
||||
const RemoteSettingsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
password: z.string(),
|
||||
port: z.number(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
const TablesSettingsSchema = z.object({
|
||||
albumDetail: DataTablePropsSchema,
|
||||
fullScreen: DataTablePropsSchema,
|
||||
nowPlaying: DataTablePropsSchema,
|
||||
sideDrawerQueue: DataTablePropsSchema,
|
||||
sideQueue: DataTablePropsSchema,
|
||||
songs: DataTablePropsSchema,
|
||||
});
|
||||
|
||||
const WindowSettingsSchema = z.object({
|
||||
disableAutoUpdate: z.boolean(),
|
||||
exitToTray: z.boolean(),
|
||||
minimizeToTray: z.boolean(),
|
||||
preventSleepOnPlayback: z.boolean(),
|
||||
releaseChannel: z.enum(['beta', 'latest']),
|
||||
startMinimized: z.boolean(),
|
||||
tray: z.boolean(),
|
||||
windowBarStyle: z.nativeEnum(Platform),
|
||||
});
|
||||
|
||||
/**
|
||||
* This schema is used for validation of the imported settings json
|
||||
*/
|
||||
export const ValidationSettingsStateSchema = z.object({
|
||||
css: CssSettingsSchema,
|
||||
discord: DiscordSettingsSchema,
|
||||
font: FontSettingsSchema,
|
||||
general: GeneralSettingsSchema,
|
||||
hotkeys: HotkeysSettingsSchema,
|
||||
lyrics: LyricsSettingsSchema,
|
||||
playback: PlaybackSettingsSchema,
|
||||
remote: RemoteSettingsSchema,
|
||||
tab: z.union([
|
||||
z.literal('general'),
|
||||
z.literal('hotkeys'),
|
||||
z.literal('playback'),
|
||||
z.literal('window'),
|
||||
z.string(),
|
||||
]),
|
||||
window: WindowSettingsSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* This schema is merged below to create the full SettingsSchema but not used during import validation
|
||||
*/
|
||||
export const NonValidatedSettingsStateSchema = z.object({
|
||||
tables: TablesSettingsSchema,
|
||||
});
|
||||
|
||||
export const SettingsStateSchema = ValidationSettingsStateSchema.merge(
|
||||
NonValidatedSettingsStateSchema,
|
||||
);
|
||||
|
||||
export enum ArtistItem {
|
||||
BIOGRAPHY = 'biography',
|
||||
COMPILATIONS = 'compilations',
|
||||
RECENT_ALBUMS = 'recentAlbums',
|
||||
SIMILAR_ARTISTS = 'similarArtists',
|
||||
TOP_SONGS = 'topSongs',
|
||||
}
|
||||
|
||||
export enum BindingActions {
|
||||
BROWSER_BACK = 'browserBack',
|
||||
BROWSER_FORWARD = 'browserForward',
|
||||
FAVORITE_CURRENT_ADD = 'favoriteCurrentAdd',
|
||||
FAVORITE_CURRENT_REMOVE = 'favoriteCurrentRemove',
|
||||
FAVORITE_CURRENT_TOGGLE = 'favoriteCurrentToggle',
|
||||
FAVORITE_PREVIOUS_ADD = 'favoritePreviousAdd',
|
||||
FAVORITE_PREVIOUS_REMOVE = 'favoritePreviousRemove',
|
||||
FAVORITE_PREVIOUS_TOGGLE = 'favoritePreviousToggle',
|
||||
GLOBAL_SEARCH = 'globalSearch',
|
||||
LOCAL_SEARCH = 'localSearch',
|
||||
MUTE = 'volumeMute',
|
||||
NAVIGATE_HOME = 'navigateHome',
|
||||
NEXT = 'next',
|
||||
PAUSE = 'pause',
|
||||
PLAY = 'play',
|
||||
PLAY_PAUSE = 'playPause',
|
||||
PREVIOUS = 'previous',
|
||||
RATE_0 = 'rate0',
|
||||
RATE_1 = 'rate1',
|
||||
RATE_2 = 'rate2',
|
||||
RATE_3 = 'rate3',
|
||||
RATE_4 = 'rate4',
|
||||
RATE_5 = 'rate5',
|
||||
SHUFFLE = 'toggleShuffle',
|
||||
SKIP_BACKWARD = 'skipBackward',
|
||||
SKIP_FORWARD = 'skipForward',
|
||||
STOP = 'stop',
|
||||
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
|
||||
TOGGLE_QUEUE = 'toggleQueue',
|
||||
TOGGLE_REPEAT = 'toggleRepeat',
|
||||
VOLUME_DOWN = 'volumeDown',
|
||||
VOLUME_UP = 'volumeUp',
|
||||
ZOOM_IN = 'zoomIn',
|
||||
ZOOM_OUT = 'zoomOut',
|
||||
}
|
||||
|
||||
export enum DiscordDisplayType {
|
||||
ARTIST_NAME = 'artist',
|
||||
FEISHIN = 'feishin',
|
||||
SONG_NAME = 'song',
|
||||
}
|
||||
|
||||
export enum DiscordLinkType {
|
||||
LAST_FM = 'last_fm',
|
||||
MBZ = 'musicbrainz',
|
||||
MBZ_LAST_FM = 'musicbrainz_last_fm',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum GenreTarget {
|
||||
ALBUM = 'album',
|
||||
TRACK = 'track',
|
||||
}
|
||||
|
||||
export enum HomeItem {
|
||||
MOST_PLAYED = 'mostPlayed',
|
||||
RANDOM = 'random',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||
RECENTLY_RELEASED = 'recentlyReleased',
|
||||
}
|
||||
|
||||
export type DataTableProps = z.infer<typeof DataTablePropsSchema>;
|
||||
|
||||
export type PersistedTableColumn = z.infer<typeof PersistedTableColumnSchema>;
|
||||
|
||||
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
||||
actions: {
|
||||
reset: () => void;
|
||||
resetSampleRate: () => void;
|
||||
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
|
||||
setGenreBehavior: (target: GenreTarget) => void;
|
||||
setHomeItems: (item: SortableItem<HomeItem>[]) => void;
|
||||
setSettings: (data: Partial<SettingsState>) => void;
|
||||
setSidebarItems: (items: SidebarItemType[]) => void;
|
||||
setTable: (type: TableType, data: DataTableProps) => void;
|
||||
setTranscodingConfig: (config: TranscodingConfig) => void;
|
||||
toggleContextMenuItem: (item: ContextMenuItemType) => void;
|
||||
toggleMediaSession: () => void;
|
||||
toggleSidebarCollapseShare: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
||||
|
||||
export type SidebarItemType = z.infer<typeof SidebarItemTypeSchema>;
|
||||
|
||||
export type SideQueueType = z.infer<typeof SideQueueTypeSchema>;
|
||||
|
||||
export type SortableItem<T> = {
|
||||
disabled: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
route: AppRoute | string;
|
||||
id: T;
|
||||
};
|
||||
|
||||
export type TranscodingConfig = z.infer<typeof TranscodingConfigSchema>;
|
||||
|
||||
export type VersionedSettings = SettingsState & { version: number };
|
||||
|
||||
export const sidebarItems: SidebarItemType[] = [
|
||||
{
|
||||
disabled: true,
|
||||
|
|
@ -91,276 +489,16 @@ export const sidebarItems: SidebarItemType[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export enum HomeItem {
|
||||
MOST_PLAYED = 'mostPlayed',
|
||||
RANDOM = 'random',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||
RECENTLY_RELEASED = 'recentlyReleased',
|
||||
}
|
||||
|
||||
export type SortableItem<T> = {
|
||||
disabled: boolean;
|
||||
id: T;
|
||||
};
|
||||
|
||||
const homeItems = Object.values(HomeItem).map((item) => ({
|
||||
disabled: false,
|
||||
id: item,
|
||||
}));
|
||||
|
||||
export enum ArtistItem {
|
||||
BIOGRAPHY = 'biography',
|
||||
COMPILATIONS = 'compilations',
|
||||
RECENT_ALBUMS = 'recentAlbums',
|
||||
SIMILAR_ARTISTS = 'similarArtists',
|
||||
TOP_SONGS = 'topSongs',
|
||||
}
|
||||
|
||||
const artistItems = Object.values(ArtistItem).map((item) => ({
|
||||
disabled: false,
|
||||
id: item,
|
||||
}));
|
||||
|
||||
export enum BindingActions {
|
||||
BROWSER_BACK = 'browserBack',
|
||||
BROWSER_FORWARD = 'browserForward',
|
||||
FAVORITE_CURRENT_ADD = 'favoriteCurrentAdd',
|
||||
FAVORITE_CURRENT_REMOVE = 'favoriteCurrentRemove',
|
||||
FAVORITE_CURRENT_TOGGLE = 'favoriteCurrentToggle',
|
||||
FAVORITE_PREVIOUS_ADD = 'favoritePreviousAdd',
|
||||
FAVORITE_PREVIOUS_REMOVE = 'favoritePreviousRemove',
|
||||
FAVORITE_PREVIOUS_TOGGLE = 'favoritePreviousToggle',
|
||||
GLOBAL_SEARCH = 'globalSearch',
|
||||
LOCAL_SEARCH = 'localSearch',
|
||||
MUTE = 'volumeMute',
|
||||
NAVIGATE_HOME = 'navigateHome',
|
||||
NEXT = 'next',
|
||||
PAUSE = 'pause',
|
||||
PLAY = 'play',
|
||||
PLAY_PAUSE = 'playPause',
|
||||
PREVIOUS = 'previous',
|
||||
RATE_0 = 'rate0',
|
||||
RATE_1 = 'rate1',
|
||||
RATE_2 = 'rate2',
|
||||
RATE_3 = 'rate3',
|
||||
RATE_4 = 'rate4',
|
||||
RATE_5 = 'rate5',
|
||||
SHUFFLE = 'toggleShuffle',
|
||||
SKIP_BACKWARD = 'skipBackward',
|
||||
SKIP_FORWARD = 'skipForward',
|
||||
STOP = 'stop',
|
||||
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
|
||||
TOGGLE_QUEUE = 'toggleQueue',
|
||||
TOGGLE_REPEAT = 'toggleRepeat',
|
||||
VOLUME_DOWN = 'volumeDown',
|
||||
VOLUME_UP = 'volumeUp',
|
||||
ZOOM_IN = 'zoomIn',
|
||||
ZOOM_OUT = 'zoomOut',
|
||||
}
|
||||
|
||||
export enum DiscordDisplayType {
|
||||
ARTIST_NAME = 'artist',
|
||||
FEISHIN = 'feishin',
|
||||
SONG_NAME = 'song',
|
||||
}
|
||||
|
||||
export enum DiscordLinkType {
|
||||
LAST_FM = 'last_fm',
|
||||
MBZ = 'musicbrainz',
|
||||
MBZ_LAST_FM = 'musicbrainz_last_fm',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum GenreTarget {
|
||||
ALBUM = 'album',
|
||||
TRACK = 'track',
|
||||
}
|
||||
|
||||
export type DataTableProps = {
|
||||
autoFit: boolean;
|
||||
columns: PersistedTableColumn[];
|
||||
followCurrentSong?: boolean;
|
||||
rowHeight: number;
|
||||
};
|
||||
|
||||
export type PersistedTableColumn = {
|
||||
column: TableColumn;
|
||||
extraProps?: Partial<ColDef>;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export interface SettingsSlice extends SettingsState {
|
||||
actions: {
|
||||
reset: () => void;
|
||||
resetSampleRate: () => void;
|
||||
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
|
||||
setGenreBehavior: (target: GenreTarget) => void;
|
||||
setHomeItems: (item: SortableItem<HomeItem>[]) => void;
|
||||
setSettings: (data: Partial<SettingsState>) => void;
|
||||
setSidebarItems: (items: SidebarItemType[]) => void;
|
||||
setTable: (type: TableType, data: DataTableProps) => void;
|
||||
setTranscodingConfig: (config: TranscodingConfig) => void;
|
||||
toggleContextMenuItem: (item: ContextMenuItemType) => void;
|
||||
toggleMediaSession: () => void;
|
||||
toggleSidebarCollapseShare: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
css: {
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
discord: {
|
||||
clientId: string;
|
||||
displayType: DiscordDisplayType;
|
||||
enabled: boolean;
|
||||
linkType: DiscordLinkType;
|
||||
showAsListening: boolean;
|
||||
showPaused: boolean;
|
||||
showServerImage: boolean;
|
||||
};
|
||||
font: {
|
||||
builtIn: string;
|
||||
custom: null | string;
|
||||
system: null | string;
|
||||
type: FontType;
|
||||
};
|
||||
general: {
|
||||
accent: string;
|
||||
albumArtRes?: null | number;
|
||||
albumBackground: boolean;
|
||||
albumBackgroundBlur: number;
|
||||
artistBackground: boolean;
|
||||
artistBackgroundBlur: number;
|
||||
artistItems: SortableItem<ArtistItem>[];
|
||||
buttonSize: number;
|
||||
disabledContextMenu: { [k in ContextMenuItemType]?: boolean };
|
||||
doubleClickQueueAll: boolean;
|
||||
externalLinks: boolean;
|
||||
followSystemTheme: boolean;
|
||||
genreTarget: GenreTarget;
|
||||
homeFeature: boolean;
|
||||
homeItems: SortableItem<HomeItem>[];
|
||||
language: string;
|
||||
lastFM: boolean;
|
||||
lastfmApiKey: string;
|
||||
musicBrainz: boolean;
|
||||
nativeAspectRatio: boolean;
|
||||
passwordStore?: string;
|
||||
playButtonBehavior: Play;
|
||||
playerbarOpenDrawer: boolean;
|
||||
resume: boolean;
|
||||
showQueueDrawerButton: boolean;
|
||||
sidebarCollapsedNavigation: boolean;
|
||||
sidebarCollapseShared: boolean;
|
||||
sidebarItems: SidebarItemType[];
|
||||
sidebarPlaylistList: boolean;
|
||||
sideQueueType: SideQueueType;
|
||||
skipButtons: {
|
||||
enabled: boolean;
|
||||
skipBackwardSeconds: number;
|
||||
skipForwardSeconds: number;
|
||||
};
|
||||
theme: AppTheme;
|
||||
themeDark: AppTheme;
|
||||
themeLight: AppTheme;
|
||||
volumeWheelStep: number;
|
||||
volumeWidth: number;
|
||||
zoomFactor: number;
|
||||
};
|
||||
hotkeys: {
|
||||
bindings: Record<
|
||||
BindingActions,
|
||||
{ allowGlobal: boolean; hotkey: string; isGlobal: boolean }
|
||||
>;
|
||||
globalMediaHotkeys: boolean;
|
||||
};
|
||||
lyrics: {
|
||||
alignment: 'center' | 'left' | 'right';
|
||||
delayMs: number;
|
||||
enableNeteaseTranslation: boolean;
|
||||
fetch: boolean;
|
||||
follow: boolean;
|
||||
fontSize: number;
|
||||
fontSizeUnsync: number;
|
||||
gap: number;
|
||||
gapUnsync: number;
|
||||
preferLocalLyrics: boolean;
|
||||
showMatch: boolean;
|
||||
showProvider: boolean;
|
||||
sources: LyricSource[];
|
||||
translationApiKey: string;
|
||||
translationApiProvider: null | string;
|
||||
translationTargetLanguage: null | string;
|
||||
};
|
||||
playback: {
|
||||
audioDeviceId?: null | string;
|
||||
crossfadeDuration: number;
|
||||
crossfadeStyle: CrossfadeStyle;
|
||||
mediaSession: boolean;
|
||||
mpvExtraParameters: string[];
|
||||
mpvProperties: MpvSettings;
|
||||
muted: boolean;
|
||||
preservePitch: boolean;
|
||||
scrobble: {
|
||||
enabled: boolean;
|
||||
notify: boolean;
|
||||
scrobbleAtDuration: number;
|
||||
scrobbleAtPercentage: number;
|
||||
};
|
||||
style: PlaybackStyle;
|
||||
transcode: TranscodingConfig;
|
||||
type: PlaybackType;
|
||||
webAudio: boolean;
|
||||
};
|
||||
remote: {
|
||||
enabled: boolean;
|
||||
password: string;
|
||||
port: number;
|
||||
username: string;
|
||||
};
|
||||
tab: 'general' | 'hotkeys' | 'playback' | 'window' | string;
|
||||
tables: {
|
||||
albumDetail: DataTableProps;
|
||||
fullScreen: DataTableProps;
|
||||
nowPlaying: DataTableProps;
|
||||
sideDrawerQueue: DataTableProps;
|
||||
sideQueue: DataTableProps;
|
||||
songs: DataTableProps;
|
||||
};
|
||||
window: {
|
||||
disableAutoUpdate: boolean;
|
||||
exitToTray: boolean;
|
||||
minimizeToTray: boolean;
|
||||
preventSleepOnPlayback: boolean;
|
||||
releaseChannel: 'beta' | 'latest';
|
||||
startMinimized: boolean;
|
||||
tray: boolean;
|
||||
windowBarStyle: Platform;
|
||||
};
|
||||
}
|
||||
|
||||
export type SideQueueType = 'sideDrawerQueue' | 'sideQueue';
|
||||
|
||||
export type TranscodingConfig = {
|
||||
bitrate?: number;
|
||||
enabled: boolean;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
type MpvSettings = {
|
||||
audioExclusiveMode: 'no' | 'yes';
|
||||
audioFormat?: 'float' | 's16' | 's32';
|
||||
audioSampleRateHz?: number;
|
||||
gaplessAudio: 'no' | 'weak' | 'yes';
|
||||
replayGainClip: boolean;
|
||||
replayGainFallbackDB?: number;
|
||||
replayGainMode: 'album' | 'no' | 'track';
|
||||
replayGainPreampDB?: number;
|
||||
};
|
||||
|
||||
// Determines the default/initial windowBarStyle value based on the current platform.
|
||||
const getPlatformDefaultWindowBarStyle = (): Platform => {
|
||||
// Prefer native window bar
|
||||
|
|
@ -774,9 +912,10 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||
{
|
||||
merge: mergeOverridingColumns,
|
||||
migrate(persistedState, version) {
|
||||
const state = persistedState as SettingsSlice;
|
||||
console.log('migrate: ', version);
|
||||
|
||||
if (version === 8) {
|
||||
const state = persistedState as SettingsSlice;
|
||||
state.general.sidebarItems = state.general.sidebarItems.filter(
|
||||
(item) => item.id !== 'Folders',
|
||||
);
|
||||
|
|
@ -789,7 +928,23 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||
}
|
||||
|
||||
if (version <= 9) {
|
||||
const state = persistedState as SettingsSlice;
|
||||
if (!state.window.releaseChannel) {
|
||||
state.window.releaseChannel = initialState.window.releaseChannel;
|
||||
}
|
||||
|
||||
if (!state.playback.mediaSession) {
|
||||
state.playback.mediaSession = initialState.playback.mediaSession;
|
||||
}
|
||||
|
||||
if (!state.general.artistBackgroundBlur) {
|
||||
state.general.artistBackgroundBlur =
|
||||
initialState.general.artistBackgroundBlur;
|
||||
}
|
||||
|
||||
if (!state.general.artistBackground) {
|
||||
state.general.artistBackground = initialState.general.artistBackground;
|
||||
}
|
||||
|
||||
state.window.windowBarStyle = Platform.LINUX;
|
||||
}
|
||||
|
||||
|
|
@ -840,3 +995,18 @@ export const useFontSettings = () => useSettingsStore((state) => state.font, sha
|
|||
export const useDiscordSettings = () => useSettingsStore((state) => state.discord, shallow);
|
||||
|
||||
export const useCssSettings = () => useSettingsStore((state) => state.css, shallow);
|
||||
|
||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||
|
||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||
useSettingsStore((state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- actions needs to be omitted from the export as it contains store functions
|
||||
const { actions, ...otherSettings } = state;
|
||||
return {
|
||||
...otherSettings,
|
||||
version: getSettingsStoreVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
export const migrateSettings = (settings: SettingsState, settingsVersion: number): SettingsState =>
|
||||
useSettingsStore.persist.getOptions().migrate!(settings, settingsVersion) as SettingsState;
|
||||
|
|
|
|||
22
src/renderer/types/fonts.ts
Normal file
22
src/renderer/types/fonts.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export type Font = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export 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' },
|
||||
];
|
||||
|
||||
export const FontValueSchema = z.enum(
|
||||
FONT_OPTIONS.map((option) => option.value) as [string, ...string[]],
|
||||
);
|
||||
131
src/shared/components/drag-drop-zone/drag-drop-zone.tsx
Normal file
131
src/shared/components/drag-drop-zone/drag-drop-zone.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { t } from 'i18next';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
interface DragDropZoneProps {
|
||||
icon: keyof typeof AppIcon;
|
||||
onItemSelected: (contents: string) => void;
|
||||
validateItem?: (contents: string) => { error?: string; isValid: boolean };
|
||||
}
|
||||
|
||||
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
|
||||
const zoneFileInput = useRef<HTMLInputElement | null>();
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const processItem = useCallback(
|
||||
(itemContents: string) => {
|
||||
const { error: validationError, isValid } = validateItem
|
||||
? validateItem(itemContents)
|
||||
: { isValid: true };
|
||||
|
||||
if (validationError || !isValid) {
|
||||
setError(validationError!);
|
||||
return;
|
||||
}
|
||||
|
||||
onItemSelected(itemContents);
|
||||
},
|
||||
[onItemSelected, validateItem],
|
||||
);
|
||||
|
||||
const onItemDropped = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const items = event.dataTransfer.items;
|
||||
|
||||
if (items.length > 1) {
|
||||
setError(t('dragDropZone.error_oneFileOnly'));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = items[0].getAsFile();
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.text()
|
||||
.then((value) => processItem(value.toString()))
|
||||
.catch((err) => {
|
||||
const error = err as Error;
|
||||
setError(
|
||||
t('dragDropZone.error_readingFile', {
|
||||
errorMessage: error.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
[processItem],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
|
||||
const onZoneClick = useCallback(() => {
|
||||
zoneFileInput.current?.click();
|
||||
}, []);
|
||||
|
||||
const onZoneInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
|
||||
if (!files || files.length > 1) {
|
||||
setError(t('dragDropZone.error_oneFileOnly'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', (event) => {
|
||||
const contents = event.target?.result;
|
||||
|
||||
if (!contents) {
|
||||
return;
|
||||
}
|
||||
|
||||
processItem(contents.toString());
|
||||
});
|
||||
|
||||
reader.readAsText(files[0]);
|
||||
},
|
||||
[processItem],
|
||||
);
|
||||
|
||||
const hasErrored = error.length > 0;
|
||||
const borderColour = hasErrored ? 'red' : 'grey';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
bd={`2px dashed ${borderColour}`}
|
||||
bdrs={'sm'}
|
||||
direction="column"
|
||||
gap={'sm'}
|
||||
justify="center"
|
||||
onClick={onZoneClick}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onItemDropped}
|
||||
p="sm"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Icon icon={icon} size="3xl" />
|
||||
<Text>{t('dragDropZone.mainText').toString()}</Text>
|
||||
{hasErrored ? (
|
||||
<Text c="red" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<input
|
||||
onChange={onZoneInputChange}
|
||||
ref={(self) => (zoneFileInput.current = self)}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
|
@ -35,6 +35,7 @@ import {
|
|||
LuEllipsis,
|
||||
LuEllipsisVertical,
|
||||
LuExternalLink,
|
||||
LuFileJson,
|
||||
LuFlag,
|
||||
LuFolderOpen,
|
||||
LuGauge,
|
||||
|
|
@ -146,6 +147,7 @@ export const AppIcon = {
|
|||
error: LuShieldAlert,
|
||||
externalLink: LuExternalLink,
|
||||
favorite: LuHeart,
|
||||
fileJson: LuFileJson,
|
||||
filter: LuListFilter,
|
||||
folder: LuFolderOpen,
|
||||
genre: LuFlag,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue