Browse Source

I18n (multi-language support) (#179)

* Add i18next, react-i18next, i18next-parser and configs

* Add language config entry

* Refactor for translations, add translation files

* Update i18next-parser

* Update translations after merge and parser fix

* Add more missing translations

* Remove theme translations and fix i18next globbing

* Fix minor UI sizing for translations

* Update translations after merge

* Fix linting

* Add mockSettings to i18n.js
master
Gelaechter 3 years ago
committed by GitHub
parent
commit
9fe4f76799
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .eslintrc.js
  2. 6
      package.json
  3. 3
      src/api/jellyfinApi.ts
  4. 10
      src/components/card/Card.tsx
  5. 14
      src/components/dashboard/Dashboard.tsx
  6. 6
      src/components/debug/DebugWindow.tsx
  7. 20
      src/components/layout/Layout.tsx
  8. 22
      src/components/layout/Sidebar.tsx
  9. 6
      src/components/layout/Titlebar.tsx
  10. 10
      src/components/library/AdvancedFilters.tsx
  11. 19
      src/components/library/AlbumList.tsx
  12. 27
      src/components/library/AlbumView.tsx
  13. 8
      src/components/library/ArtistList.tsx
  14. 43
      src/components/library/ArtistView.tsx
  15. 9
      src/components/library/FolderList.tsx
  16. 6
      src/components/library/GenreList.tsx
  17. 26
      src/components/library/MusicList.tsx
  18. 25
      src/components/player/NowPlayingMiniView.tsx
  19. 28
      src/components/player/NowPlayingView.tsx
  20. 34
      src/components/player/PlayerBar.tsx
  21. 11
      src/components/playlist/PlaylistList.tsx
  22. 75
      src/components/playlist/PlaylistView.tsx
  23. 12
      src/components/search/SearchView.tsx
  24. 17
      src/components/selectionbar/SelectionButtons.tsx
  25. 30
      src/components/settings/Config.tsx
  26. 16
      src/components/settings/ConfigPanels/AdvancedConfig.tsx
  27. 60
      src/components/settings/ConfigPanels/CacheConfig.tsx
  28. 46
      src/components/settings/ConfigPanels/ExternalConfig.tsx
  29. 23
      src/components/settings/ConfigPanels/ListViewConfig.tsx
  30. 165
      src/components/settings/ConfigPanels/LookAndFeelConfig.tsx
  31. 53
      src/components/settings/ConfigPanels/PlaybackConfig.tsx
  32. 84
      src/components/settings/ConfigPanels/PlayerConfig.tsx
  33. 25
      src/components/settings/ConfigPanels/ServerConfig.tsx
  34. 16
      src/components/settings/ConfigPanels/WindowConfig.tsx
  35. 4
      src/components/settings/DisconnectButton.tsx
  36. 104
      src/components/settings/Fonts.ts
  37. 680
      src/components/settings/ListViewColumns.ts
  38. 24
      src/components/settings/Login.tsx
  39. 11
      src/components/shared/ColumnSort.tsx
  40. 67
      src/components/shared/ContextMenu.tsx
  41. 51
      src/components/shared/ToolbarButtons.tsx
  42. 101
      src/components/shared/setDefaultSettings.ts
  43. 10
      src/components/starred/StarredView.tsx
  44. 75
      src/hooks/useColumnSort.ts
  45. 42
      src/i18n/i18n.js
  46. 113
      src/i18n/i18next-parser.config.js
  47. 341
      src/i18n/locales/de.json
  48. 341
      src/i18n/locales/en.json
  49. 1
      src/index.tsx
  50. 18
      src/main.dev.js
  51. 99
      src/shared/mockSettings.ts
  52. 15
      src/shared/utils.ts
  53. 774
      yarn.lock

6
.eslintrc.js

@ -11,6 +11,12 @@ module.exports = {
'react/jsx-props-no-spreading': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-nested-ternary': 'off',
'react/no-unescaped-entities': [
'error',
{
forbid: ['>', "'", '}'],
},
],
},
parserOptions: {
ecmaVersion: 2020,

6
package.json

@ -13,7 +13,8 @@
"start": "node -r @babel/register ./.erb/scripts/CheckPortInUse.js && yarn start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ./.erb/scripts/BabelRegister ./src/main.dev.js",
"start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.babel.js",
"test": "jest"
"test": "jest",
"i18next": "i18next -c src/i18n/i18next-parser.config.js"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@ -268,6 +269,8 @@
"fast-average-color": "^7.0.1",
"format-duration": "^1.4.0",
"history": "^5.0.0",
"i18next": "^21.6.5",
"i18next-parser": "^5.4.0",
"image-downloader": "^4.0.3",
"lodash": "^4.17.21",
"md5": "^2.3.0",
@ -281,6 +284,7 @@
"react-dom": "^17.0.2",
"react-helmet-async": "^1.1.2",
"react-hotkeys-hook": "^3.4.3",
"react-i18next": "^11.15.3",
"react-lazy-load-image-component": "^1.5.1",
"react-query": "^3.19.1",
"react-redux": "^7.2.4",

3
src/api/jellyfinApi.ts

@ -5,6 +5,7 @@ import settings from 'electron-settings';
import _ from 'lodash';
import moment from 'moment';
import { nanoid } from 'nanoid/non-secure';
import i18next from 'i18next';
import { handleDisconnect } from '../components/settings/DisconnectButton';
import { notifyToast } from '../components/shared/toast';
import { GenericItem, Item, Song } from '../types';
@ -46,7 +47,7 @@ jellyfinApi.interceptors.response.use(
(res) => res,
(err) => {
if (err.response && err.response.status === 401) {
notifyToast('warning', 'Session expired. Logging out.');
notifyToast('warning', i18next.t('Session expired. Logging out.'));
handleDisconnect();
}

10
src/components/card/Card.tsx

@ -1,6 +1,7 @@
import React from 'react';
import { Icon } from 'rsuite';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import cacheImage from '../shared/cacheImage';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
@ -54,6 +55,7 @@ const Card = ({
noModalButton,
...rest
}: any) => {
const { t } = useTranslation();
const history = useHistory();
const dispatch = useAppDispatch();
const config = useAppSelector((state) => state.config);
@ -300,7 +302,7 @@ const Card = ({
onClick={handlePlayClick}
/>
<CustomTooltip text="Add to queue (later)" delay={1000}>
<CustomTooltip text={t('Add to queue (later)')} delay={1000}>
<AppendOverlayButton
onClick={() => handlePlayAppend('later')}
size={size <= 160 ? 'xs' : 'sm'}
@ -308,7 +310,7 @@ const Card = ({
/>
</CustomTooltip>
<CustomTooltip text="Add to queue (next)" delay={1000}>
<CustomTooltip text={t('Add to queue (next)')} delay={1000}>
<AppendNextOverlayButton
onClick={() => handlePlayAppend('next')}
size={size <= 160 ? 'xs' : 'sm'}
@ -317,7 +319,7 @@ const Card = ({
</CustomTooltip>
{playClick.type !== 'playlist' && (
<CustomTooltip text="Toggle favorite" delay={1000}>
<CustomTooltip text={t('Toggle favorite')} delay={1000}>
<FavoriteOverlayButton
onClick={() => handleFavorite(rest.details)}
size={size <= 160 ? 'xs' : 'sm'}
@ -326,7 +328,7 @@ const Card = ({
</CustomTooltip>
)}
{!rest.isModal && !noModalButton && (
<CustomTooltip text="View in modal" delay={1000}>
<CustomTooltip text={t('View in modal')} delay={1000}>
<ModalViewOverlayButton
size={size <= 160 ? 'xs' : 'sm'}
icon={<Icon icon="external-link" />}

14
src/components/dashboard/Dashboard.tsx

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import { useHistory } from 'react-router-dom';
import { useQuery, useQueryClient } from 'react-query';
import { useTranslation } from 'react-i18next';
import PageLoader from '../loader/PageLoader';
import GenericPage from '../layout/GenericPage';
import GenericPageHeader from '../layout/GenericPageHeader';
@ -13,6 +14,7 @@ import { apiController } from '../../api/controller';
import { Server } from '../../types';
const Dashboard = () => {
const { t } = useTranslation();
const history = useHistory();
const dispatch = useAppDispatch();
const queryClient = useQueryClient();
@ -173,19 +175,19 @@ const Dashboard = () => {
if (isLoadingRecent || isLoadingNewest || isLoadingRandom || isLoadingFrequent) {
return (
<GenericPage hideDivider header={<GenericPageHeader title="Dashboard" />}>
<GenericPage hideDivider header={<GenericPageHeader title={t('Dashboard')} />}>
<PageLoader />
</GenericPage>
);
}
return (
<GenericPage header={<GenericPageHeader title="Dashboard" />} hideDivider>
<GenericPage header={<GenericPageHeader title={t('Dashboard')} />} hideDivider>
{newestAlbums && recentAlbums && randomAlbums && (
<>
<ScrollingMenu
noScrollbar
title="Recently Played"
title={t('Recently Played')}
data={config.serverType === Server.Jellyfin ? recentAlbums.data : recentAlbums}
cardTitle={{
prefix: '/library/album',
@ -209,8 +211,8 @@ const Dashboard = () => {
/>
<ScrollingMenu
title={t('Recently Added')}
noScrollbar
title="Recently Added"
data={newestAlbums}
cardTitle={{
prefix: '/library/album',
@ -234,8 +236,8 @@ const Dashboard = () => {
/>
<ScrollingMenu
title={t('Random')}
noScrollbar
title="Random"
data={randomAlbums}
cardTitle={{
prefix: '/library/album',
@ -260,7 +262,7 @@ const Dashboard = () => {
<ScrollingMenu
noScrollbar
title="Most Played"
title={t('Most Played')}
data={config.serverType === Server.Jellyfin ? frequentAlbums.data : frequentAlbums}
cardTitle={{
prefix: '/library/album',

6
src/components/debug/DebugWindow.tsx

@ -1,11 +1,13 @@
import React, { useState } from 'react';
import { Line } from 'react-chartjs-2';
import { Button, Checkbox, Divider, FlexboxGrid, Panel, Slider } from 'rsuite';
import { useTranslation } from 'react-i18next';
import { useAppSelector, useAppDispatch } from '../../redux/hooks';
import CustomTooltip from '../shared/CustomTooltip';
import { setFadeData } from '../../redux/playQueueSlice';
const DebugWindow = ({ ...rest }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const playQueue = useAppSelector((state) => state.playQueue);
const player = useAppSelector((state) => state.player);
@ -81,7 +83,7 @@ const DebugWindow = ({ ...rest }) => {
}}
>
<h5 style={{ paddingRight: '10px' }}>Debug</h5>
<CustomTooltip text="Clickable">
<CustomTooltip text={t('Clickable')}>
<FlexboxGrid.Item>
<Checkbox
style={{
@ -95,7 +97,7 @@ const DebugWindow = ({ ...rest }) => {
/>
</FlexboxGrid.Item>
</CustomTooltip>
<CustomTooltip text="Opacity">
<CustomTooltip text={t('Opacity')}>
<FlexboxGrid.Item>
<Slider
value={opacity}

20
src/components/layout/Layout.tsx

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useHistory } from 'react-router-dom';
import { ButtonToolbar, Content, FlexboxGrid, Icon, Nav, Whisper } from 'rsuite';
import { useTranslation } from 'react-i18next';
import Sidebar from './Sidebar';
import Titlebar from './Titlebar';
import { RootContainer, RootFooter, MainContainer } from './styled';
@ -30,6 +31,7 @@ import WindowConfig from '../settings/ConfigPanels/WindowConfig';
import AdvancedConfig from '../settings/ConfigPanels/AdvancedConfig';
const Layout = ({ footer, children, disableSidebar, font }: any) => {
const { t } = useTranslation();
const history = useHistory();
const dispatch = useAppDispatch();
const misc = useAppSelector((state) => state.misc);
@ -228,8 +230,8 @@ const Layout = ({ footer, children, disableSidebar, font }: any) => {
speaker={
<StyledPopover
style={{
maxWidth: '500px',
maxHeight: '500px',
maxWidth: '620px',
maxHeight: '620px',
overflowY: 'auto',
overflowX: 'hidden',
padding: '0px',
@ -240,13 +242,13 @@ const Layout = ({ footer, children, disableSidebar, font }: any) => {
onSelect={(e) => setActiveConfigNav(e)}
appearance="tabs"
>
<StyledNavItem eventKey="listView">List View</StyledNavItem>
<StyledNavItem eventKey="gridView">Grid View</StyledNavItem>
<StyledNavItem eventKey="playback">Playback</StyledNavItem>
<StyledNavItem eventKey="player">Player</StyledNavItem>
<StyledNavItem eventKey="theme">Theme</StyledNavItem>
<StyledNavItem eventKey="server">Server</StyledNavItem>
<StyledNavItem eventKey="other">Other</StyledNavItem>
<StyledNavItem eventKey="listView">{t('List View')}</StyledNavItem>
<StyledNavItem eventKey="gridView">{t('Grid View')}</StyledNavItem>
<StyledNavItem eventKey="playback">{t('Playback')}</StyledNavItem>
<StyledNavItem eventKey="player">{t('Player')}</StyledNavItem>
<StyledNavItem eventKey="theme">{t('Theme')}</StyledNavItem>
<StyledNavItem eventKey="server">{t('Server')}</StyledNavItem>
<StyledNavItem eventKey="other">{t('Other')}</StyledNavItem>
</Nav>
{activeConfigNav === 'listView' && <ListViewConfigPanel />}
{activeConfigNav === 'gridView' && <GridViewConfigPanel />}

22
src/components/layout/Sidebar.tsx

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { Sidenav, Nav, Icon } from 'rsuite';
import { useAppSelector } from '../../redux/hooks';
@ -14,6 +15,7 @@ const Sidebar = ({
titleBar,
...rest
}: any) => {
const { t } = useTranslation();
const history = useHistory();
const config = useAppSelector((state) => state.config);
@ -48,7 +50,7 @@ const Sidebar = ({
}
}}
>
Dashboard
{t('Dashboard')}
</SidebarNavItem>
<SidebarNavItem
tabIndex={0}
@ -62,7 +64,7 @@ const Sidebar = ({
}
}}
>
Now Playing
{t('Now Playing')}
</SidebarNavItem>
<SidebarNavItem
tabIndex={0}
@ -76,7 +78,7 @@ const Sidebar = ({
}
}}
>
Playlists
{t('Playlists')}
</SidebarNavItem>
<SidebarNavItem
tabIndex={0}
@ -90,7 +92,7 @@ const Sidebar = ({
}
}}
>
Favorites
{t('Favorites')}
</SidebarNavItem>
{config.serverType === Server.Jellyfin && (
<SidebarNavItem
@ -120,7 +122,7 @@ const Sidebar = ({
}
}}
>
Albums
{t('Albums')}
</SidebarNavItem>
<SidebarNavItem
tabIndex={0}
@ -134,7 +136,7 @@ const Sidebar = ({
}
}}
>
Artists
{t('Artists')}{' '}
</SidebarNavItem>
<SidebarNavItem
tabIndex={0}
@ -148,7 +150,7 @@ const Sidebar = ({
}
}}
>
Genres
{t('Genres')}{' '}
</SidebarNavItem>
{useAppSelector((state) => state.config).serverType !== 'funkwhale' && (
<>
@ -164,7 +166,7 @@ const Sidebar = ({
}
}}
>
Folders
{t('Folders')}{' '}
</SidebarNavItem>
</>
)}
@ -182,7 +184,7 @@ const Sidebar = ({
}
}}
>
Config
{t('Config')}{' '}
</SidebarNavItem>
<SidebarNavItem
tabIndex={0}
@ -195,7 +197,7 @@ const Sidebar = ({
}
}}
>
{expand ? 'Collapse' : 'Expand'}
{expand ? t('Collapse') : t('Expand')}
</SidebarNavItem>
</Nav>
</Sidenav.Body>

6
src/components/layout/Titlebar.tsx

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
TitleHeader,
DragRegion,
@ -12,6 +13,7 @@ import { getCurrentEntryList } from '../../shared/utils';
import logo from '../../../assets/icon.png';
const Titlebar = ({ font }: any) => {
const { t } = useTranslation();
const playQueue = useAppSelector((state) => state.playQueue);
const player = useAppSelector((state) => state.player);
const misc = useAppSelector((state) => state.misc);
@ -24,7 +26,7 @@ const Titlebar = ({ font }: any) => {
const currentEntryList = getCurrentEntryList(playQueue);
const playStatus =
player.status !== 'PLAYING' && playQueue[currentEntryList].length > 0 ? '(Paused)' : '';
player.status !== 'PLAYING' && playQueue[currentEntryList].length > 0 ? t('(Paused)') : '';
const songTitle = playQueue[currentEntryList][playQueue.currentIndex]?.title
? `(${playQueue.currentIndex + 1} / ${playQueue[currentEntryList].length}) ~ ${
@ -34,7 +36,7 @@ const Titlebar = ({ font }: any) => {
setTitle(`${playStatus} ${songTitle}`.trim());
document.title = `${playStatus} ${songTitle}`.trim();
}, [playQueue, player.status]);
}, [playQueue, player.status, t]);
// if the titlebar is native return no custom titlebar
if (misc.titleBar === 'native') {

10
src/components/library/AdvancedFilters.tsx

@ -1,5 +1,6 @@
import _ from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ButtonToolbar, ControlLabel, Divider, FlexboxGrid, RadioGroup } from 'rsuite';
import styled from 'styled-components';
import { useAppDispatch } from '../../redux/hooks';
@ -20,6 +21,7 @@ export const FilterHeader = styled.div`
`;
const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilters }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [availableGenres, setAvailableGenres] = useState<any[]>([]);
const [availableArtists, setAvailableArtists] = useState<any[]>([]);
@ -336,7 +338,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
<Divider />
<FilterHeader>
<FlexboxGrid justify="space-between">
<FlexboxGrid.Item>Years</FlexboxGrid.Item>
<FlexboxGrid.Item>{t('Years')}</FlexboxGrid.Item>
<FlexboxGrid.Item>
<StyledButton
size="xs"
@ -355,14 +357,14 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
);
}}
>
Reset
{t('Reset')}
</StyledButton>
</FlexboxGrid.Item>
</FlexboxGrid>
</FilterHeader>
<FlexboxGrid justify="space-between">
<FlexboxGrid.Item>
<ControlLabel>From</ControlLabel>
<ControlLabel>{t('From')}</ControlLabel>
<StyledInputNumber
width={100}
min={0}
@ -381,7 +383,7 @@ const AdvancedFilters = ({ filteredData, originalData, filter, setAdvancedFilter
/>
</FlexboxGrid.Item>
<FlexboxGrid.Item>
<ControlLabel>To</ControlLabel>
<ControlLabel>{t('To')}</ControlLabel>
<StyledInputNumber
width={100}
min={0}

19
src/components/library/AlbumList.tsx

@ -4,6 +4,8 @@ import settings from 'electron-settings';
import { ButtonToolbar, Nav, Whisper } from 'rsuite';
import { useQuery, useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import i18next from 'i18next';
import GridViewType from '../viewtypes/GridViewType';
import ListViewType from '../viewtypes/ListViewType';
import useSearchQuery from '../../hooks/useSearchQuery';
@ -35,15 +37,16 @@ import ColumnSort from '../shared/ColumnSort';
import useColumnSort from '../../hooks/useColumnSort';
export const ALBUM_SORT_TYPES = [
{ label: 'A-Z (Name)', value: 'alphabeticalByName', role: 'Default' },
{ label: 'A-Z (Artist)', value: 'alphabeticalByArtist', role: 'Default' },
{ label: 'Most Played', value: 'frequent', role: 'Default' },
{ label: 'Random', value: 'random', role: 'Default' },
{ label: 'Recently Added', value: 'newest', role: 'Default' },
{ label: 'Recently Played', value: 'recent', role: 'Default' },
{ label: i18next.t('A-Z (Name)'), value: 'alphabeticalByName', role: i18next.t('Default') },
{ label: i18next.t('A-Z (Artist)'), value: 'alphabeticalByArtist', role: i18next.t('Default') },
{ label: i18next.t('Most Played'), value: 'frequent', role: i18next.t('Default') },
{ label: i18next.t('Random'), value: 'random', role: i18next.t('Default') },
{ label: i18next.t('Recently Added'), value: 'newest', role: i18next.t('Default') },
{ label: i18next.t('Recently Played'), value: 'recent', role: i18next.t('Default') },
];
const AlbumList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const queryClient = useQueryClient();
@ -240,7 +243,7 @@ const AlbumList = () => {
<GenericPageHeader
title={
<>
Albums{' '}
{t('Albums')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{filteredData?.length || '...'}
</StyledTag>
@ -261,7 +264,7 @@ const AlbumList = () => {
config.serverType === Server.Jellyfin ? ['frequent', 'recent'] : []
}
cleanable={false}
placeholder="Sort Type"
placeholder={t('Sort Type')}
onChange={async (value: string) => {
setIsRefresh(true);
await queryClient.cancelQueries([

27
src/components/library/AlbumView.tsx

@ -6,6 +6,8 @@ import settings from 'electron-settings';
import { ButtonToolbar, Whisper } from 'rsuite';
import { useQuery, useQueryClient } from 'react-query';
import { useParams, useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
import {
DownloadButton,
FavoriteButton,
@ -39,7 +41,6 @@ import { addModalPage } from '../../redux/miscSlice';
import { notifyToast } from '../shared/toast';
import {
filterPlayQueue,
formatDate,
formatDuration,
getAlbumSize,
getPlayedSongsNotification,
@ -68,6 +69,7 @@ interface AlbumParams {
}
const AlbumView = ({ ...rest }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const misc = useAppSelector((state) => state.misc);
const album = useAppSelector((state) => state.album);
@ -226,7 +228,7 @@ const AlbumView = ({ ...rest }: any) => {
downloadUrls.forEach((url) => shell.openExternal(url));
} else {
clipboard.writeText(downloadUrls.join('\n'));
notifyToast('info', 'Download links copied!');
notifyToast('info', t('Download links copied!'));
}
// If not Navidrome (this assumes Airsonic), then we need to use a song's parent
@ -250,10 +252,10 @@ const AlbumView = ({ ...rest }: any) => {
config.serverType === Server.Subsonic ? { id: data.song[0].parent } : { id: data.id },
})
);
notifyToast('info', 'Download links copied!');
notifyToast('info', t('Download links copied!'));
}
} else {
notifyToast('warning', 'No parent album found');
notifyToast('warning', t('No parent album found'));
}
};
@ -344,10 +346,12 @@ const AlbumView = ({ ...rest }: any) => {
subtitle={
<div>
<PageHeaderSubtitleDataLine $top $overflow>
<StyledLink onClick={() => history.push('/library/album')}>ALBUM</StyledLink>{' '}
<StyledLink onClick={() => history.push('/library/album')}>
{t('ALBUM')}
</StyledLink>{' '}
{data.albumArtist && (
<>
by{' '}
{t('by')}{' '}
<LinkWrapper maxWidth="20vw">
<StyledLink
onClick={() => history.push(`/library/artist/${data.albumArtistId}`)}
@ -379,7 +383,12 @@ const AlbumView = ({ ...rest }: any) => {
}
}}
>
Added {formatDate(data.created)}
{t('Added {{val, datetime}}', {
val: moment(data.created),
formatParams: {
val: { year: 'numeric', month: 'short', day: 'numeric' },
},
})}
{data.genre.map((d: Genre, i: number) => {
return (
<span key={nanoid()}>
@ -505,10 +514,10 @@ const AlbumView = ({ ...rest }: any) => {
<StyledPopover>
<ButtonToolbar>
<StyledButton onClick={() => handleDownload('download')}>
Download
{t('Download')}
</StyledButton>
<StyledButton onClick={() => handleDownload('copy')}>
Copy to clipboard
{t('Copy to clipboard')}
</StyledButton>
</ButtonToolbar>
</StyledPopover>

8
src/components/library/ArtistList.tsx

@ -4,6 +4,7 @@ import settings from 'electron-settings';
import { useQuery, useQueryClient } from 'react-query';
import { useHistory } from 'react-router';
import { ButtonToolbar } from 'rsuite';
import { useTranslation } from 'react-i18next';
import useSearchQuery from '../../hooks/useSearchQuery';
import GenericPage from '../layout/GenericPage';
import GenericPageHeader from '../layout/GenericPageHeader';
@ -26,6 +27,7 @@ import { setSort } from '../../redux/artistSlice';
import { StyledTag } from '../shared/styled';
const ArtistList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const queryClient = useQueryClient();
@ -145,7 +147,7 @@ const ArtistList = () => {
<GenericPageHeader
title={
<>
Artists{' '}
{t('Artists')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{sortedData?.length || '...'}
</StyledTag>
@ -153,7 +155,7 @@ const ArtistList = () => {
}
subtitle={
<ButtonToolbar>
<RefreshButton onClick={handleRefresh} size="sm" loading={isRefreshing} width={100} />
<RefreshButton onClick={handleRefresh} size="sm" loading={isRefreshing} />
</ButtonToolbar>
}
sidetitle={
@ -212,7 +214,7 @@ const ArtistList = () => {
}
>
{isLoading && <PageLoader />}
{isError && <div>Error: {error}</div>}
{isError && <div>{t('Error: {{error}}', { error })}</div>}
{!isLoading && !isError && viewType === 'list' && (
<ListViewType
data={misc.searchQuery !== '' ? filteredData : sortedData}

43
src/components/library/ArtistView.tsx

@ -7,6 +7,7 @@ import { clipboard, shell } from 'electron';
import settings from 'electron-settings';
import { ButtonToolbar, Whisper, ButtonGroup, Icon } from 'rsuite';
import { useQuery, useQueryClient } from 'react-query';
import { useTranslation } from 'react-i18next';
import { useParams, useHistory, useLocation } from 'react-router-dom';
import {
DownloadButton,
@ -70,6 +71,7 @@ interface ArtistParams {
}
const ArtistView = ({ ...rest }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queryClient = useQueryClient();
const history = useHistory();
@ -405,7 +407,7 @@ const ArtistView = ({ ...rest }: any) => {
if (type === 'copy') {
clipboard.writeText(downloadUrls.join('\n'));
notifyToast('info', 'Download links copied!');
notifyToast('info', t('Download links copied!'));
}
} else if (data.album[0]?.parent) {
if (type === 'download') {
@ -424,7 +426,7 @@ const ArtistView = ({ ...rest }: any) => {
args: { id: data.album[0].parent },
})
);
notifyToast('info', 'Download links copied!');
notifyToast('info', t('Download links copied!'));
}
} else {
const downloadUrls: string[] = [];
@ -446,7 +448,10 @@ const ArtistView = ({ ...rest }: any) => {
})
);
} else {
notifyToast('warning', `[${albumRes.title}] No parent album found`);
notifyToast(
'warning',
t('[{{albumTitle}}] No parent album found', { albumTitle: albumRes.title })
);
}
}
@ -456,7 +461,7 @@ const ArtistView = ({ ...rest }: any) => {
if (type === 'copy') {
clipboard.writeText(downloadUrls.join('\n'));
notifyToast('info', 'Download links copied!');
notifyToast('info', t('Download links copied!'));
}
}
};
@ -538,7 +543,7 @@ const ArtistView = ({ ...rest }: any) => {
<GenericPageHeader
image={
<Card
title="None"
title={t('None')}
subtitle=""
coverArt={
isCached(`${misc.imageCachePath}artist_${data?.id}.jpg`)
@ -698,10 +703,10 @@ const ArtistView = ({ ...rest }: any) => {
<StyledPopover>
<ButtonToolbar>
<StyledButton onClick={() => handleDownload('download')}>
Download
{t('Download')}
</StyledButton>
<StyledButton onClick={() => handleDownload('copy')}>
Copy to clipboard
{t('Copy to clipboard')}
</StyledButton>
</ButtonToolbar>
</StyledPopover>
@ -726,7 +731,7 @@ const ArtistView = ({ ...rest }: any) => {
</StyledPopover>
}
>
<CustomTooltip text="Info">
<CustomTooltip text="{t('Info')}">
<StyledButton appearance="subtle" size="lg">
<Icon icon="info-circle" />
</StyledButton>
@ -861,14 +866,14 @@ const ArtistView = ({ ...rest }: any) => {
appearance="subtle"
onClick={() => history.push(`/library/artist/${artistId}/albums`)}
>
View Discography
{t('View Discography')}
</StyledButton>
<StyledButton
size="sm"
appearance="subtle"
onClick={() => history.push(`/library/artist/${artistId}/songs`)}
>
View All Songs
{t('View All Songs')}
</StyledButton>
</ButtonToolbar>
<br />
@ -879,13 +884,13 @@ const ArtistView = ({ ...rest }: any) => {
<SectionTitle
onClick={() => history.push(`/library/artist/${artistId}/topsongs`)}
>
Top Songs
{t('Top Songs')}
</SectionTitle>{' '}
<ButtonGroup>
<PlayButton
size="sm"
appearance="subtle"
text="Play Top Songs"
text={t('Play Top Songs')}
onClick={() => handlePlay('topSongs')}
/>
<PlayAppendNextButton
@ -933,7 +938,7 @@ const ArtistView = ({ ...rest }: any) => {
appearance="subtle"
onClick={() => setSeeMoreTopSongs(!seeMoreTopSongs)}
>
{seeMoreTopSongs ? 'SHOW LESS' : 'SHOW MORE'}
{seeMoreTopSongs ? t('SHOW LESS') : t('SHOW MORE')}
</StyledButton>
)}
</StyledPanel>
@ -942,13 +947,13 @@ const ArtistView = ({ ...rest }: any) => {
{albumsByYearDesc.length > 0 && (
<StyledPanel>
<ScrollingMenu
title="Latest Albums "
title={t('Latest Albums ')}
subtitle={
<ButtonGroup>
<PlayButton
size="sm"
appearance="subtle"
text="Play Latest Albums"
text={t('Play Latest Albums')}
onClick={() => handlePlay('albums')}
/>
<PlayAppendNextButton
@ -984,13 +989,13 @@ const ArtistView = ({ ...rest }: any) => {
{compilationAlbumsByYearDesc.length > 0 && (
<StyledPanel>
<ScrollingMenu
title="Appears On "
title={t('Appears On ')}
subtitle={
<ButtonGroup>
<PlayButton
size="sm"
appearance="subtle"
text="Play Compilation Albums"
text={t('Play Compilation Albums')}
onClick={() => handlePlay('albums')}
/>
<PlayAppendNextButton
@ -1028,13 +1033,13 @@ const ArtistView = ({ ...rest }: any) => {
{data.info?.similarArtist.length > 0 && (
<StyledPanel>
<ScrollingMenu
title="Related Artists "
title={t('Related Artists ')}
subtitle={
<ButtonGroup>
<PlayButton
size="sm"
appearance="subtle"
text="Play Artist Mix"
text={t('Play Artist Mix')}
onClick={() => handlePlay('mix')}
/>
<PlayAppendNextButton

9
src/components/library/FolderList.tsx

@ -4,6 +4,7 @@ import _ from 'lodash';
import { useQuery, useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { ButtonToolbar, Icon } from 'rsuite';
import { useTranslation } from 'react-i18next';
import PageLoader from '../loader/PageLoader';
import ListViewType from '../viewtypes/ListViewType';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
@ -26,6 +27,7 @@ import { apiController } from '../../api/controller';
import { setPlaylistRate } from '../../redux/playlistSlice';
const FolderList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const query = useRouterQuery();
@ -179,8 +181,8 @@ const FolderList = () => {
folderData?.title
? folderData.title
: isLoadingFolderData
? 'Loading...'
: 'Select a folder'
? t('Loading...')
: t('Select a folder')
}`}
showTitleTooltip
subtitle={
@ -195,6 +197,7 @@ const FolderList = () => {
defaultValue={musicFolder}
valueKey="id"
labelKey="title"
placeholder={t('Select')}
onChange={(e: any) => {
setMusicFolder(e);
}}
@ -215,7 +218,7 @@ const FolderList = () => {
}}
>
<Icon icon="level-up" style={{ marginRight: '10px' }} />
Go up
{t('Go up')}
</StyledButton>
</ButtonToolbar>
</StyledInputPickerContainer>

6
src/components/library/GenreList.tsx

@ -2,6 +2,7 @@ import React from 'react';
import _ from 'lodash';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router';
import { useTranslation } from 'react-i18next';
import useSearchQuery from '../../hooks/useSearchQuery';
import GenericPage from '../layout/GenericPage';
import GenericPageHeader from '../layout/GenericPageHeader';
@ -19,6 +20,7 @@ import { apiController } from '../../api/controller';
import { StyledTag } from '../shared/styled';
const GenreList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const config = useAppSelector((state) => state.config);
@ -72,7 +74,7 @@ const GenreList = () => {
<GenericPageHeader
title={
<>
Genres{' '}
{t('Genres')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{genres?.length || '...'}
</StyledTag>
@ -82,7 +84,7 @@ const GenreList = () => {
}
>
{isLoading && <PageLoader />}
{isError && <div>Error: {error}</div>}
{isError && <div>{t('Error: {{error}}', { error })}</div>}
{!isLoading && genres && !isError && (
<ListViewType
data={misc.searchQuery !== '' ? filteredData : genres}

26
src/components/library/MusicList.tsx

@ -3,6 +3,8 @@ import _ from 'lodash';
import settings from 'electron-settings';
import { ButtonToolbar } from 'rsuite';
import { useQuery, useQueryClient } from 'react-query';
import { useTranslation } from 'react-i18next';
import i18next from 'i18next';
import ListViewType from '../viewtypes/ListViewType';
import useSearchQuery from '../../hooks/useSearchQuery';
import GenericPageHeader from '../layout/GenericPageHeader';
@ -25,18 +27,20 @@ import { setFilter, setPagination } from '../../redux/viewSlice';
import { setStatus } from '../../redux/playerSlice';
export const MUSIC_SORT_TYPES = [
{ label: 'A-Z (Name)', value: 'alphabeticalByName', role: 'Default' },
{ label: 'A-Z (Album)', value: 'alphabeticalByAlbum', role: 'Default' },
{ label: 'A-Z (Album Artist)', value: 'alphabeticalByArtist', role: 'Default' },
{ label: 'A-Z (Artist)', value: 'alphabeticalByTrackArtist', replacement: 'Artist' },
{ label: 'Most Played', value: 'frequent', role: 'Default' },
{ label: 'Random', value: 'random', role: 'Default' },
{ label: 'Recently Added', value: 'newest', role: 'Default' },
{ label: 'Recently Played', value: 'recent', role: 'Default' },
{ label: 'Release Date', value: 'year', role: 'Default' },
{ label: i18next.t('A-Z (Name)'), value: 'alphabeticalByName', role: i18next.t('Default') },
{ label: i18next.t('A-Z (Album)'), value: 'alphabeticalByAlbum', role: i18next.t('Default') },
// eslint-disable-next-line prettier/prettier
{ label: i18next.t('A-Z (Album Artist)'), value: 'alphabeticalByArtist', role: i18next.t('Default') },
{ label: i18next.t('A-Z (Artist)'), value: 'alphabeticalByTrackArtist', replacement: 'Artist' },
{ label: i18next.t('Most Played'), value: 'frequent', role: i18next.t('Default') },
{ label: i18next.t('Random'), value: 'random', role: i18next.t('Default') },
{ label: i18next.t('Recently Added'), value: 'newest', role: i18next.t('Default') },
{ label: i18next.t('Recently Played'), value: 'recent', role: i18next.t('Default') },
{ label: i18next.t('Release Date'), value: 'year', role: i18next.t('Default') },
];
const MusicList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queryClient = useQueryClient();
const folder = useAppSelector((state) => state.folder);
@ -238,7 +242,7 @@ const MusicList = () => {
<GenericPageHeader
title={
<>
Songs{' '}
{t('Songs')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{musicData?.totalRecordCount || '...'}
</StyledTag>
@ -256,7 +260,7 @@ const MusicList = () => {
value={view.music.filter}
data={sortTypes || MUSIC_SORT_TYPES}
cleanable={false}
placeholder="Sort Type"
placeholder={t('Sort Type')}
onChange={async (value: string) => {
setIsRefreshing(true);
await queryClient.cancelQueries([

25
src/components/player/NowPlayingMiniView.tsx

@ -4,6 +4,7 @@ import settings from 'electron-settings';
import { ButtonToolbar, FlexboxGrid, Icon, Whisper, ControlLabel } from 'rsuite';
import { useHotkeys } from 'react-hotkeys-hook';
import { useQuery } from 'react-query';
import { useTranslation } from 'react-i18next';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import {
toggleSelected,
@ -60,6 +61,7 @@ import { apiController } from '../../api/controller';
import { Song } from '../../types';
const NowPlayingMiniView = () => {
const { t } = useTranslation();
const tableRef = useRef<any>();
const dispatch = useAppDispatch();
const playQueue = useAppSelector((state) => state.playQueue);
@ -255,7 +257,7 @@ const NowPlayingMiniView = () => {
return autoPlaylistTriggerRef.current.close();
}
setIsLoadingRandom(false);
return notifyToast('warning', `No songs found, adjust your filters`);
return notifyToast('warning', t('No songs found, adjust your filters'));
};
const handleRowFavorite = async (rowData: any) => {
@ -317,7 +319,7 @@ const NowPlayingMiniView = () => {
enterable
speaker={
<StyledPopover>
<ControlLabel>How many tracks? (1-500)*</ControlLabel>
<ControlLabel>{t('How many tracks? (1-500)*')}</ControlLabel>
<StyledInputNumber
min={1}
max={500}
@ -332,7 +334,7 @@ const NowPlayingMiniView = () => {
<br />
<FlexboxGrid justify="space-between">
<FlexboxGrid.Item>
<ControlLabel>From year</ControlLabel>
<ControlLabel>{t('From year')}</ControlLabel>
<div>
<StyledInputNumber
width={100}
@ -348,7 +350,7 @@ const NowPlayingMiniView = () => {
</div>
</FlexboxGrid.Item>
<FlexboxGrid.Item>
<ControlLabel>To year</ControlLabel>
<ControlLabel>{t('To year')}</ControlLabel>
<div>
<StyledInputNumber
width={100}
@ -363,7 +365,7 @@ const NowPlayingMiniView = () => {
</FlexboxGrid.Item>
</FlexboxGrid>
<br />
<ControlLabel>Genre</ControlLabel>
<ControlLabel>{t('Genre')}</ControlLabel>
<StyledInputPickerContainer ref={genrePickerContainerRef}>
<StyledInputPicker
style={{ width: '100%' }}
@ -373,12 +375,13 @@ const NowPlayingMiniView = () => {
valueKey="id"
labelKey="title"
virtualized
placeholder={t('Select')}
onChange={(e: string) => setRandomPlaylistGenre(e)}
/>
</StyledInputPickerContainer>
<br />
<StyledInputPickerContainer ref={musicFolderPickerContainerRef}>
<ControlLabel>Music folder</ControlLabel>
<ControlLabel>{t('Music folder')}</ControlLabel>
<br />
<StyledInputPicker
style={{ width: '100%' }}
@ -387,6 +390,7 @@ const NowPlayingMiniView = () => {
defaultValue={musicFolder}
valueKey="id"
labelKey="title"
placeholder={t('Select')}
onChange={(e: any) => {
setMusicFolder(e);
}}
@ -400,8 +404,8 @@ const NowPlayingMiniView = () => {
loading={isLoadingRandom}
disabled={!(typeof autoPlaylistTrackCount === 'number')}
>
<Icon icon="plus-circle" style={{ marginRight: '10px' }} /> Add
(next)
<Icon icon="plus-circle" style={{ marginRight: '10px' }} />
{t('Add (next)')}
</StyledButton>
<StyledButton
appearance="subtle"
@ -409,7 +413,8 @@ const NowPlayingMiniView = () => {
loading={isLoadingRandom}
disabled={!(typeof autoPlaylistTrackCount === 'number')}
>
<Icon icon="plus" style={{ marginRight: '10px' }} /> Add (later)
<Icon icon="plus" style={{ marginRight: '10px' }} />
{t('Add (later)')}
</StyledButton>
</ButtonToolbar>
<ButtonToolbar>
@ -421,7 +426,7 @@ const NowPlayingMiniView = () => {
disabled={!(typeof autoPlaylistTrackCount === 'number')}
>
<Icon icon="play" style={{ marginRight: '10px' }} />
Play
{t('Play')}
</StyledButton>
</ButtonToolbar>
</StyledPopover>

28
src/components/player/NowPlayingView.tsx

@ -4,6 +4,7 @@ import settings from 'electron-settings';
import { useQuery } from 'react-query';
import { ButtonToolbar, ButtonGroup, ControlLabel, FlexboxGrid, Icon, Whisper } from 'rsuite';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import useSearchQuery from '../../hooks/useSearchQuery';
import {
@ -64,6 +65,7 @@ import { Song } from '../../types';
import { setPlaylistRate } from '../../redux/playlistSlice';
const NowPlayingView = () => {
const { t } = useTranslation();
const tableRef = useRef<any>();
const genrePickerContainerRef = useRef(null);
const musicFolderPickerContainerRef = useRef(null);
@ -266,7 +268,7 @@ const NowPlayingView = () => {
return autoPlaylistTriggerRef.current.close();
}
setIsLoadingRandom(false);
return notifyToast('warning', `No songs found, adjust your filters`);
return notifyToast('warning', t('No songs found, adjust your filters'));
};
const handleRowFavorite = async (rowData: any) => {
@ -304,7 +306,7 @@ const NowPlayingView = () => {
<GenericPageHeader
title={
<>
Now Playing{' '}
{t('Now Playing')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{playQueue.entry?.length || '...'}
</StyledTag>
@ -339,7 +341,7 @@ const NowPlayingView = () => {
enterable
speaker={
<StyledPopover>
<ControlLabel>How many tracks? (1-500)*</ControlLabel>
<ControlLabel>{t('How many tracks? (1-500)*')}</ControlLabel>
<StyledInputNumber
min={1}
max={500}
@ -354,7 +356,7 @@ const NowPlayingView = () => {
<br />
<FlexboxGrid justify="space-between">
<FlexboxGrid.Item>
<ControlLabel>From year</ControlLabel>
<ControlLabel>{t('From year')}</ControlLabel>
<div>
<StyledInputNumber
width={100}
@ -370,7 +372,7 @@ const NowPlayingView = () => {
</div>
</FlexboxGrid.Item>
<FlexboxGrid.Item>
<ControlLabel>To year</ControlLabel>
<ControlLabel>{t('To year')}</ControlLabel>
<div>
<StyledInputNumber
width={100}
@ -386,7 +388,7 @@ const NowPlayingView = () => {
</FlexboxGrid>
<br />
<StyledInputPickerContainer ref={genrePickerContainerRef}>
<ControlLabel>Genre</ControlLabel>
<ControlLabel>{t('Genre')}</ControlLabel>
<br />
<StyledInputPicker
style={{ width: '100%' }}
@ -396,12 +398,13 @@ const NowPlayingView = () => {
valueKey="id"
labelKey="title"
virtualized
placeholder={t('Select')}
onChange={(e: string) => setRandomPlaylistGenre(e)}
/>
</StyledInputPickerContainer>
<br />
<StyledInputPickerContainer ref={musicFolderPickerContainerRef}>
<ControlLabel>Music folder</ControlLabel>
<ControlLabel>{t('Music folder')}</ControlLabel>
<br />
<StyledInputPicker
style={{ width: '100%' }}
@ -410,6 +413,7 @@ const NowPlayingView = () => {
defaultValue={musicFolder}
valueKey="id"
labelKey="title"
placeholder={t('Select')}
onChange={(e: any) => {
setMusicFolder(e);
}}
@ -423,7 +427,8 @@ const NowPlayingView = () => {
loading={isLoadingRandom}
disabled={!(typeof autoPlaylistTrackCount === 'number')}
>
<Icon icon="plus-circle" style={{ marginRight: '10px' }} /> Add (next)
<Icon icon="plus-circle" style={{ marginRight: '10px' }} />
{t('Add (next)')}
</StyledButton>
<StyledButton
appearance="subtle"
@ -431,7 +436,8 @@ const NowPlayingView = () => {
loading={isLoadingRandom}
disabled={!(typeof autoPlaylistTrackCount === 'number')}
>
<Icon icon="plus" style={{ marginRight: '10px' }} /> Add (later)
<Icon icon="plus" style={{ marginRight: '10px' }} />
{t('Add (later)')}
</StyledButton>
</ButtonToolbar>
<ButtonToolbar>
@ -443,7 +449,7 @@ const NowPlayingView = () => {
disabled={!(typeof autoPlaylistTrackCount === 'number')}
>
<Icon icon="play" style={{ marginRight: '10px' }} />
Play
{t('Play')}
</StyledButton>
</ButtonToolbar>
</StyledPopover>
@ -509,7 +515,7 @@ const NowPlayingView = () => {
);
}}
>
Auto scroll
{t('Auto scroll')}
</StyledCheckbox>
}
/>

34
src/components/player/PlayerBar.tsx

@ -9,6 +9,7 @@ import { FlexboxGrid, Grid, Row, Col, Whisper } from 'rsuite';
import { useHistory } from 'react-router-dom';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import format from 'format-duration';
import { useTranslation } from 'react-i18next';
import {
PlayerContainer,
PlayerColumn,
@ -50,6 +51,7 @@ import { notifyToast } from '../shared/toast';
const DiscordRPC = require('discord-rpc');
const PlayerBar = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const playQueue = useAppSelector((state) => state.playQueue);
const player = useAppSelector((state) => state.player);
@ -626,7 +628,7 @@ const PlayerBar = () => {
placement="topStart"
text={
playQueue[currentEntryList][playQueue.currentIndex]?.title ||
'Unknown title'
t('Unknown Title')
}
>
<LinkButton
@ -642,7 +644,7 @@ const PlayerBar = () => {
}}
>
{playQueue[currentEntryList][playQueue.currentIndex]?.title ||
'Unknown title'}
t('Unknown Title')}
</LinkButton>
</CustomTooltip>
</Row>
@ -660,7 +662,7 @@ const PlayerBar = () => {
text={
playQueue[currentEntryList][playQueue.currentIndex]?.albumArtist
? playQueue[currentEntryList][playQueue.currentIndex]?.albumArtist
: 'Unknown artist'
: t('Unknown Artist')
}
>
<span
@ -687,7 +689,7 @@ const PlayerBar = () => {
}}
>
{playQueue[currentEntryList][playQueue.currentIndex]?.albumArtist ||
'Unknown artist'}
t('Unknown Artist')}
</LinkButton>
</span>
</CustomTooltip>
@ -700,7 +702,7 @@ const PlayerBar = () => {
<FlexboxGrid.Item colspan={12} style={{ textAlign: 'center', verticalAlign: 'middle' }}>
<PlayerColumn center height="45px">
{/* Seek Backward Button */}
<CustomTooltip text="Seek backward" delay={1000}>
<CustomTooltip text={t('Seek backward')} delay={1000}>
<PlayerControlIcon
tabIndex={0}
icon="backward"
@ -715,7 +717,7 @@ const PlayerBar = () => {
/>
</CustomTooltip>
{/* Previous Song Button */}
<CustomTooltip text="Previous track" delay={1000}>
<CustomTooltip text={t('Previous Track')} delay={1000}>
<PlayerControlIcon
tabIndex={0}
icon="step-backward"
@ -730,7 +732,7 @@ const PlayerBar = () => {
/>
</CustomTooltip>
{/* Play/Pause Button */}
<CustomTooltip text="Play/Pause" delay={1000}>
<CustomTooltip text={t('Play/Pause')} delay={1000}>
<PlayerControlIcon
tabIndex={0}
icon={player.status === 'PLAYING' ? 'pause' : 'play'}
@ -745,7 +747,7 @@ const PlayerBar = () => {
/>
</CustomTooltip>
{/* Next Song Button */}
<CustomTooltip text="Next track" delay={1000}>
<CustomTooltip text={t('Next Track')} delay={1000}>
<PlayerControlIcon
tabIndex={0}
icon="step-forward"
@ -760,7 +762,7 @@ const PlayerBar = () => {
/>
</CustomTooltip>
{/* Seek Forward Button */}
<CustomTooltip text="Seek forward" delay={1000}>
<CustomTooltip text={t('Seek forward')} delay={1000}>
<PlayerControlIcon
tabIndex={0}
icon="forward"
@ -846,7 +848,7 @@ const PlayerBar = () => {
>
<>
{/* Favorite Button */}
<CustomTooltip text="Favorite">
<CustomTooltip text={t('Favorite')}>
<PlayerControlIcon
tabIndex={0}
icon={
@ -874,10 +876,10 @@ const PlayerBar = () => {
<CustomTooltip
text={
playQueue.repeat === 'all'
? 'Repeat all'
? t('Repeat all')
: playQueue.repeat === 'one'
? 'Repeat one'
: 'Repeat'
? t('Repeat one')
: t('Repeat')
}
>
<PlayerControlIcon
@ -900,7 +902,7 @@ const PlayerBar = () => {
/>
</CustomTooltip>
{/* Shuffle Button */}
<CustomTooltip text="Shuffle">
<CustomTooltip text={t('Shuffle')}>
<PlayerControlIcon
tabIndex={0}
icon="random"
@ -916,7 +918,7 @@ const PlayerBar = () => {
/>
</CustomTooltip>
{/* Display Queue Button */}
<CustomTooltip text="Mini">
<CustomTooltip text={t('Mini')}>
<PlayerControlIcon
tabIndex={0}
icon="tasks"
@ -956,7 +958,7 @@ const PlayerBar = () => {
preventOverflow
speaker={
<StyledPopover>
{muted ? 'Muted' : Math.floor(localVolume * 100)}
{muted ? t('Muted') : Math.floor(localVolume * 100)}
</StyledPopover>
}
>

11
src/components/playlist/PlaylistList.tsx

@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Form, Whisper } from 'rsuite';
import settings from 'electron-settings';
import { useTranslation } from 'react-i18next';
import useSearchQuery from '../../hooks/useSearchQuery';
import ListViewType from '../viewtypes/ListViewType';
import PageLoader from '../loader/PageLoader';
@ -33,6 +34,7 @@ import { setSort } from '../../redux/playlistSlice';
import ColumnSortPopover from '../shared/ColumnSortPopover';
const PlaylistList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const queryClient = useQueryClient();
@ -102,7 +104,7 @@ const PlaylistList = () => {
}
if (isError) {
return <span>Error: {error.message}</span>;
return <span>{t('Error: {{error}}', { error: error.message })}</span>;
}
return (
@ -112,7 +114,7 @@ const PlaylistList = () => {
<GenericPageHeader
title={
<>
Playlists{' '}
{t('Playlists')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{sortedData?.length || '...'}
</StyledTag>
@ -179,7 +181,7 @@ const PlaylistList = () => {
<Form>
<StyledInputGroup>
<StyledInput
placeholder="Enter name..."
placeholder={t('Enter name...')}
value={newPlaylistName}
onChange={(e: string) => setNewPlaylistName(e)}
/>
@ -196,7 +198,7 @@ const PlaylistList = () => {
playlistTriggerRef.current.close();
}}
>
Create
{t('Create')}
</StyledButton>
</Form>
</StyledPopover>
@ -204,7 +206,6 @@ const PlaylistList = () => {
>
<AddPlaylistButton
size="sm"
width={125}
onClick={() =>
playlistTriggerRef.current.state.isOverlayShown
? playlistTriggerRef.current.close()

75
src/components/playlist/PlaylistView.tsx

@ -7,6 +7,8 @@ import { ButtonToolbar, ControlLabel, Form, Whisper } from 'rsuite';
import { useHotkeys } from 'react-hotkeys-hook';
import { useQuery, useQueryClient } from 'react-query';
import { useParams, useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
import {
DeleteButton,
EditButton,
@ -37,8 +39,6 @@ import {
createRecoveryFile,
errorMessages,
filterPlayQueue,
formatDate,
formatDateTime,
formatDuration,
getCurrentEntryList,
getPlayedSongsNotification,
@ -80,6 +80,7 @@ interface PlaylistParams {
}
const PlaylistView = ({ ...rest }) => {
const { t } = useTranslation();
const [isModified, setIsModified] = useState(false);
const dispatch = useAppDispatch();
const playlist = useAppSelector((state) => state.playlist);
@ -232,7 +233,7 @@ const PlaylistView = ({ ...rest }) => {
if (isFailedResponse(res)) {
notifyToast('error', errorMessages(res)[0]);
} else {
notifyToast('success', `Saved playlist`);
notifyToast('success', t('Saved playlist'));
await queryClient.refetchQueries(['playlist'], {
active: true,
});
@ -275,9 +276,9 @@ const PlaylistView = ({ ...rest }) => {
// If the recovery succeeds, we can remove the recovery file
fs.unlinkSync(recoveryPath);
setNeedsRecovery(false);
notifyToast('success', `Recovered playlist from backup`);
notifyToast('success', t('Recovered playlist from backup'));
} else {
notifyToast('success', `Saved playlist`);
notifyToast('success', t('Saved playlist'));
}
await queryClient.refetchQueries(['playlist'], {
@ -285,7 +286,7 @@ const PlaylistView = ({ ...rest }) => {
});
}
} catch (err) {
notifyToast('error', 'Errored while saving playlist');
notifyToast('error', t('Errored while saving playlist'));
const playlistData = recovery
? JSON.parse(fs.readFileSync(recoveryPath, { encoding: 'utf-8' }))
: playlist[getCurrentEntryList(playlist)];
@ -323,9 +324,9 @@ const PlaylistView = ({ ...rest }) => {
});
history.replace(`/playlist/${newPlaylistId}`);
notifyToast('success', `Saved playlist`);
notifyToast('success', t('Saved playlist'));
} else {
notifyToast('error', 'Error saving playlist');
notifyToast('error', t('Error saving playlist'));
}
}
@ -361,7 +362,7 @@ const PlaylistView = ({ ...rest }) => {
});
}
} catch {
notifyToast('error', 'Error saving playlist');
notifyToast('error', t('Error saving playlist'));
} finally {
setIsSubmittingEdit(false);
}
@ -381,12 +382,12 @@ const PlaylistView = ({ ...rest }) => {
},
});
} catch {
notifyToast('error', 'Error saving playlist');
notifyToast('error', t('Error saving playlist'));
} finally {
setIsSubmittingEdit(false);
}
notifyToast('success', 'Saved playlist');
notifyToast('success', t('Saved playlist'));
queryClient.setQueryData(['playlist', playlistId], (oldData: any) => {
return { ...oldData, title: editName, comment: editDescription, public: editPublic };
});
@ -506,7 +507,7 @@ const PlaylistView = ({ ...rest }) => {
<GenericPageHeader
image={
<Card
title="None"
title={t('None')}
subtitle=""
coverArt={
data?.image.match('placeholder')
@ -535,15 +536,33 @@ const PlaylistView = ({ ...rest }) => {
<div>
<PageHeaderSubtitleDataLine $top>
<StyledLink onClick={() => history.push('/playlist')}>
<strong>PLAYLIST</strong>
<strong>{t('PLAYLIST')}</strong>
</StyledLink>{' '}
{data.songCount} songs, {formatDuration(data.duration)} {' '}
{data.public ? 'Public' : 'Private'}
{data.public ? t('Public') : t('Private')}
</PageHeaderSubtitleDataLine>
<PageHeaderSubtitleDataLine>
{data.owner && `By ${data.owner}`}
{data.created && `Created ${formatDate(data.created)}`}
{data.changed && ` • Modified ${formatDateTime(data.changed)}`}
{data.owner && t('By {{dataOwner}} • ', { dataOwner: data.owner })}
{data.created &&
t('Created {{val, datetime}}', {
val: moment(data.created),
formatParams: {
val: { year: 'numeric', month: 'short', day: 'numeric' },
},
})}
{data.changed &&
t(' • Modified {{val, datetime}}', {
val: moment(data.changed),
formatParams: {
val: {
hour: 'numeric',
minute: 'numeric',
year: 'numeric',
month: 'short',
day: 'numeric',
},
},
})}
</PageHeaderSubtitleDataLine>
<CustomTooltip text={data.comment} placement="bottomStart" disabled={!data.comment}>
<PageHeaderSubtitleDataLine
@ -585,8 +604,10 @@ const PlaylistView = ({ ...rest }) => {
appearance="subtle"
text={
needsRecovery
? 'Recover playlist'
: 'Save (WARNING: Closing the application while saving may result in data loss)'
? t('Recover playlist')
: t(
'Save (WARNING: Closing the application while saving may result in data loss)'
)
}
color={needsRecovery ? 'red' : undefined}
disabled={
@ -613,15 +634,15 @@ const PlaylistView = ({ ...rest }) => {
speaker={
<StyledPopover>
<Form>
<ControlLabel>Name</ControlLabel>
<ControlLabel>{t('Name')}</ControlLabel>
<StyledInput
placeholder="Name"
placeholder={t('Name')}
value={editName}
onChange={(e: string) => setEditName(e)}
/>
<ControlLabel>Description</ControlLabel>
<ControlLabel>{t('Description')}</ControlLabel>
<StyledInput
placeholder="Description"
placeholder={t('Description')}
value={editDescription}
onChange={(e: string) => setEditDescription(e)}
/>
@ -631,7 +652,7 @@ const PlaylistView = ({ ...rest }) => {
onChange={(_v: any, e: boolean) => setEditPublic(e)}
disabled={config.serverType === Server.Jellyfin}
>
Public
{t('Public')}
</StyledCheckbox>
<StyledButton
size="md"
@ -642,7 +663,7 @@ const PlaylistView = ({ ...rest }) => {
onClick={handleEdit}
appearance="primary"
>
Save
{t('Save')}
</StyledButton>
</Form>
</StyledPopover>
@ -661,9 +682,9 @@ const PlaylistView = ({ ...rest }) => {
trigger="click"
speaker={
<StyledPopover>
<p>Are you sure you want to delete this playlist?</p>
<p>{t('Are you sure you want to delete this playlist?')}</p>
<StyledButton onClick={handleDelete} appearance="link">
Yes
{t('Yes')}
</StyledButton>
</StyledPopover>
}

12
src/components/search/SearchView.tsx

@ -3,6 +3,7 @@ import _ from 'lodash';
import settings from 'electron-settings';
import { useHistory } from 'react-router-dom';
import { useQuery, useQueryClient } from 'react-query';
import { useTranslation } from 'react-i18next';
import useRouterQuery from '../../hooks/useRouterQuery';
import GenericPage from '../layout/GenericPage';
import GenericPageHeader from '../layout/GenericPageHeader';
@ -24,6 +25,7 @@ import { Server } from '../../types';
import { setPlaylistRate } from '../../redux/playlistSlice';
const SearchView = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const history = useHistory();
const query = useRouterQuery();
@ -203,13 +205,13 @@ const SearchView = () => {
};
return (
<GenericPage header={<GenericPageHeader title={`Search: ${urlQuery}`} />}>
<GenericPage header={<GenericPageHeader title={t('Search: {{urlQuery}}', { urlQuery })} />}>
{isLoading && <PageLoader />}
{isError && <div>Error: {error}</div>}
{!isLoading && data && (
<>
<ScrollingMenu
title="Artists"
title={t('Artists')}
data={data.artist}
cardTitle={{
prefix: '/library/artist',
@ -219,7 +221,7 @@ const SearchView = () => {
cardSubtitle={
config.serverType === Server.Subsonic && {
property: 'albumCount',
unit: ' albums',
unit: t(' albums'),
}
}
cardSize={config.lookAndFeel.gridView.cardSize}
@ -228,7 +230,7 @@ const SearchView = () => {
/>
<ScrollingMenu
title="Albums"
title={t('Albums')}
data={data.album}
cardTitle={{
prefix: '/library/album',
@ -246,7 +248,7 @@ const SearchView = () => {
handleFavorite={handleAlbumFavorite}
/>
<SectionTitleWrapper>
<SectionTitle>Songs</SectionTitle>
<SectionTitle>{t('Songs')}</SectionTitle>
</SectionTitleWrapper>
<StyledPanel bodyFill bordered>
<ListViewTable

17
src/components/selectionbar/SelectionButtons.tsx

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { IconButton, Icon } from 'rsuite';
import { useAppDispatch } from '../../redux/hooks';
import { clearSelected } from '../../redux/multiSelectSlice';
@ -24,24 +25,32 @@ const CustomIconButton = ({ tooltipText, icon, handleClick, ...rest }: any) => {
};
export const MoveUpButton = ({ handleClick }: any) => {
return <CustomIconButton handleClick={handleClick} tooltipText="Move up" icon="arrow-up2" />;
const { t } = useTranslation();
return <CustomIconButton handleClick={handleClick} tooltipText={t('Move up')} icon="arrow-up2" />;
};
export const MoveDownButton = ({ handleClick }: any) => {
return <CustomIconButton handleClick={handleClick} tooltipText="Move down" icon="arrow-down2" />;
const { t } = useTranslation();
return (
<CustomIconButton handleClick={handleClick} tooltipText={t('Move down')} icon="arrow-down2" />
);
};
export const MoveManualButton = ({ handleClick }: any) => {
return <CustomIconButton handleClick={handleClick} tooltipText="Move to index" icon="arrows-v" />;
const { t } = useTranslation();
return (
<CustomIconButton handleClick={handleClick} tooltipText={t('Move to index')} icon="arrows-v" />
);
};
export const DeselectAllButton = ({ handleClick }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
return (
<CustomIconButton
handleClick={handleClick}
tooltipText="Deselect All"
tooltipText={t('Deselect All')}
icon="close"
onClick={() => dispatch(clearSelected())}
color="red"

30
src/components/settings/Config.tsx

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { shell } from 'electron';
import { Whisper, Nav, ButtonToolbar } from 'rsuite';
import { useTranslation } from 'react-i18next';
import GenericPage from '../layout/GenericPage';
import DisconnectButton from './DisconnectButton';
import GenericPageHeader from '../layout/GenericPageHeader';
@ -23,6 +24,7 @@ import ExternalConfig from './ConfigPanels/ExternalConfig';
const GITHUB_RELEASE_URL = 'https://api.github.com/repos/jeffvli/sonixd/releases?per_page=3';
const Config = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const config = useAppSelector((state) => state.config);
const folder = useAppSelector((state) => state.folder);
@ -80,7 +82,7 @@ const Config = () => {
id="settings"
header={
<GenericPageHeader
title="Config"
title={t('Config')}
subtitle={
<>
<Nav
@ -96,7 +98,7 @@ const Config = () => {
}
}}
>
Playback
{t('Playback')}
</StyledNavItem>
<StyledNavItem
eventKey="lookandfeel"
@ -107,7 +109,7 @@ const Config = () => {
}
}}
>
Look & Feel
{t('Look & Feel')}
</StyledNavItem>
<StyledNavItem
eventKey="other"
@ -118,7 +120,7 @@ const Config = () => {
}
}}
>
Other
{t('Other')}
</StyledNavItem>
</Nav>
</>
@ -139,16 +141,16 @@ const Config = () => {
}}
disabled={isScanning}
>
{isScanning ? `${scanProgress}` : 'Scan'}
{isScanning ? `${scanProgress}` : t('Scan')}
</StyledButton>
</>
<Whisper
trigger="click"
placement="auto"
speaker={
<StyledPopover title="Confirm">
<div>Are you sure you want to reset your settings to default?</div>
<strong>WARNING: This will reload the application</strong>
<StyledPopover title={t('Confirm')}>
<div>{t('Are you sure you want to reset your settings to default?')}</div>
<strong>{t('WARNING: This will reload the application')}</strong>
<div>
<StyledButton
id="reset-submit-button"
@ -159,13 +161,13 @@ const Config = () => {
}}
appearance="primary"
>
Yes
{t('Yes')}
</StyledButton>
</div>
</StyledPopover>
}
>
<StyledButton size="sm">Reset defaults</StyledButton>
<StyledButton size="sm">{t('Reset defaults')}</StyledButton>
</Whisper>
<Whisper
trigger="hover"
@ -175,9 +177,9 @@ const Config = () => {
speaker={
<StyledPopover>
<>
Current version: {packageJson.version}
{t('Current version:')} {packageJson.version}
<br />
Latest version: {latestRelease}
{t('Latest version:')} {latestRelease}
<br />
Node: {process.versions.node}
<br />
@ -190,7 +192,7 @@ const Config = () => {
appearance="primary"
onClick={() => shell.openExternal('https://github.com/jeffvli/sonixd')}
>
View on GitHub
{t('View on GitHub')}
</StyledButton>
<StyledButton
size="xs"
@ -202,7 +204,7 @@ const Config = () => {
)
}
>
View CHANGELOG
{t('View CHANGELOG')}
</StyledButton>
</>
</StyledPopover>

16
src/components/settings/ConfigPanels/AdvancedConfig.tsx

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import settings from 'electron-settings';
import { Icon } from 'rsuite';
import { shell } from 'electron';
import { useTranslation } from 'react-i18next';
import { ConfigPanel } from '../styled';
import { StyledButton, StyledToggle } from '../../shared/styled';
import { useAppDispatch } from '../../../redux/hooks';
@ -9,6 +10,7 @@ import { setPlaybackSetting } from '../../../redux/playQueueSlice';
import ConfigOption from '../ConfigOption';
const AdvancedConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [showDebugWindow, setShowDebugWindow] = useState(
Boolean(settings.getSync('showDebugWindow'))
@ -16,10 +18,12 @@ const AdvancedConfig = ({ bordered }: any) => {
const [autoUpdate, setAutoUpdate] = useState(Boolean(settings.getSync('autoUpdate')));
return (
<ConfigPanel bordered={bordered} header="Advanced">
<ConfigPanel bordered={bordered} header={t('Advanced')}>
<ConfigOption
name="Automatic Updates"
description="Enables or disables automatic updates. When a new version is detected, it will automatically be downloaded and installed."
name={t('Automatic Updates')}
description={t(
'Enables or disables automatic updates. When a new version is detected, it will automatically be downloaded and installed.'
)}
option={
<StyledToggle
defaultChecked={autoUpdate}
@ -33,8 +37,8 @@ const AdvancedConfig = ({ bordered }: any) => {
/>
<ConfigOption
name="Show Debug Window"
description="Displays the debug window."
name={t('Show Debug Window')}
description={t('Displays the debug window.')}
option={
<StyledToggle
defaultChecked={showDebugWindow}
@ -55,7 +59,7 @@ const AdvancedConfig = ({ bordered }: any) => {
<br />
<StyledButton appearance="primary" onClick={() => shell.openPath(settings.file())}>
Open settings JSON <Icon icon="external-link" />
{t('Open settings JSON')} <Icon icon="external-link" />
</StyledButton>
</ConfigPanel>
);

60
src/components/settings/ConfigPanels/CacheConfig.tsx

@ -4,6 +4,7 @@ import { shell } from 'electron';
import fs from 'fs';
import path from 'path';
import { Message, Icon, ButtonToolbar, Whisper } from 'rsuite';
import { useTranslation } from 'react-i18next';
import { ConfigOptionDescription, ConfigPanel } from '../styled';
import {
StyledInput,
@ -23,6 +24,7 @@ import { useAppDispatch } from '../../../redux/hooks';
const fsUtils = require('nodejs-fs-utils');
const CacheConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [imgCacheSize, setImgCacheSize] = useState(0);
const [songCacheSize, setSongCacheSize] = useState(0);
@ -50,7 +52,7 @@ const CacheConfig = ({ bordered }: any) => {
const songCachePath = getSongCachePath();
fs.readdir(songCachePath, (err, files) => {
if (err) {
return notifyToast('error', `Unable to scan directory: ${err}`);
return notifyToast('error', t('Unable to scan directory: {{err}}', { err }));
}
return files.forEach((file) => {
@ -60,21 +62,21 @@ const CacheConfig = ({ bordered }: any) => {
if (path.extname(songPath) === '.mp3') {
fs.unlink(songPath, (error) => {
if (err) {
return notifyToast('error', `Unable to clear cache item: ${error}`);
return notifyToast('error', t('Unable to clear cache item: {{error}}', { error }));
}
return null;
});
}
});
});
notifyToast('success', `Cleared song cache`);
notifyToast('success', t('Cleared song cache'));
};
const handleClearImageCache = (type: 'playlist' | 'album' | 'artist' | 'folder') => {
const imageCachePath = getImageCachePath();
fs.readdir(imageCachePath, (err, files) => {
if (err) {
return notifyToast('error', `Unable to scan directory: ${err}`);
return notifyToast('error', t('Unable to scan directory: {{err}}', { err }));
}
const selectedFiles =
@ -93,18 +95,18 @@ const CacheConfig = ({ bordered }: any) => {
if (path.extname(imagePath) === '.jpg') {
fs.unlink(imagePath, (error) => {
if (err) {
return notifyToast('error', `Unable to clear cache item: ${error}`);
return notifyToast('error', t('Unable to clear cache item: {{error}}', { error }));
}
return null;
});
}
});
});
notifyToast('success', `Cleared ${type} image cache`);
notifyToast('success', t('Cleared {{type}} image cache', { type }));
};
return (
<ConfigPanel bordered={bordered} header="Cache">
<ConfigPanel bordered={bordered} header={t('Cache')}>
{errorMessage !== '' && (
<>
<Message showIcon type="error" description={errorMessage} />
@ -112,9 +114,9 @@ const CacheConfig = ({ bordered }: any) => {
</>
)}
<ConfigOptionDescription>
Songs are cached only when playback for the track fully completes and ends. Skipping to the
next or previous track after only partially completing the track will not begin the caching
process.
{t(
'Songs are cached only when playback for the track fully completes and ends. Skipping to the next or previous track after only partially completing the track will not begin the caching process.'
)}
</ConfigOptionDescription>
<br />
{isEditingCachePath && (
@ -136,7 +138,11 @@ const CacheConfig = ({ bordered }: any) => {
return setIsEditingCachePath(false);
}
return setErrorMessage(`Path: ${newCachePath} not found. Enter a valid path.`);
return setErrorMessage(
t('Path: {{newCachePath}} not found. Enter a valid path.', {
newCachePath,
})
);
}}
>
<Icon icon="check" />
@ -159,17 +165,17 @@ const CacheConfig = ({ bordered }: any) => {
return setIsEditingCachePath(false);
}}
>
Reset to default
{t('Reset to default')}
</StyledInputGroupButton>
</StyledInputGroup>
<p style={{ fontSize: 'smaller' }}>
*You will need to manually move any existing cached files to their new location.
{t('*You will need to manually move any existing cached files to their new location.')}
</p>
</>
)}
{!isEditingCachePath && (
<>
Location:{' '}
{t('Location:')}{' '}
<div style={{ overflow: 'auto' }}>
<StyledLink onClick={() => shell.openPath(getRootCachePath())}>
{getRootCachePath()} <Icon icon="external-link" />
@ -185,9 +191,9 @@ const CacheConfig = ({ bordered }: any) => {
setCacheSongs(e);
}}
>
Songs{' '}
{t('Songs')}{' '}
<StyledTag>
{songCacheSize} MB {imgCacheSize === 9999999 && '- Folder not found'}
{songCacheSize} MB {imgCacheSize === 9999999 && t('- Folder not found')}
</StyledTag>
</StyledCheckbox>
<StyledCheckbox
@ -197,42 +203,44 @@ const CacheConfig = ({ bordered }: any) => {
setCacheImages(e);
}}
>
Images{' '}
{t('Images')}{' '}
<StyledTag>
{imgCacheSize} MB {imgCacheSize === 9999999 && '- Folder not found'}
{imgCacheSize} MB {imgCacheSize === 9999999 && t('- Folder not found')}
</StyledTag>
</StyledCheckbox>
</div>
<br />
<ButtonToolbar>
<StyledButton onClick={() => setIsEditingCachePath(true)}>Edit cache location</StyledButton>
<StyledButton onClick={() => setIsEditingCachePath(true)}>
{t('Edit cache location')}
</StyledButton>
<Whisper
trigger="click"
placement="autoVertical"
speaker={
<StyledPopover>
Which cache would you like to clear?
{t('Which cache would you like to clear?')}
<ButtonToolbar>
<StyledButton size="sm" onClick={handleClearSongCache}>
Songs
{t('Songs')}
</StyledButton>
<StyledButton size="sm" onClick={() => handleClearImageCache('playlist')}>
Playlist images
{t('Playlist images')}
</StyledButton>
<StyledButton size="sm" onClick={() => handleClearImageCache('album')}>
Album images
{t('Album images')}
</StyledButton>
<StyledButton size="sm" onClick={() => handleClearImageCache('artist')}>
Artist images
{t('Artist images')}
</StyledButton>
<StyledButton size="sm" onClick={() => handleClearImageCache('folder')}>
Folder images
{t('Folder images')}
</StyledButton>
</ButtonToolbar>
</StyledPopover>
}
>
<StyledButton>Clear cache</StyledButton>
<StyledButton>{t('Clear cache')}</StyledButton>
</Whisper>
</ButtonToolbar>
</ConfigPanel>

46
src/components/settings/ConfigPanels/ExternalConfig.tsx

@ -2,6 +2,7 @@ import React from 'react';
import { shell } from 'electron';
import settings from 'electron-settings';
import { Icon, RadioGroup } from 'rsuite';
import { Trans, useTranslation } from 'react-i18next';
import { ConfigOptionDescription, ConfigPanel } from '../styled';
import {
StyledInput,
@ -19,18 +20,21 @@ import ConfigOption from '../ConfigOption';
const dialog: any = process.env.NODE_ENV === 'test' ? '' : require('electron').remote.dialog;
const ExternalConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const config = useAppSelector((state) => state.config);
return (
<ConfigPanel bordered={bordered} header="External">
<ConfigPanel bordered={bordered} header={t('External')}>
<ConfigOptionDescription>
Config for integration with external programs.
{t('Config for integration with external programs.')}
</ConfigOptionDescription>
<ConfigPanel header="Discord" collapsible $noBackground>
<ConfigOption
name="Rich Presence"
description="Integrates with Discord's rich presence to display the currently playing song as your status."
name={t('Rich Presence')}
description={t(
"Integrates with Discord's rich presence to display the currently playing song as your status."
)}
option={
<StyledToggle
defaultChecked={config.external.discord.enabled}
@ -44,9 +48,9 @@ const ExternalConfig = ({ bordered }: any) => {
}
/>
<ConfigOption
name="Discord Client Id"
name={t('Discord Client Id')}
description={
<>
<Trans t={t}>
The client/application Id of the Sonixd discord application. To use your own, create
one on the{' '}
<StyledLink
@ -54,12 +58,12 @@ const ExternalConfig = ({ bordered }: any) => {
>
developer application portal
</StyledLink>
. The large icon uses the name &quot;icon&quot;. Default is 923372440934055968.
</>
. The large icon uses the name "icon". Default is 923372440934055968.
</Trans>
}
option={
<StyledInput
placeholder="Client/Application Id"
placeholder={t('Client/Application Id')}
value={config.external.discord.clientId}
disabled={config.external.discord.enabled}
onChange={(e: boolean) => {
@ -74,7 +78,7 @@ const ExternalConfig = ({ bordered }: any) => {
<ConfigOption
name={
<>
Scrobbling
{t('Scrobbling')}
<RadioGroup
inline
defaultValue={config.external.obs.type}
@ -84,12 +88,14 @@ const ExternalConfig = ({ bordered }: any) => {
dispatch(setOBS({ ...config.external.obs, type: e }));
}}
>
<StyledRadio value="local">Local</StyledRadio>
<StyledRadio value="web">Web</StyledRadio>
<StyledRadio value="local">{t('Local')}</StyledRadio>
<StyledRadio value="web">{t('Web')}</StyledRadio>
</RadioGroup>
</>
}
description="If local, scrobbles the currently playing song to local .txt files. If web, scrobbles the currently playing song to Tuna plugin's webserver."
description={t(
"If local, scrobbles the currently playing song to local .txt files. If web, scrobbles the currently playing song to Tuna plugin's webserver."
)}
option={
<>
<StyledToggle
@ -104,8 +110,10 @@ const ExternalConfig = ({ bordered }: any) => {
}
/>
<ConfigOption
name="Polling Interval"
description="The number in milliseconds (ms) between each poll when metadata is sent."
name={t('Polling Interval')}
description={t(
'The number in milliseconds (ms) between each poll when metadata is sent.'
)}
option={
<StyledInputNumber
defaultValue={config.external.obs.pollingInterval}
@ -124,8 +132,8 @@ const ExternalConfig = ({ bordered }: any) => {
{config.external.obs.type === 'web' ? (
<ConfigOption
name="Tuna Webserver Url"
description="The full URL to the Tuna webserver."
name={t('Tuna Webserver Url')}
description={t('The full URL to the Tuna webserver.')}
option={
<StyledInput
width={200}
@ -140,8 +148,8 @@ const ExternalConfig = ({ bordered }: any) => {
/>
) : (
<ConfigOption
name="File Path"
description="The full path to the directory where song metadata will be created."
name={t('File Path')}
description={t('The full path to the directory where song metadata will be created.')}
option={
<StyledInputGroup>
<StyledInput disabled width={200} value={config.external.obs.path} />

23
src/components/settings/ConfigPanels/ListViewConfig.tsx

@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import { nanoid } from 'nanoid/non-secure';
import settings from 'electron-settings';
import { useTranslation } from 'react-i18next';
import i18next from 'i18next';
import {
StyledCheckPicker,
StyledInputNumber,
@ -34,20 +36,20 @@ const columnSelectorColumns = [
label: '#',
},
{
id: 'Column',
id: i18next.t('Column'),
dataKey: 'label',
alignment: 'left',
resizable: false,
flexGrow: 2,
label: 'Column',
label: i18next.t('Column'),
},
{
id: 'Resizable',
id: i18next.t('Resizable'),
dataKey: 'columnResizable',
alignment: 'left',
resizable: false,
width: 100,
label: 'Resizable',
label: i18next.t('Resizable'),
},
];
@ -59,6 +61,7 @@ const ListViewConfig = ({
settingsConfig,
disabledItemValues,
}: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const playQueue = useAppSelector((state) => state.playQueue);
const multiSelect = useAppSelector((state) => state.multiSelect);
@ -187,13 +190,15 @@ const ListViewConfig = ({
config={{ option: columnListType, columnList }}
virtualized
/>
<p style={{ fontSize: 'smaller' }}>* Drag & drop rows from the # column to re-order</p>
<p style={{ fontSize: 'smaller' }}>
{t('* Drag & drop rows from the # column to re-order')}
</p>
</StyledPanel>
</div>
<ConfigOption
name={`Row Height (${type})`}
description="The height in pixels (px) of each row in the list view."
name={t('Row Height {{type}}', { type })}
description={t('The height in pixels (px) of each row in the list view.')}
option={
<StyledInputNumber
defaultValue={config.lookAndFeel.listView[columnListType]?.rowHeight || 40}
@ -210,8 +215,8 @@ const ListViewConfig = ({
/>
<ConfigOption
name={`Font Size (${type})`}
description="The height in pixels (px) of each row in the list view."
name={t('Font Size {{type}}', { type })}
description={t('The height in pixels (px) of each row in the list view.')}
option={
<StyledInputNumber
defaultValue={config.lookAndFeel.listView[columnListType]?.fontSize || 12}

165
src/components/settings/ConfigPanels/LookAndFeelConfig.tsx

@ -4,6 +4,7 @@ import { ipcRenderer, shell } from 'electron';
import settings from 'electron-settings';
import { Nav, Icon, RadioGroup, Whisper } from 'rsuite';
import { WhisperInstance } from 'rsuite/lib/Whisper';
import { Trans, useTranslation } from 'react-i18next';
import { ConfigPanel } from '../styled';
import {
StyledInputPicker,
@ -43,10 +44,13 @@ import {
} from '../../../redux/configSlice';
import { Item, Server } from '../../../types';
import ConfigOption from '../ConfigOption';
import i18n, { Languages } from '../../../i18n/i18n';
import { notifyToast } from '../../shared/toast';
import { setPagination } from '../../../redux/viewSlice';
import { MUSIC_SORT_TYPES } from '../../library/MusicList';
export const ListViewConfigPanel = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const config = useAppSelector((state) => state.config);
@ -69,21 +73,21 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
const currentGenreColumns = genreCols?.map((column: any) => column.label) || [];
return (
<ConfigPanel header="List View" bordered={bordered}>
<ConfigPanel header={t('List View')} bordered={bordered}>
<Nav
activeKey={config.active.columnSelectorTab}
onSelect={(e) => dispatch(setActive({ ...config.active, columnSelectorTab: e }))}
>
<StyledNavItem eventKey="music">Songs</StyledNavItem>
<StyledNavItem eventKey="album">Albums</StyledNavItem>
<StyledNavItem eventKey="playlist">Playlists</StyledNavItem>
<StyledNavItem eventKey="artist">Artists</StyledNavItem>
<StyledNavItem eventKey="genre">Genres</StyledNavItem>
<StyledNavItem eventKey="mini">Miniplayer</StyledNavItem>
<StyledNavItem eventKey="music">{t('Songs')}</StyledNavItem>
<StyledNavItem eventKey="album">{t('Albums')}</StyledNavItem>
<StyledNavItem eventKey="playlist">{t('Playlists')}</StyledNavItem>
<StyledNavItem eventKey="artist">{t('Artists')}</StyledNavItem>
<StyledNavItem eventKey="genre">{t('Genres')}</StyledNavItem>
<StyledNavItem eventKey="mini">{t('Miniplayer')}</StyledNavItem>
</Nav>
{config.active.columnSelectorTab === 'music' && (
<ListViewConfig
type="Songs"
type={t('Songs')}
defaultColumns={currentSongColumns}
columnPicker={songColumnPicker}
columnList={songColumnListAuto}
@ -92,13 +96,13 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
rowHeight: 'musicListRowHeight',
fontSize: 'musicListFontSize',
}}
disabledItemValues={config.serverType === Server.Jellyfin ? ['Rating'] : []}
disabledItemValues={config.serverType === Server.Jellyfin ? [t('Rating')] : []}
/>
)}
{config.active.columnSelectorTab === 'album' && (
<ListViewConfig
type="Albums"
type={t('Albums')}
defaultColumns={currentAlbumColumns}
columnPicker={albumColumnPicker}
columnList={albumColumnListAuto}
@ -107,13 +111,15 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
rowHeight: 'albumListRowHeight',
fontSize: 'albumListFontSize',
}}
disabledItemValues={config.serverType === Server.Jellyfin ? ['Rating', 'Play Count'] : []}
disabledItemValues={
config.serverType === Server.Jellyfin ? [t('Rating'), t('Play Count')] : []
}
/>
)}
{config.active.columnSelectorTab === 'playlist' && (
<ListViewConfig
type="Playlists"
type={t('Playlists')}
defaultColumns={currentPlaylistColumns}
columnPicker={playlistColumnPicker}
columnList={playlistColumnListAuto}
@ -124,7 +130,7 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
}}
disabledItemValues={
config.serverType === Server.Jellyfin
? ['Modified', 'Owner', 'Track Count', 'Visibility']
? [t('Modified'), t('Owner'), t('Track Count'), t('Visibility')]
: []
}
/>
@ -132,7 +138,7 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
{config.active.columnSelectorTab === 'artist' && (
<ListViewConfig
type="Artists"
type={t('Artists')}
defaultColumns={currentArtistColumns}
columnPicker={artistColumnPicker}
columnList={artistColumnListAuto}
@ -142,14 +148,16 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
fontSize: 'artistListFontSize',
}}
disabledItemValues={
config.serverType === Server.Jellyfin ? ['Album Count', 'Rating'] : ['Duration']
config.serverType === Server.Jellyfin
? [t('Album Count'), t('Rating')]
: [t('Duration')]
}
/>
)}
{config.active.columnSelectorTab === 'genre' && (
<ListViewConfig
type="Genres"
type={t('Genres')}
defaultColumns={currentGenreColumns}
columnPicker={genreColumnPicker}
columnList={genreColumnListAuto}
@ -159,14 +167,14 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
fontSize: 'genreListFontSize',
}}
disabledItemValues={
config.serverType === Server.Jellyfin ? ['Album Count', 'Track Count'] : []
config.serverType === Server.Jellyfin ? [t('Album Count'), t('Track Count')] : []
}
/>
)}
{config.active.columnSelectorTab === 'mini' && (
<ListViewConfig
type="Mini-player"
type={t('Mini-player')}
defaultColumns={currentMiniColumns}
columnPicker={songColumnPicker}
columnList={songColumnListAuto}
@ -175,13 +183,13 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
rowHeight: 'miniListRowHeight',
fontSize: 'miniListFontSize',
}}
disabledItemValues={config.serverType === Server.Jellyfin ? ['Rating'] : []}
disabledItemValues={config.serverType === Server.Jellyfin ? [t('Rating')] : []}
/>
)}
<ConfigOption
name="Highlight On Hover"
description="Highlights the list view row when hovering it with the mouse."
name={t('Highlight On Hover')}
description={t('Highlights the list view row when hovering it with the mouse.')}
option={
<StyledToggle
defaultChecked={highlightOnRowHoverChk}
@ -204,14 +212,15 @@ export const ListViewConfigPanel = ({ bordered }: any) => {
};
export const GridViewConfigPanel = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const config = useAppSelector((state) => state.config);
return (
<ConfigPanel header="Grid View" bordered={bordered}>
<ConfigPanel header={t('Grid View')} bordered={bordered}>
<ConfigOption
name="Card Size"
description="The width and height in pixels (px) of each grid view card."
name={t('Card Size')}
description={t('The width and height in pixels (px) of each grid view card.')}
option={
<StyledInputNumber
defaultValue={config.lookAndFeel.gridView.cardSize}
@ -228,8 +237,8 @@ export const GridViewConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Gap Size"
description="The gap in pixels (px) of the grid view layout."
name={t('Gap Size')}
description={t('The gap in pixels (px) of the grid view layout.')}
option={
<StyledInputNumber
defaultValue={config.lookAndFeel.gridView.gapSize}
@ -246,8 +255,8 @@ export const GridViewConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Grid Alignment"
description="The alignment of cards in the grid view layout."
name={t('Grid Alignment')}
description={t('The alignment of cards in the grid view layout.')}
option={
<RadioGroup
name="gridAlignemntRadioList"
@ -259,8 +268,8 @@ export const GridViewConfigPanel = ({ bordered }: any) => {
settings.setSync('gridAlignment', e);
}}
>
<StyledRadio value="flex-start">Left</StyledRadio>
<StyledRadio value="center">Center</StyledRadio>
<StyledRadio value="flex-start">{t('Left')}</StyledRadio>
<StyledRadio value="center">{t('Center')}</StyledRadio>
</RadioGroup>
}
/>
@ -269,6 +278,7 @@ export const GridViewConfigPanel = ({ bordered }: any) => {
};
export const ThemeConfigPanel = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const config = useAppSelector((state) => state.config);
const [dynamicBackgroundChk, setDynamicBackgroundChk] = useState(
@ -276,6 +286,7 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
);
const [selectedTheme, setSelectedTheme] = useState(String(settings.getSync('theme')));
const languagePickerContainerRef = useRef(null);
const themePickerContainerRef = useRef(null);
const fontPickerContainerRef = useRef(null);
const titleBarPickerContainerRef = useRef(null);
@ -288,11 +299,36 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
);
return (
<ConfigPanel header="Look & Feel" bordered={bordered}>
<ConfigPanel header={t('Look & Feel')} bordered={bordered}>
<ConfigOption
name={t('Language')}
description={t('The application language.')}
option={
<StyledInputPickerContainer ref={languagePickerContainerRef}>
<StyledInputPicker
container={() => languagePickerContainerRef.current}
data={Languages}
width={200}
cleanable={false}
defaultValue={String(settings.getSync('language'))}
placeholder={t('Select')}
onChange={(e: string) => {
i18n.changeLanguage(e, (err) => {
if (err) {
notifyToast('error', 'Error while changing the language');
}
});
settings.setSync('language', e);
}}
/>
</StyledInputPickerContainer>
}
/>
<ConfigOption
name={
<>
Theme{' '}
{t('Theme')}{' '}
<StyledIconButton
size="xs"
icon={<Icon icon="refresh" />}
@ -307,7 +343,7 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
</>
}
description={
<>
<Trans>
The application theme. Want to create your own themes? Check out the documentation{' '}
<StyledLink
onClick={() => shell.openExternal('https://github.com/jeffvli/sonixd/discussions/61')}
@ -315,7 +351,7 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
here
</StyledLink>
.
</>
</Trans>
}
option={
<StyledInputPickerContainer ref={themePickerContainerRef}>
@ -328,6 +364,7 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
cleanable={false}
width={200}
defaultValue={selectedTheme}
placeholder={t('Select')}
onChange={(e: string) => {
settings.setSync('theme', e);
setSelectedTheme(e);
@ -340,8 +377,8 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Font"
description="The application font."
name={t('Font')}
description={t('The application font.')}
option={
<StyledInputPickerContainer ref={fontPickerContainerRef}>
<StyledInputPicker
@ -351,6 +388,7 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
width={200}
cleanable={false}
defaultValue={String(settings.getSync('font'))}
placeholder={t('Select')}
onChange={(e: string) => {
settings.setSync('font', e);
dispatch(setFont(e));
@ -361,8 +399,8 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Titlebar Style"
description="The titlebar style (requires app restart). "
name={t('Titlebar Style')}
description={t('The titlebar style (requires app restart). ')}
option={
<StyledInputPickerContainer ref={titleBarPickerContainerRef}>
<Whisper
@ -370,9 +408,9 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
trigger="none"
placement="auto"
speaker={
<StyledPopover title="Restart?">
<div>Do you want to restart the application now?</div>
<strong>This is highly recommended!</strong>
<StyledPopover title={t('Restart?')}>
<div>{t('Do you want to restart the application now?')}</div>
<strong>{t('This is highly recommended!')}</strong>
<div>
<StyledButton
id="titlebar-restart-button"
@ -382,7 +420,7 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
}}
appearance="primary"
>
Yes
{t('Yes')}
</StyledButton>
</div>
</StyledPopover>
@ -400,13 +438,14 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
value: 'windows',
},
{
label: 'Native',
label: t('Native'),
value: 'native',
},
]}
cleanable={false}
defaultValue={String(settings.getSync('titleBarStyle'))}
width={200}
placeholder={t('Select')}
onChange={(e: string) => {
settings.setSync('titleBarStyle', e);
dispatch(setMiscSetting({ setting: 'titleBar', value: e }));
@ -419,8 +458,8 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Dynamic Background"
description="Sets a dynamic background based on the currently playing song."
name={t('Dynamic Background')}
description={t('Sets a dynamic background based on the currently playing song.')}
option={
<StyledToggle
defaultChecked={dynamicBackgroundChk}
@ -435,45 +474,46 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Start page"
description="Page Sonixd will display on start."
name={t('Start page')}
description={t('Page Sonixd will display on start.')}
option={
<StyledInputPickerContainer ref={startPagePickerContainerRef}>
<StyledInputPicker
container={() => startPagePickerContainerRef.current}
data={[
{
label: 'Dashboard',
label: t('Dashboard'),
value: '/',
},
{
label: 'Playlists',
label: t('Playlists'),
value: '/playlist',
},
{
label: 'Favorites',
label: t('Favorites'),
value: '/starred',
},
{
label: 'Albums',
label: t('Albums'),
value: '/library/album',
},
{
label: 'Artists',
label: t('Artists'),
value: '/library/artist',
},
{
label: 'Genres',
label: t('Genres'),
value: '/library/genre',
},
{
label: 'Folders',
label: t('Folders'),
value: '/library/folder',
},
]}
cleanable={false}
defaultValue={String(settings.getSync('startPage'))}
width={200}
placeholder={t('Select')}
onChange={(e: string) => {
settings.setSync('startPage', e);
}}
@ -483,8 +523,8 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
/>
<ConfigOption
name="Default Album Sort"
description="The default album page sort selection on application startup."
name={t('Default Album Sort')}
description={t('The default album page sort selection on application startup.')}
option={
<StyledInputPickerContainer ref={albumSortDefaultPickerContainerRef}>
<StyledInputPicker
@ -505,8 +545,8 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
/>
{config.serverType === Server.Jellyfin && (
<ConfigOption
name="Default Song Sort"
description="The default song page sort selection on application startup."
name={t('Default Song Sort')}
description={t('The default song page sort selection on application startup.')}
option={
<StyledInputPickerContainer ref={musicSortDefaultPickerContainerRef}>
<StyledInputPicker
@ -528,14 +568,17 @@ export const ThemeConfigPanel = ({ bordered }: any) => {
};
export const PaginationConfigPanel = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const view = useAppSelector((state) => state.view);
return (
<ConfigPanel header="Pagination" bordered={bordered}>
<ConfigPanel header={t('Pagination')} bordered={bordered}>
<ConfigOption
name="Items per page (Songs)"
description="The number of items that will be retrieved per page. Setting this to 0 will disable pagination."
name={t('Items per page (Songs)')}
description={t(
'The number of items that will be retrieved per page. Setting this to 0 will disable pagination.'
)}
option={
<StyledInputNumber
defaultValue={view.music.pagination.recordsPerPage}

53
src/components/settings/ConfigPanels/PlaybackConfig.tsx

@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import settings from 'electron-settings';
import { ButtonToolbar } from 'rsuite';
import { useTranslation } from 'react-i18next';
import { ConfigPanel } from '../styled';
import {
StyledButton,
@ -14,6 +15,7 @@ import { setPlaybackSetting } from '../../../redux/playQueueSlice';
import ConfigOption from '../ConfigOption';
const PlaybackConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [crossfadeDuration, setCrossfadeDuration] = useState(
Number(settings.getSync('fadeDuration'))
@ -54,10 +56,12 @@ const PlaybackConfig = ({ bordered }: any) => {
return (
<>
<ConfigPanel bordered={bordered} header="Playback">
<ConfigPanel bordered={bordered} header={t('Playback')}>
<ConfigOption
name="Crossfade Duration (s)"
description="The number in seconds before starting the crossfade to the next track. Setting this to 0 will enable gapless playback."
name={t('Crossfade Duration (s)')}
description={t(
'The number in seconds before starting the crossfade to the next track. Setting this to 0 will enable gapless playback.'
)}
option={
<StyledInputNumber
defaultValue={crossfadeDuration}
@ -72,8 +76,10 @@ const PlaybackConfig = ({ bordered }: any) => {
/>
<ConfigOption
name="Polling Interval"
description="The number in milliseconds between each poll when music is playing. This is used in the calculation for crossfading and gapless playback. Recommended value for gapless playback is between 10 and 20."
name={t('Polling Interval')}
description={t(
'The number in milliseconds between each poll when music is playing. This is used in the calculation for crossfading and gapless playback. Recommended value for gapless playback is between 10 and 20.'
)}
option={
<StyledInputNumber
defaultValue={pollingInterval}
@ -88,44 +94,47 @@ const PlaybackConfig = ({ bordered }: any) => {
/>
<ConfigOption
name="Crossfade Type"
description="The fade calculation to use when crossfading between two tracks. Enable the debug window to view the differences between each fade type."
name={t('Crossfade Type')}
description={t(
'The fade calculation to use when crossfading between two tracks. Enable the debug window to view the differences between each fade type.'
)}
option={
<StyledInputPickerContainer ref={crossfadePickerContainerRef}>
<StyledInputPicker
container={() => crossfadePickerContainerRef.current}
data={[
{
label: 'Equal Power',
label: t('Equal Power'),
value: 'equalPower',
},
{
label: 'Linear',
label: t('Linear'),
value: 'linear',
},
{
label: 'Dipped',
label: t('Dipped'),
value: 'dipped',
},
{
label: 'Constant Power',
label: t('Constant Power'),
value: 'constantPower',
},
{
label: 'Constant Power (slow fade)',
label: t('Constant Power (slow fade)'),
value: 'constantPowerSlowFade',
},
{
label: 'Constant Power (slow cut)',
label: t('Constant Power (slow cut)'),
value: 'constantPowerSlowCut',
},
{
label: 'Constant Power (fast cut)',
label: t('Constant Power (fast cut)'),
value: 'constantPowerFastCut',
},
]}
cleanable={false}
defaultValue={String(settings.getSync('fadeType'))}
placeholder={t('Select')}
onChange={(e: string) => {
settings.setSync('fadeType', e);
dispatch(setPlaybackSetting({ setting: 'fadeType', value: e }));
@ -137,8 +146,10 @@ const PlaybackConfig = ({ bordered }: any) => {
/>
<ConfigOption
name="Volume Fade"
description="Enable or disable the volume fade used by the crossfading players. If disabled, the fading in track will start at full volume."
name={t('Volume Fade')}
description={t(
'Enable or disable the volume fade used by the crossfading players. If disabled, the fading in track will start at full volume.'
)}
option={
<StyledToggle
size="md"
@ -150,12 +161,11 @@ const PlaybackConfig = ({ bordered }: any) => {
/>
<ConfigOption
name="Playback Presets"
description="Don't know where to start? Apply a preset and tweak from there."
name={t('Playback Presets')}
description={t("Don't know where to start? Apply a preset and tweak from there.")}
option={
<ButtonToolbar>
<StyledButton
width={100}
onClick={() => {
setCrossfadeDuration(0);
setPollingInterval(15);
@ -163,10 +173,9 @@ const PlaybackConfig = ({ bordered }: any) => {
handleSetPollingInterval(15);
}}
>
Gapless
{t('Gapless')}
</StyledButton>
<StyledButton
width={100}
onClick={() => {
setCrossfadeDuration(7);
setPollingInterval(50);
@ -176,7 +185,7 @@ const PlaybackConfig = ({ bordered }: any) => {
handleSetVolumeFade(true);
}}
>
Fade
{t('Fade')}
</StyledButton>
</ButtonToolbar>
}

84
src/components/settings/ConfigPanels/PlayerConfig.tsx

@ -3,6 +3,8 @@ import { ipcRenderer, shell } from 'electron';
import settings from 'electron-settings';
import { Form, Whisper } from 'rsuite';
import { WhisperInstance } from 'rsuite/lib/Whisper';
import i18next from 'i18next';
import { Trans, useTranslation } from 'react-i18next';
import { ConfigOptionDescription, ConfigOptionName, ConfigPanel } from '../styled';
import {
StyledButton,
@ -45,7 +47,8 @@ const playbackFilterColumns = [
alignment: 'left',
resizable: false,
flexGrow: 2,
label: 'Filter',
// eslint-disable-next-line react-hooks/rules-of-hooks
label: i18next.t('Filter'),
},
{
id: 'Enabled',
@ -53,7 +56,8 @@ const playbackFilterColumns = [
alignment: 'left',
resizable: false,
width: 100,
label: 'Enabled',
// eslint-disable-next-line react-hooks/rules-of-hooks
label: i18next.t('Enabled'),
},
{
id: 'Delete',
@ -61,11 +65,13 @@ const playbackFilterColumns = [
alignment: 'left',
resizable: false,
width: 100,
label: 'Delete',
// eslint-disable-next-line react-hooks/rules-of-hooks
label: i18next.t('Delete'),
},
];
const PlayerConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const playQueue = useAppSelector((state) => state.playQueue);
const multiSelect = useAppSelector((state) => state.multiSelect);
@ -91,17 +97,19 @@ const PlayerConfig = ({ bordered }: any) => {
const getAudioDevices = () => {
getAudioDevice()
.then((dev) => setAudioDevices(dev))
.catch(() => notifyToast('error', 'Error fetching audio devices'));
.catch(() => notifyToast('error', t('Error fetching audio devices')));
};
getAudioDevices();
}, []);
}, [t]);
return (
<ConfigPanel bordered={bordered} header="Player">
<ConfigPanel bordered={bordered} header={t('Player')}>
<ConfigOption
name="Audio Device"
description="The audio device for Sonixd. Leaving this blank will use the system default."
name={t('Audio Device')}
description={t(
'The audio device for Sonixd. Leaving this blank will use the system default.'
)}
option={
<StyledInputPickerContainer ref={audioDevicePickerContainerRef}>
<StyledInputPicker
@ -112,6 +120,7 @@ const PlayerConfig = ({ bordered }: any) => {
labelKey="label"
valueKey="deviceId"
placement="bottomStart"
placeholder={t('Select')}
onChange={(e: string) => {
dispatch(setAudioDeviceId(e));
settings.setSync('audioDeviceId', e);
@ -121,8 +130,10 @@ const PlayerConfig = ({ bordered }: any) => {
}
/>
<ConfigOption
name="Seek Forward"
description="The number in seconds the player will skip forwards when clicking the seek forward button."
name={t('Seek Forward')}
description={t(
'The number in seconds the player will skip forwards when clicking the seek forward button.'
)}
option={
<StyledInputNumber
defaultValue={String(settings.getSync('seekForwardInterval')) || '0'}
@ -137,8 +148,10 @@ const PlayerConfig = ({ bordered }: any) => {
}
/>
<ConfigOption
name="Seek Backward"
description="The number in seconds the player will skip backwards when clicking the seek backward button."
name={t('Seek Backward')}
description={t(
'The number in seconds the player will skip backwards when clicking the seek backward button.'
)}
option={
<StyledInputNumber
defaultValue={String(settings.getSync('seekBackwardInterval')) || '0'}
@ -155,9 +168,10 @@ const PlayerConfig = ({ bordered }: any) => {
{config.serverType === Server.Jellyfin && (
<ConfigOption
name="Allow Transcoding"
description="If your audio files are not playing properly or are not in a supported web
streaming format, you will need to enable this (requires app restart)."
name={t('Allow Transcoding')}
description={t(
'If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).'
)}
option={
<>
<Whisper
@ -165,9 +179,9 @@ const PlayerConfig = ({ bordered }: any) => {
trigger="none"
placement="auto"
speaker={
<StyledPopover title="Restart?">
<div>Do you want to restart the application now?</div>
<strong>This is highly recommended!</strong>
<StyledPopover title={t('Restart?')}>
<div>{t('Do you want to restart the application now?')}</div>
<strong>{t('This is highly recommended!')}</strong>
<div>
<StyledButton
id="titlebar-restart-button"
@ -177,7 +191,7 @@ const PlayerConfig = ({ bordered }: any) => {
}}
appearance="primary"
>
Yes
{t('Yes')}
</StyledButton>
</div>
</StyledPopover>
@ -199,9 +213,9 @@ const PlayerConfig = ({ bordered }: any) => {
)}
<ConfigOption
name="Global Media Hotkeys"
name={t('Global Media Hotkeys')}
description={
<>
<Trans>
Enable or disable global media hotkeys (play/pause, next, previous, stop, etc). For
macOS, you will need to add Sonixd as a{' '}
<StyledLink
@ -213,7 +227,7 @@ const PlayerConfig = ({ bordered }: any) => {
>
trusted accessibility client.
</StyledLink>
</>
</Trans>
}
option={
<StyledToggle
@ -238,12 +252,12 @@ const PlayerConfig = ({ bordered }: any) => {
{isWindows() && isWindows10() && (
<ConfigOption
name="Windows System Media Transport Controls"
name={t('Windows System Media Transport Controls')}
description={
<>
Enable or disable the Windows System Media Transport Controls (play/pause, next,
previous, stop). This will show the Windows Media Popup (Windows 10 only) when
pressing a media key. This feauture will override the Global Media Hotkeys option.
{t(
'Enable or disable the Windows System Media Transport Controls (play/pause, next, previous, stop). This will show the Windows Media Popup (Windows 10 only) when pressing a media key. This feauture will override the Global Media Hotkeys option.'
)}
</>
}
option={
@ -269,8 +283,10 @@ const PlayerConfig = ({ bordered }: any) => {
)}
<ConfigOption
name="Scrobble"
description="Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm."
name={t('Scrobble')}
description={t(
'Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.'
)}
option={
<StyledToggle
defaultChecked={scrobble}
@ -283,16 +299,18 @@ const PlayerConfig = ({ bordered }: any) => {
/>
}
/>
<ConfigOptionName>Track Filters</ConfigOptionName>
<ConfigOptionName>{t('Track Filters')}</ConfigOptionName>
<ConfigOptionDescription>
Filter out tracks based on regex string(s) by their title when adding to the queue. Adding
by double-clicking a track will ignore all filters for that one track.
{t(
'Filter out tracks based on regex string(s) by their title when adding to the queue. Adding by double-clicking a track will ignore all filters for that one track.'
)}
</ConfigOptionDescription>
<br />
<StyledPanel bodyFill>
<Form fluid>
<StyledInputGroup>
<StyledInput
style={{ width: 'auto' }}
value={newFilter.string}
onChange={(e: string) => {
let isValid = true;
@ -305,7 +323,7 @@ const PlayerConfig = ({ bordered }: any) => {
setNewFilter({ string: e, valid: isValid });
}}
placeholder="Enter regex string"
placeholder={t('Enter regex string')}
/>
<StyledButton
type="submit"
@ -322,7 +340,7 @@ const PlayerConfig = ({ bordered }: any) => {
setNewFilter({ string: '', valid: false });
}}
>
Add
{t('Add')}
</StyledButton>
</StyledInputGroup>
</Form>

25
src/components/settings/ConfigPanels/ServerConfig.tsx

@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import settings from 'electron-settings';
import { useQuery } from 'react-query';
import { CheckboxGroup } from 'rsuite';
import { useTranslation } from 'react-i18next';
import { ConfigOptionDescription, ConfigOptionInput, ConfigPanel } from '../styled';
import { StyledCheckbox, StyledInputPicker, StyledInputPickerContainer } from '../../shared/styled';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
@ -12,6 +13,7 @@ import PageLoader from '../../loader/PageLoader';
import ConfigOption from '../ConfigOption';
const ServerConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const folder = useAppSelector((state) => state.folder);
const config = useAppSelector((state) => state.config);
@ -25,10 +27,12 @@ const ServerConfig = ({ bordered }: any) => {
}
return (
<ConfigPanel bordered={bordered} header="Server">
<ConfigPanel bordered={bordered} header={t('Server')}>
<ConfigOption
name="Media Folder"
description="Sets the parent media folder your audio files are located in. Leaving this blank will use all media folders."
name={t('Media Folder')}
description={t(
'Sets the parent media folder your audio files are located in. Leaving this blank will use all media folders.'
)}
option={
<StyledInputPickerContainer ref={musicFolderPickerContainerRef}>
<StyledInputPicker
@ -38,6 +42,7 @@ const ServerConfig = ({ bordered }: any) => {
valueKey="id"
labelKey="title"
width={200}
placeholder={t('Select')}
onChange={(e: string) => {
const selectedFolder = musicFolders.find((f: Folder) => f.id === e);
settings.setSync('musicFolder.id', e);
@ -50,7 +55,7 @@ const ServerConfig = ({ bordered }: any) => {
/>
<ConfigOptionDescription>
Select which pages to apply media folder filtering to:
{t('Select which pages to apply media folder filtering to:')}
</ConfigOptionDescription>
<ConfigOptionInput>
<CheckboxGroup>
@ -61,7 +66,7 @@ const ServerConfig = ({ bordered }: any) => {
settings.setSync('musicFolder.albums', e);
}}
>
Albums
{t('Albums')}
</StyledCheckbox>
<StyledCheckbox
defaultChecked={folder.applied.artists}
@ -70,7 +75,7 @@ const ServerConfig = ({ bordered }: any) => {
settings.setSync('musicFolder.artists', e);
}}
>
Artists
{t('Artists')}
</StyledCheckbox>
<StyledCheckbox
defaultChecked={folder.applied.dashboard}
@ -79,7 +84,7 @@ const ServerConfig = ({ bordered }: any) => {
settings.setSync('musicFolder.dashboard', e);
}}
>
Dashboard
{t('Dashboard')}
</StyledCheckbox>
<StyledCheckbox
defaultChecked={folder.applied.starred}
@ -88,7 +93,7 @@ const ServerConfig = ({ bordered }: any) => {
settings.setSync('musicFolder.starred', e);
}}
>
Favorites
{t('Favorites')}
</StyledCheckbox>
<StyledCheckbox
defaultChecked={folder.applied.search}
@ -97,7 +102,7 @@ const ServerConfig = ({ bordered }: any) => {
settings.setSync('musicFolder.search', e);
}}
>
Search
{t('Search')}
</StyledCheckbox>
{config.serverType === Server.Jellyfin && (
<StyledCheckbox
@ -107,7 +112,7 @@ const ServerConfig = ({ bordered }: any) => {
settings.setSync('musicFolder.music', e);
}}
>
Songs
{t('Songs')}
</StyledCheckbox>
)}
</CheckboxGroup>

16
src/components/settings/ConfigPanels/WindowConfig.tsx

@ -1,21 +1,25 @@
import React, { useState } from 'react';
import settings from 'electron-settings';
import { useTranslation } from 'react-i18next';
import { ConfigOptionDescription, ConfigPanel } from '../styled';
import { StyledToggle } from '../../shared/styled';
import ConfigOption from '../ConfigOption';
const WindowConfig = ({ bordered }: any) => {
const { t } = useTranslation();
const [minimizeToTray, setMinimizeToTray] = useState(Boolean(settings.getSync('minimizeToTray')));
const [exitToTray, setExitToTray] = useState(Boolean(settings.getSync('exitToTray')));
return (
<ConfigPanel bordered={bordered} header="Window">
<ConfigPanel bordered={bordered} header={t('Window')}>
<ConfigOptionDescription>
Note: These settings may not function correctly depending on your desktop environment.
{t(
'Note: These settings may not function correctly depending on your desktop environment.'
)}
</ConfigOptionDescription>
<ConfigOption
name="Minimize to Tray"
description="Minimizes to the system tray."
name={t('Minimize to Tray')}
description={t('Minimizes to the system tray.')}
option={
<StyledToggle
defaultChecked={minimizeToTray}
@ -29,8 +33,8 @@ const WindowConfig = ({ bordered }: any) => {
/>
<ConfigOption
name="Exit to Tray"
description="Exits to the system tray."
name={t('Exit to Tray')}
description={t('Exits to the system tray.')}
option={
<StyledToggle
defaultChecked={exitToTray}

4
src/components/settings/DisconnectButton.tsx

@ -1,5 +1,6 @@
import React from 'react';
import settings from 'electron-settings';
import { useTranslation } from 'react-i18next';
import { StyledButton } from '../shared/styled';
export const handleDisconnect = () => {
@ -27,9 +28,10 @@ export const handleDisconnect = () => {
};
const DisconnectButton = () => {
const { t } = useTranslation();
return (
<StyledButton onClick={handleDisconnect} size="sm">
Disconnect
{t('Disconnect')}
</StyledButton>
);
};

104
src/components/settings/Fonts.ts

@ -1,261 +1,263 @@
import i18next from 'i18next';
export const Fonts = [
{
label: 'Archivo',
value: 'Archivo',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Cormorant',
value: 'Cormorant',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Encode Sans',
value: 'Encode Sans',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Epilogue',
value: 'Epilogue',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Hahmlet',
value: 'Hahmlet',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Inter',
value: 'Inter',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'JetBrains Mono',
value: 'JetBrains Mono',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Manrope',
value: 'Manrope',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Monsterrat',
value: 'Monsterrat',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Oswald',
value: 'Oswald',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Oxygen',
value: 'Oxygen',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Poppins',
value: 'Poppins',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Raleway',
value: 'Raleway',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Roboto',
value: 'Roboto',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Sora',
value: 'Sora',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Spectral',
value: 'Spectral',
role: 'Regular',
role: i18next.t('Regular'),
},
{
label: 'Work Sans',
value: 'Work Sans',
role: 'Regular',
role: i18next.t('Regular'),
},
// LIGHT
{
label: 'Archivo (Light)',
value: 'ArchivoLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Cormorant (Light)',
value: 'CormorantLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Encode Sans (Light)',
value: 'Encode SansLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Epilogue (Light)',
value: 'EpilogueLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Hahmlet (Light)',
value: 'HahmletLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Inter (Light)',
value: 'InterLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'JetBrains Mono (Light)',
value: 'JetBrains MonoLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Manrope (Light)',
value: 'ManropeLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Monsterrat (Light)',
value: 'MonsterratLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Oswald (Light)',
value: 'OswaldLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Oxygen (Light)',
value: 'OxygenLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Poppins (Light)',
value: 'PoppinsLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Raleway (Light)',
value: 'RalewayLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Roboto (Light)',
value: 'RobotoLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Sora (Light)',
value: 'SoraLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Spectral (Light)',
value: 'SpectralLight',
role: 'Light',
role: i18next.t('Light'),
},
{
label: 'Work Sans (Light)',
value: 'Work SansLight',
role: 'Light',
role: i18next.t('Light'),
},
// MEDIUM
{
label: 'Archivo (Medium)',
value: 'ArchivoMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Cormorant (Medium)',
value: 'CormorantMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Encode Sans (Medium)',
value: 'Encode SansMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Epilogue (Medium)',
value: 'EpilogueMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Hahmlet (Medium)',
value: 'HahmletMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Inter (Medium)',
value: 'InterMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'JetBrains Mono (Medium)',
value: 'JetBrains MonoMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Manrope (Medium)',
value: 'ManropeMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Monsterrat (Medium)',
value: 'MonsterratMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Oswald (Medium)',
value: 'OswaldMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Oxygen (Medium)',
value: 'OxygenMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Poppins (Medium)',
value: 'PoppinsMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Raleway (Medium)',
value: 'RalewayMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Roboto (Medium)',
value: 'RobotoMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Sora (Medium)',
value: 'SoraMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Spectral (Medium)',
value: 'SpectralMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
{
label: 'Work Sans (Medium)',
value: 'Work SansMedium',
role: 'Medium',
role: i18next.t('Medium'),
},
];

680
src/components/settings/ListViewColumns.ts

File diff suppressed because it is too large

24
src/components/settings/Login.tsx

@ -4,6 +4,7 @@ import randomstring from 'randomstring';
import settings from 'electron-settings';
import { Form, ControlLabel, Message, RadioGroup } from 'rsuite';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import setDefaultSettings from '../shared/setDefaultSettings';
import {
StyledButton,
@ -20,6 +21,7 @@ import packageJson from '../../package.json';
import { Server } from '../../types';
const Login = () => {
const { t } = useTranslation();
const [serverType, setServerType] = useState('subsonic');
const [serverName, setServerName] = useState('');
const [userName, setUserName] = useState('');
@ -54,7 +56,7 @@ const Login = () => {
setMessage(`${err.message}`);
return;
}
setMessage('An unknown error occurred');
setMessage(t('An unknown error occurred'));
return;
}
@ -116,7 +118,7 @@ const Login = () => {
setMessage(`${err.message}`);
return;
}
setMessage('An unknown error occurred');
setMessage(t('An unknown error occurred'));
return;
}
@ -136,7 +138,7 @@ const Login = () => {
{message !== '' && <Message type="error" description={message} />}
<Form id="login-form" fluid style={{ paddingTop: '20px' }}>
<StyledInputPickerContainer ref={serverTypePickerRef}>
<ControlLabel>Server type</ControlLabel>
<ControlLabel>{t('Server type')}</ControlLabel>
<RadioGroup
inline
defaultValue="subsonic"
@ -148,32 +150,32 @@ const Login = () => {
</RadioGroup>
</StyledInputPickerContainer>
<br />
<ControlLabel>Server</ControlLabel>
<ControlLabel>{t('Server')}</ControlLabel>
<StyledInput
id="login-servername"
name="servername"
value={serverName}
onChange={(e: string) => setServerName(e)}
placeholder="Requires http(s)://"
placeholder={t('Requires http(s)://')}
/>
<br />
<ControlLabel>Username</ControlLabel>
<ControlLabel>{t('Username')}</ControlLabel>
<StyledInput
id="login-username"
name="name"
value={userName}
onChange={(e: string) => setUserName(e)}
placeholder="Enter username"
placeholder={t('Enter username')}
/>
<br />
<ControlLabel>Password</ControlLabel>
<ControlLabel>{t('Password')}</ControlLabel>
<StyledInput
id="login-password"
name="password"
type="password"
value={password}
onChange={(e: string) => setPassword(e)}
placeholder="Enter password"
placeholder={t('Enter password')}
/>
<br />
{serverType !== 'jellyfin' && (
@ -190,7 +192,7 @@ const Login = () => {
setLegacyAuth(e);
}}
>
Legacy auth (plaintext)
{t('Legacy auth (plaintext)')}
</StyledCheckbox>
<br />
</>
@ -202,7 +204,7 @@ const Login = () => {
block
onClick={serverType !== 'jellyfin' ? handleConnect : handleConnectJellyfin}
>
Connect
{t('Connect')}
</StyledButton>
</Form>
</LoginPanel>

11
src/components/shared/ColumnSort.tsx

@ -1,4 +1,5 @@
import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FlexboxGrid, RadioGroup } from 'rsuite';
import { FilterHeader } from '../library/AdvancedFilters';
import { StyledButton, StyledInputPickerContainer, StyledInputPicker, StyledRadio } from './styled';
@ -12,13 +13,14 @@ const ColumnSort = ({
clearSortType,
disabledItemValues,
}: any) => {
const { t } = useTranslation();
const sortFilterPickerContainerRef = useRef<any>();
return (
<>
<FilterHeader>
<FlexboxGrid justify="space-between">
<FlexboxGrid.Item>Sort</FlexboxGrid.Item>
<FlexboxGrid.Item>{t('Sort')}</FlexboxGrid.Item>
<FlexboxGrid.Item>
<StyledButton
size="xs"
@ -26,15 +28,15 @@ const ColumnSort = ({
disabled={!sortColumn}
onClick={clearSortType}
>
Reset
{t('Reset')}
</StyledButton>
</FlexboxGrid.Item>
</FlexboxGrid>
</FilterHeader>
<RadioGroup inline defaultValue={sortType} onChange={setSortType}>
<StyledRadio value="asc">ASC</StyledRadio>
<StyledRadio value="desc">DESC</StyledRadio>
<StyledRadio value="asc">{t('ASC')}</StyledRadio>
<StyledRadio value="desc">{t('DESC')}</StyledRadio>
</RadioGroup>
<StyledInputPickerContainer ref={sortFilterPickerContainerRef}>
<StyledInputPicker
@ -47,6 +49,7 @@ const ColumnSort = ({
virtualized
cleanable={false}
style={{ width: '250px' }}
placeholder={t('Select')}
onChange={setSortColumn}
/>
</StyledInputPickerContainer>

67
src/components/shared/ContextMenu.tsx

@ -5,6 +5,7 @@ import { nanoid } from 'nanoid/non-secure';
import { useQuery, useQueryClient } from 'react-query';
import { useHistory } from 'react-router';
import { ButtonToolbar, Col, FlexboxGrid, Grid, Form, Icon, Row, Whisper } from 'rsuite';
import { t } from 'i18next';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import {
addModalPage,
@ -346,9 +347,10 @@ export const GlobalContextMenu = () => {
const playlistSuccessToast = (songCount: number, playlistId: string) => {
notifyToast(
'success',
`Added ${songCount} item(s) to playlist ${
playlists.find((pl: any) => pl.id === playlistId)?.title
}`,
t('Added {{songCount}} item(s) to playlist {{playlist}}', {
songCount,
playlist: playlists.find((pl: any) => pl.id === playlistId)?.title,
}),
<>
<StyledButton
appearance="link"
@ -357,7 +359,7 @@ export const GlobalContextMenu = () => {
dispatch(setContextMenu({ show: false }));
}}
>
Go to playlist
{t('Go to playlist')}
</StyledButton>
</>
);
@ -491,7 +493,7 @@ export const GlobalContextMenu = () => {
}
}
} catch (err) {
notifyToast('error', 'Error adding to playlist');
notifyToast('error', t('Error adding to playlist'));
} finally {
dispatch(removeProcessingPlaylist(localSelectedPlaylistId));
queryClient.removeQueries(['playlist', localSelectedPlaylistId]);
@ -521,7 +523,12 @@ export const GlobalContextMenu = () => {
if (isFailedResponse(res)) {
notifyToast('error', errorMessages(res)[0]);
} else {
notifyToast('info', `Deleted ${multiSelect.selected.length} playlist(s)`);
notifyToast(
'info',
t('Deleted {{n}} playlists', {
n: multiSelect.selected.length,
})
);
}
await queryClient.refetchQueries(['playlists'], {
@ -543,7 +550,7 @@ export const GlobalContextMenu = () => {
await queryClient.refetchQueries(['playlists'], {
active: true,
});
notifyToast('success', `Playlist "${newPlaylistName}" created!`);
notifyToast('success', t('Playlist "{{newPlaylistName}}" created!', { newPlaylistName }));
}
} catch (err) {
notifyToast('error', err);
@ -710,7 +717,7 @@ export const GlobalContextMenu = () => {
})
);
} else {
notifyToast('error', 'Select only one row');
notifyToast('error', t('Select only one row'));
}
};
@ -719,7 +726,7 @@ export const GlobalContextMenu = () => {
if (misc.contextMenu.type.match('music|nowPlaying') && multiSelect.selected.length === 1) {
history.push(`/library/folder?folderId=${multiSelect.selected[0].parent}`);
} else {
notifyToast('error', 'Select only one row');
notifyToast('error', t('Select only one row'));
}
};
@ -747,22 +754,22 @@ export const GlobalContextMenu = () => {
numOfDividers={3}
>
<ContextMenuButton
text="Play"
text={t('Play')}
onClick={handlePlay}
disabled={misc.contextMenu.disabledOptions.includes('play')}
/>
<ContextMenuButton
text="Add to queue (next)"
text={t('Add to queue (next)')}
onClick={() => handleAddToQueue('next')}
disabled={misc.contextMenu.disabledOptions.includes('addToQueueNext')}
/>
<ContextMenuButton
text="Add to queue (later)"
text={t('Add to queue (later)')}
onClick={() => handleAddToQueue('later')}
disabled={misc.contextMenu.disabledOptions.includes('addToQueueLast')}
/>
<ContextMenuButton
text="Remove selected"
text={t('Remove selected')}
onClick={handleRemoveSelected}
disabled={misc.contextMenu.disabledOptions.includes('removeSelected')}
/>
@ -824,7 +831,7 @@ export const GlobalContextMenu = () => {
: indexToMoveTo > playlist.entry?.length) || indexToMoveTo < 0
}
>
Go
{t('Go')}
</StyledInputGroupButton>
</StyledInputGroup>
</Form>
@ -832,7 +839,7 @@ export const GlobalContextMenu = () => {
}
>
<ContextMenuButton
text="Move selected to [...]"
text={t('Move selected to [...]')}
disabled={misc.contextMenu.disabledOptions.includes('moveSelectedTo')}
/>
</Whisper>
@ -855,6 +862,7 @@ export const GlobalContextMenu = () => {
labelKey="title"
valueKey="id"
width={200}
placeholder={t('Select')}
onChange={(e: any) => setSelectedPlaylistId(e)}
/>
<StyledButton
@ -875,7 +883,7 @@ export const GlobalContextMenu = () => {
appearance="subtle"
onClick={() => setShouldCreatePlaylist(!shouldCreatePlaylist)}
>
Create new playlist
{t('Create new playlist')}
</StyledButton>
</div>
{shouldCreatePlaylist && (
@ -883,7 +891,7 @@ export const GlobalContextMenu = () => {
<br />
<StyledInputGroup>
<StyledInput
placeholder="Enter name..."
placeholder={t('Enter name...')}
value={newPlaylistName}
onChange={(e: string) => setNewPlaylistName(e)}
/>
@ -898,7 +906,7 @@ export const GlobalContextMenu = () => {
setShouldCreatePlaylist(false);
}}
>
Ok
{t('Ok')}
</StyledButton>
</StyledInputGroup>
</Form>
@ -907,7 +915,7 @@ export const GlobalContextMenu = () => {
}
>
<ContextMenuButton
text="Add to playlist"
text={t('Add to playlist')}
onClick={() =>
addToPlaylistTriggerRef.current.state.isOverlayShown
? addToPlaylistTriggerRef.current.close()
@ -923,15 +931,20 @@ export const GlobalContextMenu = () => {
trigger="none"
speaker={
<ContextMenuPopover>
<p>Are you sure you want to delete {multiSelect.selected?.length} playlist(s)?</p>
<p>
{
(t('Are you sure you want to delete {{n}} playlist(s)?'),
{ n: multiSelect.selected?.length })
}
</p>
<StyledButton onClick={handleDeletePlaylist} appearance="link">
Yes
{t('Yes')}
</StyledButton>
</ContextMenuPopover>
}
>
<ContextMenuButton
text="Delete playlist(s)"
text={t('Delete playlist(s)')}
onClick={() =>
deletePlaylistTriggerRef.current.state.isOverlayShown
? deletePlaylistTriggerRef.current.close()
@ -942,12 +955,12 @@ export const GlobalContextMenu = () => {
</Whisper>
<ContextMenuDivider />
<ContextMenuButton
text="Add to favorites"
text={t('Add to favorites')}
onClick={handleFavorite}
disabled={misc.contextMenu.disabledOptions.includes('addToFavorites')}
/>
<ContextMenuButton
text="Remove from favorites"
text={t('Remove from favorites')}
onClick={handleUnfavorite}
disabled={misc.contextMenu.disabledOptions.includes('removeFromFavorites')}
/>
@ -975,7 +988,7 @@ export const GlobalContextMenu = () => {
}
>
<ContextMenuButton
text="Set rating"
text={t('Set rating')}
onClick={handleUnfavorite}
disabled={
misc.contextMenu.disabledOptions.includes('setRating') ||
@ -985,12 +998,12 @@ export const GlobalContextMenu = () => {
</Whisper>
<ContextMenuDivider />
<ContextMenuButton
text="View in modal"
text={t('View in modal')}
onClick={handleViewInModal}
disabled={misc.contextMenu.disabledOptions.includes('viewInModal')}
/>
<ContextMenuButton
text="View in folder"
text={t('View in folder')}
onClick={handleViewInFolder}
disabled={misc.contextMenu.disabledOptions.includes('viewInFolder')}
/>

51
src/components/shared/ToolbarButtons.tsx

@ -1,3 +1,4 @@
import i18next from 'i18next';
import React from 'react';
import { Icon } from 'rsuite';
import CustomTooltip from './CustomTooltip';
@ -5,7 +6,7 @@ import { StyledButton } from './styled';
export const PlayButton = ({ text, ...rest }: any) => {
return (
<CustomTooltip text={text || 'Play'}>
<CustomTooltip text={text || i18next.t('Play')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="play" />
</StyledButton>
@ -15,7 +16,7 @@ export const PlayButton = ({ text, ...rest }: any) => {
export const PlayAppendButton = ({ text, ...rest }: any) => {
return (
<CustomTooltip text={text || 'Add to queue (later)'}>
<CustomTooltip text={text || i18next.t('Add to queue (later)')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="plus" />
</StyledButton>
@ -25,7 +26,7 @@ export const PlayAppendButton = ({ text, ...rest }: any) => {
export const PlayAppendNextButton = ({ text, ...rest }: any) => {
return (
<CustomTooltip text={text || 'Add to queue (next)'}>
<CustomTooltip text={text || i18next.t('Add to queue (next)')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="plus-circle" />
</StyledButton>
@ -35,7 +36,7 @@ export const PlayAppendNextButton = ({ text, ...rest }: any) => {
export const PlayShuffleAppendButton = ({ ...rest }) => {
return (
<CustomTooltip text="Add shuffled to queue" onClick={rest.onClick}>
<CustomTooltip text={i18next.t('Add shuffled to queue')} onClick={rest.onClick}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="plus-square" />
</StyledButton>
@ -45,7 +46,7 @@ export const PlayShuffleAppendButton = ({ ...rest }) => {
export const SaveButton = ({ text, ...rest }: any) => {
return (
<CustomTooltip text={text || 'Save'}>
<CustomTooltip text={text || i18next.t('Save')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="save" />
</StyledButton>
@ -55,7 +56,7 @@ export const SaveButton = ({ text, ...rest }: any) => {
export const EditButton = ({ ...rest }) => {
return (
<CustomTooltip text="Edit">
<CustomTooltip text={i18next.t('Edit')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="edit2" />
</StyledButton>
@ -65,7 +66,7 @@ export const EditButton = ({ ...rest }) => {
export const UndoButton = ({ ...rest }) => {
return (
<CustomTooltip text="Reset">
<CustomTooltip text={i18next.t('Reset')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="undo" />
</StyledButton>
@ -75,7 +76,7 @@ export const UndoButton = ({ ...rest }) => {
export const DeleteButton = ({ ...rest }) => {
return (
<CustomTooltip text="Delete">
<CustomTooltip text={i18next.t('Delete')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="trash" />
</StyledButton>
@ -85,7 +86,7 @@ export const DeleteButton = ({ ...rest }) => {
export const FavoriteButton = ({ isFavorite, ...rest }: any) => {
return (
<CustomTooltip text="Toggle favorite">
<CustomTooltip text={i18next.t('Toggle favorite')}>
<StyledButton tabIndex={0} {...rest}>
<Icon icon={isFavorite ? 'heart' : 'heart-o'} />
</StyledButton>
@ -95,7 +96,13 @@ export const FavoriteButton = ({ isFavorite, ...rest }: any) => {
export const DownloadButton = ({ downloadSize, ...rest }: any) => {
return (
<CustomTooltip text={downloadSize ? `Download (${downloadSize})` : 'Download'}>
<CustomTooltip
text={
downloadSize
? i18next.t('Download ({{downloadSize}})', { downloadSize })
: i18next.t('Download')
}
>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="download" />
</StyledButton>
@ -105,7 +112,7 @@ export const DownloadButton = ({ downloadSize, ...rest }: any) => {
export const ShuffleButton = ({ ...rest }) => {
return (
<CustomTooltip text="Shuffle queue">
<CustomTooltip text={i18next.t('Shuffle queue')}>
<StyledButton tabIndex={0} {...rest}>
<Icon icon="random" />
</StyledButton>
@ -115,7 +122,7 @@ export const ShuffleButton = ({ ...rest }) => {
export const ClearQueueButton = ({ ...rest }) => {
return (
<CustomTooltip text="Clear queue">
<CustomTooltip text={i18next.t('Clear queue')}>
<StyledButton tabIndex={0} {...rest}>
<Icon icon="trash2" />
</StyledButton>
@ -127,7 +134,7 @@ export const AddPlaylistButton = ({ ...rest }) => {
return (
<StyledButton tabIndex={0} {...rest}>
<Icon icon="plus-square" style={{ marginRight: '10px' }} />
Add playlist
{i18next.t('Add playlist')}
</StyledButton>
);
};
@ -136,7 +143,7 @@ export const RefreshButton = ({ ...rest }) => {
return (
<StyledButton tabIndex={0} {...rest}>
<Icon icon="refresh" style={{ marginRight: '10px' }} />
Refresh
{i18next.t('Refresh')}
</StyledButton>
);
};
@ -145,17 +152,17 @@ export const FilterButton = ({ ...rest }) => {
return (
<StyledButton tabIndex={0} {...rest}>
<Icon icon="filter" style={{ marginRight: '10px' }} />
Filter
{i18next.t('Filter')}
</StyledButton>
);
};
export const AutoPlaylistButton = ({ noText, ...rest }: any) => {
return (
<CustomTooltip text="Auto playlist">
<CustomTooltip text={i18next.t('Auto playlist')}>
<StyledButton tabIndex={0} {...rest}>
<Icon icon="plus-square" style={{ marginRight: noText ? '0px' : '10px' }} />
{!noText && 'Auto playlist'}
{!noText && i18next.t('Auto playlist')}
</StyledButton>
</CustomTooltip>
);
@ -163,7 +170,7 @@ export const AutoPlaylistButton = ({ noText, ...rest }: any) => {
export const MoveUpButton = ({ ...rest }) => {
return (
<CustomTooltip text="Move selected up">
<CustomTooltip text={i18next.t('Move selected up')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="angle-up" />
</StyledButton>
@ -173,7 +180,7 @@ export const MoveUpButton = ({ ...rest }) => {
export const MoveDownButton = ({ ...rest }) => {
return (
<CustomTooltip text="Move selected down">
<CustomTooltip text={i18next.t('Move selected down')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="angle-down" />
</StyledButton>
@ -183,7 +190,7 @@ export const MoveDownButton = ({ ...rest }) => {
export const MoveTopButton = ({ ...rest }) => {
return (
<CustomTooltip text="Move selected to top">
<CustomTooltip text={i18next.t('Move selected to top')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="arrow-up2" />
</StyledButton>
@ -193,7 +200,7 @@ export const MoveTopButton = ({ ...rest }) => {
export const MoveBottomButton = ({ ...rest }) => {
return (
<CustomTooltip text="Move selected to bottom">
<CustomTooltip text={i18next.t('Move selected to bottom')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="arrow-down2" />
</StyledButton>
@ -203,7 +210,7 @@ export const MoveBottomButton = ({ ...rest }) => {
export const RemoveSelectedButton = ({ ...rest }) => {
return (
<CustomTooltip text="Remove selected">
<CustomTooltip text={i18next.t('Remove selected')}>
<StyledButton {...rest} tabIndex={0}>
<Icon icon="close" />
</StyledButton>

101
src/components/shared/setDefaultSettings.ts

@ -1,4 +1,5 @@
import settings from 'electron-settings';
import i18next from 'i18next';
import path from 'path';
const setDefaultSettings = (force: boolean) => {
@ -46,6 +47,10 @@ const setDefaultSettings = (force: boolean) => {
settings.setSync('legacyAuth', false);
}
if (force || !settings.hasSync('language')) {
settings.setSync('language', 'en');
}
if (force || !settings.hasSync('theme')) {
settings.setSync('theme', 'defaultDark');
}
@ -250,39 +255,39 @@ const setDefaultSettings = (force: boolean) => {
label: '# (Drag/Drop)',
},
{
id: 'Title',
id: i18next.t('Title')?.toString(),
dataKey: 'combinedtitle',
alignment: 'left',
flexGrow: 5,
label: 'Title (Combined)',
label: i18next.t('Title (Combined)')?.toString(),
},
{
id: 'Album',
id: i18next.t('Album')?.toString(),
dataKey: 'album',
alignment: 'left',
flexGrow: 3,
label: 'Album',
label: i18next.t('Album')?.toString(),
},
{
id: 'Duration',
id: i18next.t('Duration')?.toString(),
dataKey: 'duration',
alignment: 'center',
flexGrow: 2,
label: 'Duration',
label: i18next.t('Duration')?.toString(),
},
{
id: 'Bitrate',
id: i18next.t('Bitrate')?.toString(),
dataKey: 'bitRate',
alignment: 'left',
flexGrow: 1,
label: 'Bitrate',
label: i18next.t('Bitrate')?.toString(),
},
{
id: 'Fav',
id: i18next.t('Fav')?.toString(),
dataKey: 'starred',
alignment: 'center',
flexGrow: 1,
label: 'Favorite',
label: i18next.t('Favorite')?.toString(),
},
]);
}
@ -306,32 +311,32 @@ const setDefaultSettings = (force: boolean) => {
label: '#',
},
{
id: 'Title',
id: i18next.t('Title')?.toString(),
dataKey: 'combinedtitle',
alignment: 'left',
flexGrow: 5,
label: 'Title (Combined)',
label: i18next.t('Title (Combined)')?.toString(),
},
{
id: 'Tracks',
id: i18next.t('Tracks')?.toString(),
dataKey: 'songCount',
alignment: 'center',
flexGrow: 1,
label: 'Track Count',
label: i18next.t('Track Count')?.toString(),
},
{
id: 'Duration',
id: i18next.t('Duration')?.toString(),
dataKey: 'duration',
alignment: 'center',
flexGrow: 2,
label: 'Duration',
label: i18next.t('Duration')?.toString(),
},
{
id: 'Fav',
id: i18next.t('Fav')?.toString(),
dataKey: 'starred',
alignment: 'center',
flexGrow: 1,
label: 'Favorite',
label: i18next.t('Favorite')?.toString(),
},
]);
}
@ -355,39 +360,39 @@ const setDefaultSettings = (force: boolean) => {
label: '#',
},
{
id: 'Title',
id: i18next.t('Title')?.toString(),
dataKey: 'title',
alignment: 'left',
flexGrow: 5,
label: 'Title',
label: i18next.t('Title')?.toString(),
},
{
id: 'Description',
id: i18next.t('Description')?.toString(),
dataKey: 'comment',
alignment: 'left',
flexGrow: 3,
label: 'Description',
label: i18next.t('Description')?.toString(),
},
{
id: 'Tracks',
id: i18next.t('Tracks')?.toString(),
dataKey: 'songCount',
alignment: 'center',
flexGrow: 1,
label: 'Track Count',
label: i18next.t('Track Count')?.toString(),
},
{
id: 'Owner',
id: i18next.t('Owner')?.toString(),
dataKey: 'owner',
alignment: 'left',
flexGrow: 2,
label: 'Owner',
label: i18next.t('Owner')?.toString(),
},
{
id: 'Modified',
id: i18next.t('Modified')?.toString(),
dataKey: 'changed',
alignment: 'left',
flexGrow: 1,
label: 'Modified',
label: i18next.t('Modified')?.toString(),
},
]);
}
@ -411,33 +416,33 @@ const setDefaultSettings = (force: boolean) => {
label: '#',
},
{
id: 'Art',
id: i18next.t('Art')?.toString(),
dataKey: 'coverart',
alignment: 'center',
resizable: true,
width: 50,
label: 'CoverArt',
label: i18next.t('CoverArt')?.toString(),
},
{
id: 'Title',
id: i18next.t('Title')?.toString(),
dataKey: 'title',
alignment: 'left',
flexGrow: 5,
label: 'Title',
label: i18next.t('Title')?.toString(),
},
{
id: 'Albums',
id: i18next.t('Albums')?.toString(),
dataKey: 'albumCount',
alignment: 'left',
flexGrow: 1,
label: 'Album Count',
label: i18next.t('Album Count')?.toString(),
},
{
id: 'Fav',
id: i18next.t('Fav')?.toString(),
dataKey: 'starred',
alignment: 'center',
flexGrow: 1,
label: 'Favorite',
label: i18next.t('Favorite')?.toString(),
},
]);
}
@ -462,28 +467,28 @@ const setDefaultSettings = (force: boolean) => {
},
{
width: 220,
id: 'Title',
id: i18next.t('Title')?.toString(),
dataKey: 'combinedtitle',
alignment: 'left',
label: 'Title (Combined)',
label: i18next.t('Title (Combined)')?.toString(),
rowIndex: 7,
resizable: true,
},
{
width: 60,
id: 'Duration',
id: i18next.t('Duration')?.toString(),
dataKey: 'duration',
alignment: 'center',
label: 'Duration',
label: i18next.t('Duration')?.toString(),
rowIndex: 3,
resizable: true,
},
{
width: 45,
id: 'Fav',
id: i18next.t('Fav')?.toString(),
dataKey: 'starred',
alignment: 'center',
label: 'Favorite',
label: i18next.t('Favorite')?.toString(),
rowIndex: 6,
resizable: true,
},
@ -509,25 +514,25 @@ const setDefaultSettings = (force: boolean) => {
label: '#',
},
{
id: 'Title',
id: i18next.t('Title')?.toString(),
dataKey: 'title',
alignment: 'left',
flexGrow: 5,
label: 'Title',
label: i18next.t('Title')?.toString(),
},
{
id: 'Albums',
id: i18next.t('Albums')?.toString(),
dataKey: 'albumCount',
alignment: 'left',
flexGrow: 3,
label: 'Album Count',
label: i18next.t('Album Count')?.toString(),
},
{
id: 'Tracks',
id: i18next.t('Tracks')?.toString(),
dataKey: 'songCount',
alignment: 'left',
flexGrow: 1,
label: 'Song Count',
label: i18next.t('Song Count')?.toString(),
},
]);
}

10
src/components/starred/StarredView.tsx

@ -3,6 +3,7 @@ import { useHistory } from 'react-router';
import { useQuery, useQueryClient } from 'react-query';
import { Nav } from 'rsuite';
import settings from 'electron-settings';
import { useTranslation } from 'react-i18next';
import useSearchQuery from '../../hooks/useSearchQuery';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import {
@ -33,6 +34,7 @@ import { FilterButton } from '../shared/ToolbarButtons';
import ColumnSortPopover from '../shared/ColumnSortPopover';
const StarredView = () => {
const { t } = useTranslation();
const history = useHistory();
const dispatch = useAppDispatch();
const queryClient = useQueryClient();
@ -176,7 +178,7 @@ const StarredView = () => {
<GenericPageHeader
title={
<>
Favorites{' '}
{t('Favorites')}{' '}
<StyledTag style={{ verticalAlign: 'middle', cursor: 'default' }}>
{favorite.active.tab === 'tracks' && (data?.song?.length || '...')}
{favorite.active.tab === 'albums' && (data?.album?.length || '...')}
@ -287,7 +289,7 @@ const StarredView = () => {
}}
tabIndex={0}
>
Tracks
{t('Tracks')}
</StyledNavItem>
<StyledNavItem
eventKey="albums"
@ -297,7 +299,7 @@ const StarredView = () => {
}
}}
>
Albums
{t('Albums')}
</StyledNavItem>
<StyledNavItem
eventKey="artists"
@ -307,7 +309,7 @@ const StarredView = () => {
}
}}
>
Artists
{t('Artists')}
</StyledNavItem>
</Nav>
}

75
src/hooks/useColumnSort.ts

@ -2,58 +2,59 @@
/* eslint-disable consistent-return */
import { useState, useEffect } from 'react';
import _ from 'lodash';
import i18next from 'i18next';
import { Item } from '../types';
const ALBUM_COLUMNS = [
{ label: 'Artist', dataKey: 'albumArtist' },
{ label: 'Created', dataKey: 'created' },
{ label: 'Duration', dataKey: 'duration' },
{ label: 'Favorite', dataKey: 'starred' },
{ label: 'Genre', dataKey: 'albumGenre' },
{ label: 'Play Count', dataKey: 'playCount' },
{ label: 'Rating', dataKey: 'userRating' },
{ label: 'Song Count', dataKey: 'songCount' },
{ label: 'Title', dataKey: 'title' },
{ label: 'Year', dataKey: 'year' },
{ label: i18next.t('Artist'), dataKey: 'albumArtist' },
{ label: i18next.t('Created'), dataKey: 'created' },
{ label: i18next.t('Duration'), dataKey: 'duration' },
{ label: i18next.t('Favorite'), dataKey: 'starred' },
{ label: i18next.t('Genre'), dataKey: 'albumGenre' },
{ label: i18next.t('Play Count'), dataKey: 'playCount' },
{ label: i18next.t('Rating'), dataKey: 'userRating' },
{ label: i18next.t('Song Count'), dataKey: 'songCount' },
{ label: i18next.t('Title'), dataKey: 'title' },
{ label: i18next.t('Year'), dataKey: 'year' },
];
const ARTIST_COLUMNS = [
{ label: 'Album Count', dataKey: 'albumCount' },
{ label: 'Duration', dataKey: 'duration' },
{ label: 'Favorite', dataKey: 'starred' },
{ label: 'Rating', dataKey: 'userRating' },
{ label: 'Title', dataKey: 'title' },
{ label: i18next.t('Album Count'), dataKey: 'albumCount' },
{ label: i18next.t('Duration'), dataKey: 'duration' },
{ label: i18next.t('Favorite'), dataKey: 'starred' },
{ label: i18next.t('Rating'), dataKey: 'userRating' },
{ label: i18next.t('Title'), dataKey: 'title' },
];
const MUSIC_COLUMNS = [
{ label: 'Artist', dataKey: 'albumArtist' },
{ label: 'Bitrate', dataKey: 'bitRate' },
{ label: 'Created', dataKey: 'created' },
{ label: 'Duration', dataKey: 'duration' },
{ label: 'Favorite', dataKey: 'starred' },
{ label: 'Genre', dataKey: 'albumGenre' },
{ label: 'Play Count', dataKey: 'playCount' },
{ label: 'Rating', dataKey: 'userRating' },
{ label: 'Size', dataKey: 'size' },
{ label: 'Title', dataKey: 'title' },
{ label: 'Year', dataKey: 'year' },
{ label: i18next.t('Artist'), dataKey: 'albumArtist' },
{ label: i18next.t('Bitrate'), dataKey: 'bitRate' },
{ label: i18next.t('Created'), dataKey: 'created' },
{ label: i18next.t('Duration'), dataKey: 'duration' },
{ label: i18next.t('Favorite'), dataKey: 'starred' },
{ label: i18next.t('Genre'), dataKey: 'albumGenre' },
{ label: i18next.t('Play Count'), dataKey: 'playCount' },
{ label: i18next.t('Rating'), dataKey: 'userRating' },
{ label: i18next.t('Size'), dataKey: 'size' },
{ label: i18next.t('Title'), dataKey: 'title' },
{ label: i18next.t('Year'), dataKey: 'year' },
];
const PLAYLIST_COLUMNS = [
{ label: 'Created', dataKey: 'created' },
{ label: 'Description', dataKey: 'comment' },
{ label: 'Duration', dataKey: 'duration' },
{ label: 'Modified', dataKey: 'changed' },
{ label: 'Owner', dataKey: 'owner' },
{ label: 'Song Count', dataKey: 'songCount' },
{ label: 'Title', dataKey: 'title' },
{ label: 'Visibility', dataKey: 'public' },
{ label: i18next.t('Created'), dataKey: 'created' },
{ label: i18next.t('Description'), dataKey: 'comment' },
{ label: i18next.t('Duration'), dataKey: 'duration' },
{ label: i18next.t('Modified'), dataKey: 'changed' },
{ label: i18next.t('Owner'), dataKey: 'owner' },
{ label: i18next.t('Song Count'), dataKey: 'songCount' },
{ label: i18next.t('Title'), dataKey: 'title' },
{ label: i18next.t('Visibility'), dataKey: 'public' },
];
const GENRE_COLUMNS = [
{ label: 'Album Count', dataKey: 'albumCount' },
{ label: 'Song Count', dataKey: 'songCount' },
{ label: 'Title', dataKey: 'title' },
{ label: i18next.t('Album Count'), dataKey: 'albumCount' },
{ label: i18next.t('Song Count'), dataKey: 'songCount' },
{ label: i18next.t('Title'), dataKey: 'title' },
];
const useColumnSort = (data: any[], type: Item, sort: { column: string; type: 'asc' | 'desc' }) => {

42
src/i18n/i18n.js

@ -0,0 +1,42 @@
import settings from 'electron-settings';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { mockSettings } from '../shared/mockSettings';
// the translations
// (tip move them in a JSON file and import them,
// or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
const de = require('./locales/de.json');
const en = require('./locales/en.json');
const resources = {
en: { translation: en },
de: { translation: de },
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
lng: process.env.NODE_ENV === 'test' ? mockSettings.language : settings.getSync('language'),
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
},
});
export default i18n;
export const Languages = [
{
label: 'English',
value: 'en',
},
{
label: 'Deutsch',
value: 'de',
},
];

113
src/i18n/i18next-parser.config.js

@ -0,0 +1,113 @@
// i18next-parser.config.js
module.exports = {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: '',
// Default value to give to empty keys
// You may also specify a function accepting the locale, namespace, and key as arguments
indentation: 2,
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
keySeparator: false,
// 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: {
hbs: ['HandlebarsLexer'],
handlebars: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
mjs: ['JavascriptLexer'],
js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JavascriptLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en', 'de'],
// 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/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: '_',
// 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: [
'../../src/**/*.{js,jsx,ts,tsx}',
'!../../src/node_modules/**',
'!../../src/**/*.prod.js',
],
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: true,
// 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,
// 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,
// Display info about the parsing including some stats
failOnWarnings: false,
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// 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',
// 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,
// 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.
};

341
src/i18n/locales/de.json

@ -0,0 +1,341 @@
{
" • Modified {{val, datetime}}": " • Geändert {{val, datetime}}",
" albums": " Alben",
"- Folder not found": "- Ordner nicht gefunden",
"(Paused)": "(Pausiert)",
"[{{albumTitle}}] No parent album found": "[{{albumTitle}}] Kein übergeordnetes Album gefunden",
"* Drag & drop rows from the # column to re-order": "* Drag & drop Zeilen von der # Spalte zum neu Anordnen",
"*You will need to manually move any existing cached files to their new location.": "*Du musst alle existierenden Cache-Dateien manuell an den neuen Speicherort verschieben.",
"# (Drag/Drop)": "# (Drag/Drop)",
"A-Z (Album Artist)": "A-Z (Alben Künstler)",
"A-Z (Album)": "A-Z (Album)",
"A-Z (Artist)": "A-Z (Künstler)",
"A-Z (Name)": "A-Z (Name)",
"Add": "Hinzufügen",
"Add (later)": "Hinzufügen (später)",
"Add (next)": "Hinzufügen (als nächstes)",
"Add playlist": "Playlist hinzufügen",
"Add shuffled to queue": "Geshuffelt zur Warteschlange hinzufügen",
"Add to favorites": "Zu Favoriten hinzufügen",
"Add to playlist": "Zu Playlist hinzufügen",
"Add to queue (later)": "Zur Warteschlange hinzufügen (später)",
"Add to queue (next)": "Zur Warteschlange hinzufügen (als nächstes)",
"Added {{n}} songs": "{{n}} Songs hinzugefügt",
"Added {{n}} songs [{{i}} filtered]": "{{n}} Songs hinzugefügt [{{i}} gefiltert]",
"Added {{songCount}} item(s) to playlist {{playlist}}": "{{songCount}} Songs zur Playlist {{playlist}} hinzugefügt",
"Added {{val, datetime}}": "Hinzugefügt {{val, datetime}}",
"Advanced": "Erweitert",
"Album": "Album",
"ALBUM": "ALBUM",
"Album Count": "Alben Anzahl",
"Album images": "Alben Bilder",
"Albums": "Alben",
"Allow Transcoding": "Transkodierung zulassen",
"An unknown error occurred": "Ein unbekannter Fehler ist aufgetreten",
"Appears On ": "Erscheint in",
"Are you sure you want to delete {{n}} playlist(s)?": "Bist du dir sicher dass du {{n}} Ülaylist(s) löschen willst?",
"Are you sure you want to delete this playlist?": "Bist du dir sicher dass du diese Playlist löschen willst?",
"Are you sure you want to reset your settings to default?": "Bist du dir sicher dass du deine Einstellungen zurücksetzen willst?",
"Art": "Kunst",
"Artist": "Künstler",
"Artist images": "Künstler Bilder",
"Artists": "Künstler",
"ASC": "AUFSTEIGEND",
"Audio Device": "Audio Gerät",
"Auto playlist": "Auto Playlist",
"Auto scroll": "Auto scroll",
"Automatic Updates": "Automatische Updates",
"Bitrate": "Bitrate",
"by": "von",
"By {{dataOwner}} • ": "Von {{dataOwner}} • ",
"Cache": "Cache",
"Card Size": "Karten Größe",
"Center": "Mitte",
"Clear cache": "Cache löschen",
"Clear queue": "Warteschlange löschen",
"Cleared {{type}} image cache": "{{type}} Bilder Cache gelöscht",
"Cleared song cache": "Song-Cache gelöscht",
"Clickable": "Anklickbar",
"Client/Application Id": "Client/Applikations Id",
"Collapse": "Einklappen",
"Column": "Spalte",
"Config": "Einstellungen",
"Config for integration with external programs.": "Einstellungen für die Integration in externe Programme.",
"Confirm": "Bestätigen",
"Connect": "Verbinden",
"Constant Power": "Konstante Stärke",
"Constant Power (fast cut)": "Konstante Stärke (schneller Abbau)",
"Constant Power (slow cut)": "Konstante Stärke (langsamer Abbau)",
"Constant Power (slow fade)": "Konstante Stärke (langsamer Übergang)",
"Copy to clipboard": "In Zwischenablage kopieren",
"CoverArt": "Titelbild",
"Create": "Erstellen",
"Create new playlist": "Neue Playlist erstellen",
"Created": "Erstellt",
"Created {{val, datetime}}": "Erstellt {{val, datetime}}",
"Crossfade Duration (s)": "Überblendedauer",
"Crossfade Type": "Überblendeart",
"Current version:": "Derzeitige Version:",
"Dashboard": "Startseite",
"Default": "Standard",
"Default Album Sort": "Standard Alben Sortierung",
"Default Song Sort": "Standard Song Sortierung",
"Delete": "Löschen",
"Delete playlist(s)": "Playlist(en) löschen",
"Deleted {{n}} playlists": "{{n}} Playlist(s) gelöscht",
"DESC": "ABSTEIGEND",
"Description": "Beschreibung",
"Deselect All": "Alle abwählen",
"Dipped": "Abgesenkt",
"Disconnect": "Verbindung trennen",
"Discord Client Id": "Discord Client Id",
"Displays the debug window.": "Zeigt das Debug-Fenster an.",
"Do you want to restart the application now?": "Willst du die Anwendung jetzt neustarten?",
"Don't know where to start? Apply a preset and tweak from there.": "Du weißt nicht wo du anfangen sollst? Wende eine Voreinstellung an und passe von da aus an.",
"Download": "Download",
"Download ({{downloadSize}})": "Download ({{downloadSize}})",
"Download links copied!": "Download links kopiert!",
"Duration": "Laufzeit",
"Dynamic Background": "Dynamischer Hintergrund",
"Edit": "Bearbeiten",
"Edit cache location": "Cache Speicherort bearbeiten",
"Enable or disable global media hotkeys (play/pause, next, previous, stop, etc). For macOS, you will need to add Sonixd as a <2>trusted accessibility client.</2>": "Aktiviere oder deaktiviere die globalen Medien-Hotkeys (Play/Pause, Weiter, Zurück, Stopp, etc.). Für macOS musst du Sonixd als <2>vertrauenswürdigen Accessibility-Client hinzufügen.</2>",
"Enable or disable the volume fade used by the crossfading players. If disabled, the fading in track will start at full volume.": "Aktiviere oder deaktiviere die Lautstärkeüberblendung die von den Crossfading-Playern verwendet wird. Wenn diese Funktion deaktiviert ist, beginnt die Überblendung bei voller Lautstärke.",
"Enable or disable the Windows System Media Transport Controls (play/pause, next, previous, stop). This will show the Windows Media Popup (Windows 10 only) when pressing a media key. This feauture will override the Global Media Hotkeys option.": "Aktiviere oder deaktiviere die Windows System Media Transport Controls (Play/Pause, Weiter, Zurück, Stopp). Dadurch wird beim drücken einer Medientaste das Windows Media Popup (nur Windows 10) angezeigt. Diese Funktion hat Vorrang vor der Globale Medien-Hotkeys Option.",
"Enabled": "Aktiviert",
"Enables or disables automatic updates. When a new version is detected, it will automatically be downloaded and installed.": "Aktiviert oder deaktiviert automatische Updates. Wenn eine neue Version gefunden wird, wird sie automatisch heruntergeladen und installiert.",
"Enter name...": "Name eingeben",
"Enter password": "Passwort eingeben",
"Enter regex string": "Regex-String eingeben",
"Enter username": "Nutzernamen eingeben",
"Equal Power": "Gleiche Stärke",
"Error adding to playlist": "Fehler beim Hinzufügen zur Playlist",
"Error fetching audio devices": "Fehler beim Lesen der Audiogeräte",
"Error saving playlist": "Fehler beim Speichern der Playlist",
"Error: {{error}}": "Fehler: {{error}}",
"Errored while saving playlist": "Fehler beim Speichern der Playlist",
"Exit to Tray": "In die Taskleiste schließen",
"Exits to the system tray.": "Schließt in die Taskleiste.",
"Expand": "Erweitern",
"External": "Extern",
"Fade": "Überblenden",
"Fav": "Fav",
"Favorite": "Favorit",
"Favorites": "Favoriten",
"File Path": "Dateipfad",
"Filter": "Filter",
"Filter out tracks based on regex string(s) by their title when adding to the queue. Adding by double-clicking a track will ignore all filters for that one track.": "Filtere tracks basierend auf Regex-String(s) nach ihren Titeln wenn du sie zur Warteschlange hinzufügst. Hinzufügen eines Tracks durch Doppelklicken ignoriert alle Filter für diesen einen Track.",
"Folder images": "Ordner Bilder",
"Folders": "Ordner",
"Font": "Schriftart",
"Font Size {{type}}": "Schriftgröße {{type}}",
"From": "Von",
"From year": "Vom Jahr",
"Gap Size": "Lücken Größe",
"Gapless": "Lückenlos",
"Genre": "Genre",
"Genres": "Genres",
"Global Media Hotkeys": "Globale Medien-Hotkeys",
"Go": "Los",
"Go to playlist": "Gehe zu Playlist",
"Go up": "Gehe aufwärts",
"Grid Alignment": "Gitterausrichtung",
"Grid View": "Gitteransicht",
"Highlight On Hover": "Hervorhebung bei Hover",
"Highlights the list view row when hovering it with the mouse.": "Hebt die Zeile in der Listenansicht beim Darüberfahren mit der Maus hervor.",
"How many tracks? (1-500)*": "Wieviele Tracks? (1-500)*",
"If local, scrobbles the currently playing song to local .txt files. If web, scrobbles the currently playing song to Tuna plugin's webserver.": "Wenn Lokal, wird der aktuell spielende Song in lokale .txt-Dateien gescrobbelt. Wenn Web, wird der aktuell wiedergegebene Song auf den Webserver des Tuna-Plugins gescrobbelt.",
"If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).": "Wenn Audiodateien nicht richtig abgespielt werden oder nicht in einem unterstützten Webstreaming-Format vorliegen, aktiviere diese Funktion (Neustart der App erforderlich).",
"Images": "Bilder",
"Integrates with Discord's rich presence to display the currently playing song as your status.": "Integriert sich in Discords Rich Presence um den aktuell gespielten Song als Status anzuzeigen.",
"Items per page (Songs)": "Elemente pro Seite (Songs)",
"Language": "Sprache",
"Latest Albums ": "Neuste Alben ",
"Latest version:": "Neuste Version:",
"Left": "Links",
"Legacy auth (plaintext)": "Veraltete Autorisierung (Klartext)",
"Light": "Leicht",
"Linear": "Linear",
"List View": "Listenansicht",
"Loading...": "Lädt...",
"Local": "Lokal",
"Location:": "Ort:",
"Look & Feel": "Erscheinungsbild",
"Media Folder": "Medien Ordner",
"Medium": "Medium",
"Mini": "Mini",
"Mini-player": "Mini-player",
"Minimize to Tray": "In Taskleiste minimieren",
"Minimizes to the system tray.": "Minimiert in die Taskleiste.",
"Miniplayer": "Miniplayer",
"Modified": "Geändert",
"Most Played": "Meistgespielt",
"Move down": "Nach unten verschieben",
"Move selected down": "Auswahl nach unten verschieben",
"Move selected to [...]": "Auswahl verschieben nach [...]",
"Move selected to bottom": "Auswahl ans Ende verschieben",
"Move selected to top": "Auswahl an den Anfang verschieben",
"Move selected up": "Auswahl nach oben verschieben",
"Move to index": "Zum Index verschieben",
"Move up": "Nach oben verschieben",
"Music folder": "Musik Ordner",
"Muted": "Gestummt",
"Name": "Name",
"Native": "System",
"Next Track": "Nächster Track",
"No Album Playing": "Kein Album wird gespielt",
"No parent album found": "Kein übergeordnetes Album gefunden",
"No songs found, adjust your filters": "Keine Songs gefunden, passe deine Filter an",
"No Track Playing": "Kein Track wird gespielt",
"None": "Keine",
"Note: These settings may not function correctly depending on your desktop environment.": "Hinweis: Diese Einstellungen funktionieren je nach Desktop-Umgebung möglicherweise nicht korrekt.",
"Now Playing": "Warteschlange",
"Ok": "Ok",
"Opacity": "Sichtbarkeit",
"Open settings JSON": "Öffne Einstellungs-JSON",
"Other": "Sonstiges",
"Owner": "Besitzer",
"Page Sonixd will display on start.": "Die Seite welche beim Start von Sonixd gezeigt wird.",
"Pagination": "Seitenumbruch",
"Password": "Passwort",
"Path": "Pfad",
"Path: {{newCachePath}} not found. Enter a valid path.": "Pfad: {{newCachePath}} nicht gefunden. Gib einen gültigen Pfad an.",
"Play": "Abspielen",
"Play Artist Mix": "Spiele Künstler Mix",
"Play Compilation Albums": "Compilation-Alben abspielen",
"Play Count": "Wiedergaben",
"Play Latest Albums": "Neuste Alben abspielen",
"Play Top Songs": "Spiele Top-Songs",
"Play/Pause": "Play/Pause",
"Playback": "Wiedergabe",
"Playback Presets": "Wiedergabe Presets",
"Player": "Player",
"Playing {{n}} songs": "Wiedergabe von {{n}} Songs",
"Playing {{n}} songs [{{i}} filtered]": "Wiedergabe von {{n}} songs [{{i}} gefiltert]",
"PLAYLIST": "PLAYLIST",
"Playlist \"{{newPlaylistName}}\" created!": "Playlist \"{{newPlaylistName}}\" erstellt!",
"Playlist images": "Playlist Bilder",
"Playlists": "Playlists",
"Plays": "Wiedergaben",
"Polling Interval": "Abfrageintervall",
"Previous Track": "Previous Track",
"Private": "Privat",
"Public": "Öffentlich",
"Random": "Zuffälig",
"Rate": "Bewerten",
"Rating": "Bewertung",
"Recently Added": "Kürzlich hinzugefügt",
"Recently Played": "Kürzlich gespielt",
"Recover playlist": "Playlist wiederherstellen",
"Recovered playlist from backup": "Playlist aus Backup wiederhergestellt",
"Refresh": "Aktualisieren",
"Regular": "Normal",
"Related Artists ": "Ähnliche Künstler ",
"Release Date": "Erscheinungsdatum",
"Remove from favorites": "Aus Favoriten entfernen",
"Remove selected": "Ausgewählte entfernen",
"Repeat": "Wiederholen",
"Repeat all": "Alle wiederholen",
"Repeat one": "Einen wiederholen",
"Requires http(s)://": "Benötigt http(s)://",
"Reset": "Zurücksetzen",
"Reset defaults": "Auf Standardeinstellungen zurücksetzen",
"Reset to default": "Auf Standardeinstellungen zurücksetzen",
"Resizable": "Anpassbar",
"Restart?": "Neustarten?",
"Rich Presence": "Rich Presence",
"Row Height {{type}}": "Zeilen Höhe {{type}}",
"Save": "Speichern",
"Save (WARNING: Closing the application while saving may result in data loss)": "Speichern (WARNUNG: Das Schließen der Anwendung während des Speicherns kann zu Datenverlust führen)",
"Saved playlist": "Playlist gespeichert",
"Scan": "Scannen",
"Scrobble": "Scrobbeln",
"Scrobbling": "Scrobbling",
"Search": "Suche",
"Search: {{urlQuery}}": "Suche: {{urlQuery}}",
"Seek backward": "Zurückspulen",
"Seek Backward": "Zurückspulen",
"Seek forward": "Vorspulen",
"Seek Forward": "Vorspulen",
"Select": "Auswählen",
"Select a folder": "Wähle einen Ordner",
"Select only one row": "Wähle nur eine Zeile",
"Select which pages to apply media folder filtering to:": "Wähle auf welchen Seiten die Medienordner gefiltert werden sollen:",
"Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.": "Sende Player-Updates an den Server. Dies wird von Servern wie Jellyfin und Navidrome vorrausgesetzt, um die Wiedergaben zu zählen und externe Dienste wie Last.fm zu nutzen.",
"Server": "Server",
"Server type": "Server Art",
"Session expired. Logging out.": "Sitzung abgelaufen. Abmeldung läuft.",
"Set rating": "Bewertung festlegen",
"Sets a dynamic background based on the currently playing song.": "Legt einen dynamischen Hintergrund fest, der auf dem aktuell gespielten Lied basiert.",
"Sets the parent media folder your audio files are located in. Leaving this blank will use all media folders.": "Legt den übergeordneten Medienordner fest, in dem sich die Audiodateien befinden. Ist dieses Feld leer werden alle Medienordner verwendet.",
"Show Debug Window": "Debug-Fenster anzeigen",
"SHOW LESS": "WENIGER ANZEIGEN",
"SHOW MORE": "MEHR ANZEIGEN",
"Shuffle": "Shuffeln",
"Shuffle queue": "Warteschlange Shuffeln",
"Size": "Größe",
"Song Count": "Song Anzahl",
"Songs": "Songs",
"Songs are cached only when playback for the track fully completes and ends. Skipping to the next or previous track after only partially completing the track will not begin the caching process.": "Titel werden nur dann gecached, wenn die Wiedergabe des Titels vollständig abgeschlossen ist. Springt man zum nächsten oder vorherigen Titel, bevor der Titel vollständig abgespielt ist, wird der Caching-Vorgang nicht gestartet.",
"Sort": "Sortieren",
"Sort Type": "Typ Sortieren",
"Start page": "Startseite",
"The alignment of cards in the grid view layout.": "Die Anordnung der Karten im Layout der Gitteransicht.",
"The application font.": "Die Schriftart in der Anwendung.",
"The application language.": "Die Sprache der Anwendung.",
"The application theme. Want to create your own themes? Check out the documentation <2>here</2>.": "Das Theme der Anwendung. Möchtest du deine eigenen Themes erstellen? Sieh dir <2>hier</2> die Dokumentation an.",
"The audio device for Sonixd. Leaving this blank will use the system default.": "Das Audiogerät für Sonixd. Ist diese Feld leer, wird die Systemvorgabe verwendet.",
"The client/application Id of the Sonixd discord application. To use your own, create one on the <2>developer application portal</2>. The large icon uses the name \"icon\". Default is 923372440934055968.": "Die Client/Applikations ID der Sonixd Discord Application. Um deine eigene zu verwenden, erstellst du eine auf dem <2>Entwickleranwendungsportal</2>. Das große Icon verwendet den Namen \"icon\". Standard ist 923372440934055968.",
"The default album page sort selection on application startup.": "Die standardmäßige Sortierung der Alben Seite beim Start der Anwendung.",
"The default song page sort selection on application startup.": "Die standardmäßige Sortierung der Song Seite beim Start der Anwendung.",
"The fade calculation to use when crossfading between two tracks. Enable the debug window to view the differences between each fade type.": "Die Überblendungsberechnung, die beim Überblenden zwischen zwei Tracks verwendet werden soll. Aktiviere das Debug-Fenster, um die Unterschiede zwischen den einzelnen Überblendungsarten zu sehen.",
"The full path to the directory where song metadata will be created.": "Der vollständige Pfad zu dem Verzeichnis, in dem die Song-Metadaten erstellt werden sollen.",
"The full URL to the Tuna webserver.": "Die vollständige URL zu dem Tuna Webserver.",
"The gap in pixels (px) of the grid view layout.": "Die Lücke in Pixeln (px) des Layouts der Gitteransicht.",
"The height in pixels (px) of each row in the list view.": "Die höhe in Pixeln (px) von jeder Zeile in der Listenansicht.",
"The number in milliseconds (ms) between each poll when metadata is sent.": "Die Zeit in Millisekunden (ms) zwischen den einzelnen Anfragen welche Metadaten senden.",
"The number in milliseconds between each poll when music is playing. This is used in the calculation for crossfading and gapless playback. Recommended value for gapless playback is between 10 and 20.": "Die Zeit in Millisekunden zwischen den einzelnen Abfragen bei der Musikwiedergabe. Dieser Wert wird für die Berechnung der Überblendung und der lückenlosen Wiedergabe verwendet. Der empfohlene Wert für die lückenlose Wiedergabe liegt zwischen 10 und 20.",
"The number in seconds before starting the crossfade to the next track. Setting this to 0 will enable gapless playback.": "Die Zeit in Sekunden, bevor die Überblendung zum nächsten Titel beginnt. Stellt man hier den Wert 0 ein, wird die lückenlose Wiedergabe aktiviert.",
"The number in seconds the player will skip backwards when clicking the seek backward button.": "Die Zeit in Sekunden, die der Player zurück springt, wenn man auf die Schaltfläche \"Zurückspulen\" klickt.",
"The number in seconds the player will skip forwards when clicking the seek forward button.": "Die Zeit in Sekunden, die der Player vorwärts springt, wenn man auf die Schaltfläche \"Vorspulen\" klickt.",
"The number of items that will be retrieved per page. Setting this to 0 will disable pagination.": "Die Anzahl der Elemente, die pro Seite abgerufen werden. Bei einem Wert von 0 wird der Seitenumbruch deaktiviert.",
"The titlebar style (requires app restart). ": "Das Aussehen der Titelleiste (erfordert einen Neustart)",
"The width and height in pixels (px) of each grid view card.": "Die Breite und Höhe in Pixeln (px) der einzelnen Karten der Gitteransicht.",
"Theme": "Theme",
"This is highly recommended!": "Dies ist streng empfohlen!",
"Title": "Titel",
"Title (Combined)": "Titel (Kombiniert)",
"Titlebar Style": "Aussehen der Titelleiste",
"To": "Bis",
"To year": "Zum Jahr",
"Toggle favorite": "Ent-/Favorisieren",
"Top Songs": "Top Songs",
"Track": "Track",
"Track #": "Track #",
"Track Count": "Track Anzahl",
"Track Filters": "Track Filter",
"Tracks": "Tracks",
"Tuna Webserver Url": "Tuna Webserver Url",
"Unable to clear cache item: {{error}}": "Cache-Element kann nicht gelöscht werden: {{error}}",
"Unable to scan directory: {{err}}": "Verzeichnis kann nicht gescannt werden: {{err}}",
"Unknown Album": "Unbekanntes Album",
"Unknown Artist": "Unbekannter Künstler",
"Unknown Title": "Unbekannter Titel",
"Username": "Nutzername",
"View All Songs": "Alle Songs anzeigen",
"View CHANGELOG": "CHANGELOG öffnen",
"View Discography": "Discographie anzeigen",
"View in folder": "In Ordner anzeigen",
"View in modal": "In Fenster anzeigen",
"View on GitHub": "Auf GitHub ansehen",
"Visibility": "Sichtbarkeit",
"Volume Fade": "Lautstärkeüberblendung",
"WARNING: This will reload the application": "WARNUNG: Dadurch wird die Anwendung neu geladen",
"Web": "Web",
"Which cache would you like to clear?": "Welcher Cache soll gelöscht werden?",
"Window": "Fenster",
"Windows System Media Transport Controls": "Windows System Media Transport Controls",
"Year": "Jahr",
"Years": "Jahre",
"Yes": "Ja"
}

341
src/i18n/locales/en.json

@ -0,0 +1,341 @@
{
" • Modified {{val, datetime}}": " • Modified {{val, datetime}}",
" albums": " albums",
"- Folder not found": "- Folder not found",
"(Paused)": "(Paused)",
"[{{albumTitle}}] No parent album found": "[{{albumTitle}}] No parent album found",
"* Drag & drop rows from the # column to re-order": "* Drag & drop rows from the # column to re-order",
"*You will need to manually move any existing cached files to their new location.": "*You will need to manually move any existing cached files to their new location.",
"# (Drag/Drop)": "# (Drag/Drop)",
"A-Z (Album Artist)": "A-Z (Album Artist)",
"A-Z (Album)": "A-Z (Album)",
"A-Z (Artist)": "A-Z (Artist)",
"A-Z (Name)": "A-Z (Name)",
"Add": "Add",
"Add (later)": "Add (later)",
"Add (next)": "Add (next)",
"Add playlist": "Add playlist",
"Add shuffled to queue": "Add shuffled to queue",
"Add to favorites": "Add to favorites",
"Add to playlist": "Add to playlist",
"Add to queue (later)": "Add to queue (later)",
"Add to queue (next)": "Add to queue (next)",
"Added {{n}} songs": "Added {{n}} songs",
"Added {{n}} songs [{{i}} filtered]": "Added {{n}} songs [{{i}} filtered]",
"Added {{songCount}} item(s) to playlist {{playlist}}": "Added {{songCount}} item(s) to playlist {{playlist}}",
"Added {{val, datetime}}": "Added {{val, datetime}}",
"Advanced": "Advanced",
"Album": "Album",
"ALBUM": "ALBUM",
"Album Count": "Album Count",
"Album images": "Album images",
"Albums": "Albums",
"Allow Transcoding": "Allow Transcoding",
"An unknown error occurred": "An unknown error occurred",
"Appears On ": "Appears On ",
"Are you sure you want to delete {{n}} playlist(s)?": "Are you sure you want to delete {{n}} playlist(s)?",
"Are you sure you want to delete this playlist?": "Are you sure you want to delete this playlist?",
"Are you sure you want to reset your settings to default?": "Are you sure you want to reset your settings to default?",
"Art": "Art",
"Artist": "Artist",
"Artist images": "Artist images",
"Artists": "Artists",
"ASC": "ASC",
"Audio Device": "Audio Device",
"Auto playlist": "Auto playlist",
"Auto scroll": "Auto scroll",
"Automatic Updates": "Automatic Updates",
"Bitrate": "Bitrate",
"by": "by",
"By {{dataOwner}} • ": "By {{dataOwner}} • ",
"Cache": "Cache",
"Card Size": "Card Size",
"Center": "Center",
"Clear cache": "Clear cache",
"Clear queue": "Clear queue",
"Cleared {{type}} image cache": "Cleared {{type}} image cache",
"Cleared song cache": "Cleared song cache",
"Clickable": "Clickable",
"Client/Application Id": "Client/Application Id",
"Collapse": "Collapse",
"Column": "Column",
"Config": "Config",
"Config for integration with external programs.": "Config for integration with external programs.",
"Confirm": "Confirm",
"Connect": "Connect",
"Constant Power": "Constant Power",
"Constant Power (fast cut)": "Constant Power (fast cut)",
"Constant Power (slow cut)": "Constant Power (slow cut)",
"Constant Power (slow fade)": "Constant Power (slow fade)",
"Copy to clipboard": "Copy to clipboard",
"CoverArt": "CoverArt",
"Create": "Create",
"Create new playlist": "Create new playlist",
"Created": "Created",
"Created {{val, datetime}}": "Created {{val, datetime}}",
"Crossfade Duration (s)": "Crossfade Duration (s)",
"Crossfade Type": "Crossfade Type",
"Current version:": "Current version:",
"Dashboard": "Dashboard",
"Default": "Default",
"Default Album Sort": "Default Album Sort",
"Default Song Sort": "Default Song Sort",
"Delete": "Delete",
"Delete playlist(s)": "Delete playlist(s)",
"Deleted {{n}} playlists": "Deleted {{n}} playlists",
"DESC": "DESC",
"Description": "Description",
"Deselect All": "Deselect All",
"Dipped": "Dipped",
"Disconnect": "Disconnect",
"Discord Client Id": "Discord Client Id",
"Displays the debug window.": "Displays the debug window.",
"Do you want to restart the application now?": "Do you want to restart the application now?",
"Don't know where to start? Apply a preset and tweak from there.": "Don't know where to start? Apply a preset and tweak from there.",
"Download": "Download",
"Download ({{downloadSize}})": "Download ({{downloadSize}})",
"Download links copied!": "Download links copied!",
"Duration": "Duration",
"Dynamic Background": "Dynamic Background",
"Edit": "Edit",
"Edit cache location": "Edit cache location",
"Enable or disable global media hotkeys (play/pause, next, previous, stop, etc). For macOS, you will need to add Sonixd as a <2>trusted accessibility client.</2>": "Enable or disable global media hotkeys (play/pause, next, previous, stop, etc). For macOS, you will need to add Sonixd as a <2>trusted accessibility client.</2>",
"Enable or disable the volume fade used by the crossfading players. If disabled, the fading in track will start at full volume.": "Enable or disable the volume fade used by the crossfading players. If disabled, the fading in track will start at full volume.",
"Enable or disable the Windows System Media Transport Controls (play/pause, next, previous, stop). This will show the Windows Media Popup (Windows 10 only) when pressing a media key. This feauture will override the Global Media Hotkeys option.": "Enable or disable the Windows System Media Transport Controls (play/pause, next, previous, stop). This will show the Windows Media Popup (Windows 10 only) when pressing a media key. This feauture will override the Global Media Hotkeys option.",
"Enabled": "Enabled",
"Enables or disables automatic updates. When a new version is detected, it will automatically be downloaded and installed.": "Enables or disables automatic updates. When a new version is detected, it will automatically be downloaded and installed.",
"Enter name...": "Enter name...",
"Enter password": "Enter password",
"Enter regex string": "Enter regex string",
"Enter username": "Enter username",
"Equal Power": "Equal Power",
"Error adding to playlist": "Error adding to playlist",
"Error fetching audio devices": "Error fetching audio devices",
"Error saving playlist": "Error saving playlist",
"Error: {{error}}": "Error: {{error}}",
"Errored while saving playlist": "Errored while saving playlist",
"Exit to Tray": "Exit to Tray",
"Exits to the system tray.": "Exits to the system tray.",
"Expand": "Expand",
"External": "External",
"Fade": "Fade",
"Fav": "Fav",
"Favorite": "Favorite",
"Favorites": "Favorites",
"File Path": "File Path",
"Filter": "Filter",
"Filter out tracks based on regex string(s) by their title when adding to the queue. Adding by double-clicking a track will ignore all filters for that one track.": "Filter out tracks based on regex string(s) by their title when adding to the queue. Adding by double-clicking a track will ignore all filters for that one track.",
"Folder images": "Folder images",
"Folders": "Folders",
"Font": "Font",
"Font Size {{type}}": "Font Size {{type}}",
"From": "From",
"From year": "From year",
"Gap Size": "Gap Size",
"Gapless": "Gapless",
"Genre": "Genre",
"Genres": "Genres",
"Global Media Hotkeys": "Global Media Hotkeys",
"Go": "Go",
"Go to playlist": "Go to playlist",
"Go up": "Go up",
"Grid Alignment": "Grid Alignment",
"Grid View": "Grid View",
"Highlight On Hover": "Highlight On Hover",
"Highlights the list view row when hovering it with the mouse.": "Highlights the list view row when hovering it with the mouse.",
"How many tracks? (1-500)*": "How many tracks? (1-500)*",
"If local, scrobbles the currently playing song to local .txt files. If web, scrobbles the currently playing song to Tuna plugin's webserver.": "If local, scrobbles the currently playing song to local .txt files. If web, scrobbles the currently playing song to Tuna plugin's webserver.",
"If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).": "If your audio files are not playing properly or are not in a supported web streaming format, you will need to enable this (requires app restart).",
"Images": "Images",
"Integrates with Discord's rich presence to display the currently playing song as your status.": "Integrates with Discord's rich presence to display the currently playing song as your status.",
"Items per page (Songs)": "Items per page (Songs)",
"Language": "Language",
"Latest Albums ": "Latest Albums ",
"Latest version:": "Latest version:",
"Left": "Left",
"Legacy auth (plaintext)": "Legacy auth (plaintext)",
"Light": "Light",
"Linear": "Linear",
"List View": "List View",
"Loading...": "Loading...",
"Local": "Local",
"Location:": "Location:",
"Look & Feel": "Look & Feel",
"Media Folder": "Media Folder",
"Medium": "Medium",
"Mini": "Mini",
"Mini-player": "Mini-player",
"Minimize to Tray": "Minimize to Tray",
"Minimizes to the system tray.": "Minimizes to the system tray.",
"Miniplayer": "Miniplayer",
"Modified": "Modified",
"Most Played": "Most Played",
"Move down": "Move down",
"Move selected down": "Move selected down",
"Move selected to [...]": "Move selected to [...]",
"Move selected to bottom": "Move selected to bottom",
"Move selected to top": "Move selected to top",
"Move selected up": "Move selected up",
"Move to index": "Move to index",
"Move up": "Move up",
"Music folder": "Music folder",
"Muted": "Muted",
"Name": "Name",
"Native": "Native",
"Next Track": "Next Track",
"No Album Playing": "No Album Playing",
"No parent album found": "No parent album found",
"No songs found, adjust your filters": "No songs found, adjust your filters",
"No Track Playing": "No Track Playing",
"None": "None",
"Note: These settings may not function correctly depending on your desktop environment.": "Note: These settings may not function correctly depending on your desktop environment.",
"Now Playing": "Now Playing",
"Ok": "Ok",
"Opacity": "Opacity",
"Open settings JSON": "Open settings JSON",
"Other": "Other",
"Owner": "Owner",
"Page Sonixd will display on start.": "Page Sonixd will display on start.",
"Pagination": "Pagination",
"Password": "Password",
"Path": "Path",
"Path: {{newCachePath}} not found. Enter a valid path.": "Path: {{newCachePath}} not found. Enter a valid path.",
"Play": "Play",
"Play Artist Mix": "Play Artist Mix",
"Play Compilation Albums": "Play Compilation Albums",
"Play Count": "Play Count",
"Play Latest Albums": "Play Latest Albums",
"Play Top Songs": "Play Top Songs",
"Play/Pause": "Play/Pause",
"Playback": "Playback",
"Playback Presets": "Playback Presets",
"Player": "Player",
"Playing {{n}} songs": "Playing {{n}} songs",
"Playing {{n}} songs [{{i}} filtered]": "Playing {{n}} songs [{{i}} filtered]",
"PLAYLIST": "PLAYLIST",
"Playlist \"{{newPlaylistName}}\" created!": "Playlist \"{{newPlaylistName}}\" created!",
"Playlist images": "Playlist images",
"Playlists": "Playlists",
"Plays": "Plays",
"Polling Interval": "Polling Interval",
"Previous Track": "Previous Track",
"Private": "Private",
"Public": "Public",
"Random": "Random",
"Rate": "Rate",
"Rating": "Rating",
"Recently Added": "Recently Added",
"Recently Played": "Recently Played",
"Recover playlist": "Recover playlist",
"Recovered playlist from backup": "Recovered playlist from backup",
"Refresh": "Refresh",
"Regular": "Regular",
"Related Artists ": "Related Artists ",
"Release Date": "Release Date",
"Remove from favorites": "Remove from favorites",
"Remove selected": "Remove selected",
"Repeat": "Repeat",
"Repeat all": "Repeat all",
"Repeat one": "Repeat one",
"Requires http(s)://": "Requires http(s)://",
"Reset": "Reset",
"Reset defaults": "Reset defaults",
"Reset to default": "Reset to default",
"Resizable": "Resizable",
"Restart?": "Restart?",
"Rich Presence": "Rich Presence",
"Row Height {{type}}": "Row Height {{type}}",
"Save": "Save",
"Save (WARNING: Closing the application while saving may result in data loss)": "Save (WARNING: Closing the application while saving may result in data loss)",
"Saved playlist": "Saved playlist",
"Scan": "Scan",
"Scrobble": "Scrobble",
"Scrobbling": "Scrobbling",
"Search": "Search",
"Search: {{urlQuery}}": "Search: {{urlQuery}}",
"Seek backward": "Seek backward",
"Seek Backward": "Seek Backward",
"Seek forward": "Seek forward",
"Seek Forward": "Seek Forward",
"Select": "Select",
"Select a folder": "Select a folder",
"Select only one row": "Select only one row",
"Select which pages to apply media folder filtering to:": "Select which pages to apply media folder filtering to:",
"Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.": "Send player updates to your server. This is required by servers such as Jellyfin and Navidrome to track play counts and use external services such as Last.fm.",
"Server": "Server",
"Server type": "Server type",
"Session expired. Logging out.": "Session expired. Logging out.",
"Set rating": "Set rating",
"Sets a dynamic background based on the currently playing song.": "Sets a dynamic background based on the currently playing song.",
"Sets the parent media folder your audio files are located in. Leaving this blank will use all media folders.": "Sets the parent media folder your audio files are located in. Leaving this blank will use all media folders.",
"Show Debug Window": "Show Debug Window",
"SHOW LESS": "SHOW LESS",
"SHOW MORE": "SHOW MORE",
"Shuffle": "Shuffle",
"Shuffle queue": "Shuffle queue",
"Size": "Size",
"Song Count": "Song Count",
"Songs": "Songs",
"Songs are cached only when playback for the track fully completes and ends. Skipping to the next or previous track after only partially completing the track will not begin the caching process.": "Songs are cached only when playback for the track fully completes and ends. Skipping to the next or previous track after only partially completing the track will not begin the caching process.",
"Sort": "Sort",
"Sort Type": "Sort Type",
"Start page": "Start page",
"The alignment of cards in the grid view layout.": "The alignment of cards in the grid view layout.",
"The application font.": "The application font.",
"The application language.": "The application language.",
"The application theme. Want to create your own themes? Check out the documentation <2>here</2>.": "The application theme. Want to create your own themes? Check out the documentation <2>here</2>.",
"The audio device for Sonixd. Leaving this blank will use the system default.": "The audio device for Sonixd. Leaving this blank will use the system default.",
"The client/application Id of the Sonixd discord application. To use your own, create one on the <2>developer application portal</2>. The large icon uses the name \"icon\". Default is 923372440934055968.": "The client/application Id of the Sonixd discord application. To use your own, create one on the <2>developer application portal</2>. The large icon uses the name \"icon\". Default is 923372440934055968.",
"The default album page sort selection on application startup.": "The default album page sort selection on application startup.",
"The default song page sort selection on application startup.": "The default song page sort selection on application startup.",
"The fade calculation to use when crossfading between two tracks. Enable the debug window to view the differences between each fade type.": "The fade calculation to use when crossfading between two tracks. Enable the debug window to view the differences between each fade type.",
"The full path to the directory where song metadata will be created.": "The full path to the directory where song metadata will be created.",
"The full URL to the Tuna webserver.": "The full URL to the Tuna webserver.",
"The gap in pixels (px) of the grid view layout.": "The gap in pixels (px) of the grid view layout.",
"The height in pixels (px) of each row in the list view.": "The height in pixels (px) of each row in the list view.",
"The number in milliseconds (ms) between each poll when metadata is sent.": "The number in milliseconds (ms) between each poll when metadata is sent.",
"The number in milliseconds between each poll when music is playing. This is used in the calculation for crossfading and gapless playback. Recommended value for gapless playback is between 10 and 20.": "The number in milliseconds between each poll when music is playing. This is used in the calculation for crossfading and gapless playback. Recommended value for gapless playback is between 10 and 20.",
"The number in seconds before starting the crossfade to the next track. Setting this to 0 will enable gapless playback.": "The number in seconds before starting the crossfade to the next track. Setting this to 0 will enable gapless playback.",
"The number in seconds the player will skip backwards when clicking the seek backward button.": "The number in seconds the player will skip backwards when clicking the seek backward button.",
"The number in seconds the player will skip forwards when clicking the seek forward button.": "The number in seconds the player will skip forwards when clicking the seek forward button.",
"The number of items that will be retrieved per page. Setting this to 0 will disable pagination.": "The number of items that will be retrieved per page. Setting this to 0 will disable pagination.",
"The titlebar style (requires app restart). ": "The titlebar style (requires app restart). ",
"The width and height in pixels (px) of each grid view card.": "The width and height in pixels (px) of each grid view card.",
"Theme": "Theme",
"This is highly recommended!": "This is highly recommended!",
"Title": "Title",
"Title (Combined)": "Title (Combined)",
"Titlebar Style": "Titlebar Style",
"To": "To",
"To year": "To year",
"Toggle favorite": "Toggle favorite",
"Top Songs": "Top Songs",
"Track": "Track",
"Track #": "Track #",
"Track Count": "Track Count",
"Track Filters": "Track Filters",
"Tracks": "Tracks",
"Tuna Webserver Url": "Tuna Webserver Url",
"Unable to clear cache item: {{error}}": "Unable to clear cache item: {{error}}",
"Unable to scan directory: {{err}}": "Unable to scan directory: {{err}}",
"Unknown Album": "Unknown Album",
"Unknown Artist": "Unknown Artist",
"Unknown Title": "Unknown Title",
"Username": "Username",
"View All Songs": "View All Songs",
"View CHANGELOG": "View CHANGELOG",
"View Discography": "View Discography",
"View in folder": "View in folder",
"View in modal": "View in modal",
"View on GitHub": "View on GitHub",
"Visibility": "Visibility",
"Volume Fade": "Volume Fade",
"WARNING: This will reload the application": "WARNING: This will reload the application",
"Web": "Web",
"Which cache would you like to clear?": "Which cache would you like to clear?",
"Window": "Window",
"Windows System Media Transport Controls": "Windows System Media Transport Controls",
"Year": "Year",
"Years": "Years",
"Yes": "Yes"
}

1
src/index.tsx

@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { HelmetProvider } from 'react-helmet-async';
import { store } from './redux/store';
import './i18n/i18n';
import App from './App';
const queryClient = new QueryClient({

18
src/main.dev.js

@ -12,7 +12,6 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import Player from 'mpris-service';
import path from 'path';
import os from 'os';
import settings from 'electron-settings';
import { ipcMain, app, BrowserWindow, shell, globalShortcut, Menu, Tray } from 'electron';
import electronLocalshortcut from 'electron-localshortcut';
@ -20,6 +19,7 @@ import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
import { configureStore } from '@reduxjs/toolkit';
import { forwardToRenderer, triggerAlias, replayActionMain } from 'electron-redux';
import i18next from 'i18next';
import playerReducer, { setStatus } from './redux/playerSlice';
import playQueueReducer, {
decrementCurrentIndex,
@ -369,8 +369,8 @@ if (isWindows() && isWindows10()) {
Controls.displayUpdater.type = windowsMedia.MediaPlaybackType.music;
Controls.displayUpdater.musicProperties.title = 'Sonixd';
Controls.displayUpdater.musicProperties.artist = 'No Track Playing';
Controls.displayUpdater.musicProperties.albumTitle = 'No Album Playing';
Controls.displayUpdater.musicProperties.artist = i18next.t('No Track Playing');
Controls.displayUpdater.musicProperties.albumTitle = i18next.t('No Album Playing');
Controls.displayUpdater.update();
Controls.on('buttonpressed', (sender, eventArgs) => {
@ -411,12 +411,12 @@ if (isWindows() && isWindows10()) {
Controls.playbackStatus = windowsMedia.MediaPlaybackStatus.playing;
}
Controls.displayUpdater.musicProperties.title = arg.title || 'Unknown Title';
Controls.displayUpdater.musicProperties.title = arg.title || i18next.t('Unknown Title');
Controls.displayUpdater.musicProperties.artist =
arg.artist?.length !== 0
? arg.artist?.map((artist) => artist.title).join(', ')
: 'Unknown Artist';
Controls.displayUpdater.musicProperties.albumTitle = arg.album || 'Unknown Album';
: i18next.t('Unknown Artist');
Controls.displayUpdater.musicProperties.albumTitle = arg.album || i18next.t('Unknown Album');
if (arg.image.includes('placeholder')) {
windowsStorage.StorageFile.getFileFromPathAsync(
@ -447,17 +447,17 @@ const createWinThumbarButtons = () => {
if (isWindows()) {
mainWindow.setThumbarButtons([
{
tooltip: 'Previous Track',
tooltip: i18next.t('Previous Track'),
icon: getAssetPath('skip-previous.png'),
click: () => previousTrack(),
},
{
tooltip: 'Play/Pause',
tooltip: i18next.t('Play/Pause'),
icon: getAssetPath('play-circle.png'),
click: () => playPause(),
},
{
tooltip: 'Next Track',
tooltip: i18next.t('Next Track'),
icon: getAssetPath('skip-next.png'),
click: () => {
nextTrack();

99
src/shared/mockSettings.ts

@ -1,6 +1,9 @@
import i18next from 'i18next';
export const mockSettings = {
serverType: 'subsonic',
autoUpdate: true,
language: 'en',
theme: 'defaultDark',
showDebugWindow: false,
globalMediaHotkeys: true,
@ -59,44 +62,44 @@ export const mockSettings = {
label: '# (Drag/Drop)',
},
{
id: 'Title',
id: i18next.t('Title'),
dataKey: 'combinedtitle',
alignment: 'left',
resizable: true,
width: 273,
label: 'Title (Combined)',
label: i18next.t('Title (Combined)'),
},
{
id: 'Album',
id: i18next.t('Album'),
dataKey: 'album',
alignment: 'left',
resizable: true,
width: 263,
label: 'Album',
label: i18next.t('Album'),
},
{
id: 'Duration',
id: i18next.t('Duration'),
dataKey: 'duration',
alignment: 'center',
resizable: true,
width: 110,
label: 'Duration',
label: i18next.t('Duration'),
},
{
id: 'Bitrate',
id: i18next.t('Bitrate'),
dataKey: 'bitRate',
alignment: 'left',
resizable: true,
width: 72,
label: 'Bitrate',
label: i18next.t('Bitrate'),
},
{
id: 'Fav',
id: i18next.t('Fav'),
dataKey: 'starred',
alignment: 'center',
resizable: true,
width: 100,
label: 'Favorite',
label: i18next.t('Favorite'),
},
],
albumListFontSize: 14,
@ -111,36 +114,36 @@ export const mockSettings = {
label: '#',
},
{
id: 'Title',
id: i18next.t('Title'),
dataKey: 'combinedtitle',
alignment: 'left',
resizable: true,
width: 457,
label: 'Title (Combined)',
label: i18next.t('Title (Combined)'),
},
{
id: 'Tracks',
id: i18next.t('Tracks'),
dataKey: 'songCount',
alignment: 'center',
resizable: true,
width: 100,
label: 'Track Count',
label: i18next.t('Track Count'),
},
{
id: 'Duration',
id: i18next.t('Duration'),
dataKey: 'duration',
alignment: 'center',
resizable: true,
width: 80,
label: 'Duration',
label: i18next.t('Duration'),
},
{
id: 'Fav',
id: i18next.t('Fav'),
dataKey: 'starred',
alignment: 'center',
resizable: true,
width: 100,
label: 'Favorite',
label: i18next.t('Favorite'),
},
],
artistListFontSize: 14,
@ -156,36 +159,36 @@ export const mockSettings = {
uniqueId: 'bOCYMfNieUHtjl1XhM-GT',
},
{
id: 'Art',
id: i18next.t('Art'),
dataKey: 'coverart',
alignment: 'center',
resizable: true,
width: 74,
label: 'CoverArt',
label: i18next.t('CoverArt'),
uniqueId: '2Z8rUZi47VnlQSBfZzRk8',
},
{
id: 'Name',
id: i18next.t('Name'),
dataKey: 'name',
alignment: 'left',
flexGrow: 5,
label: 'Name',
label: i18next.t('Name'),
uniqueId: 'Vv_luiyR3rp5b07Szd0zd',
},
{
id: 'Album Count',
id: i18next.t('Album Count'),
dataKey: 'albumCount',
alignment: 'left',
flexGrow: 1,
label: 'Album Count',
label: i18next.t('Album Count'),
uniqueId: 'IScD9714XLFrQYSAFkmoL',
},
{
id: 'Fav',
id: i18next.t('Fav'),
dataKey: 'starred',
alignment: 'center',
flexGrow: 1,
label: 'Favorite',
label: i18next.t('Favorite'),
uniqueId: 'eFrudHQBTnBXNuD3mL-c1',
},
],
@ -202,27 +205,27 @@ export const mockSettings = {
uniqueId: 'ZXNE6gsaLm0kVRyueOBHS',
},
{
id: 'Name',
id: i18next.t('Name'),
dataKey: 'name',
alignment: 'left',
flexGrow: 5,
label: 'Name',
label: i18next.t('Name'),
uniqueId: 'FTY1gWAjc0i6NVjim_8aZ',
},
{
id: 'Album Count',
id: i18next.t('Album Count'),
dataKey: 'albumCount',
alignment: 'left',
flexGrow: 1,
label: 'Album Count',
label: i18next.t('Album Count'),
uniqueId: 'oHqG0mGN_E7iLairZGnZL',
},
{
id: 'Tracks',
id: i18next.t('Tracks'),
dataKey: 'songCount',
alignment: 'center',
flexGrow: 1,
label: 'Track Count',
label: i18next.t('Track Count'),
uniqueId: 'c1qxv4S5YC7YUvbOG_WJF',
},
],
@ -238,52 +241,52 @@ export const mockSettings = {
label: '#',
},
{
id: 'Art',
id: i18next.t('Art'),
dataKey: 'coverart',
alignment: 'center',
resizable: true,
width: 100,
label: 'CoverArt',
label: i18next.t('CoverArt'),
},
{
id: 'Title',
id: i18next.t('Title'),
dataKey: 'name',
alignment: 'left',
resizable: true,
width: 300,
label: 'Title',
label: i18next.t('Title'),
},
{
id: 'Description',
id: i18next.t('Description'),
dataKey: 'comment',
alignment: 'left',
resizable: true,
width: 200,
label: 'Description',
label: i18next.t('Description'),
},
{
id: 'Tracks',
id: i18next.t('Tracks'),
dataKey: 'songCount',
alignment: 'center',
resizable: true,
width: 100,
label: 'Track Count',
label: i18next.t('Track Count'),
},
{
id: 'Owner',
id: i18next.t('Owner'),
dataKey: 'owner',
alignment: 'left',
resizable: true,
width: 150,
label: 'Owner',
label: i18next.t('Owner'),
},
{
id: 'Modified',
id: i18next.t('Modified'),
dataKey: 'changed',
alignment: 'left',
resizable: true,
width: 100,
label: 'Modified',
label: i18next.t('Modified'),
},
],
miniListFontSize: 14,
@ -298,20 +301,20 @@ export const mockSettings = {
label: '# (Drag/Drop)',
},
{
id: 'Title',
id: i18next.t('Title'),
dataKey: 'title',
alignment: 'left',
resizable: true,
width: 250,
label: 'Title',
label: i18next.t('Title'),
},
{
id: 'Duration',
id: i18next.t('Duration'),
dataKey: 'duration',
alignment: 'center',
resizable: true,
width: 80,
label: 'Duration',
label: i18next.t('Duration'),
},
],
font: 'Poppins',

15
src/shared/utils.ts

@ -5,6 +5,7 @@ import path from 'path';
import moment from 'moment';
import arrayMove from 'array-move';
import settings from 'electron-settings';
import i18next from 'i18next';
import { mockSettings } from './mockSettings';
const download = require('image-downloader');
@ -479,17 +480,23 @@ export const getPlayedSongsNotification = (options: {
}) => {
if (options.type === 'play') {
if (options.original === options.filtered) {
return `Playing ${options.original} songs`;
return i18next.t('Playing {{n}} songs', { n: options.original });
}
return `Playing ${options.filtered} songs [${options.original - options.filtered} filtered]`;
return i18next.t('Playing {{n}} songs [{{i}} filtered]', {
n: options.filtered,
i: options.original - options.filtered,
});
}
if (options.original === options.filtered) {
return `Added ${options.original} songs`;
return i18next.t('Added {{n}} songs', { n: options.original });
}
return `Added ${options.filtered} songs [${options.original - options.filtered} filtered]`;
return i18next.t('Added {{n}} songs [{{i}} filtered]', {
n: options.filtered,
i: options.original - options.filtered,
});
};
export const getUniqueRandomNumberArr = (count: number, maxRange: number) => {

774
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save