feat: add i18n support to web app

This commit is contained in:
Christian Benincasa
2026-04-11 11:54:27 -04:00
parent 515729d370
commit c814a14d1d
30 changed files with 1621 additions and 484 deletions

1266
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

148
web/docs/i18n.md Normal file
View File

@@ -0,0 +1,148 @@
# Internationalization (i18n) Guide
Tunarr's web app uses [Lingui v5](https://lingui.dev/) for internationalization.
## Overview
- **Library:** Lingui v5.9.0 with SWC plugin and Vite plugin
- **Source locale:** English (`en`) — all source strings are written in English
- **Catalog format:** GNU `.po` files (human-editable, compatible with standard translation tools)
- **Runtime:** Lingui compiles `.po` files to `.ts` modules via `pnpm compile-messages`
## Adding Translatable Strings
### JSX text content — use `<Trans>`
```tsx
import { Trans } from '@lingui/react/macro';
<Typography><Trans>Settings</Trans></Typography>
```
### String props (labels, placeholders, aria attributes) — use `t` from `useLingui()`
```tsx
import { useLingui } from '@lingui/react/macro';
function MyComponent() {
const { t } = useLingui();
return <TextField placeholder={t`Server Port`} />;
}
```
### Plurals — use `<Plural>` or `plural()`
```tsx
import { Plural } from '@lingui/react/macro';
<Plural value={count} one="# channel" other="# channels" />
```
```ts
import { plural } from '@lingui/core/macro';
const msg = plural(count, { one: '# item', other: '# items' });
```
### Interpolation
```tsx
<Trans>Delete "{name}"?</Trans>
```
### Non-component code (snackbar messages, etc.)
```tsx
import { useLingui } from '@lingui/react/macro';
const { t } = useLingui();
enqueueSnackbar(t`Settings saved`);
```
## Extraction & Compilation Workflow
1. **Wrap strings** with `<Trans>`, `t`, `<Plural>`, etc.
2. **Extract** new strings into `.po` catalog files:
```bash
cd web && pnpm extract-messages
```
This updates `src/locales/en/messages.po` and creates/updates all other locale `.po` files.
3. **Translate** the new `msgid` entries in each locale's `.po` file (leave `msgstr` empty to fall back to English automatically).
4. **Compile** catalogs to TypeScript:
```bash
cd web && pnpm compile-messages
```
5. **Commit** both the `.po` source files and compiled `.ts` files.
## Adding a New Language
1. **Update `lingui.config.ts`** — add the locale code to the `locales` array and add a fallback:
```ts
locales: ['en', 'es', 'fr', 'pseudo-LOCALE'],
fallbackLocales: {
fr: 'en',
// ...
},
```
2. **Update `SupportedLocales`** in `web/src/store/settings/store.ts`:
```ts
export type SupportedLocales = 'en' | 'es' | 'fr' | 'pseudo-LOCALE';
```
3. **Add to the language picker** in `web/src/components/settings/general/WebSettings.tsx`:
```tsx
<MenuItem value="fr">Français</MenuItem>
```
4. **Add to `LINGUI_TO_DAYJS`** in `web/src/helpers/localeLoader.ts`:
```ts
export const LINGUI_TO_DAYJS: Record<string, string> = {
en: 'en',
es: 'es',
fr: 'fr',
'pseudo-LOCALE': 'en',
};
```
5. **Add MUI locale** in `web/src/Tunarr.tsx` if MUI ships a locale for it:
```tsx
import { frFR as muiFrFR } from '@mui/material/locale';
import { frFR as pickersFrFR } from '@mui/x-date-pickers/locales';
// ...
if (locale === 'fr') return createTheme(Theme, muiFrFR, pickersFrFR);
```
6. **Run extraction and compilation:**
```bash
cd web
pnpm extract-messages # creates src/locales/fr/messages.po
# translate the .po file
pnpm compile-messages # generates src/locales/fr/messages.ts
```
## Testing with pseudo-LOCALE
`pseudo-LOCALE` is a development locale that transforms all translatable strings into a visually distinct format (e.g., adds diacritics). Any string that is **not** pseudo-localized when `pseudo-LOCALE` is active is missing i18n wrapping.
To activate it in the running app:
1. Go to **Settings → Web Settings**
2. Select **pseudo-LOCALE (dev)** in the Language dropdown (only visible in dev mode)
Scan all pages for any plain English text — those strings need `<Trans>` or `t` wrapping.
## Architecture
```
Zustand store (browser-local)
└── settings.ui.i18n.locale → controls Lingui catalog + dayjs locale + MUI locale
└── settings.ui.i18n.timeFormat → controls dayjs time display (12h / 24h / auto)
```
- **`setUiLocale(locale)`** — async action that loads the Lingui catalog and dayjs locale module, then updates the store. Components re-render automatically.
- **`setTimeFormat(format)`** — async action that loads `dayjs/locale/en-gb` when needed (for 24h), then updates the store.
- **`DayjsProvider`** — derives the effective dayjs locale from `locale` + `timeFormat` (auto → language native, 12h → en, 24h → en-gb) and sets it globally.
- **`Tunarr.tsx`** — loads English synchronously on startup (preventing blank text), then loads the persisted locale asynchronously via `useEffect`.
- **`I18nProvider`** — wraps the entire app so all `<Trans>` / `t` macros access the active catalog.
Locale preference is stored in the browser (Zustand + localStorage). Server-side locale storage is intentionally not implemented — Tunarr is primarily a single-user/household application.

18
web/lingui.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from '@lingui/cli';
export default defineConfig({
sourceLocale: 'en',
locales: ['en', 'es', 'pseudo-LOCALE'],
pseudoLocale: 'pseudo-LOCALE',
fallbackLocales: {
es: 'en',
'pseudo-LOCALE': 'en',
default: 'en',
},
catalogs: [
{
path: '<rootDir>/src/locales/{locale}/messages',
include: ['src'],
},
],
});

View File

@@ -6,10 +6,12 @@
"scripts": {
"build": "tsc -p tsconfig.build.json --noEmit",
"build-dev": "tsc -p tsconfig.build.json --noEmit --watch",
"regen-routes": "tsr generate",
"generate-client": "openapi-ts",
"bundle": "vite build",
"dev": "vite",
"extract-messages": "lingui extract",
"compile-messages": "lingui compile --typescript",
"regen-routes": "tsr generate",
"generate-client": "openapi-ts",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest --run",
@@ -22,6 +24,8 @@
"@emotion/styled": "^11.14.0",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^5.2.2",
"@lingui/core": "^5.9.0",
"@lingui/react": "^5.9.0",
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
"@mui/x-date-pickers": "^8.4.0",
@@ -66,6 +70,9 @@
"@tanstack/react-devtools": "^0.9.4",
"@tanstack/react-form-devtools": "^0.2.13",
"@tanstack/react-router-devtools": "^1.158.1",
"@lingui/cli": "^5.9.0",
"@lingui/swc-plugin": "^5.10.1",
"@lingui/vite-plugin": "^5.9.0",
"@tanstack/react-table": "8.19.3",
"@tanstack/router-cli": "^1.35.4",
"@tanstack/router-vite-plugin": "^1.133.13",
@@ -81,7 +88,7 @@
"@types/uuid": "^9.0.6",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react-swc": "^3.11.0",
"@vitejs/plugin-react-swc": "^4.2.3",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",

View File

@@ -1,3 +1,4 @@
import { Trans } from '@lingui/react/macro';
import { Alert, Box, Toolbar, useTheme } from '@mui/material';
import Container from '@mui/material/Container';
import CssBaseline from '@mui/material/CssBaseline';
@@ -13,8 +14,6 @@ import { TopBar } from './components/TopBar.tsx';
import { useServerEventsSnackbar } from './hooks/useServerEvents.ts';
import { useIsDarkMode } from './hooks/useTunarrTheme.ts';
import { useVersion } from './hooks/useVersion.tsx';
import { strings } from './strings.ts';
export function Root({ children }: { children?: React.ReactNode }) {
useServerEventsSnackbar();
@@ -75,7 +74,11 @@ export function Root({ children }: { children?: React.ReactNode }) {
</RouterButtonLink>
}
>
{strings.FFMPEG_MISSING}
<Trans>
FFmpeg not found. For all features to work, we recommend
installing FFmpeg 7.1+ or update your FFmpeg executable path in
settings.
</Trans>
</Alert>
) : null}
{children ?? <Outlet />}
@@ -88,7 +91,9 @@ export function Root({ children }: { children?: React.ReactNode }) {
export default function App() {
return (
<>
<RouterLink to={'/channels'}>Channels</RouterLink>
<RouterLink to={'/channels'}>
<Trans>Channels</Trans>
</RouterLink>
</>
);
}

View File

@@ -1,39 +1,67 @@
import { setUiLocale } from '@/store/settings/actions.ts';
import { DayjsProvider } from '@/providers/DayjsProvider.tsx';
import useStore from '@/store/index.ts';
import { ThemeProvider } from '@mui/material';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { ThemeProvider, createTheme } from '@mui/material';
import { esES as muiEsES } from '@mui/material/locale';
import { esES as pickersEsES } from '@mui/x-date-pickers/locales';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { SnackbarProvider } from 'notistack';
import { useEffect, useMemo } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ServerEventsProvider } from './components/server_events/ServerEventsProvider.tsx';
import { TunarrApiProvider } from './context/TunarrApiContext.tsx';
import { messages as enMessages } from './locales/en/messages';
import { queryClient } from './queryClient.ts';
import { router } from './router.ts';
import { Theme } from './theme.ts';
// Load English immediately as a synchronous fallback so the first render
// always has text (no blank flash or message IDs).
i18n.loadAndActivate({ locale: 'en', messages: enMessages });
export const Tunarr = () => {
const locale = useStore((store) => store.settings.ui.i18n.locale);
// On mount and whenever the stored locale changes, load the catalog and
// dayjs locale module. setUiLocale handles both; the English catalog is
// already loaded above so 'en' is effectively idempotent.
useEffect(() => {
void setUiLocale(locale);
}, [locale]);
const muiLocaleTheme = useMemo(() => {
if (locale === 'es') {
return createTheme(Theme, muiEsES, pickersEsES);
}
return Theme;
}, [locale]);
return (
<TunarrApiProvider queryClient={queryClient}>
<DayjsProvider>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale}>
<DndProvider backend={HTML5Backend}>
<ServerEventsProvider>
<QueryClientProvider client={queryClient}>
<SnackbarProvider maxSnack={2} autoHideDuration={5000}>
<ThemeProvider theme={Theme} noSsr>
<RouterProvider basepath="/web" router={router} />
</ThemeProvider>
</SnackbarProvider>
</QueryClientProvider>
</ServerEventsProvider>
</DndProvider>
</LocalizationProvider>
</DayjsProvider>
<I18nProvider i18n={i18n}>
<DayjsProvider>
<LocalizationProvider
dateAdapter={AdapterDayjs}
>
<DndProvider backend={HTML5Backend}>
<ServerEventsProvider>
<QueryClientProvider client={queryClient}>
<SnackbarProvider maxSnack={2} autoHideDuration={5000}>
<ThemeProvider theme={muiLocaleTheme} noSsr>
<RouterProvider basepath="/web" router={router} />
</ThemeProvider>
</SnackbarProvider>
</QueryClientProvider>
</ServerEventsProvider>
</DndProvider>
</LocalizationProvider>
</DayjsProvider>
</I18nProvider>
</TunarrApiProvider>
);
};

View File

@@ -16,7 +16,7 @@ import { useCyclicShuffle } from '../../hooks/programming_controls/useCyclicShuf
import { useEpisodeNumberSort } from '../../hooks/programming_controls/useEpisodeNumberSort.ts';
import { useProgramShuffle } from '../../hooks/programming_controls/useRandomSort.ts';
import { useReleaseDateSort } from '../../hooks/programming_controls/useReleaseDateSort.ts';
import { strings } from '../../strings.ts';
import { useLingui } from '@lingui/react/macro';
import { ElevatedTooltip } from '../base/ElevatedTooltip.tsx';
import { StyledMenu } from '../base/StyledMenu.tsx';
import AddBlockShuffleModal from '../programming_controls/AddBlockShuffleModal.tsx';
@@ -36,6 +36,7 @@ type SortOption =
| 'shows';
export function ChannelProgrammingSort() {
const { t } = useLingui();
const [sort, setSort] = useState<SortOption | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [addBlockShuffleModalOpen, setAddBlockShuffleModalOpen] =
@@ -140,7 +141,7 @@ export function ChannelProgrammingSort() {
</MenuItem>
<ElevatedTooltip
elevation={5}
title={strings.SHUFFLE_TOOLTIP}
title={t`Completely randomizes the order of programs.`}
placement="right"
>
<MenuItem
@@ -157,7 +158,7 @@ export function ChannelProgrammingSort() {
<ElevatedTooltip
elevation={5}
title={strings.CYCLIC_SHUFFLE_TOOLTIP}
title={t`Like Random Shuffle, but tries to preserve the sequence of episodes for each TV show. If a TV show has multiple instances of its episodes, they are also cycled appropriately.`}
placement="right"
>
<MenuItem
@@ -174,7 +175,7 @@ export function ChannelProgrammingSort() {
</ElevatedTooltip>
<ElevatedTooltip
elevation={5}
title={strings.BLOCK_SHUFFLE_TOOLTIP}
title={t`Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.`}
placement="right"
>
<MenuItem
@@ -192,7 +193,7 @@ export function ChannelProgrammingSort() {
<ElevatedTooltip
elevation={5}
title={strings.ALPHA_SORT_TOOLTIP}
title={t`Sorts alphabetically by program title`}
placement="right"
>
<MenuItem
@@ -210,7 +211,7 @@ export function ChannelProgrammingSort() {
<ElevatedTooltip
elevation={5}
title={strings.RELEASE_SORT_TOOLTIP}
title={t`Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.`}
placement="right"
>
<MenuItem
@@ -227,7 +228,7 @@ export function ChannelProgrammingSort() {
</ElevatedTooltip>
<ElevatedTooltip
elevation={5}
title={strings.EPISODE_SORT_TOOLTIP}
title={t`Sorts the list by TV Show and the episodes in each TV show by their season/episode number. Movies are moved to the bottom of the schedule.`}
placement="right"
>
<MenuItem

View File

@@ -17,9 +17,9 @@ import React, { useState } from 'react';
import { useCustomShowBlockShuffle } from '../../hooks/programming_controls/useBlockShuffle.ts';
import { useProgramShuffle } from '../../hooks/programming_controls/useRandomSort.ts';
import { useCustomShowReleaseDateSort } from '../../hooks/programming_controls/useReleaseDateSort.ts';
import { useLingui } from '@lingui/react/macro';
import { setCurrentCustomShowProgramming } from '../../store/customShowEditor/actions.ts';
import { useCustomShowEditor } from '../../store/selectors.ts';
import { strings } from '../../strings.ts';
import { ElevatedTooltip } from '../base/ElevatedTooltip.tsx';
import { StyledMenu } from '../base/StyledMenu.tsx';
import AddBlockShuffleModal from '../programming_controls/AddBlockShuffleModal.tsx';
@@ -32,6 +32,7 @@ type OrdereredSort<T extends string> = `${T}-asc` | `${T}-desc`;
type PossibleSorts = 'random' | OrdereredSort<'release'> | 'block';
export const CustomShowSortToolsMenu = () => {
const { t } = useLingui();
const { programList } = useCustomShowEditor();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = !!anchorEl;
@@ -125,7 +126,7 @@ export const CustomShowSortToolsMenu = () => {
Sort By...
</MenuItem>
<ElevatedTooltip
title={strings.SHUFFLE_TOOLTIP}
title={t`Completely randomizes the order of programs.`}
placement="right"
elevation={10}
>
@@ -144,7 +145,7 @@ export const CustomShowSortToolsMenu = () => {
</MenuItem>
</ElevatedTooltip>
<ElevatedTooltip
title={strings.RELEASE_SORT_TOOLTIP}
title={t`Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.`}
placement="right"
elevation={10}
>
@@ -163,7 +164,7 @@ export const CustomShowSortToolsMenu = () => {
</MenuItem>
</ElevatedTooltip>
<ElevatedTooltip
title={strings.BLOCK_SHUFFLE_TOOLTIP}
title={t`Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.`}
placement="right"
elevation={10}
>

View File

@@ -1,9 +1,17 @@
import DarkModeButton from '@/components/settings/DarkModeButton.tsx';
import useStore from '@/store/index.ts';
import { setUiLocale } from '@/store/settings/actions.ts';
import type { SupportedLocales } from '@/store/settings/store.ts';
import {
setTimeFormat,
setUiLocale,
} from '@/store/settings/actions.ts';
import type { SupportedLocales, TimeFormat } from '@/store/settings/store.ts';
import { Trans, useLingui } from '@lingui/react/macro';
import {
Box,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
ToggleButton,
ToggleButtonGroup,
@@ -13,12 +21,18 @@ import { useCallback } from 'react';
import type { Nullable } from '../../../types/util.ts';
export const WebSettings = () => {
const { t } = useLingui();
const locale = useStore((state) => state.settings.ui.i18n.locale);
const timeFormat = useStore((state) => state.settings.ui.i18n.timeFormat);
const handleUiLocaleChange = useCallback(
(value: Nullable<SupportedLocales>) => {
const handleLocaleChange = useCallback((value: SupportedLocales) => {
void setUiLocale(value);
}, []);
const handleTimeFormatChange = useCallback(
(value: Nullable<TimeFormat>) => {
if (value) {
setUiLocale(value);
void setTimeFormat(value);
}
},
[],
@@ -27,34 +41,74 @@ export const WebSettings = () => {
return (
<Stack spacing={2}>
<Box>
<Typography variant="h5">Web Settings</Typography>
<Typography variant="h5">
<Trans>Web Settings</Trans>
</Typography>
<Typography variant="subtitle2">
These settings are stored in your browser and are saved automatically
when changed.
<Trans>
These settings are stored in your browser and are saved
automatically when changed.
</Trans>
</Typography>
</Box>
<Box>
<ToggleButtonGroup
value={locale}
exclusive
onChange={(_, value) =>
handleUiLocaleChange(value as Nullable<SupportedLocales>)
}
aria-label="text alignment"
>
<ToggleButton value="en" aria-label="left aligned">
12-hour
</ToggleButton>
<ToggleButton value="en-gb" aria-label="centered">
24-hour
</ToggleButton>
</ToggleButtonGroup>
<Typography variant="h6" sx={{ mb: 2 }}>
<Trans>Language</Trans>
</Typography>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="language-select-label">
<Trans>Language</Trans>
</InputLabel>
<Select
labelId="language-select-label"
value={locale}
label={t`Language`}
onChange={(e) =>
handleLocaleChange(e.target.value as SupportedLocales)
}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Español</MenuItem>
{import.meta.env.DEV && (
<MenuItem value="pseudo-LOCALE">pseudo-LOCALE (dev)</MenuItem>
)}
</Select>
</FormControl>
</Box>
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Theme Settings
<Trans>Time Format</Trans>
</Typography>
<ToggleButtonGroup
value={timeFormat}
exclusive
onChange={(_, value) =>
handleTimeFormatChange(value as Nullable<TimeFormat>)
}
aria-label={t`Time format`}
>
<ToggleButton value="12h">
<Trans>12-hour</Trans>
</ToggleButton>
<ToggleButton value="24h">
<Trans>24-hour</Trans>
</ToggleButton>
<ToggleButton value="auto">
<Trans>Auto</Trans>
</ToggleButton>
</ToggleButtonGroup>
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
<Trans>
Auto uses the time convention for the selected language.
</Trans>
</Typography>
</Box>
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
<Trans>Theme Settings</Trans>
</Typography>
<DarkModeButton />
</Box>

View File

@@ -0,0 +1,27 @@
/**
* Maps Lingui locale codes to their corresponding dayjs locale codes.
* 'pseudo-LOCALE' has no dayjs equivalent, so it falls back to 'en'.
*/
export const LINGUI_TO_DAYJS: Record<string, string> = {
en: 'en',
es: 'es',
'pseudo-LOCALE': 'en',
};
/**
* Returns the dayjs locale code for a given Lingui locale.
*/
export function getLinguiToDayjsLocale(locale: string): string {
return LINGUI_TO_DAYJS[locale] ?? 'en';
}
/**
* Dynamically imports the dayjs locale module for a given Lingui locale.
* 'en' is always built into dayjs and requires no dynamic import.
*/
export async function loadDayjsLocale(locale: string): Promise<void> {
const dayjsLocale = getLinguiToDayjsLocale(locale);
if (dayjsLocale !== 'en') {
await import(`dayjs/locale/${dayjsLocale}.js`);
}
}

View File

@@ -1,4 +1,5 @@
import type { router } from '@/router.ts';
import { t } from '@lingui/core/macro';
import {
Computer,
Delete,
@@ -52,52 +53,52 @@ export const useNavItems = () => {
return useMemo<NavItem[]>(() => {
const items: NavItem[] = [
{
name: 'Welcome',
name: t`Welcome`,
path: '/welcome',
hidden: !showWelcome,
icon: <Home />,
},
{ name: 'Guide', path: '/guide', icon: <Tv /> },
{ name: t`Guide`, path: '/guide', icon: <Tv /> },
{
name: 'Channels',
name: t`Channels`,
path: '/channels',
icon: <SettingsRemote />,
},
// { name: 'Watch', path: '/watch', hidden: true, icon: <LiveTv /> },
{
name: 'Library',
name: t`Library`,
path: '/library',
icon: <VideoLibrary />,
children: [
{
name: 'Filler Lists',
name: t`Filler Lists`,
path: '/library/fillers',
icon: <Preview />,
},
{
name: 'Smart Collections',
name: t`Smart Collections`,
path: '/library/smart_collections',
icon: <Psychology />,
},
{
name: 'Custom Shows',
name: t`Custom Shows`,
path: '/library/custom-shows' as const,
icon: <Theaters />,
},
{
name: 'Trash',
name: t`Trash`,
path: '/library/trash',
icon: <Delete />,
},
],
},
{
name: 'Sources',
name: t`Sources`,
path: '/media_sources',
icon: <StorageIcon />,
},
{
name: 'System',
name: t`System`,
path: '/system',
icon: <Computer />,
badge: highestSev
@@ -108,24 +109,24 @@ export const useNavItems = () => {
: undefined,
children: [
{
name: 'Status',
name: t`Status`,
path: '/system',
hidden: true,
},
{
name: 'Debug',
name: t`Debug`,
path: '/system/debug',
hidden: true,
},
{
name: 'Logs',
name: t`Logs`,
path: '/system/logs',
hidden: true,
},
],
},
{
name: 'Settings',
name: t`Settings`,
path: '/settings/general',
icon: <Settings />,
children: [

19
web/src/i18n.ts Normal file
View File

@@ -0,0 +1,19 @@
import { i18n } from '@lingui/core';
import { messages as enMessages } from './locales/en/messages';
/**
* Loads and activates a Lingui message catalog for the given locale.
* Falls back to English if the catalog cannot be loaded.
* Idempotent: skips loading if the locale is already active.
*/
export async function loadCatalog(locale: string) {
if (i18n.locale === locale) return;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { messages } = await import(`./locales/${locale}/messages`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
i18n.loadAndActivate({ locale, messages });
} catch {
i18n.loadAndActivate({ locale: 'en', messages: enMessages });
}
}

View File

@@ -0,0 +1,100 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-02-10 20:45-0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/pages/channels/ChannelsPage.tsx:195
msgid "Cancel"
msgstr "Cancel"
#: src/App.tsx:93
#: src/hooks/useNavItems.tsx:63
msgid "Channels"
msgstr "Channels"
#: src/hooks/useNavItems.tsx:84
msgid "Custom Shows"
msgstr "Custom Shows"
#: src/hooks/useNavItems.tsx:117
msgid "Debug"
msgstr "Debug"
#: src/pages/channels/ChannelsPage.tsx:201
msgid "Delete"
msgstr "Delete"
#: src/pages/channels/ChannelsPage.tsx:180
msgid "Delete Channel"
msgstr "Delete Channel"
#: src/pages/channels/ChannelsPage.tsx:184
msgid "Deleting a Channel will remove all programming from the channel. This action cannot be undone."
msgstr "Deleting a Channel will remove all programming from the channel. This action cannot be undone."
#: src/hooks/useNavItems.tsx:74
msgid "Filler Lists"
msgstr "Filler Lists"
#: src/hooks/useNavItems.tsx:61
#: src/pages/guide/GuidePage.tsx:120
msgid "Guide"
msgstr "Guide"
#: src/pages/guide/GuidePage.tsx:137
msgid "Guide Start Time"
msgstr "Guide Start Time"
#: src/hooks/useNavItems.tsx:69
msgid "Library"
msgstr "Library"
#: src/hooks/useNavItems.tsx:122
msgid "Logs"
msgstr "Logs"
#: src/pages/guide/GuidePage.tsx:142
msgid "Reset to current date/time"
msgstr "Reset to current date/time"
#: src/hooks/useNavItems.tsx:129
msgid "Settings"
msgstr "Settings"
#: src/pages/guide/GuidePage.tsx:155
msgid "Show Stealth"
msgstr "Show Stealth"
#: src/hooks/useNavItems.tsx:79
msgid "Smart Collections"
msgstr "Smart Collections"
#: src/hooks/useNavItems.tsx:96
msgid "Sources"
msgstr "Sources"
#: src/hooks/useNavItems.tsx:112
msgid "Status"
msgstr "Status"
#: src/hooks/useNavItems.tsx:101
msgid "System"
msgstr "System"
#: src/hooks/useNavItems.tsx:89
msgid "Trash"
msgstr "Trash"
#: src/hooks/useNavItems.tsx:56
msgid "Welcome"
msgstr "Welcome"

View File

@@ -0,0 +1 @@
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"KkOthv\":[\"Guide\"],\"lfFsZ4\":[\"Channels\"]}")as Messages;

View File

@@ -0,0 +1,14 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-11 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: es\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

View File

@@ -0,0 +1 @@
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{}")as Messages;

View File

@@ -0,0 +1,100 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-02-11 20:42-0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: pseudo-LOCALE\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/pages/channels/ChannelsPage.tsx:195
msgid "Cancel"
msgstr ""
#: src/App.tsx:93
#: src/hooks/useNavItems.tsx:63
msgid "Channels"
msgstr ""
#: src/hooks/useNavItems.tsx:84
msgid "Custom Shows"
msgstr ""
#: src/hooks/useNavItems.tsx:117
msgid "Debug"
msgstr ""
#: src/pages/channels/ChannelsPage.tsx:201
msgid "Delete"
msgstr ""
#: src/pages/channels/ChannelsPage.tsx:180
msgid "Delete Channel"
msgstr ""
#: src/pages/channels/ChannelsPage.tsx:184
msgid "Deleting a Channel will remove all programming from the channel. This action cannot be undone."
msgstr ""
#: src/hooks/useNavItems.tsx:74
msgid "Filler Lists"
msgstr ""
#: src/hooks/useNavItems.tsx:61
#: src/pages/guide/GuidePage.tsx:120
msgid "Guide"
msgstr ""
#: src/pages/guide/GuidePage.tsx:137
msgid "Guide Start Time"
msgstr ""
#: src/hooks/useNavItems.tsx:69
msgid "Library"
msgstr ""
#: src/hooks/useNavItems.tsx:122
msgid "Logs"
msgstr ""
#: src/pages/guide/GuidePage.tsx:142
msgid "Reset to current date/time"
msgstr ""
#: src/hooks/useNavItems.tsx:129
msgid "Settings"
msgstr ""
#: src/pages/guide/GuidePage.tsx:155
msgid "Show Stealth"
msgstr ""
#: src/hooks/useNavItems.tsx:79
msgid "Smart Collections"
msgstr ""
#: src/hooks/useNavItems.tsx:96
msgid "Sources"
msgstr ""
#: src/hooks/useNavItems.tsx:112
msgid "Status"
msgstr ""
#: src/hooks/useNavItems.tsx:101
msgid "System"
msgstr ""
#: src/hooks/useNavItems.tsx:89
msgid "Trash"
msgstr ""
#: src/hooks/useNavItems.tsx:56
msgid "Welcome"
msgstr ""

View File

@@ -0,0 +1 @@
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"KkOthv\":[\"Ĝũĩďē\"],\"lfFsZ4\":[\"Ćĥàńńēĺś\"]}")as Messages;

View File

@@ -2,7 +2,6 @@ import languages from '@cospired/i18n-iso-languages';
import en from '@cospired/i18n-iso-languages/langs/en.json';
import { ColorSpace, LCH, OKLCH, sRGB } from 'colorjs.io/fn';
import dayjs from 'dayjs';
import 'dayjs/locale/en-gb';
import localeData from 'dayjs/plugin/localeData';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import React from 'react';
@@ -17,7 +16,6 @@ ColorSpace.register(sRGB);
dayjs.extend(localizedFormat);
dayjs.extend(localeData);
dayjs.locale('en-gb');
// Initialize the languages database with English names
// TODO: localize this and make it a context provider

View File

@@ -1,6 +1,7 @@
import { betterHumanize } from '@/helpers/dayjs.ts';
import { useTranscodeConfigs } from '@/hooks/settingsHooks.ts';
import type { Maybe } from '@/types/util.ts';
import { Trans } from '@lingui/react/macro';
import { Check, Close, Edit, MoreVert } from '@mui/icons-material';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import type { BoxProps } from '@mui/material';
@@ -182,12 +183,14 @@ export default function ChannelsPage() {
{deleteChannelConfirmation && (
<>
<DialogTitle id="delete-channel-title">
Delete Channel "{deleteChannelConfirmation.name}"?
<Trans>Delete Channel</Trans> "{deleteChannelConfirmation.name}"?
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-channel-description">
Deleting a Channel will remove all programming from the channel.
This action cannot be undone.
<Trans>
Deleting a Channel will remove all programming from the
channel. This action cannot be undone.
</Trans>
</DialogContentText>
</DialogContent>
<DialogActions>
@@ -195,13 +198,13 @@ export default function ChannelsPage() {
onClick={() => setDeleteChannelConfirmation(undefined)}
autoFocus
>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
onClick={() => removeChannel(deleteChannelConfirmation.id)}
variant="contained"
>
Delete
<Trans>Delete</Trans>
</Button>
</DialogActions>
</>

View File

@@ -1,3 +1,5 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
ArrowBackIos,
ArrowForwardIos,
@@ -115,7 +117,7 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) {
return (
<>
<Typography variant="h3" mb={2}>
Guide
<Trans>Guide</Trans>
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }}>
<Stack
@@ -132,12 +134,12 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) {
minDateTime={roundCurrentTime(15)}
value={start}
onChange={(v) => handleDayChange(v)}
label="Guide Start Time"
label={t`Guide Start Time`}
/>
</FormControl>
<Typography>{dayjs.duration(end.diff(start)).humanize()}</Typography>
{!dayjs().isBetween(start, end) && (
<Tooltip title={'Reset to current date/time'} placement="top">
<Tooltip title={t`Reset to current date/time`} placement="top">
<IconButton onClick={handleNavigationReset}>
<History />
</IconButton>
@@ -150,7 +152,7 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) {
onChange={(_, checked) => setShowStealth(checked)}
/>
}
label="Show Stealth"
label={t`Show Stealth`}
/>
</Stack>
<Stack

View File

@@ -1,6 +1,7 @@
import { getLinguiToDayjsLocale } from '@/helpers/localeLoader.ts';
import useStore from '@/store';
import originalDayjs from 'dayjs';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import type { ContextType } from './DayjsContext.tsx';
import { DayjsContext } from './DayjsContext.tsx';
@@ -10,14 +11,35 @@ type Props = {
export const DayjsProvider = ({ children }: Props) => {
const locale = useStore((store) => store.settings.ui.i18n.locale);
const timeFormat = useStore((store) => store.settings.ui.i18n.timeFormat);
const effectiveDayjsLocale = useMemo(() => {
if (timeFormat === '12h') return 'en';
if (timeFormat === '24h') return 'en-gb';
return getLinguiToDayjsLocale(locale);
}, [locale, timeFormat]);
// Async-load the dayjs locale module if needed (handles stored locale on startup
// and the 'en-gb' module for 24h format)
useEffect(() => {
const load = async () => {
if (effectiveDayjsLocale !== 'en') {
await import(`dayjs/locale/${effectiveDayjsLocale}.js`);
}
originalDayjs.locale(effectiveDayjsLocale);
};
void load();
}, [effectiveDayjsLocale]);
const value = useMemo(() => {
originalDayjs.locale(locale);
originalDayjs.locale(effectiveDayjsLocale);
return {
dayjs: (date?: originalDayjs.ConfigType) => {
return originalDayjs(date);
},
} satisfies ContextType;
}, [locale]);
}, [effectiveDayjsLocale]);
return (
<DayjsContext.Provider value={value}>{children}</DayjsContext.Provider>
);

View File

@@ -1,6 +1,7 @@
import type { SupportedLocales } from '@/store/settings/store.ts';
import { loadDayjsLocale } from '@/helpers/localeLoader.ts';
import { loadCatalog } from '@/i18n.ts';
import type { SupportedLocales, TimeFormat } from '@/store/settings/store.ts';
import type { PaginationState, SortingState } from '@tanstack/react-table';
import dayjs from 'dayjs';
import useStore from '..';
export const setBackendUri = (uri: string) =>
@@ -49,11 +50,22 @@ export const setChannelPaginationState = (p: PaginationState) =>
settings.ui.channelTablePagination = p;
});
export const setUiLocale = (locale: SupportedLocales) =>
export const setUiLocale = async (locale: SupportedLocales) => {
await loadDayjsLocale(locale);
await loadCatalog(locale);
useStore.setState(({ settings }) => {
dayjs.locale(locale); // Changes the default dayjs locale globally
settings.ui.i18n.locale = locale;
});
};
export const setTimeFormat = async (format: TimeFormat) => {
if (format === '24h') {
await import('dayjs/locale/en-gb');
}
useStore.setState(({ settings }) => {
settings.ui.i18n.timeFormat = format;
});
};
export const setShowAdvancedSettings = (value: boolean) =>
useStore.setState(({ settings }) => {

View File

@@ -3,9 +3,9 @@ import type { DeepPartial } from 'ts-essentials';
import { z } from 'zod';
import type { StateCreator } from 'zustand';
// Only these 2 are supported currently
export const SupportedLocales = ['en', 'en-gb'] as const;
export const SupportedLocales = ['en', 'es', 'pseudo-LOCALE'] as const;
export type SupportedLocales = TupleToUnion<typeof SupportedLocales>;
export type TimeFormat = '12h' | '24h' | 'auto';
const CurrentSettingsSchemaVersion = 1;
@@ -35,6 +35,7 @@ export const SettingsStateInternalSchema = z.object({
channelTableColumnModel: z.record(z.string(), z.boolean()),
i18n: z.object({
locale: z.enum(SupportedLocales),
timeFormat: z.enum(['12h', '24h', 'auto']).default('auto'),
}),
tableSettings: z.record(z.string(), TableSettingsSchema),
showAdvancedSettings: z.boolean(),
@@ -73,6 +74,7 @@ export const createSettingsSlice: StateCreator<SettingsState> = () => ({
},
i18n: {
locale: 'en',
timeFormat: 'auto',
},
tableSettings: {},
showAdvancedSettings: false,

View File

@@ -1,14 +1,5 @@
export const strings = {
SHUFFLE_TOOLTIP: 'Completely randomizes the order of programs.',
ALPHA_SORT_TOOLTIP: 'Sorts alphabetically by program title',
CYCLIC_SHUFFLE_TOOLTIP:
'Like Random Shuffle, but tries to preserve the sequence of episodes for each TV show. If a TV show has multiple instances of its episodes, they are also cycled appropriately.',
BLOCK_SHUFFLE_TOOLTIP:
'Alternates TV shows in blocks of episodes. You can pick the number of episodes per show in each block and if the order of shows in each block should be randomized. Movies are moved to the bottom.',
EPISODE_SORT_TOOLTIP:
'Sorts the list by TV Show and the episodes in each TV show by their season/episode number. Movies are moved to the bottom of the schedule.',
RELEASE_SORT_TOOLTIP:
'Sorts everything by its release date. This will only work correctly if the release dates in Plex are correct. In case any item does not have a release date specified, it will be moved to the bottom.',
FFMPEG_MISSING:
'FFmpeg not found. For all features to work, we recommend installing FFmpeg 7.1+ or update your FFmpeg executable path in settings.',
};
// This file has been migrated to Lingui i18n macros.
// All strings are now inlined at their usage sites using <Trans> and t``.
// This file is kept as an empty export to avoid breaking any lingering imports
// at compile time, but all imports of `strings` should be removed.
export const strings = {} as Record<string, never>;

31
web/src/test/i18n.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { i18n } from '@lingui/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { loadCatalog } from '../i18n.ts';
describe('loadCatalog', () => {
beforeEach(() => {
// Reset to a known state before each test
i18n.loadAndActivate({ locale: 'en', messages: {} });
});
it('activates the English locale', async () => {
await loadCatalog('en');
expect(i18n.locale).toBe('en');
});
it('is idempotent — loading the same locale twice does not error', async () => {
await loadCatalog('en');
await expect(loadCatalog('en')).resolves.toBeUndefined();
expect(i18n.locale).toBe('en');
});
it('falls back to English when given a nonexistent locale', async () => {
await loadCatalog('nonexistent-LOCALE');
expect(i18n.locale).toBe('en');
});
it('activates Spanish locale', async () => {
await loadCatalog('es');
expect(i18n.locale).toBe('es');
});
});

View File

@@ -1,14 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { i18n } from '@lingui/core';
import { cleanup } from '@testing-library/react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { afterEach } from 'vitest';
import { afterEach, beforeAll } from 'vitest';
// Load dayjs plugins needed by components
dayjs.extend(duration);
dayjs.extend(relativeTime);
// Initialize Lingui with an empty English catalog so components using
// <Trans> and t`` render their message IDs (English source strings) in tests.
beforeAll(() => {
i18n.loadAndActivate({ locale: 'en', messages: {} });
});
// Automatically cleanup after each test
afterEach(() => {
cleanup();

View File

@@ -1,11 +1,13 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { Theme } from '@/theme.ts';
import { ThemeProvider } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement, ReactNode } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ThemeProvider } from '@mui/material';
import { Theme } from '@/theme.ts';
/**
* Creates a fresh QueryClient configured for testing.
@@ -37,11 +39,13 @@ function TestProviders({ children, queryClient }: TestProvidersProps) {
const client = queryClient ?? createTestQueryClient();
return (
<QueryClientProvider client={client}>
<DndProvider backend={HTML5Backend}>
<ThemeProvider theme={Theme}>{children}</ThemeProvider>
</DndProvider>
</QueryClientProvider>
<I18nProvider i18n={i18n}>
<QueryClientProvider client={client}>
<DndProvider backend={HTML5Backend}>
<ThemeProvider theme={Theme}>{children}</ThemeProvider>
</DndProvider>
</QueryClientProvider>
</I18nProvider>
);
}

View File

@@ -8,6 +8,13 @@
"clean-build": {
"dependsOn": ["^build"]
},
"compile-messages": {
"dependsOn": ["extract-messages"]
},
"dev": {
"dependsOn": ["compile-messages"]
},
"extract-messages": {},
"typecheck": {
"dependsOn": ["^build"]
},

View File

@@ -1,6 +1,7 @@
import dotenv from '@dotenvx/dotenvx';
dotenv.config({ debug: false });
import { lingui } from '@lingui/vite-plugin';
import { tanstackRouter } from '@tanstack/router-vite-plugin';
import react from '@vitejs/plugin-react-swc';
import path from 'node:path';
@@ -33,7 +34,10 @@ const version = (() => {
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
react({
plugins: [['@lingui/swc-plugin', {}]],
}),
lingui(),
tanstackRouter({
semicolons: true,
routesDirectory: path.resolve(__dirname, './src/routes'),