Add localization support (#333)

* Add updated i18n config and en locale
This commit is contained in:
Jeff 2023-10-30 19:22:45 -07:00 committed by GitHub
parent 11863fd4c1
commit 8430b1ec95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 2679 additions and 908 deletions

38
package-lock.json generated
View File

@ -42,7 +42,7 @@
"framer-motion": "^10.13.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.6.16",
"i18next": "^21.10.0",
"idb-keyval": "^6.2.1",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
@ -57,7 +57,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-i18next": "^11.18.6",
"react-icons": "^4.10.1",
"react-player": "^2.11.0",
"react-router": "^6.16.0",
@ -125,7 +125,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"i18next-parser": "^6.6.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
@ -11871,7 +11871,8 @@
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/html-minifier-terser": {
"version": "6.1.0",
@ -12129,9 +12130,9 @@
}
},
"node_modules/i18next": {
"version": "21.6.16",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz",
"integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==",
"version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
"integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==",
"funding": [
{
"type": "individual",
@ -16968,12 +16969,11 @@
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"node_modules/react-i18next": {
"version": "11.16.7",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz",
"integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==",
"version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
"integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==",
"dependencies": {
"@babel/runtime": "^7.14.5",
"html-escaper": "^2.0.2",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
@ -30233,7 +30233,8 @@
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"html-minifier-terser": {
"version": "6.1.0",
@ -30419,9 +30420,9 @@
"dev": true
},
"i18next": {
"version": "21.6.16",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz",
"integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==",
"version": "21.10.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz",
"integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==",
"requires": {
"@babel/runtime": "^7.17.2"
}
@ -33952,12 +33953,11 @@
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"react-i18next": {
"version": "11.16.7",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz",
"integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==",
"version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
"integrity": "sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==",
"requires": {
"@babel/runtime": "^7.14.5",
"html-escaper": "^2.0.2",
"html-parse-stringify": "^3.0.1"
}
},

View File

@ -26,7 +26,7 @@
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
@ -252,7 +252,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"i18next-parser": "^6.6.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
@ -321,7 +321,7 @@
"framer-motion": "^10.13.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.6.16",
"i18next": "^21.10.0",
"idb-keyval": "^6.2.1",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
@ -336,7 +336,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-i18next": "^11.18.6",
"react-icons": "^4.10.1",
"react-player": "^2.11.0",
"react-router": "^6.16.0",

View File

@ -1,32 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const en = require('./locales/en.json');
const resources = {
en: { translation: en },
};
export const Languages = [
{
label: 'English',
value: 'en',
},
];
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
lng: 'en',
resources,
});
export default i18n;

72
src/i18n/i18n.ts Normal file
View File

@ -0,0 +1,72 @@
import { PostProcessorModule } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
const resources = {
en: { translation: en },
};
export const languages = [
{
label: 'English',
value: 'en',
},
];
const lowerCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'lowerCase',
process: (value: string) => {
return value.toLocaleLowerCase();
},
};
const upperCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'upperCase',
process: (value: string) => {
return value.toLocaleUpperCase();
},
};
const titleCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'titleCase',
process: (value: string) => {
return value.replace(/\w\S*/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
});
},
};
const sentenceCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'sentenceCase',
process: (value: string) => {
const sentences = value.split('. ');
return sentences
.map((sentence) => {
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLocaleLowerCase();
})
.join('. ');
},
};
i18n.use(lowerCasePostProcessor)
.use(upperCasePostProcessor)
.use(titleCasePostProcessor)
.use(sentenceCasePostProcessor)
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
resources,
});
export default i18n;

View File

@ -1,117 +1,44 @@
// i18next-parser.config.js
// Reference: https://github.com/i18next/i18next-parser#options
module.exports = {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: '',
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
i18nextOptions: null,
// Default value to give to empty keys
// You may also specify a function accepting the locale, namespace, and key as arguments
indentation: 2,
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
input: [
'../components/**/*.{js,jsx,ts,tsx}',
'../features/**/*.{js,jsx,ts,tsx}',
'../layouts/**/*.{js,jsx,ts,tsx}',
'!../../src/node_modules/**',
'!../../src/**/*.prod.js',
],
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
keySeparator: '.',
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
// if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en'],
// An array of the locales in your applications
namespaceSeparator: false,
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'src/renderer/i18n/locales/$LOCALE.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", //
// }
resetDefaultValueLocale: 'en',
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
skipDefaultValues: false,
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: true,
// Whether to ignore default values
// You may also specify a function accepting the locale and namespace as arguments
useKeysAsDefaultValue: true,
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
// You may also specify a function accepting the locale and namespace as arguments
verbose: false,
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
contextSeparator: '_',
createOldCatalogs: true,
customValueTemplate: null,
defaultNamespace: 'translation',
defaultValue: '',
failOnUpdate: false,
failOnWarnings: false,
i18nextOptions: null,
indentation: 4,
input: [
'../renderer/components/**/*.{js,jsx,ts,tsx}',
'../renderer/features/**/*.{js,jsx,ts,tsx}',
'../renderer/layouts/**/*.{js,jsx,ts,tsx}',
'!../src/node_modules/**',
'!../src/**/*.prod.js',
],
keepRemoved: false,
keySeparator: '.',
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
locales: ['en'],
namespaceSeparator: false,
output: 'src/renderer/i18n/locales/$LOCALE.json',
pluralSeparator: '_',
resetDefaultValueLocale: 'en',
skipDefaultValues: false,
sort: true,
useKeysAsDefaultValue: true,
verbose: false,
};

View File

@ -1,9 +1,606 @@
{
"player": {
"next": "player.next",
"play": "player.play",
"prev": "player.prev",
"seekBack": "player.seekBack",
"seekForward": "player.seekForward"
}
"action": {
"addToFavorites": "add to $t(entity.favorite_other)",
"addToPlaylist": "add to $t(entity.playlist_one)",
"clearQueue": "clear queue",
"createPlaylist": "create $t(entity.playlist_one)",
"deletePlaylist": "delete $t(entity.playlist_one)",
"deselectAll": "deselect all",
"editPlaylist": "edit $t(entity.playlist_one)",
"goToPage": "go to page",
"moveToBottom": "move to bottom",
"moveToTop": "move to top",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "remove from $t(entity.favorite_other)",
"removeFromPlaylist": "remove from $t(entity.playlist_one)",
"removeFromQueue": "remove from queue",
"setRating": "set rating",
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)"
},
"common": {
"action_one": "action",
"action_other": "actions",
"add": "add",
"areYouSure": "are you sure?",
"ascending": "ascending",
"backward": "backward",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"cancel": "cancel",
"center": "center",
"channel_one": "channel",
"channel_other": "channels",
"clear": "clear",
"collapse": "collapse",
"comingSoon": "coming soon...",
"configure": "configure",
"confirm": "confirm",
"create": "create",
"currentSong": "current $t(entity.track_one)",
"decrease": "decrease",
"delete": "delete",
"descending": "descending",
"description": "description",
"disable": "disable",
"disc": "disc",
"dismiss": "dismiss",
"duration": "duration",
"edit": "edit",
"enable": "enable",
"expand": "expand",
"favorite": "favorite",
"filter_one": "filter",
"filter_other": "filters",
"filters": "filters",
"forceRestartRequired": "restart to apply changes... close the notification to restart",
"forward": "forward",
"gap": "gap",
"home": "home",
"increase": "increase",
"left": "left",
"limit": "limit",
"manage": "manage",
"maximize": "maximize",
"menu": "menu",
"minimize": "minimize",
"modified": "modified",
"name": "name",
"no": "no",
"none": "none",
"noResultsFromQuery": "the query returned no results",
"note": "note",
"ok": "ok",
"owner": "owner",
"path": "path",
"playerMustBePaused": "player must be paused",
"previousSong": "previous $t(entity.track_one)",
"quit": "quit",
"random": "random",
"rating": "rating",
"refresh": "refresh",
"reset": "reset",
"resetToDefault": "reset to default",
"restartRequired": "restart required",
"right": "right",
"save": "save",
"saveAndReplace": "save and replace",
"saveAs": "save as",
"search": "search",
"setting": "setting",
"setting_other": "settings",
"size": "size",
"sortOrder": "order",
"title": "title",
"trackNumber": "track",
"unknown": "unknown",
"version": "version",
"year": "year",
"yes": "yes"
},
"entity": {
"album_one": "album",
"album_other": "albums",
"albumArtist_one": "album artist",
"albumArtist_other": "album artists",
"albumArtistCount_one": "{{count}} album artist",
"albumArtistCount_other": "{{count}} album artists",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"artist_one": "artist",
"artist_other": "artists",
"artistWithCount_one": "{{count}} artist",
"artistWithCount_other": "{{count}} artists",
"favorite_one": "favorite",
"favorite_other": "favorites",
"folder_one": "folder",
"folder_other": "folders",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"genre_one": "genre",
"genre_other": "genres",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genres",
"playlist_one": "playlist",
"playlist_other": "playlists",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_other": "{{count}} playlists",
"smartPlaylist": "smart $t(entity.playlist_one)",
"track_one": "track",
"track_other": "tracks",
"trackWithCount_one": "{{count}} track",
"trackWithCount_other": "{{count}} tracks"
},
"error": {
"apiRouteError": "unable to route request",
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
"authenticationFailed": "authentication failed",
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint} is not implemented for {{serverType}}",
"genericError": "an error occurred",
"invalidServer": "invalid server",
"localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds",
"mpvRequired": "MPV required",
"playbackError": "an error occurred when trying to play the media",
"remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server",
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
"remotePortError": "an error occurred when trying to set the remote server port",
"remotePortWarning": "restart the server to apply the new port",
"serverNotSelectedError": "no server selected",
"serverRequired": "server required",
"sessionExpiredError": "your session has expired",
"systemFontError": "an error occurred when trying to get system fonts"
},
"filter": {
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"communityRating": "community rating",
"criticRating": "critic rating",
"dateAdded": "date added",
"disc": "disc",
"duration": "duration",
"favorited": "favorited",
"fromYear": "from year",
"isCompilation": "is compilation",
"isFavorited": "is favorited",
"isRated": "is rated",
"isRecentlyPlayed": "is recently played",
"lastPlayed": "last played",
"mostPlayed": "most played",
"name": "name",
"note": "note",
"path": "path",
"playCount": "play count",
"random": "random",
"rating": "rating",
"recentlyAdded": "recently added",
"recentlyPlayed": "recently played",
"releaseDate": "release date",
"releaseYear": "release year",
"search": "search",
"songCount": "song count",
"title": "title",
"toYear": "to year",
"trackNumber": "track"
},
"form": {
"addServer": {
"error_savePassword": "an error occurred when trying to save the password",
"ignoreCors": "ignore cors ($t(common.restartRequired))",
"ignoreSsl": "ignore ssl ($t(common.restartRequired))",
"input_legacyAuthentication": "enable legacy authentication",
"input_name": "server name",
"input_password": "password",
"input_savePassword": "save password",
"input_url": "url",
"input_username": "username",
"success": "server added successfully",
"title": "add server"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "skip duplicates",
"success": "added {{message}} $t(entity.song_other) to {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "add to $t(entity.playlist_one)"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "public",
"success": "$t(entity.playlist_one) created successfully",
"title": "create $t(entity.playlist_one)"
},
"deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist_one) to confirm",
"success": "$t(entity.playlist_one) deleted successfully",
"title": "delete $t(entity.playlist_one)"
},
"editPlaylist": {
"title": "edit $t(entity.playlist_one)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)",
"title": "lyric search"
},
"queryEditor": {
"input_optionMatchAll": "match all",
"input_optionMatchAny": "match any"
},
"updateServer": {
"success": "server updated successfully",
"title": "update server"
}
},
"page": {
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumDetail": {
"moreFromArtist": "more from this $t(entity.genre_one)",
"moreFromGeneric": "more from {{item}}"
},
"albumList": {
"title": "$t(entity.album_other)"
},
"appMenu": {
"collapseSidebar": "collapse sidebar",
"expandSidebar": "expand sidebar",
"goBack": "go back",
"goForward": "go forward",
"manageServers": "manage servers",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"selectServer": "select server",
"settings": "$t(common.setting_other)",
"version": "version {{version}}"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} selected",
"play": "$t(player.play)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "dynamic background",
"followCurrentLyric": "follow current lyric",
"lyricAlignment": "lyric alignment",
"lyricGap": "lyric gap",
"lyricSize": "lyric size",
"opacity": "opacity",
"showLyricMatch": "show lyric match",
"showLyricProvider": "show lyric provider",
"synchronized": "synchronized",
"unsynchronized": "unsynchronized",
"useImageAspectRatio": "use image aspect ratio"
},
"lyrics": "lyrics",
"related": "related",
"upNext": "up next"
},
"genreList": {
"title": "$t(entity.genre_other)"
},
"globalSearch": {
"commands": {
"goToPage": "go to page",
"searchFor": "search for {{query}}",
"serverCommands": "server commands"
},
"title": "commands"
},
"home": {
"explore": "explore from your library",
"mostPlayed": "most played",
"newlyAdded": "newly added releases",
"recentlyPlayed": "recently played",
"title": "$t(common.home)"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"generalTab": "general",
"hotkeysTab": "hotkeys",
"playbackTab": "playback",
"windowTab": "window"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"nowPlaying": "now playing",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(entity.setting_other)",
"tracks": "$t(entity.track_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
}
},
"player": {
"addLast": "add last",
"addNext": "add next",
"favorite": "favorite",
"mute": "mute",
"muted": "muted",
"next": "next",
"play": "play",
"playbackFetchCancel": "this is taking a while... close the notification to cancel",
"playbackFetchInProgress": "loading songs...",
"playbackFetchNoResults": "no songs found",
"playbackSpeed": "playback speed",
"playRandom": "play random",
"previous": "previous",
"queue_clear": "clear queue",
"queue_moveToBottom": "move selected to top",
"queue_moveToTop": "move selected to bottom",
"queue_remove": "remove selected",
"repeat": "repeat",
"repeat_all": "repeat all",
"repeat_off": "repeat disabled",
"repeat_one": "repeat one",
"shuffle": "shuffle",
"shuffle_off": "shuffle disabled",
"skip": "skip",
"skip_back": "skip backwards",
"skip_forward": "skip forwards",
"stop": "stop",
"toggleFullscreenPlayer": "toggle fullscreen player",
"unfavorite": "unfavorite"
},
"setting": {
"accentColor": "accent color",
"accentColor_description": "sets the accent color for the application",
"applicationHotkeys": "application hotkeys",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"audioDevice": "audio device",
"audioDevice_description": "select the audio device to use for playback (web player only)",
"audioExclusiveMode": "audio exclusive mode",
"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",
"audioPlayer_description": "select the audio player to use for playback",
"crossfadeDuration": "crossfade duration",
"crossfadeDuration_description": "sets the duration of the crossfade effect",
"crossfadeStyle": "crossfade style",
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
"customFontPath": "custom font path",
"customFontPath_description": "sets the path to the custom font to use for the application",
"disableAutomaticUpdates": "disable 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}}",
"discordIdleStatus": "show rich presence idle status",
"discordIdleStatus_description": "when enabled, update status while player is idle",
"discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableRemote": "enable remote control server",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"exitToTray": "exit to tray",
"exitToTray_description": "exit the application to the system tray",
"floatingQueueArea": "show floating queue hover area",
"floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue",
"followLyric": "follow current lyric",
"followLyric_description": "scroll the lyric to the current playing position",
"font": "font",
"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",
"fontType_optionBuiltIn": "built-in font",
"fontType_optionCustom": "custom font",
"fontType_optionSystem": "system font",
"gaplessAudio": "gapless audio",
"gaplessAudio_description": "sets the gapless audio setting for mpv",
"gaplessAudio_optionWeak": "weak (recommended)",
"globalMediaHotkeys": "global media hotkeys",
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"hotkey_browserBack": "browser back",
"hotkey_browserForward": "browser forward",
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
"hotkey_favoritePreviousSong": "favorite $t(common.previousSong)",
"hotkey_globalSearch": "global search",
"hotkey_localSearch": "in-page search",
"hotkey_playbackNext": "next track",
"hotkey_playbackPause": "pause",
"hotkey_playbackPlay": "play",
"hotkey_playbackPlayPause": "play / pause",
"hotkey_playbackPrevious": "previous track",
"hotkey_playbackStop": "stop",
"hotkey_rate0": "rating clear",
"hotkey_rate1": "rating 1 star",
"hotkey_rate2": "rating 2 stars",
"hotkey_rate3": "rating 3 stars",
"hotkey_rate4": "rating 4 stars",
"hotkey_rate5": "rating 5 stars",
"hotkey_skipBackward": "skip backward",
"hotkey_skipForward": "skip forward",
"hotkey_toggleCurrentSongFavorite": "toggle $t(common.currentSong) favorite",
"hotkey_toggleFullScreenPlayer": "toggle full screen player",
"hotkey_togglePreviousSongFavorite": "toggle $t(common.previousSong) favorite",
"hotkey_toggleQueue": "toggle queue",
"hotkey_toggleRepeat": "toggle repeat",
"hotkey_toggleShuffle": "toggle shuffle",
"hotkey_unfavoriteCurrentSong": "unfavorite $t(common.currentSong)",
"hotkey_unfavoritePreviousSong": "unfavorite $t(common.previousSong)",
"hotkey_volumeDown": "volume down",
"hotkey_volumeMute": "volume mute",
"hotkey_volumeUp": "volume up",
"hotkey_zoomIn": "zoom in",
"hotkey_zoomOut": "zoom out",
"language": "language",
"language_description": "sets the language for the application ($t(common.restartRequired))",
"lyricFetch": "fetch lyrics from the internet",
"lyricFetch_description": "fetch lyrics from various internet sources",
"lyricFetchProvider": "providers to fetch lyrics from",
"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)",
"lyricOffset_description": "offset the lyric by the specified amount of milliseconds",
"minimizeToTray": "minimize to tray",
"minimizeToTray_description": "minimize the application to the system tray",
"minimumScrobblePercentage": "minimum scrobble duration (percentage)",
"minimumScrobblePercentage_description": "the minimum percentage of the song that must be played before it is scrobbled",
"minimumScrobbleSeconds": "minimum scrobble (seconds)",
"minimumScrobbleSeconds_description": "the minimum duration in seconds of the song that must be played before it is scrobbled",
"mpvExecutablePath": "mpv executable path",
"mpvExecutablePath_description": "sets the path to the mpv executable",
"mpvExecutablePath_help": "one per line",
"mpvExtraParameters": "mpv parameters",
"playbackStyle": "playback style",
"playbackStyle_description": "select the playback style to use for the audio player",
"playbackStyle_optionCrossFade": "crossfade",
"playbackStyle_optionNormal": "normal",
"playButtonBehavior": "play button behavior",
"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)",
"remotePassword": "remote control server password",
"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",
"remotePort_description": "sets the port for the remote control server",
"remoteUsername": "remote control server username",
"remoteUsername_description": "sets the username for the remote control server. if both username and password are empty, authentication will be disabled",
"replayGainClipping": "{{ReplayGain}} clipping",
"replayGainClipping_description": "Prevent clipping caused by {{ReplayGain}} by automatically lowering the gain",
"replayGainFallback": "{{ReplayGain}} fallback",
"replayGainFallback_description": "gain in db to apply if the file has no {{ReplayGain}} tags",
"replayGainMode": "{{ReplayGain}} mode",
"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)",
"replayGainPreamp_description": "adjust the preamp gain applied to the {{ReplayGain}} values",
"sampleRate": "sample rate",
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media",
"savePlayQueue": "save play queue",
"savePlayQueue_description": "save the play queue when the application is closed and restore it when the application is opened",
"scrobble": "scrobble",
"scrobble_description": "scrobble plays to your media server",
"showSkipButton": "show skip buttons",
"showSkipButton_description": "show or hide the skip buttons on the player bar",
"showSkipButtons": "show skip buttons",
"showSkipButtons_description": "show or hide the skip buttons on the player bar",
"sidebarCollapsedNavigation": "sidebar (collapsed) navigation",
"sidebarCollapsedNavigation_description": "show or hide the navigation in the collapsed sidebar",
"sidebarConfiguration": "sidebar configuration",
"sidebarConfiguration_description": "select the items and order in which they appear in the sidebar",
"sidebarPlaylistList": "sidebar playlist list",
"sidebarPlaylistList_description": "show or hide the playlist list in the sidebar",
"sidePlayQueueStyle": "side play queue style",
"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",
"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",
"useSystemTheme": "use system theme",
"useSystemTheme_description": "follow the system-defined light or dark preference",
"volumeWheelStep": "volume wheel step",
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
"windowBarStyle": "window bar style",
"windowBarStyle_description": "select the style of the window bar",
"zoom": "zoom percentage",
"zoom_description": "sets the zoom percentage for the application"
},
"table": {
"column": {
"album": "album",
"albumArtist": "album artist",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "comment",
"dateAdded": "date added",
"discNumber": "disc",
"favorite": "favorite",
"genre": "$t(entity.genre_one)",
"lastPlayed": "last played",
"path": "path",
"playCount": "plays",
"rating": "rating",
"releaseDate": "release date",
"releaseYear": "year",
"songCount": "$t(entity.track_other)",
"title": "title",
"trackNumber": "track"
},
"config": {
"general": {
"autoFitColumns": "auto fit columns",
"displayType": "display type",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"tableColumns": "table columns"
},
"label": {
"actions": "$t(common.action_other)",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"dateAdded": "date added",
"discNumber": "disc number",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre_one)",
"lastPlayed": "last played",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"playCount": "play count",
"rating": "$t(common.rating)",
"releaseDate": "release date",
"rowIndex": "row index",
"size": "$t(common.size)",
"title": "$t(common.title)",
"titleCombined": "$t(common.title) (combined)",
"trackNumber": "track number",
"year": "$t(common.year)"
},
"view": {
"card": "card",
"poster": "poster",
"table": "table"
}
}
}
}

View File

@ -54,6 +54,7 @@ import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import i18n from '/@/i18n/i18n';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
@ -212,7 +213,12 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
toast.error({
message: i18n.t('error.serverNotSelectedError', {
postProcess: 'sentenceCase',
}) as string,
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
});
throw new Error(`No server selected`);
}
@ -221,10 +227,16 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request',
title: i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' }) as string,
});
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
throw new Error(
i18n.t('error.endpointNotImplementedError', {
endpoint,
postProcess: 'sentenceCase',
serverType,
}) as string,
);
}
return endpoints[serverType][endpoint];

View File

@ -9,6 +9,7 @@ import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils'
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { toast } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
@ -276,9 +277,12 @@ axiosClient.interceptors.response.use(
if (res.status === 429) {
toast.error({
message:
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
title: 'Your session has expired.',
message: i18n.t('error.loginRateError', {
postProcess: 'sentenceCase',
}) as string,
title: i18n.t('error.sessionExpiredError', {
postProcess: 'sentenceCase',
}) as string,
});
const serverId = currentServer.id;
@ -292,7 +296,11 @@ axiosClient.interceptors.response.use(
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
throw new Error(
i18n.t('error.authenticatedFailed', {
postProcess: 'sentenceCase',
}) as string,
);
}
const newCredential = res.data.token;

View File

@ -6,6 +6,7 @@ import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast/index';
import i18n from '/@/i18n/i18n';
const c = initContract();
@ -106,7 +107,7 @@ axiosClient.interceptors.response.use(
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
}
}

View File

@ -26,6 +26,7 @@ import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@ -39,6 +40,7 @@ const remote = isElectron() ? window.electron.remote : null;
export const App = () => {
const theme = useTheme();
const accent = useSettingsStore((store) => store.general.accent);
const language = useSettingsStore((store) => store.general.language);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
@ -178,6 +180,12 @@ export const App = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
return (
<MantineProvider
withGlobalStyles

View File

@ -3,6 +3,7 @@ import { useState } from 'react';
import { Group, Image, Stack } from '@mantine/core';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { Link, generatePath } from 'react-router-dom';
import styled from 'styled-components';
@ -109,6 +110,7 @@ interface FeatureCarouselProps {
}
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
const { t } = useTranslation();
const handlePlayQueueAdd = usePlayQueueAdd();
const [itemIndex, setItemIndex] = useState(0);
const [direction, setDirection] = useState(0);
@ -224,7 +226,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
});
}}
>
Play
{t('player.play', { postProcess: 'titleCase' })}
</Button>
<Group spacing="sm">
<Button

View File

@ -6,14 +6,21 @@ import { Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTER_GROUP_OPTIONS_DATA = [
{
label: 'Match all',
label: i18n.t('form.queryEditor.input', {
context: 'optionMatchAll',
postProcess: 'sentenceCase',
}),
value: 'all',
},
{
label: 'Match any',
label: i18n.t('form.queryEditor.input', {
context: 'optionMatchAny',
postProcess: 'sentenceCase',
}),
value: 'any',
},
];

View File

@ -42,6 +42,7 @@ import { TitleCell } from '/@/renderer/components/virtual-table/cells/title-cell
import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/use-fixed-table-header';
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
import i18n from '/@/i18n/i18n';
export * from './table-config-dropdown';
export * from './table-pagination';
@ -77,7 +78,7 @@ const tableColumns: { [key: string]: ColDef } = {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { isLink: true, position: 'left' }),
colId: TableColumn.ALBUM,
headerName: 'Album',
headerName: i18n.t('table.column.album'),
valueGetter: (params: ValueGetterParams) =>
params.data
? {
@ -92,7 +93,7 @@ const tableColumns: { [key: string]: ColDef } = {
albumArtist: {
cellRenderer: AlbumArtistCell,
colId: TableColumn.ALBUM_ARTIST,
headerName: 'Album Artist',
headerName: i18n.t('table.column.albumArtist'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.albumArtists : undefined,
width: 150,
@ -103,7 +104,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'albumCount',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Albums',
headerName: i18n.t('table.column.albumCount'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.albumCount : undefined,
@ -112,7 +113,7 @@ const tableColumns: { [key: string]: ColDef } = {
artist: {
cellRenderer: ArtistCell,
colId: TableColumn.ARTIST,
headerName: 'Artist',
headerName: i18n.t('table.column.artist'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
width: 150,
},
@ -120,7 +121,7 @@ const tableColumns: { [key: string]: ColDef } = {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.BIOGRAPHY,
field: 'biography',
headerName: 'Biography',
headerName: i18n.t('table.column.biography'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
width: 200,
},
@ -130,6 +131,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'bitRate',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.bitrate'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
@ -140,7 +142,7 @@ const tableColumns: { [key: string]: ColDef } = {
colId: TableColumn.BPM,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'BPM',
headerName: i18n.t('table.column.bpm'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
width: 60,
@ -151,6 +153,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'channels',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.channels'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.channels : undefined,
width: 100,
@ -158,7 +161,7 @@ const tableColumns: { [key: string]: ColDef } = {
comment: {
cellRenderer: NoteCell,
colId: TableColumn.COMMENT,
headerName: 'Note',
headerName: i18n.t('table.column.comment'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
width: 150,
},
@ -168,7 +171,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'createdAt',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Date Added',
headerName: i18n.t('table.column.dateAdded'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
@ -182,7 +185,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'discNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Disc',
headerName: i18n.t('table.column.discNumber'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.discNumber : undefined,
@ -203,7 +206,7 @@ const tableColumns: { [key: string]: ColDef } = {
genre: {
cellRenderer: GenreCell,
colId: TableColumn.GENRE,
headerName: 'Genre',
headerName: i18n.t('table.column.genre'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
width: 100,
},
@ -212,7 +215,7 @@ const tableColumns: { [key: string]: ColDef } = {
colId: TableColumn.LAST_PLAYED,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Last Played',
headerName: i18n.t('table.column.lastPlayed'),
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).fromNow() : '',
valueGetter: (params: ValueGetterParams) =>
@ -222,7 +225,7 @@ const tableColumns: { [key: string]: ColDef } = {
path: {
cellRenderer: GenericCell,
colId: TableColumn.PATH,
headerName: 'Path',
headerName: i18n.t('table.column.path'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
width: 200,
},
@ -232,7 +235,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'playCount',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Plays',
headerName: i18n.t('table.column.playCount'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.playCount : undefined,
@ -244,7 +247,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'releaseDate',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Release Date',
headerName: i18n.t('table.column.releaseDate'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
@ -258,7 +261,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'releaseYear',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Year',
headerName: i18n.t('table.column.releaseYear'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.releaseYear : undefined,
@ -315,7 +318,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'songCount',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Songs',
headerName: i18n.t('table.column.songCount'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.songCount : undefined,
@ -325,14 +328,14 @@ const tableColumns: { [key: string]: ColDef } = {
cellRenderer: TitleCell,
colId: TableColumn.TITLE,
field: 'name',
headerName: 'Title',
headerName: i18n.t('table.column.title'),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
width: 250,
},
titleCombined: {
cellRenderer: CombinedTitleCell,
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
headerName: i18n.t('table.column.title'),
initialWidth: 500,
minWidth: 150,
valueGetter: (params: ValueGetterParams) =>
@ -356,7 +359,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'trackNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
headerName: i18n.t('table.column.trackNumber'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.trackNumber : undefined,
@ -384,7 +387,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'trackNumber',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
headerName: i18n.t('table.column.trackNumber'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.trackNumber : undefined,
@ -397,7 +400,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'userFavorite',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
headerName: 'Favorite',
headerName: i18n.t('table.column.favorite'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.userFavorite : undefined,
@ -411,7 +414,7 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'userRating',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
headerName: 'Rating',
headerName: i18n.t('table.column.rating'),
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
width: 95,

View File

@ -5,85 +5,274 @@ import { Switch } from '/@/renderer/components/switch';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option';
import i18n from '/@/i18n/i18n';
export const SONG_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Album', value: TableColumn.ALBUM },
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
{ label: 'Artist', value: TableColumn.ARTIST },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Year', value: TableColumn.YEAR },
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
{ label: 'Disc Number', value: TableColumn.DISC_NUMBER },
{ label: 'Track Number', value: TableColumn.TRACK_NUMBER },
{ label: 'Bitrate', value: TableColumn.BIT_RATE },
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Note', value: TableColumn.COMMENT },
{ label: 'Channels', value: TableColumn.CHANNELS },
{ label: 'BPM', value: TableColumn.BPM },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
{ label: 'Path', value: TableColumn.PATH },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Size', value: TableColumn.SIZE },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.album', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM,
},
{
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM_ARTIST,
},
{
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
value: TableColumn.ARTIST,
},
{
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
value: TableColumn.GENRE,
},
{
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
value: TableColumn.YEAR,
},
{
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
value: TableColumn.RELEASE_DATE,
},
{
label: i18n.t('table.config.label.discNumber', { postProcess: 'titleCase' }),
value: TableColumn.DISC_NUMBER,
},
{
label: i18n.t('table.config.label.trackNumber', { postProcess: 'titleCase' }),
value: TableColumn.TRACK_NUMBER,
},
{
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
value: TableColumn.BIT_RATE,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
},
{
label: i18n.t('table.config.label.note', { postProcess: 'titleCase' }),
value: TableColumn.COMMENT,
},
{
label: i18n.t('table.config.label.channels', { postProcess: 'titleCase' }),
value: TableColumn.CHANNELS,
},
{
label: i18n.t('table.config.label.bpm', { postProcess: 'titleCase' }),
value: TableColumn.BPM,
},
{
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
value: TableColumn.DATE_ADDED,
},
{
label: i18n.t('table.config.label.path', { postProcess: 'titleCase' }),
value: TableColumn.PATH,
},
{
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
value: TableColumn.PLAY_COUNT,
},
{
label: i18n.t('table.config.label.size', { postProcess: 'titleCase' }),
value: TableColumn.SIZE,
},
{
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
value: TableColumn.USER_FAVORITE,
},
{
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
value: TableColumn.USER_RATING,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
// { label: 'Skip', value: TableColumn.SKIP },
];
export const ALBUM_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Album Artist', value: TableColumn.ALBUM_ARTIST },
{ label: 'Artist', value: TableColumn.ARTIST },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Year', value: TableColumn.YEAR },
{ label: 'Release Date', value: TableColumn.RELEASE_DATE },
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM_ARTIST,
},
{
label: i18n.t('table.config.label.artist', { postProcess: 'titleCase' }),
value: TableColumn.ARTIST,
},
{
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
value: TableColumn.SONG_COUNT,
},
{
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
value: TableColumn.GENRE,
},
{
label: i18n.t('table.config.label.year', { postProcess: 'titleCase' }),
value: TableColumn.YEAR,
},
{
label: i18n.t('table.config.label.releaseDate', { postProcess: 'titleCase' }),
value: TableColumn.RELEASE_DATE,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
},
{
label: i18n.t('table.config.label.dateAdded', { postProcess: 'titleCase' }),
value: TableColumn.DATE_ADDED,
},
{
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
value: TableColumn.PLAY_COUNT,
},
{
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
value: TableColumn.USER_FAVORITE,
},
{
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
value: TableColumn.USER_RATING,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
export const ALBUMARTIST_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Biography', value: TableColumn.BIOGRAPHY },
{ label: 'Genre', value: TableColumn.GENRE },
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Album Count', value: TableColumn.ALBUM_COUNT },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.biography', { postProcess: 'titleCase' }),
value: TableColumn.BIOGRAPHY,
},
{
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
value: TableColumn.GENRE,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
},
{
label: i18n.t('table.config.label.playCount', { postProcess: 'titleCase' }),
value: TableColumn.PLAY_COUNT,
},
{
label: i18n.t('table.config.label.albumCount', { postProcess: 'titleCase' }),
value: TableColumn.ALBUM_COUNT,
},
{
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
value: TableColumn.SONG_COUNT,
},
{
label: i18n.t('table.config.label.favorite', { postProcess: 'titleCase' }),
value: TableColumn.USER_FAVORITE,
},
{
label: i18n.t('table.config.label.rating', { postProcess: 'titleCase' }),
value: TableColumn.USER_RATING,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
export const PLAYLIST_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Owner', value: TableColumn.OWNER },
// { label: 'Genre', value: TableColumn.GENRE },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }),
value: TableColumn.TITLE_COMBINED,
},
{
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
value: TableColumn.DURATION,
},
{
label: i18n.t('table.config.label.owner', { postProcess: 'titleCase' }),
value: TableColumn.OWNER,
},
{
label: i18n.t('table.config.label.songCount', { postProcess: 'titleCase' }),
value: TableColumn.SONG_COUNT,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
export const GENRE_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Actions', value: TableColumn.ACTIONS },
{
label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }),
value: TableColumn.ROW_INDEX,
},
{
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
value: TableColumn.TITLE,
},
{
label: i18n.t('table.config.label.actions', { postProcess: 'titleCase' }),
value: TableColumn.ACTIONS,
},
];
interface TableConfigDropdownProps {

View File

@ -1,8 +1,9 @@
import { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { RiHashtag } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { MotionFlex } from '../motion';
@ -29,6 +30,7 @@ export const TablePagination = ({
setPagination,
setIdPagination,
}: TablePaginationProps) => {
const { t } = useTranslation();
const [isGoToPageOpen, handlers] = useDisclosure(false);
const containerQuery = useContainerQuery();
@ -115,7 +117,9 @@ export const TablePagination = ({
radius="sm"
size="sm"
sx={{ height: '26px', padding: '0', width: '26px' }}
tooltip={{ label: 'Go to page' }}
tooltip={{
label: t('action.goToPage', { postProcess: 'sentenceCase' }),
}}
variant="default"
onClick={() => handlers.toggle()}
>

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Center, Group, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiCheckFill } from 'react-icons/ri';
import { Link, Navigate } from 'react-router-dom';
import { Button, PageHeader, Text } from '/@/renderer/components';
@ -15,6 +16,7 @@ import { useCurrentServer } from '/@/renderer/store';
const localSettings = isElectron() ? window.electron.localSettings : null;
const ActionRequiredRoute = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
@ -38,17 +40,17 @@ const ActionRequiredRoute = () => {
const checks = [
{
component: <MpvRequired />,
title: 'MPV required',
title: t('error.mpvRequired', { postProcess: 'sentenceCase' }),
valid: !isMpvRequired,
},
{
component: <ServerCredentialRequired />,
title: 'Credentials required',
title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),
valid: !isCredentialRequired,
},
{
component: <ServerRequired />,
title: 'Server required',
title: t('error.serverRequired', { postProcess: 'serverRequired' }),
valid: !isServerRequired,
},
];

View File

@ -1,8 +1,9 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
@ -63,6 +64,7 @@ interface AlbumDetailContentProps {
}
export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentProps) => {
const { t } = useTranslation();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
@ -206,7 +208,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
handlePreviousPage: () => handlePreviousPage('artist'),
hasPreviousPage: pagination.artist > 0,
},
title: 'More from this artist',
title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }),
uniqueId: 'mostPlayed',
},
{
@ -217,7 +219,10 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
(a) => a.id !== detailQuery?.data?.id,
).length,
loading: relatedAlbumGenresQuery?.isLoading || relatedAlbumGenresQuery.isFetching,
title: `More from ${detailQuery?.data?.genres?.[0]?.name}`,
title: t('page.albumDetail.moreFromGeneric', {
item: detailQuery?.data?.genres?.[0]?.name,
postProcess: 'sentenceCase',
}),
uniqueId: 'relatedGenres',
},
];

View File

@ -1,8 +1,9 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
@ -31,47 +32,112 @@ import {
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: 'Community Rating',
name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),
value: AlbumListSort.COMMUNITY_RATING,
},
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: AlbumListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
name: i18n.t('filter.criticRating', { postProcess: 'titleCase' }),
value: AlbumListSort.CRITIC_RATING,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RELEASE_DATE,
},
],
navidrome: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
value: AlbumListSort.ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: AlbumListSort.DURATION,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: AlbumListSort.RATING,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: 'Recently Played',
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_PLAYED,
},
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
value: AlbumListSort.SONG_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
value: AlbumListSort.FAVORITED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: AlbumListSort.YEAR,
},
],
};
@ -81,6 +147,7 @@ interface AlbumListHeaderFiltersProps {
}
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext();
const server = useCurrentServer();
@ -362,7 +429,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
},
}}
tooltip={{ label: 'Filters' }}
tooltip={{
label: t('common.filter', { count: 2, postProcess: 'sentenceCase' }),
}}
variant="subtle"
onClick={handleOpenFiltersModal}
>
@ -372,7 +441,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'sentenceCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@ -394,26 +463,26 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
icon={<RiPlayFill />}
onClick={() => handlePlay?.({ playType: Play.NOW })}
>
Play
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay?.({ playType: Play.LAST })}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay?.({ playType: Play.NEXT })}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@ -430,7 +499,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
tooltip={{
label: t('common.configure', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
@ -443,21 +514,21 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}

View File

@ -1,7 +1,8 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import type { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
@ -18,6 +19,7 @@ import {
usePlayButtonBehavior,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { titleCase } from '/@/renderer/utils';
interface AlbumListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@ -27,6 +29,7 @@ interface AlbumListHeaderProps {
}
export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
@ -69,7 +72,10 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
/>
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{title ||
titleCase(t('page.albumList.title', { postProcess: 'titleCase' }))}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>

View File

@ -1,6 +1,7 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
@ -23,6 +24,7 @@ export const JellyfinAlbumFilters = ({
pageKey,
serverId,
}: JellyfinAlbumFiltersProps) => {
const { t } = useTranslation();
const filter = useListFilterByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
@ -51,7 +53,7 @@ export const JellyfinAlbumFilters = ({
const toggleFilters = [
{
label: 'Is favorited',
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -193,7 +195,7 @@ export const JellyfinAlbumFilters = ({
<NumberInput
defaultValue={filter?._custom?.jellyfin?.minYear}
hideControls={false}
label="From year"
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.maxYear}
@ -202,7 +204,7 @@ export const JellyfinAlbumFilters = ({
<NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear}
hideControls={false}
label="To year"
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.minYear}
@ -215,7 +217,7 @@ export const JellyfinAlbumFilters = ({
searchable
data={genreList}
defaultValue={selectedGenres}
label="Genres"
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
/>
</Group>
@ -227,7 +229,7 @@ export const JellyfinAlbumFilters = ({
data={selectableAlbumArtists}
defaultValue={filter?._custom?.jellyfin?.AlbumArtistIds?.split(',')}
disabled={disableArtistFilter}
label="Artist"
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}

View File

@ -6,6 +6,7 @@ import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
@ -22,6 +23,7 @@ export const NavidromeAlbumFilters = ({
pageKey,
serverId,
}: NavidromeAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
@ -62,7 +64,7 @@ export const NavidromeAlbumFilters = ({
const toggleFilters = [
{
label: 'Is rated',
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -83,7 +85,7 @@ export const NavidromeAlbumFilters = ({
value: filter._custom?.navidrome?.has_rating,
},
{
label: 'Is favorited',
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -104,7 +106,7 @@ export const NavidromeAlbumFilters = ({
value: filter._custom?.navidrome?.starred,
},
{
label: 'Is compilation',
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -125,7 +127,7 @@ export const NavidromeAlbumFilters = ({
value: filter._custom?.navidrome?.compilation,
},
{
label: 'Is recently played',
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -226,7 +228,7 @@ export const NavidromeAlbumFilters = ({
<NumberInput
defaultValue={filter._custom?.navidrome?.year}
hideControls={false}
label="Year"
label={t('common.year', { postProcess: 'titleCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e)}
@ -236,7 +238,7 @@ export const NavidromeAlbumFilters = ({
searchable
data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id}
label="Genre"
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
/>
</Group>
@ -247,9 +249,8 @@ export const NavidromeAlbumFilters = ({
data={selectableAlbumArtists}
defaultValue={filter._custom?.navidrome?.artist_id}
disabled={disableArtistFilter}
label="Artist"
label={t('entity.artist', { count: 1, postProcess: 'titleCase' })}
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}

View File

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri';
import { QueueSong } from '/@/renderer/api/types';
import { Button, DropdownMenu, PageHeader, SpinnerIcon, Paper } from '/@/renderer/components';
@ -17,6 +18,7 @@ export const AlbumArtistDetailTopSongsListHeader = ({
itemCount,
data,
}: AlbumArtistDetailTopSongsListHeaderProps) => {
const { t } = useTranslation();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
@ -55,19 +57,19 @@ export const AlbumArtistDetailTopSongsListHeader = ({
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
{t('player.add', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>

View File

@ -1,9 +1,10 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api';
@ -62,6 +63,7 @@ export const AlbumArtistListHeaderFilters = ({
gridRef,
tableRef,
}: AlbumArtistListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useListContext();
@ -77,7 +79,7 @@ export const AlbumArtistListHeaderFilters = ({
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
?.name) ||
'Unknown';
t('common.unknown', { postProcess: 'titleCase' });
const handleItemSize = (e: number) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
@ -359,7 +361,7 @@ export const AlbumArtistListHeaderFilters = ({
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@ -407,21 +409,27 @@ export const AlbumArtistListHeaderFilters = ({
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
@ -465,7 +473,11 @@ export const AlbumArtistListHeaderFilters = ({
)}
{!isGrid && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@ -482,7 +494,11 @@ export const AlbumArtistListHeaderFilters = ({
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}

View File

@ -1,7 +1,8 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import type { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useListContext } from '../../../context/list-context';
import { useListStoreByKey } from '../../../store/list.store';
import { FilterBar } from '../../shared/components/filter-bar';
@ -26,6 +27,7 @@ export const AlbumArtistListHeader = ({
gridRef,
tableRef,
}: AlbumArtistListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
@ -64,7 +66,9 @@ export const AlbumArtistListHeader = ({
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.albumArtistList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>

View File

@ -11,6 +11,7 @@ import {
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
@ -87,6 +88,7 @@ export interface ContextMenuProviderProps {
}
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const clickOutsideRef = useClickOutside(() => setOpened(false));
@ -229,7 +231,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
@ -245,14 +247,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}
closeAllModals();
}, [ctx, deletePlaylistMutation]);
}, [ctx, deletePlaylistMutation, t]);
const openDeletePlaylistModal = useCallback(() => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
<Stack>
<Text>Are you sure you want to delete the following playlist(s)?</Text>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
<ul>
{ctx.data.map((item) => (
<li key={item.id}>
@ -265,9 +267,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
</Stack>
</ConfirmModal>
),
title: 'Delete playlist(s)',
title: t('page.contextMenu.deletePlaylist', { postProcess: 'titleCase' }),
});
}, [ctx.data, handleDeletePlaylist]);
}, [ctx.data, handleDeletePlaylist, t]);
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
@ -301,7 +303,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
@ -337,14 +339,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
},
);
}
}
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]);
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type, t]);
const handleRemoveFromFavorites = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
@ -459,9 +461,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
});
}, [ctx.data, ctx.dataNodes]);
}, [ctx.data, ctx.dataNodes, t]);
const removeFromPlaylistMutation = useRemoveFromPlaylist();
@ -484,13 +486,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onError: (err) => {
toast.error({
message: err.message,
title: 'Error removing song(s) from playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: `${songId.length} song(s) were removed from the playlist`,
});
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
closeAllModals();
},
@ -504,10 +503,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
loading={removeFromPlaylistMutation.isLoading}
onConfirm={confirm}
>
Are you sure you want to remove the following song(s) from the playlist?
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
title: 'Remove song(s) from playlist',
title: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
});
}, [
ctx.context?.playlistId,
@ -516,6 +515,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
ctx.dataNodes,
removeFromPlaylistMutation,
serverType,
t,
]);
const updateRatingMutation = useSetRating({});
@ -631,74 +631,78 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
return {
addToFavorites: {
id: 'addToFavorites',
label: 'Add favorite',
label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }),
leftIcon: <RiHeartFill size="1.1rem" />,
onClick: handleAddToFavorites,
},
addToPlaylist: {
id: 'addToPlaylist',
label: 'Add to playlist',
label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
leftIcon: <RiPlayListAddFill size="1.1rem" />,
onClick: handleAddToPlaylist,
},
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
createPlaylist: {
id: 'createPlaylist',
label: t('page.contextMenu.createPlaylist', { postProcess: 'sentenceCase' }),
onClick: () => {},
},
deletePlaylist: {
id: 'deletePlaylist',
label: 'Delete playlist',
label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }),
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: openDeletePlaylistModal,
},
deselectAll: {
id: 'deselectAll',
label: 'Deselect all',
label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }),
leftIcon: <RiCloseCircleLine size="1.1rem" />,
onClick: handleDeselectAll,
},
moveToBottomOfQueue: {
id: 'moveToBottomOfQueue',
label: 'Move to bottom',
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
leftIcon: <RiArrowDownLine size="1.1rem" />,
onClick: handleMoveToBottom,
},
moveToTopOfQueue: {
id: 'moveToTopOfQueue',
label: 'Move to top',
label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }),
leftIcon: <RiArrowUpLine size="1.1rem" />,
onClick: handleMoveToTop,
},
play: {
id: 'play',
label: 'Play',
label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }),
leftIcon: <RiPlayFill size="1.1rem" />,
onClick: () => handlePlay(Play.NOW),
},
playLast: {
id: 'playLast',
label: 'Add to queue',
label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }),
leftIcon: <RiAddBoxFill size="1.1rem" />,
onClick: () => handlePlay(Play.LAST),
},
playNext: {
id: 'playNext',
label: 'Add to queue next',
label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }),
leftIcon: <RiAddCircleFill size="1.1rem" />,
onClick: () => handlePlay(Play.NEXT),
},
removeFromFavorites: {
id: 'removeFromFavorites',
label: 'Remove favorite',
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
leftIcon: <RiDislikeFill size="1.1rem" />,
onClick: handleRemoveFromFavorites,
},
removeFromPlaylist: {
id: 'removeFromPlaylist',
label: 'Remove from playlist',
label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveFromPlaylist,
},
removeFromQueue: {
id: 'moveToBottomOfQueue',
label: 'Remove songs',
id: 'removeSongs',
label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }),
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveSelected,
},
@ -784,6 +788,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handleRemoveSelected,
handleUpdateRating,
openDeletePlaylistModal,
t,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);
@ -888,7 +893,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
size="sm"
/>
<ContextMenuButton disabled>
{ctx.data?.length} selected
{t('page.contextMenu.numberSelected', {
count: ctx.data?.length || 0,
postProcess: 'lowerCase',
})}
</ContextMenuButton>
</Stack>
</ContextMenu>

View File

@ -1,7 +1,8 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
@ -31,6 +32,7 @@ interface GenreListHeaderFiltersProps {
}
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { pageKey, customFilters } = useListContext();
const server = useCurrentServer();
@ -269,7 +271,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@ -291,7 +293,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@ -308,37 +310,43 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
tooltip={{
label: t('common.configure', { postProcess: 'titleCase' }),
}}
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.size', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
@ -363,7 +371,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
{(display === ListDisplayType.TABLE ||
display === ListDisplayType.TABLE_PAGINATED) && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'titleCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@ -380,7 +392,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'titleCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}

View File

@ -1,7 +1,7 @@
import { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, MutableRefObject } from 'react';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
@ -17,6 +17,7 @@ import {
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
interface GenreListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@ -25,6 +26,7 @@ interface GenreListHeaderProps {
}
export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeaderProps) => {
const { t } = useTranslation();
const cq = useContainerQuery();
const server = useCurrentServer();
const { pageKey } = useListContext();
@ -66,7 +68,9 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Genres</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.genreList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>

View File

@ -11,9 +11,11 @@ import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel
import { Platform } from '/@/renderer/types';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
import { RiRefreshLine } from 'react-icons/ri';
const HomeRoute = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
@ -105,7 +107,7 @@ const HomeRoute = () => {
data: random?.data?.items,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
title: 'Explore from your library',
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
uniqueId: 'random',
},
{
@ -115,7 +117,7 @@ const HomeRoute = () => {
},
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
title: 'Recently played',
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
uniqueId: 'recentlyPlayed',
},
{
@ -125,7 +127,7 @@ const HomeRoute = () => {
},
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
title: 'Newly added releases',
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
uniqueId: 'recentlyAdded',
},
{
@ -135,7 +137,7 @@ const HomeRoute = () => {
},
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
title: 'Most played',
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
uniqueId: 'mostPlayed',
},
];
@ -148,7 +150,9 @@ const HomeRoute = () => {
backgroundColor: 'var(--titlebar-bg)',
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Home</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.home.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: 200,

View File

@ -4,6 +4,7 @@ import { useForm } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { openModal } from '@mantine/modals';
import orderBy from 'lodash/orderBy';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import {
InternetProviderLyricSearchResponse,
@ -12,6 +13,7 @@ import {
} from '../../../api/types';
import { useLyricSearch } from '../queries/lyric-search-query';
import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';
const SearchItem = styled.button`
all: unset;
@ -84,6 +86,7 @@ interface LyricSearchFormProps {
}
export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
const { t } = useTranslation();
const form = useForm({
initialValues: {
artist: artist || '',
@ -117,11 +120,17 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
<Group grow>
<TextInput
data-autofocus
label="Name"
label={t('form.lyricSearch.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Artist"
label={t('form.lyricSearch.input', {
context: 'artist',
postProcess: 'titleCase',
})}
{...form.getInputProps('artist')}
/>
</Group>
@ -170,6 +179,6 @@ export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSe
/>
),
size: 'lg',
title: 'Lyrics Search',
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,
});
};

View File

@ -1,5 +1,6 @@
import { Box, Group } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
import { LyricsOverride } from '/@/renderer/api/types';
import { Button, NumberInput, Tooltip } from '/@/renderer/components';
@ -22,6 +23,7 @@ export const LyricsActions = ({
onResetLyric,
onSearchOverride,
}: LyricsActionsProps) => {
const { t } = useTranslation();
const currentSong = useCurrentSong();
const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings();
@ -54,7 +56,7 @@ export const LyricsActions = ({
})
}
>
Search
{t('common.search', { postProcess: 'titleCase' })}
</Button>
) : null}
<Button
@ -65,7 +67,7 @@ export const LyricsActions = ({
<RiSubtractFill />
</Button>
<Tooltip
label="Offset (ms)"
label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}
openDelay={500}
>
<NumberInput
@ -90,7 +92,7 @@ export const LyricsActions = ({
variant="subtle"
onClick={onResetLyric}
>
Reset
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Group>
@ -104,7 +106,7 @@ export const LyricsActions = ({
variant="subtle"
onClick={onRemoveLyric}
>
Clear
{t('common.clear', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Box>

View File

@ -3,6 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Group } from '@mantine/core';
import { Button, Popover } from '/@/renderer/components';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import {
RiArrowDownLine,
RiArrowUpLine,
@ -27,6 +28,7 @@ interface PlayQueueListOptionsProps {
}
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
const { t } = useTranslation();
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
useQueueControls();
@ -115,7 +117,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Shuffle queue' }}
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleShuffleQueue}
>
@ -124,7 +126,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Move selected to bottom' }}
tooltip={{ label: t('action.moveToBottom', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleMoveToBottom}
>
@ -133,7 +135,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Move selected to top' }}
tooltip={{ label: t('action.moveToTop', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleMoveToTop}
>
@ -142,7 +144,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Remove selected' }}
tooltip={{
label: t('action.removeFromQueue', { postProcess: 'sentenceCase' }),
}}
variant="default"
onClick={handleRemoveSelected}
>
@ -151,7 +155,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Clear queue' }}
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
variant="default"
onClick={handleClearQueue}
>
@ -167,7 +171,9 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
tooltip={{
label: t('common.configure', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
>
<RiListSettingsLine size="1.1rem" />

View File

@ -3,6 +3,7 @@ import { useHotkeys } from '@mantine/hooks';
import { useQueryClient } from '@tanstack/react-query';
import formatDuration from 'format-duration';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { IoIosPause } from 'react-icons/io';
import {
RiMenuAddFill,
@ -92,6 +93,7 @@ const ControlsContainer = styled.div`
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
@ -171,7 +173,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiStopFill size={15} />}
tooltip={{
label: 'Stop',
label: t('player.stop', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
@ -183,10 +185,11 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
tooltip={{
label:
shuffle === PlayerShuffle.NONE
? 'Shuffle disabled'
: shuffle === PlayerShuffle.TRACK
? 'Shuffle tracks'
: 'Shuffle albums',
? t('player.shuffle', {
context: 'off',
postProcess: 'sentenceCase',
})
: t('player.shuffle', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
@ -194,7 +197,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
<PlayerButton
icon={<RiSkipBackFill size={15} />}
tooltip={{ label: 'Previous track', openDelay: 500 }}
tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handlePrevTrack}
/>
@ -202,7 +208,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiRewindFill size={15} />}
tooltip={{
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
label: t('player.skip', {
context: 'back',
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="secondary"
@ -218,7 +227,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
)
}
tooltip={{
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
label:
status === PlayerStatus.PAUSED
? t('player.play', { postProcess: 'sentenceCase' })
: t('player.pause', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="main"
@ -228,7 +240,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiSpeedFill size={15} />}
tooltip={{
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
label: t('player.stop', {
context: 'forward',
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="secondary"
@ -237,7 +252,10 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
)}
<PlayerButton
icon={<RiSkipForwardFill size={15} />}
tooltip={{ label: 'Next track', openDelay: 500 }}
tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handleNextTrack}
/>
@ -253,10 +271,19 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
tooltip={{
label: `${
repeat === PlayerRepeat.NONE
? 'Repeat disabled'
? t('player.repeat', {
context: 'off',
postProcess: 'sentenceCase',
})
: repeat === PlayerRepeat.ALL
? 'Repeat all'
: 'Repeat one'
? t('player.repeat', {
context: 'all',
postProcess: 'sentenceCase',
})
: t('player.repeat', {
context: 'one',
postProcess: 'sentenceCase',
})
}`,
openDelay: 500,
}}
@ -267,7 +294,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<PlayerButton
icon={<RiMenuAddFill size={15} />}
tooltip={{
label: 'Shuffle all',
label: t('player.playRandom', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"

View File

@ -1,5 +1,6 @@
import { Group, Center } from '@mantine/core';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri';
import styled from 'styled-components';
@ -50,11 +51,12 @@ const GridContainer = styled.div<TransparendGridContainerProps>`
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
padding: 1rem;
background: rgb(var(--main-bg-transparent), ${({ opacity }) => opacity}%);
background: rgb(var(--main-bg-transparent) ${({ opacity }) => opacity}%);
border-radius: 5px;
`;
export const FullScreenPlayerQueue = () => {
const { t } = useTranslation();
const { activeTab, opacity } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
@ -62,19 +64,19 @@ export const FullScreenPlayerQueue = () => {
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: 'Up Next',
label: t('page.fullScreenPlayer.upNext'),
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: 'Related',
label: t('page.fullScreenPlayer.related'),
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: 'Lyrics',
label: t('page.fullScreenPlayer.lyrics'),
onClick: () => setStore({ activeTab: 'lyrics' }),
},
];
@ -125,7 +127,7 @@ export const FullScreenPlayerQueue = () => {
order={3}
weight={700}
>
COMING SOON
{t('common.comingSoon', { postProcess: 'upperCase' })}
</TextTitle>
</Group>
</Center>

View File

@ -2,6 +2,7 @@ import { useLayoutEffect, useRef } from 'react';
import { Divider, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { Variants, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
import { useLocation } from 'react-router';
import styled from 'styled-components';
@ -70,6 +71,7 @@ const BackgroundImageOverlay = styled.div`
`;
const Controls = () => {
const { t } = useTranslation();
const { dynamicBackground, expanded, opacity, useImageAspectRatio } =
useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
@ -104,7 +106,7 @@ const Controls = () => {
<Button
compact
size="sm"
tooltip={{ label: 'Minimize' }}
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleToggleFullScreenPlayer}
>
@ -115,7 +117,7 @@ const Controls = () => {
<Button
compact
size="sm"
tooltip={{ label: 'Configure' }}
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
variant="subtle"
>
<RiSettings3Line size="1.5rem" />
@ -123,7 +125,11 @@ const Controls = () => {
</Popover.Target>
<Popover.Dropdown>
<Option>
<Option.Label>Dynamic Background</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.dynamicBackground', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
defaultChecked={dynamicBackground}
@ -137,7 +143,11 @@ const Controls = () => {
</Option>
{dynamicBackground && (
<Option>
<Option.Label>Opacity</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.opacity', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Slider
defaultValue={opacity}
@ -151,7 +161,11 @@ const Controls = () => {
</Option>
)}
<Option>
<Option.Label>Use image aspect ratio</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.useImageAspectRatio', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={useImageAspectRatio}
@ -165,7 +179,11 @@ const Controls = () => {
</Option>
<Divider my="sm" />
<Option>
<Option.Label>Follow current lyrics</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.followCurrentLyric', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.follow}
@ -176,7 +194,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Show lyrics provider</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.showLyricProvider', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.showProvider}
@ -187,7 +209,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Show lyrics match</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.showLyricMatch', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.showMatch}
@ -198,7 +224,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics size</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.lyric', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Group
noWrap
@ -206,7 +236,11 @@ const Controls = () => {
>
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) => `Synchronized: ${e}px`}
label={(e) =>
`${t('page.fullscreenPlayer.synchronized', {
postProcess: 'titleCase',
})}: ${e}px`
}
max={72}
min={8}
w="100%"
@ -214,7 +248,11 @@ const Controls = () => {
/>
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) => `Unsynchronized: ${e}px`}
label={(e) =>
`${t('page.fullscreenPlayer.unsynchronized', {
postProcess: 'sentenceCase',
})}: ${e}px`
}
max={72}
min={8}
w="100%"
@ -226,7 +264,11 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics gap</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.lyricGap', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Group
noWrap
@ -254,13 +296,32 @@ const Controls = () => {
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics alignment</Option.Label>
<Option.Label>
{t('page.fullscreenPlayer.lyricAlignment', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Select
data={[
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
{
label: t('common.left', {
postProcess: 'titleCase',
}),
value: 'left',
},
{
label: t('common.center', {
postProcess: 'titleCase',
}),
value: 'center',
},
{
label: t('common.right', {
postProcess: 'titleCase',
}),
value: 'right',
},
]}
value={lyricConfig.alignment}
onChange={(e) => handleLyricsSettings('alignment', e)}

View File

@ -2,6 +2,7 @@ import React, { MouseEvent } from 'react';
import { Center, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom';
import styled from 'styled-components';
@ -92,6 +93,7 @@ const LeftControlsContainer = styled.div`
`;
export const LeftControls = () => {
const { t } = useTranslation();
const { setSideBar } = useAppStoreActions();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
@ -147,7 +149,9 @@ export const LeftControls = () => {
onClick={handleToggleFullScreenPlayer}
>
<Tooltip
label="Toggle fullscreen player"
label={t('player.toggleFullscreenPlayer', {
postProcess: 'sentenceCase',
})}
openDelay={500}
>
{currentSong?.imageUrl ? (
@ -182,7 +186,12 @@ export const LeftControls = () => {
right: 2,
top: 2,
}}
tooltip={{ label: 'Expand', openDelay: 500 }}
tooltip={{
label: t('common.expand', {
postProcess: 'titleCase',
}),
openDelay: 500,
}}
variant="default"
onClick={handleToggleSidebarImage}
>

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Flex, Group } from '@mantine/core';
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import {
RiVolumeUpFill,
@ -34,6 +35,7 @@ const remote = isElectron() ? window.electron.remote : null;
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
export const RightControls = () => {
const { t } = useTranslation();
const isMinWidth = useMediaQuery('(max-width: 480px)');
const volume = useVolume();
const muted = useMuted();
@ -213,7 +215,7 @@ export const RightControls = () => {
<PlayerButton
icon={<>{speed} x</>}
tooltip={{
label: 'Playback speed',
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
@ -249,7 +251,9 @@ export const RightControls = () => {
},
}}
tooltip={{
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
label: currentSong?.userFavorite
? t('player.unfavorite', { postProcess: 'titleCase' })
: t('player.favorite', { postProcess: 'titleCase' }),
openDelay: 500,
}}
variant="secondary"
@ -277,7 +281,10 @@ export const RightControls = () => {
<RiVolumeDownFill size="1.2rem" />
)
}
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
tooltip={{
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
openDelay: 500,
}}
variant="secondary"
onClick={handleMute}
onWheel={handleVolumeWheel}

View File

@ -20,6 +20,7 @@ import { api } from '/@/renderer/api';
import { useAuthStore } from '/@/renderer/store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Play, PlayQueueAddOptions, ServerListItem } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
interface ShuffleAllSlice extends RandomSongListQuery {
actions: {
@ -260,6 +261,6 @@ export const openShuffleAllModal = async (
/>
),
size: 'sm',
title: 'Shuffle all',
title: i18n.t('player.playRandom', { postProcess: 'sentenceCase' }) as string,
});
};

View File

@ -18,6 +18,7 @@ import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import debounce from 'lodash/debounce';
import { QueueSong } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { useTranslation } from 'react-i18next';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
@ -28,6 +29,7 @@ const remote = isElectron() ? window.electron.remote : null;
const mediaSession = !isElectron() || !utils?.isLinux() ? navigator.mediaSession : null;
export const useCenterControls = (args: { playersRef: any }) => {
const { t } = useTranslation();
const { playersRef } = args;
const settings = useSettingsStore((state) => state.playback);
@ -613,11 +615,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleError = useCallback(
(message: string) => {
toast.error({ id: 'mpv-error', message, title: 'An error occurred during playback' });
toast.error({
id: 'mpv-error',
message,
title: t('error.playbackError', { postProcess: 'sentenceCase' }),
});
pause();
mpvPlayer!.pause();
},
[pause],
[pause, t],
);
useEffect(() => {

View File

@ -28,6 +28,7 @@ import {
getGenreSongsById,
} from '/@/renderer/features/player/utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
let queryKey;
@ -62,6 +63,7 @@ const remote = isElectron() ? window.electron.remote : null;
const addToQueue = usePlayerStore.getState().actions.addToQueue;
export const useHandlePlayQueueAdd = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const playerType = usePlayerType();
const server = useCurrentServer();
@ -86,15 +88,18 @@ export const useHandlePlayQueueAdd = () => {
toast.info({
autoClose: false,
id: fetchId,
message:
'This is taking a while... close the notification to cancel the request',
message: t('player.playbackFetchCancel', {
postProcess: 'sentenceCase',
}),
onClose: () => {
queryClient.cancelQueries({
exact: false,
queryKey: getRootQueryKey(itemType, server?.id),
});
},
title: 'Adding to queue',
title: t('player.playbackFetchInProgress', {
postProcess: 'sentenceCase',
}),
});
}, 2000),
};
@ -140,7 +145,7 @@ export const useHandlePlayQueueAdd = () => {
return toast.error({
message: err.message,
title: 'Play queue add failed',
title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
}
@ -152,8 +157,8 @@ export const useHandlePlayQueueAdd = () => {
if (!songs || songs?.length === 0)
return toast.warn({
message: 'The query returned no results',
title: 'No tracks added',
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
title: t('player.playbackFetchNoResults'),
});
if (initialIndex) {
@ -190,7 +195,7 @@ export const useHandlePlayQueueAdd = () => {
return null;
},
[play, playerType, queryClient, server],
[play, playerType, queryClient, server, t],
);
return handlePlayQueueAdd;

View File

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { Box, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useMemo, useState } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { PlaylistListSort, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
@ -11,6 +11,7 @@ import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-t
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
export const AddToPlaylistContextModal = ({
id,
@ -21,6 +22,7 @@ export const AddToPlaylistContextModal = ({
genreId?: string[];
songId?: string[];
}>) => {
const { t } = useTranslation();
const { albumId, artistId, genreId, songId } = innerProps;
const server = useCurrentServer();
const [isLoading, setIsLoading] = useState(false);
@ -140,7 +142,10 @@ export const AddToPlaylistContextModal = ({
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
if (!server)
throw new Error(
t('error.serverNotSelectedError', { postProcess: 'sentenceCase' }),
);
return api.controller.getPlaylistSongList({
apiClientProps: {
server,
@ -175,7 +180,7 @@ export const AddToPlaylistContextModal = ({
playlistSelect.find((playlist) => playlist.value === playlistId)
?.label
}] ${err.message}`,
title: 'Failed to add songs to playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
},
@ -186,12 +191,16 @@ export const AddToPlaylistContextModal = ({
const addMessage =
values.skipDuplicates &&
allSongIds.length * values.playlistId.length !== totalUniquesAdded
? `around ${Math.floor(totalUniquesAdded / values.playlistId.length)}`
? `${Math.floor(totalUniquesAdded / values.playlistId.length)}`
: allSongIds.length;
setIsLoading(false);
toast.success({
message: `Added ${addMessage} songs to ${values.playlistId.length} playlist(s)`,
message: t('form.addToPlaylist', {
message: addMessage,
numOfPlaylists: values.playlistId.length,
postProcess: 'sentenceCase',
}),
});
closeModal(id);
return null;
@ -206,12 +215,18 @@ export const AddToPlaylistContextModal = ({
searchable
data={playlistSelect}
disabled={playlistList.isLoading}
label="Playlists"
label={t('form.addToPlaylist.input', {
context: 'playlists',
postProcess: 'titleCase',
})}
size="md"
{...form.getInputProps('playlistId')}
/>
<Switch
label="Skip duplicates"
label={t('form.addToPlaylist.input', {
context: 'skipDuplicates',
postProcess: 'titleCase',
})}
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
/>
<Group position="right">
@ -222,7 +237,7 @@ export const AddToPlaylistContextModal = ({
variant="subtle"
onClick={() => closeModal(id)}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@ -231,7 +246,7 @@ export const AddToPlaylistContextModal = ({
type="submit"
variant="filled"
>
Add
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Group>

View File

@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useRef, useState } from 'react';
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/renderer/api/types';
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
import {
@ -10,12 +10,14 @@ import {
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface CreatePlaylistFormProps {
onCancel: () => void;
}
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
const { t } = useTranslation();
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
@ -69,10 +71,15 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({ message: `Playlist has been created` });
toast.success({
message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),
});
onCancel();
},
},
@ -88,17 +95,26 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
<TextInput
data-autofocus
required
label="Name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
/>
<Group>
{isPublicDisplayed && (
<Switch
label="Is public?"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('_custom.navidrome.public', {
type: 'checkbox',
})}
@ -130,7 +146,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@ -138,7 +154,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
type="submit"
variant="filled"
>
Save
{t('common.create', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>

View File

@ -1,8 +1,9 @@
import { MutableRefObject, useMemo, useRef } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { MutableRefObject, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
@ -45,6 +46,7 @@ interface PlaylistDetailContentProps {
}
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string };
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
@ -102,13 +104,10 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
@ -126,7 +125,7 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: 'Delete playlist',
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
};

View File

@ -4,6 +4,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
RiMoreFill,
RiSettings3Fill,
@ -101,6 +102,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
tableRef,
handleToggleShowQueryBuilder,
}: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -267,19 +269,16 @@ export const PlaylistDetailSongListHeaderFilters = ({
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
navigate(AppRoute.PLAYLISTS, { replace: true });
},
},
);
closeAllModals();
}, [deletePlaylistMutation, detailQuery.data, navigate]);
}, [deletePlaylistMutation, detailQuery.data, navigate, t]);
const openDeletePlaylistModal = () => {
openModal({
@ -288,7 +287,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<Text>Are you sure you want to delete this playlist?</Text>
</ConfirmModal>
),
title: 'Delete playlist(s)',
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
};
@ -345,19 +344,19 @@ export const PlaylistDetailSongListHeaderFilters = ({
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
@ -369,20 +368,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
})
}
>
Edit playlist
{t('action.editPlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
onClick={openDeletePlaylistModal}
>
Delete playlist
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('action.refresh', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
<>
@ -391,7 +390,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
$danger
onClick={handleToggleShowQueryBuilder}
>
Toggle smart playlist editor
{t('action.toggleSmartPlaylistEditor', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
</>
)}

View File

@ -1,6 +1,7 @@
import { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types';
import { Badge, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
@ -23,6 +24,7 @@ export const PlaylistDetailSongListHeader = ({
itemCount,
handleToggleShowQueryBuilder,
}: PlaylistDetailHeaderProps) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
@ -58,7 +60,7 @@ export const PlaylistDetailSongListHeader = ({
itemCount
)}
</Paper>
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
</LibraryHeaderBar>
</PageHeader>
<Paper p="1rem">

View File

@ -1,8 +1,9 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { useListContext } from '../../../context/list-context';
import { useListStoreByKey } from '../../../store/list.store';
@ -42,6 +43,7 @@ export const PlaylistListHeaderFilters = ({
gridRef,
tableRef,
}: PlaylistListHeaderFiltersProps) => {
const { t } = useTranslation();
const { pageKey } = useListContext();
const queryClient = useQueryClient();
const server = useCurrentServer();
@ -285,7 +287,7 @@ export const PlaylistListHeaderFilters = ({
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@ -308,7 +310,7 @@ export const PlaylistListHeaderFilters = ({
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@ -328,27 +330,29 @@ export const PlaylistListHeaderFilters = ({
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
@ -382,7 +386,11 @@ export const PlaylistListHeaderFilters = ({
</DropdownMenu.Item>
{!isGrid && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.generaltableColumns', {
postProcess: 'titleCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@ -399,7 +407,11 @@ export const PlaylistListHeaderFilters = ({
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'titleCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}

View File

@ -11,6 +11,7 @@ import { useContainerQuery } from '/@/renderer/hooks';
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, ServerType } from '/@/renderer/types';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { RiFileAddFill } from 'react-icons/ri';
import { LibraryItem } from '/@/renderer/api/types';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
@ -24,6 +25,7 @@ interface PlaylistListHeaderProps {
}
export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistListHeaderProps) => {
const { t } = useTranslation();
const { pageKey } = useListContext();
const cq = useContainerQuery();
const server = useCurrentServer();
@ -37,7 +39,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
tableRef?.current?.api?.purgeInfiniteCache();
},
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
});
};
@ -74,7 +76,9 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.playlistList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
@ -88,7 +92,10 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
)}
</Paper>
<Button
tooltip={{ label: 'Create playlist', openDelay: 500 }}
tooltip={{
label: t('action.createPlaylist', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="filled"
onClick={handleCreatePlaylistModal}
>

View File

@ -19,6 +19,7 @@ import {
convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
import { SongListSort } from '/@/renderer/api/types';
import {
@ -86,6 +87,7 @@ export const PlaylistQueryBuilder = forwardRef(
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const { t } = useTranslation();
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
@ -354,7 +356,11 @@ export const PlaylistQueryBuilder = forwardRef(
};
const sortOptions = [
{ label: 'Random', type: 'string', value: 'random' },
{
label: t('filter.random', { postProcess: 'titleCase' }),
type: 'string',
value: 'random',
},
...NDSongQueryFields,
];
@ -414,21 +420,21 @@ export const PlaylistQueryBuilder = forwardRef(
<Select
data={[
{
label: 'Ascending',
label: t('common.ascending', { postProcess: 'titleCase' }),
value: 'asc',
},
{
label: 'Descending',
label: t('common.descending', { postProcess: 'titleCase' }),
value: 'desc',
},
]}
label="Order"
label={t('common.order', { postProcess: 'titleCase' })}
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortOrder')}
/>
<NumberInput
label="Limit"
label={t('common.limit', { postProcess: 'titleCase' })}
maxWidth="20%"
width={75}
{...extraFiltersForm.getInputProps('limit')}
@ -444,7 +450,7 @@ export const PlaylistQueryBuilder = forwardRef(
variant="filled"
onClick={handleSaveAs}
>
Save as
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
@ -462,7 +468,7 @@ export const PlaylistQueryBuilder = forwardRef(
icon={<RiSaveLine color="var(--danger-color)" />}
onClick={handleSave}
>
Save and replace
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>

View File

@ -4,6 +4,7 @@ import { CreatePlaylistBody, CreatePlaylistResponse, ServerType } from '/@/rende
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface SaveAsPlaylistFormProps {
body: Partial<CreatePlaylistBody>;
@ -18,6 +19,7 @@ export const SaveAsPlaylistForm = ({
onSuccess,
onCancel,
}: SaveAsPlaylistFormProps) => {
const { t } = useTranslation();
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
@ -40,10 +42,15 @@ export const SaveAsPlaylistForm = ({
{ body: values, serverId },
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: (data) => {
toast.success({ message: `Playlist has been created` });
toast.success({
message: t('form.createPlaylist.success', { postProcess: 'sentenceCase' }),
});
onSuccess(data);
onCancel();
},
@ -60,16 +67,25 @@ export const SaveAsPlaylistForm = ({
<TextInput
data-autofocus
required
label="Name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
@ -78,7 +94,7 @@ export const SaveAsPlaylistForm = ({
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@ -86,7 +102,7 @@ export const SaveAsPlaylistForm = ({
type="submit"
variant="filled"
>
Save
{t('common.save', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>

View File

@ -18,6 +18,8 @@ import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
interface UpdatePlaylistFormProps {
body: Partial<UpdatePlaylistBody>;
@ -27,6 +29,7 @@ interface UpdatePlaylistFormProps {
}
export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlaylistFormProps) => {
const { t } = useTranslation();
const mutation = useUpdatePlaylist({});
const server = useCurrentServer();
@ -60,10 +63,12 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error updating playlist' });
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({ message: `Playlist has been saved` });
onCancel();
},
},
@ -80,23 +85,35 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
<TextInput
data-autofocus
required
label="Name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
/>
{isOwnerDisplayed && (
<Select
data={userList || []}
{...form.getInputProps('_custom.navidrome.ownerId')}
label="Owner"
label={t('form.createPlaylist.input', {
context: 'owner',
postProcess: 'titleCase',
})}
/>
)}
{isPublicDisplayed && (
<Switch
label="Is Public?"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
@ -105,7 +122,7 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@ -113,7 +130,7 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
type="submit"
variant="filled"
>
Save
{t('common.save', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
@ -166,6 +183,6 @@ export const openUpdatePlaylistModal = async (args: {
onCancel={closeAllModals}
/>
),
title: 'Edit playlist',
title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,
});
};

View File

@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { RiArrowDownSLine, RiArrowUpSLine } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { PlaylistDetailSongListContent } from '../components/playlist-detail-song-list-content';
@ -19,6 +20,7 @@ import { PlaylistSongListQuery, ServerType, SongListSort, SortOrder } from '/@/r
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string };
@ -114,7 +116,7 @@ const PlaylistDetailSongListRoute = () => {
}
/>
),
title: 'Save as',
title: t('common.saveAs', { postProcess: 'sentenceCase' }),
});
};

View File

@ -1,4 +1,5 @@
import { useCallback, Dispatch } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
import { AppRoute } from '/@/renderer/router/routes';
@ -10,6 +11,7 @@ interface GoToCommandsProps {
}
export const GoToCommands = ({ setQuery, setPages, handleClose }: GoToCommandsProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const goTo = useCallback(
@ -25,19 +27,35 @@ export const GoToCommands = ({ setQuery, setPages, handleClose }: GoToCommandsPr
return (
<>
<Command.Group>
<Command.Item onSelect={() => goTo(AppRoute.HOME)}>Home</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.SEARCH)}>Search</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.SETTINGS)}>Settings</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.HOME)}>
{t('page.sidebar.home', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.SEARCH)}>
{t('page.sidebar.search', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.SETTINGS)}>
{t('page.sidebar.settings', { postProcess: 'titleCase' })}
</Command.Item>
</Command.Group>
<Command.Group heading="Library">
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_ALBUMS)}>Albums</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_SONGS)}>Tracks</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_ALBUM_ARTISTS)}>
Album artists
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_ALBUMS)}>
{t('page.sidebar.albums', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_SONGS)}>
{t('page.sidebar.tracks', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_ALBUM_ARTISTS)}>
{t('page.sidebar.albumArtists', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_GENRES)}>
{t('page.sidebar.genres', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_FOLDERS)}>
{t('page.sidebar.folders', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.PLAYLISTS)}>
{t('page.sidebar.playlists', { postProcess: 'titleCase' })}
</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_GENRES)}>Genres</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.LIBRARY_FOLDERS)}>Folders</Command.Item>
<Command.Item onSelect={() => goTo(AppRoute.PLAYLISTS)}>Playlists</Command.Item>
</Command.Group>
<Command.Separator />
</>

View File

@ -1,6 +1,7 @@
import { Dispatch, useCallback } from 'react';
import { openModal, closeAllModals } from '@mantine/modals';
import { nanoid } from 'nanoid/non-secure';
import { Dispatch, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import { LibraryItem } from '/@/renderer/api/types';
@ -25,6 +26,7 @@ export const HomeCommands = ({
setPages,
handleClose,
}: HomeCommandsProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const server = useCurrentServer();
@ -34,9 +36,9 @@ export const HomeCommands = ({
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
});
}, [handleClose, server?.type]);
}, [handleClose, server?.type, t]);
const handleSearch = () => {
navigate(
@ -58,21 +60,31 @@ export const HomeCommands = ({
return (
<>
<Command.Group heading="Commands">
<Command.Group heading={t('page.globalSearch.title', { postProcess: 'titleCase' })}>
<Command.Item
value="Search"
value={t('common.search', { postProcess: 'sentenceCase' })}
onSelect={handleSearch}
>
{query ? `Search for "${query}"...` : 'Search...'}
{query
? t('page.globalSearch.commands.searchFor', {
postProcess: 'sentenceCase',
query,
})
: `${t('common.search', { postProcess: 'sentenceCase' })}...`}
</Command.Item>
<Command.Item onSelect={handleCreatePlaylistModal}>
{t('action.createPlaylist', { postProcess: 'sentenceCase' })}...
</Command.Item>
<Command.Item onSelect={handleCreatePlaylistModal}>Create playlist...</Command.Item>
<Command.Item onSelect={() => setPages([...pages, CommandPalettePages.GO_TO])}>
Go to page...
{t('page.globalSearch.commands.goToPage', { postProcess: 'sentenceCase' })}...
</Command.Item>
<Command.Item
onSelect={() => setPages([...pages, CommandPalettePages.MANAGE_SERVERS])}
>
Server commands...
{t('page.globalSearch.commands.serverCommands', {
postProcess: 'sentenceCase',
})}
...
</Command.Item>
</Command.Group>
</>

View File

@ -1,5 +1,6 @@
import { Center, Flex } from '@mantine/core';
import { useCallback, MouseEvent } from 'react';
import { Center, Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
@ -68,6 +69,7 @@ export const LibraryCommandItem = ({
itemType,
handlePlayQueueAdd,
}: LibraryCommandItemProps) => {
const { t } = useTranslation();
let Placeholder = RiAlbumFill;
switch (itemType) {
@ -153,7 +155,10 @@ export const LibraryCommandItem = ({
<Button
compact
size="md"
tooltip={{ label: 'Play', openDelay: 500 }}
tooltip={{
label: t('player.play', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="default"
onClick={(e) => handlePlay(e, id, Play.NOW)}
>
@ -162,7 +167,11 @@ export const LibraryCommandItem = ({
<Button
compact
size="md"
tooltip={{ label: 'Add to queue', openDelay: 500 }}
tooltip={{
label: t('player.addLast', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="default"
onClick={(e) => handlePlay(e, id, Play.LAST)}
>
@ -171,7 +180,10 @@ export const LibraryCommandItem = ({
<Button
compact
size="md"
tooltip={{ label: 'Play next', openDelay: 500 }}
tooltip={{
label: t('player.addNext', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="default"
onClick={(e) => handlePlay(e, id, Play.NEXT)}
>

View File

@ -4,6 +4,7 @@ import { Command, CommandPalettePages } from '/@/renderer/features/search/compon
import { ServerList } from '/@/renderer/features/servers';
import { useAuthStoreActions, useServerList } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
@ -14,6 +15,7 @@ interface ServerCommandsProps {
}
export const ServerCommands = ({ setQuery, setPages, handleClose }: ServerCommandsProps) => {
const { t } = useTranslation();
const serverList = useServerList();
const navigate = useNavigate();
const { setCurrentServer } = useAuthStoreActions();
@ -21,12 +23,12 @@ export const ServerCommands = ({ setQuery, setPages, handleClose }: ServerComman
const handleManageServersModal = useCallback(() => {
openModal({
children: <ServerList />,
title: 'Manage Servers',
title: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),
});
handleClose();
setQuery('');
setPages([CommandPalettePages.HOME]);
}, [handleClose, setPages, setQuery]);
}, [handleClose, setPages, setQuery, t]);
const handleSelectServer = useCallback(
(server: ServerListItem) => {
@ -41,16 +43,20 @@ export const ServerCommands = ({ setQuery, setPages, handleClose }: ServerComman
return (
<>
<Command.Group heading="Select a server">
<Command.Group
heading={t('page.appMenu.selectServer', { postProcess: 'sentenceCase' })}
>
{Object.keys(serverList).map((key) => (
<Command.Item
key={key}
onSelect={() => handleSelectServer(serverList[key])}
>{`Switch to ${serverList[key].name}...`}</Command.Item>
>{`${serverList[key].name}...`}</Command.Item>
))}
</Command.Group>
<Command.Group heading="Manage">
<Command.Item onSelect={handleManageServersModal}>Manage servers...</Command.Item>
<Command.Group heading={t('common.manage', { postProcess: 'sentenceCase' })}>
<Command.Item onSelect={handleManageServersModal}>
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}...
</Command.Item>
</Command.Group>
<Command.Separator />
</>

View File

@ -7,9 +7,10 @@ import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import { useTranslation } from 'react-i18next';
const localSettings = isElectron() ? window.electron.localSettings : null;
@ -24,10 +25,10 @@ interface AddServerFormProps {
}
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const { t } = useTranslation();
const focusTrapRef = useFocusTrap(true);
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer } = useAuthStoreActions();
const serverList = useAuthStore((state) => state.serverList);
const form = useForm({
initialValues: {
@ -47,7 +48,9 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const authFunction = api.controller.authenticate;
if (!authFunction) {
return toast.error({ message: 'Selected server type is invalid' });
return toast.error({
message: t('error.invalidServer', { postProcess: 'sentenceCase' }),
});
}
try {
@ -63,7 +66,9 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
);
if (!data) {
return toast.error({ message: 'Authentication failed' });
return toast.error({
message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),
});
}
const serverItem = {
@ -81,17 +86,19 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
setCurrentServer(serverItem);
closeAllModals();
if (Object.keys(serverList).length === 0) {
toast.success({ message: 'Server has been added, reloading...' });
setTimeout(() => window.location.reload(), 2000);
} else {
toast.success({ message: 'Server has been added' });
}
toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
});
if (localSettings && values.savePassword) {
const saved = await localSettings.passwordSet(values.password, serverItem.id);
if (!saved) {
toast.error({ message: 'Could not save password' });
toast.error({
message: t('form.addServer.error', {
context: 'savePassword',
postProcess: 'sentenceCase',
}),
});
}
}
} catch (err: any) {
@ -115,25 +122,40 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
<Group grow>
<TextInput
data-autofocus
label="Name"
label={t('form.addServer.input', {
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('name')}
/>
<TextInput
label="Url"
label={t('form.addServer.input', {
context: 'url',
postProcess: 'titleCase',
})}
{...form.getInputProps('url')}
/>
</Group>
<TextInput
label="Username"
label={t('form.addServer.input', {
context: 'username',
postProcess: 'titleCase',
})}
{...form.getInputProps('username')}
/>
<PasswordInput
label="Password"
label={t('form.addServer.input', {
context: 'password',
postProcess: 'titleCase',
})}
{...form.getInputProps('password')}
/>
{localSettings && form.values.type === ServerType.NAVIDROME && (
<Checkbox
label="Save password"
label={t('form.addServer.input', {
context: 'savePassword',
postProcess: 'titleCase',
})}
{...form.getInputProps('savePassword', {
type: 'checkbox',
})}
@ -141,7 +163,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
)}
{form.values.type === ServerType.SUBSONIC && (
<Checkbox
label="Enable legacy authentication"
label={t('form.addServer.input', {
context: 'legacyAuthentication',
postProcess: 'titleCase',
})}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
@ -150,7 +175,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
@ -158,7 +183,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
type="submit"
variant="filled"
>
Add
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>

View File

@ -5,11 +5,13 @@ import { useForm } from '@mantine/form';
import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiInformationLine } from 'react-icons/ri';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import i18n from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
@ -22,7 +24,7 @@ interface EditServerFormProps {
const ModifiedFieldIndicator = () => {
return (
<Tooltip label="Field has been modified">
<Tooltip label={i18n.t('common.modified', { postProcess: 'titleCase' }) as string}>
<span>
<RiInformationLine color="red" />
</span>
@ -31,6 +33,7 @@ const ModifiedFieldIndicator = () => {
};
export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditServerFormProps) => {
const { t } = useTranslation();
const { updateServer } = useAuthStoreActions();
const focusTrapRef = useFocusTrap();
const [isLoading, setIsLoading] = useState(false);
@ -54,7 +57,9 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer
const authFunction = api.controller.authenticate;
if (!authFunction) {
return toast.error({ message: 'Selected server type is invalid' });
return toast.error({
message: t('error.invalidServer', { postProcess: 'sentenceCase' }),
});
}
try {
@ -70,7 +75,9 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer
);
if (!data) {
return toast.error({ message: 'Authentication failed' });
return toast.error({
message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),
});
}
const serverItem = {
@ -85,13 +92,20 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer
};
updateServer(server.id, serverItem);
toast.success({ message: 'Server has been updated' });
toast.success({
message: t('form.updateServer.title', { postProcess: 'sentenceCase' }),
});
if (localSettings) {
if (values.savePassword) {
const saved = await localSettings.passwordSet(values.password, server.id);
if (!saved) {
toast.error({ message: 'Could not save password' });
toast.error({
message: t('form.addServer.error', {
context: 'savePassword',
postProcess: 'sentenceCase',
}),
});
}
} else {
localSettings.passwordRemove(server.id);
@ -111,31 +125,46 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer
<Stack ref={focusTrapRef}>
<TextInput
required
label="Name"
label={t('form.addServer.input', {
context: 'name',
postProcess: 'titleCase',
})}
rightSection={form.isDirty('name') && <ModifiedFieldIndicator />}
{...form.getInputProps('name')}
/>
<TextInput
required
label="Url"
label={t('form.addServer.input', {
context: 'url',
postProcess: 'titleCase',
})}
rightSection={form.isDirty('url') && <ModifiedFieldIndicator />}
{...form.getInputProps('url')}
/>
<TextInput
required
label="Username"
label={t('form.addServer.input', {
context: 'username',
postProcess: 'titleCase',
})}
rightSection={form.isDirty('username') && <ModifiedFieldIndicator />}
{...form.getInputProps('username')}
/>
<PasswordInput
data-autofocus
required
label="Password"
label={t('form.addServer.input', {
context: 'password',
postProcess: 'titleCase',
})}
{...form.getInputProps('password')}
/>
{localSettings && isNavidrome && (
<Checkbox
label="Save password"
label={t('form.addServer.input', {
context: 'savePassword',
postProcess: 'titleCase',
})}
{...form.getInputProps('savePassword', {
type: 'checkbox',
})}
@ -143,7 +172,10 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer
)}
{isSubsonic && (
<Checkbox
label="Enable legacy authentication"
label={t('form.addServer.input', {
context: 'legacyAuthentication',
postProcess: 'titleCase',
})}
{...form.getInputProps('legacyAuth', {
type: 'checkbox',
})}
@ -154,14 +186,14 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer
variant="subtle"
onClick={onCancel}
>
Cancel
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
loading={isLoading}
type="submit"
variant="filled"
>
Save
{t('common.save', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>

View File

@ -4,6 +4,7 @@ import { Accordion, Button, ContextModalVars, Switch } from '/@/renderer/compone
import { useLocalStorage } from '@mantine/hooks';
import { openContextModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiAddFill, RiServerFill } from 'react-icons/ri';
import { AddServerForm } from '/@/renderer/features/servers/components/add-server-form';
import { ServerListItem } from '/@/renderer/features/servers/components/server-list-item';
@ -13,6 +14,7 @@ import { titleCase } from '/@/renderer/utils';
const localSettings = isElectron() ? window.electron.localSettings : null;
export const ServerList = () => {
const { t } = useTranslation();
const serverListQuery = useServerList();
const handleAddServerModal = () => {
@ -24,7 +26,7 @@ export const ServerList = () => {
),
},
modal: 'base',
title: 'Add server',
title: t('form.addServer.title', { postProcess: 'titleCase' }),
});
};
@ -74,7 +76,7 @@ export const ServerList = () => {
variant="filled"
onClick={handleAddServerModal}
>
Add server
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
<Stack>
@ -104,14 +106,18 @@ export const ServerList = () => {
<Group>
<Switch
checked={ignoreCORS === 'true'}
label="Ignore CORS (requires restart)"
label={t('form.addServer.ignoreCors', {
postProcess: 'sentenceCase',
})}
onChange={handleUpdateIgnoreCORS}
/>
</Group>
<Group>
<Switch
checked={ignoreSSL === 'true'}
label="Ignore SSL (requires restart)"
label={t('form.addServer.ignoreSsl', {
postProcess: 'sentenceCase',
})}
onChange={handleUpdateIgnoreSSL}
/>
</Group>

View File

@ -10,8 +10,10 @@ import {
useGeneralSettings,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { useTranslation } from 'react-i18next';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontType } from '/@/renderer/types';
import i18n, { languages } from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
const ipc = isElectron() ? window.electron.ipc : null;
@ -33,17 +35,32 @@ const FONT_OPTIONS: Font[] = [
{ label: 'Work Sans', value: 'Work Sans' },
];
const FONT_TYPES: Font[] = [{ label: 'Built-in font', value: FontType.BUILT_IN }];
const FONT_TYPES: Font[] = [
{
label: i18n.t('setting.fontType', {
context: 'optionBuiltIn',
postProcess: 'sentenceCase',
}),
value: FontType.BUILT_IN,
},
];
if (window.queryLocalFonts) {
FONT_TYPES.push({ label: 'System font', value: FontType.SYSTEM });
FONT_TYPES.push({
label: i18n.t('setting.fontType', { context: 'optionSystem', postProcess: 'sentenceCase' }),
value: FontType.SYSTEM,
});
}
if (isElectron()) {
FONT_TYPES.push({ label: 'Custom font', value: FontType.CUSTOM });
FONT_TYPES.push({
label: i18n.t('setting.fontType', { context: 'optionCustom', postProcess: 'sentenceCase' }),
value: FontType.CUSTOM,
});
}
export const ApplicationSettings = () => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const fontSettings = useFontSettings();
const { setSettings } = useSettingsStoreActions();
@ -100,7 +117,9 @@ export const ApplicationSettings = () => {
const status = await navigator.permissions.query({ name: 'local-fonts' });
if (status.state === 'denied') {
throw new Error('Access denied to local fonts');
throw new Error(
t('error.localFontAccessDenied', { postProcess: 'sentenceCase' }),
);
}
const data = await window.queryLocalFonts();
@ -112,7 +131,7 @@ export const ApplicationSettings = () => {
);
} catch (error) {
toast.error({
message: 'An error occurred when trying to get system fonts',
message: t('error.systemFontError', { postProcess: 'sentenceCase' }),
});
setSettings({
@ -125,19 +144,32 @@ export const ApplicationSettings = () => {
}
};
getFonts();
}, [fontSettings, localFonts, setSettings]);
}, [fontSettings, localFonts, setSettings, t]);
const handleChangeLanguage = (e: string) => {
setSettings({
general: {
...settings,
language: e,
},
});
};
const options: SettingOption[] = [
{
control: (
<Select
disabled
data={[]}
data={languages}
value={settings.language}
onChange={handleChangeLanguage}
/>
),
description: 'Sets the application language',
description: t('setting.language', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Language',
title: t('setting.language', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -155,10 +187,12 @@ export const ApplicationSettings = () => {
}}
/>
),
description:
'What font to use. Built-in font selects one of the fonts provided by Feishin. System font allows you to select any font provided by your OS. Custom allows you to provide your own font',
description: t('setting.fontType', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: FONT_TYPES.length === 1,
title: 'Use system font',
title: t('setting.fontType', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -177,9 +211,9 @@ export const ApplicationSettings = () => {
}}
/>
),
description: 'Sets the application content font',
description: t('setting.font', { context: 'description', postProcess: 'sentenceCase' }),
isHidden: localFonts && fontSettings.type !== FontType.BUILT_IN,
title: 'Font (Content)',
title: t('setting.font', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -199,9 +233,9 @@ export const ApplicationSettings = () => {
}}
/>
),
description: 'Sets the application content font',
description: t('setting.font', { context: 'description', postProcess: 'sentenceCase' }),
isHidden: !localFonts || fontSettings.type !== FontType.SYSTEM,
title: 'Font (Content)',
title: t('setting.font', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -219,9 +253,12 @@ export const ApplicationSettings = () => {
}
/>
),
description: 'Path to custom font',
description: t('setting.customFontPath', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: fontSettings.type !== FontType.CUSTOM,
title: 'Path to custom font',
title: t('setting.customFontPath', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -244,9 +281,14 @@ export const ApplicationSettings = () => {
}}
/>
),
description: 'Sets the application zoom factor in percent',
description: t('setting.zoom', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Zoom factor',
title: t('setting.zoom', {
postProcess: 'sentenceCase',
}),
},
];

View File

@ -1,5 +1,6 @@
import isElectron from 'is-electron';
import { Group } from '@mantine/core';
import { t } from 'i18next';
import isElectron from 'is-electron';
import { Select, Tooltip, NumberInput, Switch, Slider } from '/@/renderer/components';
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
import {
@ -8,15 +9,29 @@ import {
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
const localSettings = isElectron() ? window.electron.localSettings : null;
const SIDE_QUEUE_OPTIONS = [
{ label: 'Fixed', value: 'sideQueue' },
{ label: 'Floating', value: 'sideDrawerQueue' },
{
label: t('setting.sidePlayQueueStyle', {
context: 'optionAttached',
postProcess: 'sentenceCase',
}),
value: 'sideQueue',
},
{
label: t('setting.sidePlayQueueStyle', {
context: 'optionDetached',
postProcess: 'sentenceCase',
}),
value: 'sideDrawerQueue',
},
];
export const ControlSettings = () => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
@ -39,14 +54,17 @@ export const ControlSettings = () => {
}
/>
),
description: 'Show or hide the skip buttons on the playerbar',
description: t('setting.showSkipButtons', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Show skip buttons',
title: t('setting.showSkipButtons', { postProcess: 'sentenceCase' }),
},
{
control: (
<Group>
<Tooltip label="Backward">
<Tooltip label={t('common.backward', { postProcess: 'titleCase' })}>
<NumberInput
defaultValue={settings.skipButtons.skipBackwardSeconds}
min={0}
@ -66,7 +84,7 @@ export const ControlSettings = () => {
}
/>
</Tooltip>
<Tooltip label="Forward">
<Tooltip label={t('common.forward', { postProcess: 'titleCase' })}>
<NumberInput
defaultValue={settings.skipButtons.skipForwardSeconds}
min={0}
@ -88,18 +106,38 @@ export const ControlSettings = () => {
</Tooltip>
</Group>
),
description:
'The number (in seconds) to skip forward or backward when using the skip buttons',
description: t('setting.skipDuration', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Skip duration',
title: t('setting.skipDuration', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={[
{ label: 'Now', value: Play.NOW },
{ label: 'Next', value: Play.NEXT },
{ label: 'Last', value: Play.LAST },
{
label: t('setting.playButtonBehavior', {
context: 'optionPlay',
postProcess: 'titleCase',
}),
value: Play.NOW,
},
{
label: t('setting.playButtonBehavior', {
context: 'optionAddNext',
postProcess: 'titleCase',
}),
value: Play.NEXT,
},
{
label: t('setting.playButtonBehavior', {
context: 'optionAddLast',
postProcess: 'titleCase',
}),
value: Play.LAST,
},
]}
defaultValue={settings.playButtonBehavior}
onChange={(e) =>
@ -112,9 +150,12 @@ export const ControlSettings = () => {
}
/>
),
description: 'The default behavior of the play button when adding songs to the queue',
description: t('setting.playButtonBehavior', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Play button behavior',
title: t('setting.playButtonBehavior', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -131,9 +172,12 @@ export const ControlSettings = () => {
}}
/>
),
description: 'The style of the sidebar play queue',
description: t('setting.sidePlayQueueStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Side play queue style',
title: t('setting.sidePlayQueueStyle', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -149,10 +193,12 @@ export const ControlSettings = () => {
}}
/>
),
description:
'Display a hover icon on the right side of the application view the play queue',
description: t('setting.sidePlayQueueStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Show floating queue hover area',
title: t('setting.floatingQueueArea', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -171,10 +217,12 @@ export const ControlSettings = () => {
}}
/>
),
description:
'The amount of volume to change when scrolling the mouse wheel on the volume slider',
description: t('setting.volumeWheelStep', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Volume wheel step',
title: t('setting.volumeWheelStep', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -191,9 +239,12 @@ export const ControlSettings = () => {
}}
/>
),
description: 'When exiting, save the current play queue and restore it when reopening',
description: t('setting.savePlayQueue', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Save play queue',
title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -210,10 +261,12 @@ export const ControlSettings = () => {
}
/>
),
description:
'When navigating to a playlist, go to the playlist song list page instead of the default page',
description: t('setting.skipPlaylistPage', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Go to playlist songs page by default',
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
},
];

View File

@ -3,10 +3,12 @@ import { SettingsSection } from '/@/renderer/features/settings/components/settin
import { useRemoteSettings, useSettingsStoreActions } from '/@/renderer/store';
import { NumberInput, Switch, Text, TextInput, toast } from '/@/renderer/components';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
const remote = isElectron() ? window.electron.remote : null;
export const RemoteSettings = () => {
const { t } = useTranslation();
const settings = useRemoteSettings();
const { setSettings } = useSettingsStoreActions();
@ -25,7 +27,9 @@ export const RemoteSettings = () => {
} else {
toast.error({
message: errorMsg,
title: enabled ? 'Error enabling remote' : 'Error disabling remote',
title: enabled
? t('error.remoteEnableError', { postProcess: 'sentenceCase' })
: t('error.remoteDisableError', { postProcess: 'sentenceCase' }),
});
}
}, 50);
@ -40,12 +44,12 @@ export const RemoteSettings = () => {
},
});
toast.warn({
message: 'To have your port change take effect, stop and restart the server',
message: t('error.remotePortWarning', { postProcess: 'sentenceCase' }),
});
} else {
toast.error({
message: errorMsg,
title: 'Error setting port',
title: t('error.remotePortError', { postProcess: 'sentenceCase' }),
});
}
}, 100);
@ -56,7 +60,6 @@ export const RemoteSettings = () => {
{
control: (
<Switch
aria-label="Enable remote control server"
defaultChecked={settings.enabled}
onChange={async (e) => {
const enabled = e.currentTarget.checked;
@ -65,8 +68,15 @@ export const RemoteSettings = () => {
/>
),
description: (
<div>
Start an HTTP server to remotely control Feishin. This will listen on{' '}
<Text
$noSelect
$secondary
size="sm"
>
{t('setting.enableRemote', {
context: 'description',
postProcess: 'sentenceCase',
})}{' '}
<a
href={url}
rel="noreferrer noopener"
@ -74,15 +84,14 @@ export const RemoteSettings = () => {
>
{url}
</a>
</div>
</Text>
),
isHidden,
title: 'Enable remote control',
title: t('setting.enableRemote', { postProcess: 'sentenceCase' }),
},
{
control: (
<NumberInput
aria-label="Set remote port"
max={65535}
value={settings.port}
onBlur={async (e) => {
@ -92,15 +101,16 @@ export const RemoteSettings = () => {
}}
/>
),
description:
'Remote server port. Changes here only take effect when you enable the remote',
description: t('setting.remotePort', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden,
title: 'Remove server port',
title: t('setting.remotePort', { postProcess: 'sentenceCase' }),
},
{
control: (
<TextInput
aria-label="Set remote username"
defaultValue={settings.username}
onBlur={(e) => {
const username = e.currentTarget.value;
@ -115,15 +125,16 @@ export const RemoteSettings = () => {
}}
/>
),
description:
'Username that must be provided to access remote. If both username and password are empty, disable authentication',
description: t('setting.remoteUsername', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden,
title: 'Remote username',
title: t('setting.remoteUsername', { postProcess: 'sentenceCase' }),
},
{
control: (
<TextInput
aria-label="Set remote password"
defaultValue={settings.password}
onBlur={(e) => {
const password = e.currentTarget.value;
@ -138,22 +149,14 @@ export const RemoteSettings = () => {
}}
/>
),
description: 'Password to access remote',
description: t('setting.remotePassword', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden,
title: 'Remote password',
title: t('setting.remotePassword', { postProcess: 'sentenceCase' }),
},
];
return (
<>
<SettingsSection options={controlOptions} />
<Text size="lg">
<b>
NOTE: these credentials are by default transferred insecurely. Do not use a
password you care about. Changing username/password will disconnect clients and
require them to reauthenticate
</b>
</Text>
</>
);
return <SettingsSection options={controlOptions} />;
};

View File

@ -2,6 +2,7 @@ import { ChangeEvent, useCallback, useState } from 'react';
import { Group } from '@mantine/core';
import { Reorder, useDragControls } from 'framer-motion';
import isEqual from 'lodash/isEqual';
import { useTranslation } from 'react-i18next';
import { MdDragIndicator } from 'react-icons/md';
import { Button, Checkbox, Switch } from '/@/renderer/components';
import { useSettingsStoreActions, useGeneralSettings } from '../../../../store/settings.store';
@ -54,6 +55,7 @@ const DraggableSidebarItem = ({ item, handleChangeDisabled }: DraggableSidebarIt
};
export const SidebarSettings = () => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const { setSidebarItems, setSettings } = useSettingsStoreActions();
@ -107,8 +109,11 @@ export const SidebarSettings = () => {
onChange={handleSetSidebarPlaylistList}
/>
}
description="Show playlist list in sidebar"
title="Sidebar playlist list"
description={t('setting.sidebarPlaylistList', {
context: 'description',
postProcess: 'sentenceCase',
})}
title={t('setting.sidebarPlaylistList', { postProcess: 'sentenceCase' })}
/>
<SettingsOptions
control={
@ -117,8 +122,11 @@ export const SidebarSettings = () => {
onChange={handleSetSidebarCollapsedNavigation}
/>
}
description="Show navigation buttons in the collapsed sidebar"
title="Sidebar (collapsed) navigation"
description={t('setting.sidebarPlaylistList', {
context: 'description',
postProcess: 'sentenceCase',
})}
title={t('setting.sidebarCollapsedNavigation', { postProcess: 'sentenceCase' })}
/>
<SettingsOptions
control={
@ -128,11 +136,14 @@ export const SidebarSettings = () => {
variant="filled"
onClick={handleSave}
>
Save sidebar configuration
{t('common.save', { postProcess: 'titleCase' })}
</Button>
}
description="Select the items and order in which they appear in the sidebar"
title="Sidebar configuration"
description={t('setting.sidebarCollapsedNavigation', {
context: 'description',
postProcess: 'sentenceCase',
})}
title={t('setting.sidebarConfiguration', { postProcess: 'sentenceCase' })}
/>
<Reorder.Group
axis="y"

View File

@ -7,8 +7,10 @@ import {
import { THEME_DATA } from '/@/renderer/hooks';
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { AppTheme } from '/@/renderer/themes/types';
import { useTranslation } from 'react-i18next';
export const ThemeSettings = () => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
@ -27,9 +29,12 @@ export const ThemeSettings = () => {
}}
/>
),
description: 'Follows the system-defined light or dark preference',
description: t('setting.useSystemTheme', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: 'Use system theme',
title: t('setting.useSystemTheme', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -46,9 +51,12 @@ export const ThemeSettings = () => {
}}
/>
),
description: 'Sets the default theme',
description: t('setting.theme', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.followSystemTheme,
title: 'Theme',
title: t('setting.theme', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -65,9 +73,12 @@ export const ThemeSettings = () => {
}}
/>
),
description: 'Sets the dark theme',
description: t('setting.themeDark', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !settings.followSystemTheme,
title: 'Theme (dark)',
title: t('setting.themeDark', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -84,9 +95,12 @@ export const ThemeSettings = () => {
}}
/>
),
description: 'Sets the light theme',
description: t('setting.themeLight', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !settings.followSystemTheme,
title: 'Theme (light)',
title: t('setting.themeLight', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -114,8 +128,11 @@ export const ThemeSettings = () => {
<Text>{settings.accent}</Text>
</Stack>
),
description: 'Sets the accent color',
title: 'Accent color',
description: t('setting.accentColor', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.accentColor', { postProcess: 'sentenceCase' }),
},
];

View File

@ -2,48 +2,92 @@ import { useCallback, useMemo, useState, KeyboardEvent, ChangeEvent } from 'reac
import { Group } from '@mantine/core';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { RiDeleteBinLine, RiEditLine, RiKeyboardBoxLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button, TextInput, Checkbox } from '/@/renderer/components';
import { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import i18n from '/@/i18n/i18n';
const ipc = isElectron() ? window.electron.ipc : null;
const BINDINGS_MAP: Record<BindingActions, string> = {
browserBack: 'Browser back',
browserForward: 'Browser forward',
favoriteCurrentAdd: 'Favorite current song',
favoriteCurrentRemove: 'Unfavorite current song',
favoriteCurrentToggle: 'Toggle current song favorite',
favoritePreviousAdd: 'Favorite previous song',
favoritePreviousRemove: 'Unfavorite previous song',
favoritePreviousToggle: 'Toggle previous song favorite',
globalSearch: 'Global search',
localSearch: 'In-page search',
next: 'Next track',
pause: 'Pause',
play: 'Play',
playPause: 'Play / Pause',
previous: 'Previous track',
rate0: 'Rating clear',
rate1: 'Rating 1 star',
rate2: 'Rating 2 star',
rate3: 'Rating 3 star',
rate4: 'Rating 4 star',
rate5: 'Rating 5 star',
skipBackward: 'Skip backward',
skipForward: 'Skip forward',
stop: 'Stop',
toggleFullscreenPlayer: 'Toggle fullscreen player',
toggleQueue: 'Toggle queue',
toggleRepeat: 'Toggle repeat',
toggleShuffle: 'Toggle shuffle',
volumeDown: 'Volume down',
volumeMute: 'Volume mute',
volumeUp: 'Volume up',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
browserBack: i18n.t('setting.hotkey', { context: 'browserBack', postProcess: 'sentenceCase' }),
browserForward: i18n.t('setting.hotkey', {
context: 'browserForward',
postProcess: 'sentenceCase',
}),
favoriteCurrentAdd: i18n.t('setting.hotkey', {
context: 'favoriteCurrentSong',
postProcess: 'sentenceCase',
}),
favoriteCurrentRemove: i18n.t('setting.hotkey', {
context: 'unfavoriteCurrentSong',
postProcess: 'sentenceCase',
}),
favoriteCurrentToggle: i18n.t('setting.hotkey', {
context: 'toggleCurrentSongFavorite',
postProcess: 'sentenceCase',
}),
favoritePreviousAdd: i18n.t('setting.hotkey', {
context: 'favoritePreviousSong',
postProcess: 'sentenceCase',
}),
favoritePreviousRemove: i18n.t('setting.hotkey', {
context: 'unfavoritePreviousSong',
postProcess: 'sentenceCase',
}),
favoritePreviousToggle: i18n.t('setting.hotkey', {
context: 'togglePreviousSongFavorite',
postProcess: 'sentenceCase',
}),
globalSearch: i18n.t('setting.hotkey', {
context: 'globalSearch',
postProcess: 'sentenceCase',
}),
localSearch: i18n.t('setting.hotkey', { context: 'localSearch', postProcess: 'sentenceCase' }),
next: i18n.t('setting.hotkey', { context: 'playbackNext', postProcess: 'sentenceCase' }),
pause: i18n.t('setting.hotkey', { context: 'playbackPause', postProcess: 'sentenceCase' }),
play: i18n.t('setting.hotkey', { context: 'playbackPlay', postProcess: 'sentenceCase' }),
playPause: i18n.t('setting.hotkey', {
context: 'playbackPlayPause',
postProcess: 'sentenceCase',
}),
previous: i18n.t('setting.hotkey', {
context: 'playbackPrevious',
postProcess: 'sentenceCase',
}),
rate0: i18n.t('setting.hotkey', { context: 'rate0', postProcess: 'sentenceCase' }),
rate1: i18n.t('setting.hotkey', { context: 'rate1', postProcess: 'sentenceCase' }),
rate2: i18n.t('setting.hotkey', { context: 'rate2', postProcess: 'sentenceCase' }),
rate3: i18n.t('setting.hotkey', { context: 'rate3', postProcess: 'sentenceCase' }),
rate4: i18n.t('setting.hotkey', { context: 'rate4', postProcess: 'sentenceCase' }),
rate5: i18n.t('setting.hotkey', { context: 'rate5', postProcess: 'sentenceCase' }),
skipBackward: i18n.t('setting.hotkey', {
context: 'skipBackward',
postProcess: 'sentenceCase',
}),
skipForward: i18n.t('setting.hotkey', { context: 'skipForward', postProcess: 'sentenceCase' }),
stop: i18n.t('setting.hotkey', { context: 'playbackStop', postProcess: 'sentenceCase' }),
toggleFullscreenPlayer: i18n.t('setting.hotkey', {
context: 'toggleFullScreenPlayer',
postProcess: 'sentenceCase',
}),
toggleQueue: i18n.t('setting.hotkey', { context: 'toggleQueue', postProcess: 'sentenceCase' }),
toggleRepeat: i18n.t('setting.hotkey', {
context: 'toggleRepeat',
postProcess: 'sentenceCase',
}),
toggleShuffle: i18n.t('setting.hotkey', {
context: 'toggleShuffle',
postProcess: 'sentenceCase',
}),
volumeDown: i18n.t('setting.hotkey', { context: 'volumeDown', postProcess: 'sentenceCase' }),
volumeMute: i18n.t('setting.hotkey', { context: 'volumeMute', postProcess: 'sentenceCase' }),
volumeUp: i18n.t('setting.hotkey', { context: 'volumeUp', postProcess: 'sentenceCase' }),
zoomIn: i18n.t('setting.hotkey', { context: 'zoomIn', postProcess: 'sentenceCase' }),
zoomOut: i18n.t('setting.hotkey', { context: 'zoomOut', postProcess: 'sentenceCase' }),
};
const HotkeysContainer = styled.div`
@ -59,6 +103,7 @@ const HotkeysContainer = styled.div`
`;
export const HotkeyManagerSettings = () => {
const { t } = useTranslation();
const { bindings, globalMediaHotkeys } = useHotkeySettings();
const { setSettings } = useSettingsStoreActions();
const [selected, setSelected] = useState<BindingActions | null>(null);
@ -175,8 +220,11 @@ export const HotkeyManagerSettings = () => {
<>
<SettingsOptions
control={<></>}
description="Configure application hotkeys. Toggle the checkbox to set as a global hotkey (desktop only)"
title="Application hotkeys"
description={t('setting.applicationHotkeys', {
context: 'description',
postProcess: 'sentenceCase',
})}
title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })}
/>
<HotkeysContainer>
{Object.keys(bindings)

View File

@ -1,4 +1,5 @@
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { SettingOption, SettingsSection } from '../settings-section';
import { Switch } from '/@/renderer/components';
import { useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';
@ -6,6 +7,7 @@ import { useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';
const localSettings = isElectron() ? window.electron.localSettings : null;
export const WindowHotkeySettings = () => {
const { t } = useTranslation();
const settings = useHotkeySettings();
const { setSettings } = useSettingsStoreActions();
@ -13,7 +15,6 @@ export const WindowHotkeySettings = () => {
{
control: (
<Switch
aria-label="Toggle global media hotkeys"
defaultChecked={settings.globalMediaHotkeys}
disabled={!isElectron()}
onChange={(e) => {
@ -33,10 +34,12 @@ export const WindowHotkeySettings = () => {
}}
/>
),
description:
'Enable or disable the usage of your system media hotkeys to control the audio player',
description: t('setting.globalMediaHotkeys', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Global media hotkeys',
title: t('setting.globalMediaHotkeys', { postProcess: 'sentenceCase' }),
},
];

View File

@ -9,6 +9,7 @@ import {
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { PlaybackType, PlayerStatus, PlaybackStyle, CrossfadeStyle } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@ -18,6 +19,7 @@ const getAudioDevice = async () => {
};
export const AudioSettings = () => {
const { t } = useTranslation();
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const status = useCurrentStatus();
@ -30,13 +32,17 @@ export const AudioSettings = () => {
.then((dev) =>
setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))),
)
.catch(() => toast.error({ message: 'Error fetching audio devices' }));
.catch(() =>
toast.error({
message: t('error.audioDeviceFetchError', { postProcess: 'sentenceCase' }),
}),
);
};
if (settings.type === PlaybackType.WEB) {
getAudioDevices();
}
}, [settings.type]);
}, [settings.type, t]);
const audioOptions: SettingOption[] = [
{
@ -61,10 +67,16 @@ export const AudioSettings = () => {
}}
/>
),
description: 'The audio player to use for playback',
description: t('setting.audioPlayer', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Audio player',
note:
status === PlayerStatus.PLAYING
? t('common.playerMustBePaused', { postProcess: 'sentenceCase' })
: undefined,
title: t('setting.audioPlayer', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -76,16 +88,31 @@ export const AudioSettings = () => {
onChange={(e) => setSettings({ playback: { ...settings, audioDeviceId: e } })}
/>
),
description: 'The audio device to use for playback (web player only)',
description: t('setting.audioDevice', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron() || settings.type !== PlaybackType.WEB,
title: 'Audio device',
title: t('setting.audioDevice', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={[
{ label: 'Normal', value: PlaybackStyle.GAPLESS },
{ label: 'Crossfade', value: PlaybackStyle.CROSSFADE },
{
label: t('setting.playbackStyle', {
context: 'optionNormal',
postProcess: 'titleCase',
}),
value: PlaybackStyle.GAPLESS,
},
{
label: t('setting.playbackStyle', {
context: 'optionCrossFade',
postProcess: 'titleCase',
}),
value: PlaybackStyle.CROSSFADE,
},
]}
defaultValue={settings.style}
disabled={settings.type !== PlaybackType.WEB || status === PlayerStatus.PLAYING}
@ -94,10 +121,16 @@ export const AudioSettings = () => {
}
/>
),
description: 'Adjust the playback style (web player only)',
description: t('setting.playbackStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Playback style',
title: t('setting.playbackStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -116,10 +149,15 @@ export const AudioSettings = () => {
}
/>
),
description: 'Adjust the crossfade duration (web player only)',
description: t('setting.crossfadeDuration', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Duration',
title: t('setting.crossfadeDuration', {
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -153,10 +191,13 @@ export const AudioSettings = () => {
}}
/>
),
description: 'Change the crossfade algorithm (web player only)',
description: t('setting.crossfadeStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Style',
title: t('setting.crossfadeStyle', { postProcess: 'sentenceCase' }),
},
];

View File

@ -7,6 +7,7 @@ import { MultiSelect, MultiSelectProps, NumberInput, Switch } from '/@/renderer/
import isElectron from 'is-electron';
import styled from 'styled-components';
import { LyricSource } from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
const localSettings = isElectron() ? window.electron.localSettings : null;
@ -17,6 +18,7 @@ const WorkingButtonSelect = styled(MultiSelect)<MultiSelectProps>`
`;
export const LyricSettings = () => {
const { t } = useTranslation();
const settings = useLyricsSettings();
const { setSettings } = useSettingsStoreActions();
@ -36,8 +38,11 @@ export const LyricSettings = () => {
}}
/>
),
description: 'Enable or disable following of current lyric',
title: 'Follow current lyric',
description: t('setting.followLyric', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.followLyric', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -54,9 +59,12 @@ export const LyricSettings = () => {
}}
/>
),
description: 'Enable or disable fetching lyrics for the current song',
description: t('setting.lyricFetch', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Fetch lyrics from the internet',
title: t('setting.lyricFetch', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -77,10 +85,12 @@ export const LyricSettings = () => {
}}
/>
),
description:
'Lyric fetchers should be added in order of preference. This is the order in which they will be queried.',
description: t('setting.lyricFetchProvider', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Providers to fetch lyrics',
title: t('setting.lyricFetchProvider', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -99,10 +109,12 @@ export const LyricSettings = () => {
}}
/>
),
description:
'Lyric offset (in milliseconds). Positive values mean that lyrics are shown later, and negative mean that lyrics are shown earlier',
description: t('setting.lyricOffset', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Lyric offset',
title: t('setting.lyricOffset', { postProcess: 'sentenceCase' }),
},
];

View File

@ -12,6 +12,7 @@ import {
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
const localSettings = isElectron() ? window.electron.localSettings : null;
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@ -60,6 +61,7 @@ export const getMpvProperties = (settings: SettingsState['playback']['mpvPropert
};
export const MpvSettings = () => {
const { t } = useTranslation();
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
@ -116,10 +118,13 @@ export const MpvSettings = () => {
onChange={handleSetMpvPath}
/>
),
description: 'The location of your mpv executable',
description: t('setting.mpvExecutablePath', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: 'MPV executable path',
title: t('setting.mpvExecutablePath', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -128,9 +133,10 @@ export const MpvSettings = () => {
autosize
defaultValue={settings.mpvExtraParameters.join('\n')}
minRows={4}
placeholder={
'(Add one per line):\n--gapless-audio=weak\n--prefetch-playlist=yes'
}
placeholder={`(${t('setting.mpvExtraParameters', {
context: 'help',
postProcess: 'sentenceCase',
})}):\n--gapless-audio=weak\n--prefetch-playlist=yes`}
width={225}
onBlur={(e) => {
handleSetExtraParameters(e.currentTarget.value.split('\n'));
@ -145,7 +151,10 @@ export const MpvSettings = () => {
$secondary
size="sm"
>
Options to pass to the player
{t('setting.mpvExtraParameters', {
context: 'description',
postProcess: 'sentenceCase',
})}
</Text>
<Text size="sm">
<a
@ -159,8 +168,12 @@ export const MpvSettings = () => {
</Stack>
),
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: 'MPV parameters',
note: t('common.restartRequired', {
postProcess: 'sentenceCase',
}),
title: t('setting.mpvExtraParameters', {
postProcess: 'sentenceCase',
}),
},
];
@ -169,18 +182,26 @@ export const MpvSettings = () => {
control: (
<Select
data={[
{ label: 'No', value: 'no' },
{ label: 'Yes', value: 'yes' },
{ label: 'Weak (recommended)', value: 'weak' },
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'no' },
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'yes' },
{
label: t('setting.gaplessAudio', {
context: 'optionWeak',
postProcess: 'sentenceCase',
}),
value: 'weak',
},
]}
defaultValue={settings.mpvProperties.gaplessAudio}
onChange={(e) => handleSetMpvProperty('gaplessAudio', e)}
/>
),
description:
'Try to play consecutive audio files with no silence or disruption at the point of file change (--gapless-audio)',
description: t('setting.gaplessAudio', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.LOCAL,
title: 'Gapless audio',
title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -193,10 +214,12 @@ export const MpvSettings = () => {
}}
/>
),
description:
'Select the output sample rate to be used if the sample frequency selected is different from that of the current media',
description: t('setting.sampleRate', {
context: 'description',
postProcess: 'sentenceCase',
}),
note: 'Page refresh required for web player',
title: 'Sample rate',
title: t('setting.sampleRate', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -211,10 +234,12 @@ export const MpvSettings = () => {
/>
),
description:
'Enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio (--audio-exclusive)',
description: t('setting.audioExclusiveMode', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.LOCAL,
title: 'Audio exclusive mode',
title: t('setting.audioExclusiveMode', { postProcess: 'sentenceCase' }),
},
];
@ -223,18 +248,42 @@ export const MpvSettings = () => {
control: (
<Select
data={[
{ label: 'None', value: 'no' },
{ label: 'Track', value: 'track' },
{ label: 'Album', value: 'album' },
{
label: t('setting.replayGainMode', {
context: 'optionNone',
postProcess: 'titleCase',
}),
value: 'no',
},
{
label: t('setting.replayGainMode', {
context: 'optionTrack',
postProcess: 'titleCase',
}),
value: 'track',
},
{
label: t('setting.replayGainMode', {
context: 'optionAlbum',
postProcess: 'titleCase',
}),
value: 'album',
},
]}
defaultValue={settings.mpvProperties.replayGainMode}
onChange={(e) => handleSetMpvProperty('replayGainMode', e)}
/>
),
description:
'Adjust volume gain according to replaygain values stored in the file metadata (--replaygain)',
note: 'Restart required',
title: 'ReplayGain mode',
description: t('setting.replayGainMode', {
ReplayGain: 'ReplayGain',
context: 'description',
postProcess: 'sentenceCase',
}),
note: t('common.restartRequired', { postProcess: 'sentenceCase' }),
title: t('setting.replayGainMode', {
ReplayGain: 'ReplayGain',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -244,9 +293,15 @@ export const MpvSettings = () => {
onChange={(e) => handleSetMpvProperty('replayGainPreampDB', e)}
/>
),
description:
'Pre-amplification gain in dB to apply to the selected replaygain gain (--replaygain-preamp)',
title: 'ReplayGain preamp (dB)',
description: t('setting.replayGainMode', {
ReplayGain: 'ReplayGain',
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.replayGainPreamp', {
ReplayGain: 'ReplayGain',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -257,9 +312,14 @@ export const MpvSettings = () => {
}
/>
),
description:
'Prevent clipping caused by replaygain by automatically lowering the gain (--replaygain-clip)',
title: 'ReplayGain clipping',
description: t('setting.replayGainClipping', {
ReplayGain: 'ReplayGain',
postProcess: 'sentenceCase',
}),
title: t('setting.replayGainClipping', {
ReplayGain: 'ReplayGain',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -269,9 +329,14 @@ export const MpvSettings = () => {
onBlur={(e) => handleSetMpvProperty('replayGainFallbackDB', e)}
/>
),
description:
'Gain in dB to apply if the file has no replay gain tags. This option is always applied if the replaygain logic is somehow inactive. If this is applied, no other replaygain options are applied',
title: 'ReplayGain fallback (dB)',
description: t('setting.replayGainFallback', {
ReplayGain: 'ReplayGain',
postProcess: 'sentenceCase',
}),
title: t('setting.replayGainFallback', {
ReplayGain: 'ReplayGain',
postProcess: 'sentenceCase',
}),
},
];

View File

@ -20,12 +20,8 @@ export const PlaybackTab = () => {
<AudioSettings />
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
<Divider />
{isElectron() && (
<>
<ScrobbleSettings />
<Divider />
</>
)}
<ScrobbleSettings />
<Divider />
<LyricSettings />
</Stack>
);

View File

@ -1,8 +1,10 @@
import { NumberInput, Slider, Switch, Text } from '/@/renderer/components';
import { NumberInput, Slider, Switch } from '/@/renderer/components';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { SettingOption, SettingsSection } from '../settings-section';
import { useTranslation } from 'react-i18next';
export const ScrobbleSettings = () => {
const { t } = useTranslation();
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
@ -25,8 +27,11 @@ export const ScrobbleSettings = () => {
}}
/>
),
description: 'Enable or disable scrobbling to your media server',
title: 'Scrobble',
description: t('setting.scrobble', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.scrobble', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -50,9 +55,11 @@ export const ScrobbleSettings = () => {
}}
/>
),
description:
'The percentage of the song that must be played before submitting a scrobble',
title: 'Minimum scrobble percentage*',
description: t('setting.minimumScrobblePercentage', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.minimumScrobblePercentage', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -76,21 +83,13 @@ export const ScrobbleSettings = () => {
}}
/>
),
description:
'The duration in seconds of a song that must be played before submitting a scrobble',
title: 'Minimum scrobble duration (seconds)*',
description: t('setting.minimumScrobblePercentage', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.minimumScrobbleSeconds', { postProcess: 'sentenceCase' }),
},
];
return (
<>
<SettingsSection options={scrobbleOptions} />
<Text
$secondary
size="sm"
>
*The scrobble will be submitted if one or more of the above conditions is met
</Text>
</>
);
return <SettingsSection options={scrobbleOptions} />;
};

View File

@ -2,6 +2,7 @@ import { lazy } from 'react';
import { Tabs } from '/@/renderer/components';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
const GeneralTab = lazy(() =>
@ -36,6 +37,7 @@ const TabContainer = styled.div`
`;
export const SettingsContent = () => {
const { t } = useTranslation();
const currentTab = useSettingsStore((state) => state.tab);
const { setSettings } = useSettingsStoreActions();
@ -49,10 +51,20 @@ export const SettingsContent = () => {
onTabChange={(e) => e && setSettings({ tab: e })}
>
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="playback">Playback</Tabs.Tab>
<Tabs.Tab value="hotkeys">Hotkeys</Tabs.Tab>
{isElectron() && <Tabs.Tab value="window">Window</Tabs.Tab>}
<Tabs.Tab value="general">
{t('page.setting.generalTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
<Tabs.Tab value="playback">
{t('page.setting.playbackTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
<Tabs.Tab value="hotkeys">
{t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
{isElectron() && (
<Tabs.Tab value="window">
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="general">
<GeneralTab />

View File

@ -1,11 +1,13 @@
import { Flex, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { RiSettings2Fill } from 'react-icons/ri';
import { Button, ConfirmModal, PageHeader } from '/@/renderer/components';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useSettingsStoreActions } from '../../../store/settings.store';
export const SettingsHeader = () => {
const { t } = useTranslation();
const { reset } = useSettingsStoreActions();
const handleResetToDefault = () => {
@ -15,8 +17,12 @@ export const SettingsHeader = () => {
const openResetConfirmModal = () => {
openModal({
children: <ConfirmModal onConfirm={handleResetToDefault}>Are you sure?</ConfirmModal>,
title: 'Reset settings to default',
children: (
<ConfirmModal onConfirm={handleResetToDefault}>
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
title: t('common.resetToDefault', { postProcess: 'sentenceCase' }),
});
};
@ -31,14 +37,16 @@ export const SettingsHeader = () => {
>
<Group noWrap>
<RiSettings2Fill size="2rem" />
<LibraryHeaderBar.Title>Settings</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('common.setting', { count: 2, postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
</Group>
<Button
compact
variant="default"
onClick={openResetConfirmModal}
>
Reset to default
{t('common.resetToDefault', { postProcess: 'sentenceCase' })}
</Button>
</Flex>
</LibraryHeaderBar>

View File

@ -20,7 +20,7 @@ export const SettingsSection = ({ options }: SettingsSectionProps) => {
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
key={`option-${option.title}`}
{...option}
/>
))}

View File

@ -5,8 +5,10 @@ import {
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useDiscordSetttings, useSettingsStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
export const DiscordSettings = () => {
const { t } = useTranslation();
const settings = useDiscordSetttings();
const { setSettings } = useSettingsStoreActions();
@ -25,10 +27,19 @@ export const DiscordSettings = () => {
}}
/>
),
description:
'Enable playback status in Discord rich presence. Image keys include: "icon", "playing", and "paused"',
description: t('setting.discordRichPresence', {
context: 'description',
discord: 'Discord',
icon: 'icon',
paused: 'paused',
playing: 'playing',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Discord rich presence',
title: t('setting.discordRichPresence', {
discord: 'Discord',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -44,9 +55,17 @@ export const DiscordSettings = () => {
}}
/>
),
description: 'The Discord application ID (defaults to 1165957668758900787)',
description: t('setting.discordApplicationId', {
context: 'description',
defaultId: '1165957668758900787',
discord: 'Discord',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Discord application ID',
title: t('setting.discordApplicationId', {
discord: 'Discord',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -67,9 +86,15 @@ export const DiscordSettings = () => {
}}
/>
),
description: 'The time in seconds between each update (minimum 15 seconds)',
description: t('setting.discordUpdateInterval', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Rich presence update interval (seconds)',
title: t('setting.discordUpdateInterval', {
discord: 'Discord',
postProcess: 'sentenceCase',
}),
},
{
control: (
@ -85,9 +110,14 @@ export const DiscordSettings = () => {
}}
/>
),
description: 'When enabled, the rich presence will update while player is idle',
description: t('setting.discordIdleStatus', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Show rich presence when idle',
title: t('setting.discordIdleStatus', {
postProcess: 'sentenceCase',
}),
},
];

View File

@ -1,4 +1,5 @@
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { useWindowSettings, useSettingsStoreActions } from '../../../../store/settings.store';
import {
SettingsSection,
@ -9,6 +10,7 @@ import { Switch } from '/@/renderer/components';
const localSettings = isElectron() ? window.electron.localSettings : null;
export const UpdateSettings = () => {
const { t } = useTranslation();
const settings = useWindowSettings();
const { setSettings } = useSettingsStoreActions();
@ -31,9 +33,12 @@ export const UpdateSettings = () => {
}}
/>
),
description: 'Enabling this option will disable checking for new versions on startup',
description: t('setting.disableAutomaticUpdates', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Disable automatic updates',
title: t('setting.disableAutomaticUpdates', { postProcess: 'sentenceCase' }),
},
];

View File

@ -1,5 +1,6 @@
import isElectron from 'is-electron';
import { Platform } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
import { useWindowSettings, useSettingsStoreActions } from '../../../../store/settings.store';
import {
SettingsSection,
@ -17,6 +18,7 @@ const WINDOW_BAR_OPTIONS = [
const localSettings = isElectron() ? window.electron.localSettings : null;
export const WindowSettings = () => {
const { t } = useTranslation();
const settings = useWindowSettings();
const { setSettings } = useSettingsStoreActions();
@ -43,12 +45,15 @@ export const WindowSettings = () => {
toast.info({
autoClose: false,
id: 'restart-toast',
message:
'Restart to apply changes... close the notification to restart Feishin',
message: t('common.forceRestartRequired', {
postProcess: 'sentenceCase',
}),
onClose: () => {
window.electron.ipc!.send('app-restart');
},
title: 'Restart required',
title: t('common.restartRequired', {
postProcess: 'sentenceCase',
}),
});
} else {
toast.update({
@ -69,9 +74,12 @@ export const WindowSettings = () => {
}}
/>
),
description: 'Adjust the style of the application window bar',
description: t('setting.windowBarStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Window bar style',
title: t('setting.windowBarStyle', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -91,9 +99,12 @@ export const WindowSettings = () => {
}}
/>
),
description: 'Minimize the application to the system tray',
description: t('setting.minimizeToTray', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Minimize to tray',
title: t('setting.minimizeToTray', { postProcess: 'sentenceCase' }),
},
{
control: (
@ -113,9 +124,12 @@ export const WindowSettings = () => {
}}
/>
),
description: 'Exit the application to the system tray',
description: t('setting.exitToTray', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: 'Exit to tray',
title: t('setting.exitToTray', { postProcess: 'sentenceCase' }),
},
];

View File

@ -1,5 +1,6 @@
import { Group } from '@mantine/core';
import { forwardRef, ReactNode, Ref, useState } from 'react';
import { Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import styles from './library-header.module.scss';
import { LibraryItem } from '/@/renderer/api/types';
@ -20,12 +21,30 @@ export const LibraryHeader = forwardRef(
{ imageUrl, imagePlaceholderUrl, background, title, item, children }: LibraryHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { t } = useTranslation();
const [isImageError, setIsImageError] = useState<boolean | null>(false);
const onImageError = () => {
setIsImageError(true);
};
const itemTypeString = () => {
switch (item.type) {
case LibraryItem.ALBUM:
return t('entity.album', { count: 1 });
case LibraryItem.ARTIST:
return t('entity.artist', { count: 1 });
case LibraryItem.ALBUM_ARTIST:
return t('entity.albumArtist', { count: 1 });
case LibraryItem.PLAYLIST:
return t('entity.playlist', { count: 1 });
case LibraryItem.SONG:
return t('entity.track', { count: 1 });
default:
return t('common.unknown');
}
};
return (
<div
ref={ref}
@ -59,7 +78,7 @@ export const LibraryHeader = forwardRef(
tt="uppercase"
weight={600}
>
{item.type}
{itemTypeString()}
</Text>
</Group>
<h1 className={styles.title}>{title}</h1>

View File

@ -1,4 +1,5 @@
import { ButtonProps } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { RiSortAsc, RiSortDesc } from 'react-icons/ri';
import { SortOrder } from '/@/renderer/api/types';
import { Button, Tooltip } from '/@/renderer/components';
@ -10,8 +11,15 @@ interface OrderToggleButtonProps {
}
export const OrderToggleButton = ({ sortOrder, onToggle, buttonProps }: OrderToggleButtonProps) => {
const { t } = useTranslation();
return (
<Tooltip label={sortOrder}>
<Tooltip
label={
sortOrder === SortOrder.ASC
? t('common.ascending', { postProcess: 'titleCase' })
: t('common.descending', { postProcess: 'titleCase' })
}
>
<Button
compact
fw="600"

View File

@ -1,16 +1,17 @@
import i18n from '/@/i18n/i18n';
import { Play } from '/@/renderer/types';
export const PLAY_TYPES = [
{
label: 'Play',
label: i18n.t('player.play', { postProcess: 'sentenceCase' }),
play: Play.NOW,
},
{
label: 'Add to queue',
label: i18n.t('player.addLast', { postProcess: 'sentenceCase' }),
play: Play.LAST,
},
{
label: 'Add to queue next',
label: i18n.t('player.addNext', { postProcess: 'sentenceCase' }),
play: Play.NEXT,
},
];

View File

@ -1,4 +1,5 @@
import { Grid, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { RiSearchLine, RiMenuFill, RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { useNavigate } from 'react-router';
import styled from 'styled-components';
@ -19,6 +20,7 @@ const ActionsContainer = styled.div`
`;
export const ActionBar = () => {
const { t } = useTranslation();
const cq = useContainerQuery({ md: 300 });
const navigate = useNavigate();
const { open } = useCommandPalette();
@ -36,7 +38,7 @@ export const ActionBar = () => {
<TextInput
readOnly
icon={<RiSearchLine />}
placeholder="Search"
placeholder={t('common.search', { postProcess: 'titleCase' })}
size="md"
onClick={open}
onKeyDown={(e) => {

View File

@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { Group, UnstyledButton } from '@mantine/core';
import { motion } from 'framer-motion';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiArrowLeftSLine, RiArrowRightSLine, RiMenuFill } from 'react-icons/ri';
import { NavLink, useNavigate } from 'react-router-dom';
import styled from 'styled-components';
@ -24,6 +25,7 @@ const SidebarContainer = styled(motion.div)<{ $windowBarStyle: Platform }>`
`;
export const CollapsedSidebar = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { windowBarStyle } = useWindowSettings();
const { sidebarItems, sidebarCollapsedNavigation } = useGeneralSettings();
@ -74,7 +76,7 @@ export const CollapsedSidebar = () => {
activeIcon={<RiMenuFill size="25" />}
component={UnstyledButton}
icon={<RiMenuFill size="25" />}
label="Menu"
label={t('common.menu', { postProcess: 'titleCase' })}
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>

View File

@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { Flex, Group } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
@ -20,6 +21,7 @@ interface SidebarPlaylistListProps {
}
const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
const { t } = useTranslation();
const path = data?.items[index].id
? data.defaultFullPlaylist
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
@ -71,7 +73,10 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
<Button
compact
size="md"
tooltip={{ label: 'Play', openDelay: 500 }}
tooltip={{
label: t('player.play', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="default"
onClick={() => {
if (!data?.items?.[index].id) return;
@ -83,7 +88,10 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
<Button
compact
size="md"
tooltip={{ label: 'Add to queue', openDelay: 500 }}
tooltip={{
label: t('player.addLast', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="default"
onClick={() => {
if (!data?.items?.[index].id) return;
@ -95,7 +103,10 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
<Button
compact
size="md"
tooltip={{ label: 'Add to queue next', openDelay: 500 }}
tooltip={{
label: t('player.addNext', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="default"
onClick={() => {
if (!data?.items?.[index].id) return;

View File

@ -1,7 +1,8 @@
import { MouseEvent, useMemo } from 'react';
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { AnimatePresence, motion } from 'framer-motion';
import { MouseEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiAddFill, RiArrowDownSLine, RiDiscLine, RiListUnordered } from 'react-icons/ri';
import { Link, useLocation } from 'react-router-dom';
import styled from 'styled-components';
@ -68,6 +69,7 @@ const SidebarImage = styled.img`
`;
export const Sidebar = () => {
const { t } = useTranslation();
const location = useLocation();
const sidebar = useSidebarStore();
const { setSideBar } = useAppStoreActions();
@ -89,7 +91,7 @@ export const Sidebar = () => {
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
title: t('form.createPlaylist.title', { postProcess: 'titleCase' }),
});
};
@ -176,7 +178,7 @@ export const Sidebar = () => {
fw="600"
sx={{ fontSize: '1.2rem' }}
>
Playlists
{t('page.sidebar.playlists', { postProcess: 'titleCase' })}
</Box>
{playlistsQuery.isLoading && <Spinner />}
</Group>
@ -184,7 +186,12 @@ export const Sidebar = () => {
<Button
compact
size="md"
tooltip={{ label: 'Create playlist', openDelay: 500 }}
tooltip={{
label: t('action.createPlaylist', {
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="default"
onClick={handleCreatePlaylistModal}
>
@ -195,7 +202,12 @@ export const Sidebar = () => {
component={Link}
size="md"
to={AppRoute.PLAYLISTS}
tooltip={{ label: 'Playlist list', openDelay: 500 }}
tooltip={{
label: t('action.viewPlaylists', {
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="default"
onClick={(e) => e.stopPropagation()}
>
@ -223,7 +235,9 @@ export const Sidebar = () => {
onClick={expandFullScreenPlayer}
>
<Tooltip
label="Toggle fullscreen player"
label={t('player.toggleFullScreenPlayer', {
postProcess: 'sentenceCase',
})}
openDelay={500}
>
{upsizedImageUrl ? (
@ -248,7 +262,10 @@ export const Sidebar = () => {
radius={100}
size="md"
sx={{ cursor: 'default', position: 'absolute', right: 5, top: 5 }}
tooltip={{ label: 'Collapse', openDelay: 500 }}
tooltip={{
label: t('common.collapse', { postProcess: 'titleCase' }),
openDelay: 500,
}}
variant="default"
onClick={(e) => {
e.stopPropagation();

View File

@ -1,10 +1,11 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface JellyfinSongFiltersProps {
customFilters?: Partial<SongListFilter>;
@ -19,6 +20,7 @@ export const JellyfinSongFilters = ({
onFilterChange,
serverId,
}: JellyfinSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey });
@ -49,7 +51,7 @@ export const JellyfinSongFilters = ({
const toggleFilters = [
{
label: 'Is favorited',
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -151,14 +153,14 @@ export const JellyfinSongFilters = ({
<NumberInput
required
defaultValue={filter?._custom?.jellyfin?.minYear}
label="From year"
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onChange={handleMinYearFilter}
/>
<NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear}
label="To year"
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onChange={handleMaxYearFilter}
@ -171,7 +173,7 @@ export const JellyfinSongFilters = ({
searchable
data={genreList}
defaultValue={selectedGenres}
label="Genres"
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
width={250}
onChange={handleGenresFilter}
/>

View File

@ -1,10 +1,11 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface NavidromeSongFiltersProps {
customFilters?: Partial<SongListFilter>;
@ -19,6 +20,7 @@ export const NavidromeSongFilters = ({
pageKey,
serverId,
}: NavidromeSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey });
@ -61,7 +63,7 @@ export const NavidromeSongFilters = ({
const toggleFilters = [
{
label: 'Is favorited',
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
@ -119,7 +121,7 @@ export const NavidromeSongFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
label="Year"
label={t('common.year', { postProcess: 'titleCase' })}
max={5000}
min={0}
value={filter._custom?.navidrome?.year}
@ -132,7 +134,7 @@ export const NavidromeSongFilters = ({
searchable
data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id}
label="Genre"
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150}
onChange={handleGenresFilter}
/>

View File

@ -1,7 +1,8 @@
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { openModal } from '@mantine/modals';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
@ -78,6 +79,7 @@ interface SongListHeaderFiltersProps {
}
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { pageKey, handlePlay, customFilters } = useListContext();
const { display, table, filter, grid } = useListStoreByKey({
@ -421,7 +423,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
},
}}
tooltip={{ label: 'Filters' }}
tooltip={{ label: t('common.filters', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleOpenFiltersModal}
>
@ -431,7 +433,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
<Button
compact
size="md"
tooltip={{ label: 'Refresh' }}
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
onClick={handleRefresh}
>
@ -454,19 +456,19 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
icon={<RiPlayFill />}
onClick={() => handlePlay?.({ playType: Play.NOW })}
>
Play
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay?.({ playType: Play.LAST })}
>
Add to queue
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay?.({ playType: Play.NEXT })}
>
Add to queue next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
@ -496,27 +498,29 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
{t('table.config.view.card', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
{t('table.config.view.poster', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
{t('table.config.view.table', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
@ -526,7 +530,9 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
Table (paginated)
</DropdownMenu.Item> */}
<DropdownMenu.Divider />
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.size', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
@ -537,7 +543,11 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
</DropdownMenu.Item>
{isGrid && (
<>
<DropdownMenu.Label>Item gap</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.gap', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={grid?.itemGap || 0}
@ -548,7 +558,11 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
</DropdownMenu.Item>
</>
)}
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
@ -563,7 +577,11 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}

View File

@ -2,6 +2,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useListStoreByKey } from '../../../store/list.store';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
@ -23,6 +24,7 @@ interface SongListHeaderProps {
}
export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { pageKey, handlePlay, customFilters } = useListContext();
const { setFilter, setTablePagination } = useListStoreActions();
@ -71,7 +73,9 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
/>
<LibraryHeaderBar.Title>{title || 'Tracks'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{title || t('page.trackList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>

View File

@ -1,6 +1,7 @@
import { Group } from '@mantine/core';
import { openModal, closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import {
RiLockLine,
RiWindowFill,
@ -35,6 +36,7 @@ const browser = isElectron() ? window.electron.browser : null;
const localSettings = isElectron() ? window.electron.localSettings : null;
export const AppMenu = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const currentServer = useCurrentServer();
const serverList = useServerList();
@ -100,27 +102,27 @@ export const AppMenu = () => {
icon={<RiArrowLeftSLine />}
onClick={() => navigate(-1)}
>
Go back
{t('page.appMenu.goBack', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiArrowRightSLine />}
onClick={() => navigate(1)}
>
Go forward
{t('page.appMenu.goForward', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{collapsed ? (
<DropdownMenu.Item
icon={<RiLayoutRightLine />}
onClick={handleExpandSidebar}
>
Expand sidebar
{t('page.appMenu.expandSidebar', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
) : (
<DropdownMenu.Item
icon={<RiLayoutLeftLine />}
onClick={handleCollapseSidebar}
>
Collapse sidebar
{t('page.appMenu.collapseSidebar', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
)}
<DropdownMenu.Divider />
@ -129,17 +131,19 @@ export const AppMenu = () => {
icon={<RiSettings3Line />}
to={AppRoute.SETTINGS}
>
Settings
{t('page.appMenu.settings', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiEdit2Line />}
onClick={handleManageServersModal}
>
Manage servers
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Select a server</DropdownMenu.Label>
<DropdownMenu.Label>
{t('page.appMenu.selectServer', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
{Object.keys(serverList).map((serverId) => {
const server = serverList[serverId];
const isNavidromeExpired =
@ -175,7 +179,10 @@ export const AppMenu = () => {
rightSection={<RiExternalLinkLine />}
target="_blank"
>
Version {packageJson.version}
{t('page.appMenu.version', {
postProcess: 'sentenceCase',
version: packageJson.version,
})}
</DropdownMenu.Item>
{isElectron() && (
<>
@ -184,13 +191,13 @@ export const AppMenu = () => {
icon={<RiWindowFill />}
onClick={handleBrowserDevTools}
>
Open browser devtools
{t('page.appMenu.openBrowserDevtools', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiCloseCircleLine />}
onClick={handleQuit}
>
Quit
{t('page.appMenu.quit', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</>
)}

View File

@ -22,6 +22,7 @@ import {
FontType,
} from '/@/renderer/types';
import { randomString } from '/@/renderer/utils';
import i18n from '/@/i18n/i18n';
const utils = isElectron() ? window.electron.utils : null;
@ -33,26 +34,61 @@ export type SidebarItemType = {
};
export const sidebarItems = [
{ disabled: true, id: 'Now Playing', label: 'Now Playing', route: AppRoute.NOW_PLAYING },
{
disabled: true,
id: 'Now Playing',
label: i18n.t('page.sidebar.nowPlaying'),
route: AppRoute.NOW_PLAYING,
},
{
disabled: true,
id: 'Search',
label: 'Search',
label: i18n.t('page.sidebar.search'),
route: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }),
},
{ disabled: false, id: 'Home', label: 'Home', route: AppRoute.HOME },
{ disabled: false, id: 'Albums', label: 'Albums', route: AppRoute.LIBRARY_ALBUMS },
{ disabled: false, id: 'Tracks', label: 'Tracks', route: AppRoute.LIBRARY_SONGS },
{ disabled: false, id: 'Home', label: i18n.t('page.sidebar.home'), route: AppRoute.HOME },
{
disabled: false,
id: 'Albums',
label: i18n.t('page.sidebar.albums'),
route: AppRoute.LIBRARY_ALBUMS,
},
{
disabled: false,
id: 'Tracks',
label: i18n.t('page.sidebar.tracks'),
route: AppRoute.LIBRARY_SONGS,
},
{
disabled: false,
id: 'Artists',
label: 'Artists',
label: i18n.t('page.sidebar.artists'),
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
},
{ disabled: false, id: 'Genres', label: 'Genres', route: AppRoute.LIBRARY_GENRES },
{ disabled: true, id: 'Folders', label: 'Folders', route: AppRoute.LIBRARY_FOLDERS },
{ disabled: true, id: 'Playlists', label: 'Playlists', route: AppRoute.PLAYLISTS },
{ disabled: true, id: 'Settings', label: 'Settings', route: AppRoute.SETTINGS },
{
disabled: false,
id: 'Genres',
label: i18n.t('page.sidebar.genres'),
route: AppRoute.LIBRARY_GENRES,
},
{
disabled: true,
id: 'Folders',
label: i18n.t('page.sidebar.folders'),
route: AppRoute.LIBRARY_FOLDERS,
},
{
disabled: true,
id: 'Playlists',
label: i18n.t('page.sidebar.playlists'),
route: AppRoute.PLAYLISTS,
},
{
disabled: true,
id: 'Settings',
label: i18n.t('page.sidebar.settings'),
route: AppRoute.SETTINGS,
},
];
export type PersistedTableColumn = {
@ -135,7 +171,7 @@ export interface SettingsState {
accent: string;
defaultFullPlaylist: boolean;
followSystemTheme: boolean;
language: string;
playButtonBehavior: Play;
resume: boolean;
showQueueDrawerButton: boolean;
@ -246,6 +282,7 @@ const initialState: SettingsState = {
accent: 'rgb(53, 116, 252)',
defaultFullPlaylist: true,
followSystemTheme: false,
language: 'en',
playButtonBehavior: Play.NOW,
resume: false,
showQueueDrawerButton: false,

View File

@ -7,3 +7,4 @@ export * from './get-header-color';
export * from './parse-search-params';
export * from './format-duration-string';
export * from './rgb-to-rgba';
export * from './sentence-case';

View File

@ -0,0 +1,3 @@
export const sentenceCase = (string: string) => {
return string.charAt(0).toUpperCase() + string.slice(1);
};