From 663893dccb551bc28f1e3057c1758c627a9fbb62 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 9 Aug 2023 21:30:27 -0700 Subject: [PATCH 001/200] Fix missing related artist images --- src/renderer/api/navidrome/navidrome-normalize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 22ca37cf..e3f75e24 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -202,7 +202,7 @@ const normalizeAlbumArtist = ( similarArtists: item.similarArtists?.map((artist) => ({ id: artist.id, - imageUrl: getImageUrl({ url: artist?.artistImageUrl || null }), + imageUrl: artist?.artistImageUrl || null, name: artist.name, })) || null, songCount: item.songCount, From 877b2e9f3be25b28ee8f00745aef499355143bdc Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 11 Aug 2023 21:08:13 -0700 Subject: [PATCH 002/200] Fix normalized album duration values (#205) --- src/renderer/api/jellyfin/jellyfin-normalize.ts | 2 +- .../features/albums/components/album-detail-header.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index cbc6a0c5..e0bd6108 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -208,7 +208,7 @@ const normalizeAlbum = ( })), backdropImageUrl: null, createdAt: item.DateCreated, - duration: item.RunTimeTicks / 10000000, + duration: item.RunTimeTicks / 10000, genres: item.GenreItems?.map((entry) => ({ id: entry.Id, imageUrl: null, diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index af3fc0cb..7c648d14 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -37,8 +37,7 @@ export const AlbumDetailHeader = forwardRef( id: 'duration', secondary: false, value: - detailQuery?.data?.duration && - formatDurationString(detailQuery.data.duration * 1000), + detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration), }, ]; From a8bd53b75766f376b5c4706cc176c3c9b2abc302 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 24 Aug 2023 18:04:01 -0700 Subject: [PATCH 003/200] Adjust jellyfin playlist fetch --- .../api/jellyfin/jellyfin-controller.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 9bef6ed8..860db0f4 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -529,6 +529,20 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise folder.CollectionType === jfType._enum.collection.PLAYLISTS, + )?.[0]; + const res = await jfApiClient(apiClientProps).getPlaylistList({ params: { userId: apiClientProps.server?.userId, @@ -537,8 +551,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise Date: Thu, 24 Aug 2023 18:10:58 -0700 Subject: [PATCH 004/200] Fix JF song filter import (#223) --- .../features/songs/components/jellyfin-song-filters.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 42720ff8..5d7c2ec6 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -1,11 +1,10 @@ import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; -import { useListFilterByKey } from '../../../store/list.store'; import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; -import { SongListFilter, useListStoreActions } from '/@/renderer/store'; +import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; interface JellyfinSongFiltersProps { customFilters?: Partial; @@ -21,7 +20,7 @@ export const JellyfinSongFilters = ({ serverId, }: JellyfinSongFiltersProps) => { const { setFilter } = useListStoreActions(); - const { filter } = useListFilterByKey({ key: pageKey }); + const filter = useListFilterByKey({ key: pageKey }); const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined; From b60ba2789237490144e8dd9a8077811794c31a32 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 24 Aug 2023 18:17:20 -0700 Subject: [PATCH 005/200] Allow reuathentication for jellyfin (#214) --- src/renderer/api/jellyfin/jellyfin-api.ts | 6 ++++++ src/renderer/features/titlebar/components/app-menu.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 4710d62f..12237410 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -272,6 +272,12 @@ axiosClient.interceptors.response.use( if (error.response && error.response.status === 401) { const currentServer = useAuthStore.getState().currentServer; + if (currentServer) { + useAuthStore + .getState() + .actions.updateServer(currentServer.id, { credential: undefined }); + } + authenticationFailure(currentServer); } diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 6f445bc1..f153e86f 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -141,7 +141,7 @@ export const AppMenu = () => { const server = serverList[serverId]; const isNavidromeExpired = server.type === ServerType.NAVIDROME && !server.ndCredential; - const isJellyfinExpired = false; + const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; const isSessionExpired = isNavidromeExpired || isJellyfinExpired; return ( From 1acfa93f1addb004de3734c2c1486279c2bec716 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Fri, 25 Aug 2023 01:28:50 +0000 Subject: [PATCH 006/200] Improve MPV initialization and restore (#222) - set mpv settings only after it has successfully started (at least on linux, settings were not taken) - change timing of restore queue to behave properly --- src/main/main.ts | 25 ++++++++++++++----------- src/renderer/app.tsx | 3 +-- src/renderer/store/player.store.ts | 1 + 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 61adc9e0..20bf20f0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,11 +11,6 @@ import { access, constants, readFile, writeFile } from 'fs'; import path, { join } from 'path'; import { deflate, inflate } from 'zlib'; -import electronLocalShortcut from 'electron-localshortcut'; -import log from 'electron-log'; -import { autoUpdater } from 'electron-updater'; -import uniq from 'lodash/uniq'; -import MpvAPI from 'node-mpv'; import { app, BrowserWindow, @@ -27,6 +22,11 @@ import { nativeImage, BrowserWindowConstructorOptions, } from 'electron'; +import electronLocalShortcut from 'electron-localshortcut'; +import log from 'electron-log'; +import { autoUpdater } from 'electron-updater'; +import uniq from 'lodash/uniq'; +import MpvAPI from 'node-mpv'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { store } from './features/core/settings/index'; import MenuBuilder from './menu'; @@ -453,12 +453,15 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record { - console.log('MPV failed to start', error); - }); + // eslint-disable-next-line promise/catch-or-return + mpv.start() + .catch((error) => { + console.log('MPV failed to start', error); + }) + .finally(() => { + console.log('Setting MPV properties: ', properties); + mpv.setMultipleProperties(properties || {}); + }); mpv.on('status', (status, ...rest) => { console.log('MPV Event: status', status.property, status.value, rest); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 792d33cc..6d72976a 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -73,6 +73,7 @@ export const App = () => { mpvPlayer?.volume(properties.volume); } + mpvPlayer?.restoreQueue(); }; if (isElectron() && playbackType === PlaybackType.LOCAL) { @@ -94,8 +95,6 @@ export const App = () => { useEffect(() => { if (isElectron()) { - mpvPlayer!.restoreQueue(); - mpvPlayerListener!.rendererSaveQueue(() => { const { current, queue } = usePlayerStore.getState(); const stateToSave: Partial> = { diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index ddaf86cd..dcbf190a 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -701,6 +701,7 @@ export const usePlayerStore = create()( state.current = { ...state.current, ...data.current, + time: 0, }; state.queue = { ...state.queue, From 0ae53b023c157492301442100ab32c5c9eded583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Rodr=C3=ADguez?= Date: Sun, 10 Sep 2023 22:01:32 +0200 Subject: [PATCH 007/200] improved client detection (#229) Co-authored-by: = <=> --- .../api/jellyfin/jellyfin-controller.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 860db0f4..eaf14aa4 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -54,11 +54,37 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; import packageJson from '../../../../package.json'; import { z } from 'zod'; import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types'; +import isElectron from 'is-electron'; const formatCommaDelimitedString = (value: string[]) => { return value.join(','); }; +function getHostname(): string { + if (isElectron()) { + return 'Desktop Client'; + } + const agent = navigator.userAgent; + switch (true) { + case agent.toLowerCase().indexOf('edge') > -1: + return 'Microsoft Edge'; + case agent.toLowerCase().indexOf('edg/') > -1: + return 'Edge Chromium'; // Match also / to avoid matching for the older Edge + case agent.toLowerCase().indexOf('opr') > -1: + return 'Opera'; + case agent.toLowerCase().indexOf('chrome') > -1: + return 'Chrome'; + case agent.toLowerCase().indexOf('trident') > -1: + return 'Internet Explorer'; + case agent.toLowerCase().indexOf('firefox') > -1: + return 'Firefox'; + case agent.toLowerCase().indexOf('safari') > -1: + return 'Safari'; + default: + return 'PC'; + } +} + const authenticate = async ( url: string, body: { @@ -74,7 +100,9 @@ const authenticate = async ( Username: body.username, }, headers: { - 'x-emby-authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`, + 'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${ + packageJson.version + }"`, }, }); From c8397bb5ef63c904f2296b2e6e745e0fd11d3c35 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 10 Sep 2023 22:03:46 +0000 Subject: [PATCH 008/200] Add transparency/opacity for queue sidebar (#231) * add opacity * add background for song metadata * Add padding and border radius to opacity elements * Remove font-weight transition on active lyrics (#233) --------- Co-authored-by: jeffvli --- src/renderer/features/lyrics/lyric-line.tsx | 4 ++-- .../components/full-screen-player-image.tsx | 13 +++++++++++-- .../components/full-screen-player-queue.tsx | 16 +++++++++++++--- .../player/components/full-screen-player.tsx | 18 +++++++++++++++++- src/renderer/store/full-screen-player.store.ts | 2 ++ src/renderer/themes/default.scss | 1 + src/renderer/themes/light.scss | 1 + 7 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index 445e5810..f617d9c1 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -11,13 +11,13 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { const StyledText = styled(TextTitle)` color: var(--main-fg); - font-weight: 400; + font-weight: 600; text-align: ${(props) => props.$alignment}; font-size: ${(props) => props.$fontSize}px; opacity: 0.5; + padding: 0 1rem; &.active { - font-weight: 800; opacity: 1; } diff --git a/src/renderer/features/player/components/full-screen-player-image.tsx b/src/renderer/features/player/components/full-screen-player-image.tsx index 61ab8b49..6d84f2eb 100644 --- a/src/renderer/features/player/components/full-screen-player-image.tsx +++ b/src/renderer/features/player/components/full-screen-player-image.tsx @@ -37,7 +37,15 @@ const ImageContainer = styled(motion.div)` margin-bottom: 1rem; `; -const MetadataContainer = styled(Stack)` +interface TransparentMetadataContainer { + opacity: number; +} + +const MetadataContainer = styled(Stack)` + background: rgba(var(--main-bg-transparent), ${({ opacity }) => opacity}%); + padding: 1rem; + border-radius: 5px; + h1 { font-size: 3.5vh; } @@ -120,7 +128,7 @@ const ImageWithPlaceholder = ({ export const FullScreenPlayerImage = () => { const { queue } = usePlayerData(); - const useImageAspectRatio = useFullScreenPlayerStore((state) => state.useImageAspectRatio); + const { opacity, useImageAspectRatio } = useFullScreenPlayerStore(); const currentSong = queue.current; const { color: background } = useFastAverageColor({ algorithm: 'dominant', @@ -208,6 +216,7 @@ export const FullScreenPlayerImage = () => { ` + padding: 1rem; + background: rgba(var(--main-bg-transparent), ${({ opacity }) => opacity}%); display: grid; grid-template-rows: auto minmax(0, 1fr); grid-template-columns: 1fr; + border-radius: 5px; `; export const FullScreenPlayerQueue = () => { - const { activeTab } = useFullScreenPlayerStore(); + const { activeTab, opacity } = useFullScreenPlayerStore(); const { setStore } = useFullScreenPlayerStoreActions(); const headerItems = [ @@ -73,7 +80,10 @@ export const FullScreenPlayerQueue = () => { ]; return ( - + { - const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore(); + const { dynamicBackground, expanded, opacity, useImageAspectRatio } = + useFullScreenPlayerStore(); const { setStore } = useFullScreenPlayerStoreActions(); const { setSettings } = useSettingsStoreActions(); const lyricConfig = useLyricsSettings(); @@ -134,6 +135,21 @@ const Controls = () => { /> + {dynamicBackground && ( + + )} - - {isQueryBuilderExpanded && ( - - )} - + {isQueryBuilderExpanded && ( + + )} )} From e6ed9229c2a2911a7c474576d7858134f89fb0e2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 22 Oct 2023 22:21:31 +0000 Subject: [PATCH 088/200] [bugfix]: fix queue offset when removing tracks (#301) * [bugfix]: fix queue offset when removing tracks * Fix song index numbers when removing songs --------- Co-authored-by: jeffvli --- .../context-menu/context-menu-provider.tsx | 4 +++- src/renderer/store/player.store.ts | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 19937b86..0f43dbc8 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -613,10 +613,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { } } + ctx.tableApi?.redrawRows(); + if (isCurrentSongRemoved) { remote?.updateSong({ song: playerData.current.song }); } - }, [ctx.dataNodes, playerType, removeFromQueue]); + }, [ctx.dataNodes, ctx.tableApi, playerType, removeFromQueue]); const handleDeselectAll = useCallback(() => { ctx.tableApi?.deselectAll(); diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index dcbf190a..1bc06d87 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -631,10 +631,17 @@ export const usePlayerStore = create()( removeFromQueue: (uniqueIds) => { const queue = get().queue.default; const currentSong = get().current.song; + const currentPosition = get().current.index; + let queueShift = 0; - const newQueue = queue.filter( - (song) => !uniqueIds.includes(song.uniqueId), - ); + const newQueue = queue.filter((song, index) => { + const shouldKeep = !uniqueIds.includes(song.uniqueId); + if (!shouldKeep && index < currentPosition) { + queueShift += 1; + } + + return shouldKeep; + }); const newShuffledQueue = get().queue.shuffled.filter( (uniqueId) => !uniqueIds.includes(uniqueId), ); @@ -648,6 +655,10 @@ export const usePlayerStore = create()( if (isCurrentSongRemoved) { state.current.song = newQueue[0]; state.current.index = 0; + } else { + // if we removed any songs prior to the current one, + // shift the index back as necessary + state.current.index -= queueShift; } }); From 74cab01013cee928811062fabbe6f11544dc9eef Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 22 Oct 2023 22:25:17 +0000 Subject: [PATCH 089/200] [feature]: Support using system fonts (#304) * [feature]: Support using system fonts Uses the **experimental** queryLocalFonts API, when prompted, to get the fonts and do CSS. Resolves #270 and #288 (by proxy) Caveats/notes: - This is experimental, and is only supported by Chrome/Chromium/Edgeium (see https://caniuse.com/?search=querylocalfonts) - As far as I can tell, the only way to dynamically change the font (shown in https://wicg.github.io/local-font-access/#example-style-with-local-fonts) was by DOM manipulation; css variables did not seem to work - This shows **all** fonts, including their variants (bold/italic/etc); given that the style names could be localized, not sure of a way to parse this (on my system, for instance, I had 859 different combinations) - I made fonts a separate top-level setting because it was easier to manipulate without causing as many rerenders; feel free to put that back * add permission chec * add electron magic to support custom font * restrict content types --- src/main/main.ts | 30 +++ src/main/preload/local-settings.ts | 7 +- src/renderer/api/types.ts | 9 + src/renderer/app.tsx | 43 +++- .../general/application-settings.tsx | 186 +++++++++++++++++- src/renderer/preload.d.ts | 3 +- src/renderer/store/settings.store.ts | 18 +- src/renderer/types.ts | 6 + 8 files changed, 282 insertions(+), 20 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 1d293297..0409a21f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,6 +21,8 @@ import { Menu, nativeImage, BrowserWindowConstructorOptions, + protocol, + net, } from 'electron'; import electronLocalShortcut from 'electron-localshortcut'; import log from 'electron-log'; @@ -43,6 +45,8 @@ export default class AppUpdater { } } +protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]); + process.on('uncaughtException', (error: any) => { console.log('Error in main process', error); }); @@ -653,8 +657,34 @@ app.on('window-all-closed', () => { } }); +const FONT_HEADERS = [ + 'font/collection', + 'font/otf', + 'font/sfnt', + 'font/ttf', + 'font/woff', + 'font/woff2', +]; + app.whenReady() .then(() => { + protocol.handle('feishin', async (request) => { + const filePath = `file://${request.url.slice('feishin://'.length)}`; + const response = await net.fetch(filePath); + const contentType = response.headers.get('content-type'); + + if (!contentType || !FONT_HEADERS.includes(contentType)) { + getMainWindow()?.webContents.send('custom-font-error', filePath); + + return new Response(null, { + status: 403, + statusText: 'Forbidden', + }); + } + + return response; + }); + createWindow(); createTray(); app.on('activate', () => { diff --git a/src/main/preload/local-settings.ts b/src/main/preload/local-settings.ts index ee8044e0..a57b08c4 100644 --- a/src/main/preload/local-settings.ts +++ b/src/main/preload/local-settings.ts @@ -1,4 +1,4 @@ -import { ipcRenderer, webFrame } from 'electron'; +import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron'; import Store from 'electron-store'; const store = new Store(); @@ -39,9 +39,14 @@ const setZoomFactor = (zoomFactor: number) => { webFrame.setZoomFactor(zoomFactor / 100); }; +const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => { + ipcRenderer.on('custom-font-error', cb); +}; + export const localSettings = { disableMediaKeys, enableMediaKeys, + fontError, get, passwordGet, passwordRemove, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 52bf1bc5..dc128474 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1130,3 +1130,12 @@ export enum LyricSource { } export type LyricsOverride = Omit & { id: string }; + +// This type from https://wicg.github.io/local-font-access/#fontdata +// NOTE: it is still experimental, so this should be updates as appropriate +export type FontData = { + family: string; + fullName: string; + postscriptName: string; + style: string; +}; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 6d72976a..26e7a522 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model'; import { ModuleRegistry } from '@ag-grid-community/core'; import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; @@ -23,7 +23,7 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player'; import { AddToPlaylistContextModal } from '/@/renderer/features/playlists'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store'; -import { PlaybackType, PlayerStatus } from '/@/renderer/types'; +import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types'; import '@ag-grid-community/styles/ag-grid.css'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); @@ -37,17 +37,48 @@ const remote = isElectron() ? window.electron.remote : null; export const App = () => { const theme = useTheme(); - const contentFont = useSettingsStore((state) => state.general.fontContent); + const { builtIn, custom, system, type } = useSettingsStore((state) => state.font); const { type: playbackType } = usePlaybackSettings(); const { bindings } = useHotkeySettings(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); const { clearQueue, restoreQueue } = useQueueControls(); const remoteSettings = useRemoteSettings(); + const textStyleRef = useRef(); useEffect(() => { - const root = document.documentElement; - root.style.setProperty('--content-font-family', contentFont); - }, [contentFont]); + if (type === FontType.SYSTEM && system) { + const root = document.documentElement; + root.style.setProperty('--content-font-family', 'dynamic-font'); + + if (!textStyleRef.current) { + textStyleRef.current = document.createElement('style'); + document.body.appendChild(textStyleRef.current); + } + + textStyleRef.current.textContent = ` + @font-face { + font-family: "dynamic-font"; + src: local("${system}"); + }`; + } else if (type === FontType.CUSTOM && custom) { + const root = document.documentElement; + root.style.setProperty('--content-font-family', 'dynamic-font'); + + if (!textStyleRef.current) { + textStyleRef.current = document.createElement('style'); + document.body.appendChild(textStyleRef.current); + } + + textStyleRef.current.textContent = ` + @font-face { + font-family: "dynamic-font"; + src: url("feishin://${custom}"); + }`; + } else { + const root = document.documentElement; + root.style.setProperty('--content-font-family', builtIn); + } + }, [builtIn, custom, system, type]); const providerValue = useMemo(() => { return { handlePlayQueueAdd }; diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index f0b7c2aa..061f6b00 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -1,14 +1,27 @@ +import type { IpcRendererEvent } from 'electron'; import isElectron from 'is-electron'; -import { NumberInput, Select } from '/@/renderer/components'; +import { FileInput, NumberInput, Select, toast } from '/@/renderer/components'; import { SettingsSection, SettingOption, } from '/@/renderer/features/settings/components/settings-section'; -import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { + useFontSettings, + useGeneralSettings, + useSettingsStoreActions, +} from '/@/renderer/store/settings.store'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FontType } from '/@/renderer/types'; const localSettings = isElectron() ? window.electron.localSettings : null; +const ipc = isElectron() ? window.electron.ipc : null; -const FONT_OPTIONS = [ +type Font = { + label: string; + value: string; +}; + +const FONT_OPTIONS: Font[] = [ { label: 'Archivo', value: 'Archivo' }, { label: 'Fredoka', value: 'Fredoka' }, { label: 'Inter', value: 'Inter' }, @@ -20,9 +33,99 @@ const FONT_OPTIONS = [ { label: 'Work Sans', value: 'Work Sans' }, ]; +const FONT_TYPES: Font[] = [{ label: 'Built-in font', value: FontType.BUILT_IN }]; + +if (window.queryLocalFonts) { + FONT_TYPES.push({ label: 'System font', value: FontType.SYSTEM }); +} + +if (isElectron()) { + FONT_TYPES.push({ label: 'Custom font', value: FontType.CUSTOM }); +} + export const ApplicationSettings = () => { const settings = useGeneralSettings(); + const fontSettings = useFontSettings(); const { setSettings } = useSettingsStoreActions(); + const [localFonts, setLocalFonts] = useState([]); + + const fontList = useMemo(() => { + if (fontSettings.custom) { + const newFile = new File([], fontSettings.custom.split(/(\\|\/)/g).pop()!); + newFile.path = fontSettings.custom; + return newFile; + } + return null; + }, [fontSettings.custom]); + + const onFontError = useCallback( + (_: IpcRendererEvent, file: string) => { + toast.error({ + message: `${file} is not a valid font file`, + }); + + setSettings({ + font: { + ...fontSettings, + custom: null, + }, + }); + }, + [fontSettings, setSettings], + ); + + useEffect(() => { + if (localSettings) { + localSettings.fontError(onFontError); + + return () => { + ipc!.removeAllListeners('custom-font-error'); + }; + } + + return () => {}; + }, [onFontError]); + + useEffect(() => { + const getFonts = async () => { + if ( + fontSettings.type === FontType.SYSTEM && + localFonts.length === 0 && + window.queryLocalFonts + ) { + try { + // WARNING (Oct 17 2023): while this query is valid for chromium-based + // browsers, it is still experimental, and so Typescript will complain + // @ts-ignore + const status = await navigator.permissions.query({ name: 'local-fonts' }); + + if (status.state === 'denied') { + throw new Error('Access denied to local fonts'); + } + + const data = await window.queryLocalFonts(); + setLocalFonts( + data.map((font) => ({ + label: font.fullName, + value: font.postscriptName, + })), + ); + } catch (error) { + toast.error({ + message: 'An error occurred when trying to get system fonts', + }); + + setSettings({ + font: { + ...fontSettings, + type: FontType.BUILT_IN, + }, + }); + } + } + }; + getFonts(); + }, [fontSettings, localFonts, setSettings]); const options: SettingOption[] = [ { @@ -39,24 +142,87 @@ export const ApplicationSettings = () => { { control: ( { + if (!e) return; + setSettings({ + font: { + ...fontSettings, + builtIn: e, }, }); }} /> ), description: 'Sets the application content font', - isHidden: false, + isHidden: localFonts && fontSettings.type !== FontType.BUILT_IN, title: 'Font (Content)', }, + { + control: ( + + {isOwnerDisplayed && ( + handleLyricsSettings('alignment', e)} diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index aa205f74..928f005e 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -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} > {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} > diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index d429f0cd..93b3327b 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -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 = () => { {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 = () => { ) } - tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }} + tooltip={{ + label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume, + openDelay: 500, + }} variant="secondary" onClick={handleMute} onWheel={handleVolumeWheel} diff --git a/src/renderer/features/player/components/shuffle-all-modal.tsx b/src/renderer/features/player/components/shuffle-all-modal.tsx index 4d058c29..ce127c05 100644 --- a/src/renderer/features/player/components/shuffle-all-modal.tsx +++ b/src/renderer/features/player/components/shuffle-all-modal.tsx @@ -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, }); }; diff --git a/src/renderer/features/player/hooks/use-center-controls.ts b/src/renderer/features/player/hooks/use-center-controls.ts index 24c60b0e..4c2410fb 100644 --- a/src/renderer/features/player/hooks/use-center-controls.ts +++ b/src/renderer/features/player/hooks/use-center-controls.ts @@ -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(() => { diff --git a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts index af729d84..8dbbcb55 100644 --- a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts +++ b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts @@ -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; diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index 014dd74e..08a623a2 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -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')} /> @@ -222,7 +237,7 @@ export const AddToPlaylistContextModal = ({ variant="subtle" onClick={() => closeModal(id)} > - Cancel + {t('common.cancel', { postProcess: 'titleCase' })} diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index 9c38201e..63c9fc47 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -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(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) => { {isPublicDisplayed && ( { variant="subtle" onClick={onCancel} > - Cancel + {t('common.cancel', { postProcess: 'titleCase' })} diff --git a/src/renderer/features/playlists/components/playlist-detail-content.tsx b/src/renderer/features/playlists/components/playlist-detail-content.tsx index fa7dd8e7..12dc2192 100644 --- a/src/renderer/features/playlists/components/playlist-detail-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-content.tsx @@ -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? ), - title: 'Delete playlist', + title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }), }); }; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 12ec7ca3..c438a038 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -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 = ({ Are you sure you want to delete this playlist? ), - title: 'Delete playlist(s)', + title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }), }); }; @@ -345,19 +344,19 @@ export const PlaylistDetailSongListHeaderFilters = ({ icon={} onClick={() => handlePlay(Play.NOW)} > - Play + {t('player.play', { postProcess: 'sentenceCase' })} } onClick={() => handlePlay(Play.LAST)} > - Add to queue + {t('player.addLast', { postProcess: 'sentenceCase' })} } onClick={() => handlePlay(Play.NEXT)} > - Add to queue next + {t('player.addNext', { postProcess: 'sentenceCase' })} - Edit playlist + {t('action.editPlaylist', { postProcess: 'sentenceCase' })} } onClick={openDeletePlaylistModal} > - Delete playlist + {t('action.deletePlaylist', { postProcess: 'sentenceCase' })} } onClick={handleRefresh} > - Refresh + {t('action.refresh', { postProcess: 'sentenceCase' })} {server?.type === ServerType.NAVIDROME && !isSmartPlaylist && ( <> @@ -391,7 +390,9 @@ export const PlaylistDetailSongListHeaderFilters = ({ $danger onClick={handleToggleShowQueryBuilder} > - Toggle smart playlist editor + {t('action.toggleSmartPlaylistEditor', { + postProcess: 'sentenceCase', + })} )} diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 2828c03c..29e7a51e 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -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 )} - {isSmartPlaylist && Smart playlist} + {isSmartPlaylist && {t('entity.smartPlaylist')}} diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index 5c1365b2..1cbd23b2 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -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 = ({ - Display type + + {t('table.config.general.displayType', { postProcess: 'titleCase' })} + - Card + {t('table.config.view.card', { postProcess: 'titleCase' })} - Poster + {t('table.config.view.poster', { postProcess: 'titleCase' })} - Table + {t('table.config.view.table', { postProcess: 'titleCase' })} {/* {!isGrid && ( <> - Table Columns + + {t('table.config.generaltableColumns', { + postProcess: 'titleCase', + })} + - Auto Fit Columns + + {t('table.config.general.autoFitColumns', { + postProcess: 'titleCase', + })} + { + 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%" > - Playlists + + {t('page.playlistList.title', { postProcess: 'titleCase' })} + @@ -166,6 +183,6 @@ export const openUpdatePlaylistModal = async (args: { onCancel={closeAllModals} /> ), - title: 'Edit playlist', + title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string, }); }; diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index 94c8d9b8..eb55407f 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -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(null); const { playlistId } = useParams() as { playlistId: string }; @@ -114,7 +116,7 @@ const PlaylistDetailSongListRoute = () => { } /> ), - title: 'Save as', + title: t('common.saveAs', { postProcess: 'sentenceCase' }), }); }; diff --git a/src/renderer/features/search/components/go-to-commands.tsx b/src/renderer/features/search/components/go-to-commands.tsx index dc9f31c5..4947ba5a 100644 --- a/src/renderer/features/search/components/go-to-commands.tsx +++ b/src/renderer/features/search/components/go-to-commands.tsx @@ -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 ( <> - goTo(AppRoute.HOME)}>Home - goTo(AppRoute.SEARCH)}>Search - goTo(AppRoute.SETTINGS)}>Settings + goTo(AppRoute.HOME)}> + {t('page.sidebar.home', { postProcess: 'titleCase' })} + + goTo(AppRoute.SEARCH)}> + {t('page.sidebar.search', { postProcess: 'titleCase' })} + + goTo(AppRoute.SETTINGS)}> + {t('page.sidebar.settings', { postProcess: 'titleCase' })} + - goTo(AppRoute.LIBRARY_ALBUMS)}>Albums - goTo(AppRoute.LIBRARY_SONGS)}>Tracks - goTo(AppRoute.LIBRARY_ALBUM_ARTISTS)}> - Album artists + goTo(AppRoute.LIBRARY_ALBUMS)}> + {t('page.sidebar.albums', { postProcess: 'titleCase' })} + + goTo(AppRoute.LIBRARY_SONGS)}> + {t('page.sidebar.tracks', { postProcess: 'titleCase' })} + + goTo(AppRoute.LIBRARY_ALBUM_ARTISTS)}> + {t('page.sidebar.albumArtists', { postProcess: 'titleCase' })} + + goTo(AppRoute.LIBRARY_GENRES)}> + {t('page.sidebar.genres', { postProcess: 'titleCase' })} + + goTo(AppRoute.LIBRARY_FOLDERS)}> + {t('page.sidebar.folders', { postProcess: 'titleCase' })} + + goTo(AppRoute.PLAYLISTS)}> + {t('page.sidebar.playlists', { postProcess: 'titleCase' })} - goTo(AppRoute.LIBRARY_GENRES)}>Genres - goTo(AppRoute.LIBRARY_FOLDERS)}>Folders - goTo(AppRoute.PLAYLISTS)}>Playlists diff --git a/src/renderer/features/search/components/home-commands.tsx b/src/renderer/features/search/components/home-commands.tsx index a68351ab..914772d4 100644 --- a/src/renderer/features/search/components/home-commands.tsx +++ b/src/renderer/features/search/components/home-commands.tsx @@ -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: 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 ( <> - + - {query ? `Search for "${query}"...` : 'Search...'} + {query + ? t('page.globalSearch.commands.searchFor', { + postProcess: 'sentenceCase', + query, + }) + : `${t('common.search', { postProcess: 'sentenceCase' })}...`} + + + {t('action.createPlaylist', { postProcess: 'sentenceCase' })}... - Create playlist... setPages([...pages, CommandPalettePages.GO_TO])}> - Go to page... + {t('page.globalSearch.commands.goToPage', { postProcess: 'sentenceCase' })}... setPages([...pages, CommandPalettePages.MANAGE_SERVERS])} > - Server commands... + {t('page.globalSearch.commands.serverCommands', { + postProcess: 'sentenceCase', + })} + ... diff --git a/src/renderer/features/search/components/library-command-item.tsx b/src/renderer/features/search/components/library-command-item.tsx index 2f0195d8..54716fe7 100644 --- a/src/renderer/features/search/components/library-command-item.tsx +++ b/src/renderer/features/search/components/library-command-item.tsx @@ -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 = ({ diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index 2887e438..02c9d9bf 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -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 ( - + @@ -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 } {...form.getInputProps('name')} /> } {...form.getInputProps('url')} /> } {...form.getInputProps('username')} /> {localSettings && isNavidrome && ( - Cancel + {t('common.cancel', { postProcess: 'titleCase' })} diff --git a/src/renderer/features/servers/components/server-list.tsx b/src/renderer/features/servers/components/server-list.tsx index 0a3c476e..05ecaa95 100644 --- a/src/renderer/features/servers/components/server-list.tsx +++ b/src/renderer/features/servers/components/server-list.tsx @@ -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' })} @@ -104,14 +106,18 @@ export const ServerList = () => { diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index 061f6b00..202badb8 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -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: ( @@ -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' }), }, ]; diff --git a/src/renderer/features/settings/components/general/remote-settings.tsx b/src/renderer/features/settings/components/general/remote-settings.tsx index 798c52af..0178b676 100644 --- a/src/renderer/features/settings/components/general/remote-settings.tsx +++ b/src/renderer/features/settings/components/general/remote-settings.tsx @@ -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: ( { const enabled = e.currentTarget.checked; @@ -65,8 +68,15 @@ export const RemoteSettings = () => { /> ), description: ( -
- Start an HTTP server to remotely control Feishin. This will listen on{' '} + + {t('setting.enableRemote', { + context: 'description', + postProcess: 'sentenceCase', + })}{' '} { > {url} -
+ ), isHidden, - title: 'Enable remote control', + title: t('setting.enableRemote', { postProcess: 'sentenceCase' }), }, { control: ( { @@ -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: ( { 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: ( { 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 ( - <> - - - - 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 - - - - ); + return ; }; diff --git a/src/renderer/features/settings/components/general/sidebar-settings.tsx b/src/renderer/features/settings/components/general/sidebar-settings.tsx index cb9952f1..4b67035f 100644 --- a/src/renderer/features/settings/components/general/sidebar-settings.tsx +++ b/src/renderer/features/settings/components/general/sidebar-settings.tsx @@ -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' })} /> { 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' })} /> { variant="filled" onClick={handleSave} > - Save sidebar configuration + {t('common.save', { postProcess: 'titleCase' })} } - 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' })} /> { + 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 = () => { {settings.accent}
), - description: 'Sets the accent color', - title: 'Accent color', + description: t('setting.accentColor', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.accentColor', { postProcess: 'sentenceCase' }), }, ]; diff --git a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx index 9e95991d..a84c7ff4 100644 --- a/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx @@ -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 = { - 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(null); @@ -175,8 +220,11 @@ export const HotkeyManagerSettings = () => { <> } - 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' })} /> {Object.keys(bindings) diff --git a/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx b/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx index 00d23ed8..f6b98dab 100644 --- a/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx +++ b/src/renderer/features/settings/components/hotkeys/window-hotkey-settings.tsx @@ -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: ( { @@ -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' }), }, ]; diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index 7d946761..28575279 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -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: ( 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: (