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 = () => {
l