Skip to main content

Internationalisation

The player's UI is localised with i18next + react-i18next, with device language detection via expo-localization. Everything lives in src/i18n/.

Setup (src/i18n/index.ts)

i18next is initialised synchronously at import time - all catalogs are bundled JSON (no async backend), so the instance is ready before the first useTranslation call. Configuration highlights:

  • lng and fallbackLng are both en; supportedLngs is derived from SUPPORTED_LANGUAGES.
  • interpolation.escapeValue: false (React already escapes) and returnNull: false (a missing key yields a string, never null).
  • The root layout imports @/i18n for its side effect; the real language is applied afterwards by the LanguageProvider - until then the app renders under the fallback, and the provider gates first paint so users never see a flash of the wrong language.

src/i18n/locale.ts exposes getLocale() - the active BCP-47 code read from the i18next singleton - kept in a separate module so locale-aware formatters (src/lib/format.ts) don't pull React into their dependency graph.

Available languages

SUPPORTED_LANGUAGES currently ships six catalogs in src/i18n/locales/:

CodeLabel
enEnglish - the source-of-truth catalog and the runtime fallback
esEspañol
frFrançais
deDeutsch
ptPortuguês
itItaliano

All are LTR; there is no RTL handling yet. The in-app Settings language picker derives its options from this list, so adding an entry here is what surfaces it in the UI.

Language selection (src/i18n/language-provider.tsx)

The user preference is 'system' (default) or a concrete code, persisted under audiosilo.language in AsyncStorage. Resolution order:

  1. A persisted explicit preference wins (resolveLanguage(pref) returns it unchanged).
  2. 'system' walks the device's preferred locales (Localization.getLocales(), in the user's OS-level preference order) and picks the first supported languageCode.
  3. Nothing supported → en.

LanguageProvider restores the preference, applies it via i18n.changeLanguage(...) before first paint (children render only after hydration, mirroring the ThemeProvider pattern), and exposes { pref, language, setPref } through useLanguage(). A failed storage read degrades to the default language rather than wedging first paint.

Key conventions

Keys are namespaced by surface, matching the top-level objects in en.json: common, settings, account, connect, home, library, downloads, search, book, player, nav, ui, demo - e.g. t('settings.title'), t('common.cancel').

Two mechanisms keep keys honest:

  • Typed keys. src/i18n/i18next.d.ts augments i18next's CustomTypeOptions with typeof en from locales/en.json, so t('settings.titel') is a compile error. Only the English catalog is type-checked; the others may lag behind it.
  • Runtime fallback. A key missing from the active catalog resolves to the English string (never the raw key) - asserted by the catalog-switching tests in src/i18n/language.test.ts, which register a deliberately partial throwaway locale to exercise the fallback.

There is no automated parity check between the catalogs (the shipped ones are currently complete - verify with a quick diff of flattened keys when touching them); English-first plus the runtime fallback is what keeps a lagging translation harmless rather than breaking.

Adding strings

  1. Add the key to src/i18n/locales/en.json first - that updates the type union, so t() calls compile.
  2. Use it via useTranslation() in components (const { t } = useTranslation()) or i18n.t(...) outside React.
  3. Translate it in the other five catalogs in the same change where practical. A missing translation falls back to English at runtime, so a lagging catalog degrades gracefully - but don't lean on that as a workflow.

Adding a language, step by step

The UI catalogs and the native-metadata files are two separate, hand-maintained lists - you must touch both (the comment block above SUPPORTED_LANGUAGES exists precisely because this was missed once):

  1. Create the UI catalog: src/i18n/locales/<code>.json, translated from en.json (same key structure).
  2. Register it in src/i18n/index.ts: import the JSON, add it to resources, and add { code, label } to SUPPORTED_LANGUAGES (the label is the language's own name - it appears untranslated in the picker).
  3. Create the native-metadata file: assets/locales/<code>.json. This is a different shape from the UI catalog - it holds the OS-level strings: iOS CFBundleDisplayName and NSCameraUsageDescription (the QR-scanner permission prompt), Android app_name.
  4. Register it in app.json under expo.locales ("<code>": "./assets/locales/<code>.json"). Note this list deliberately omits en - English is the development language baked into the native projects; CFBundleAllowMixedLocalizations is already set on iOS.
  5. Run the full gate - language.test.ts exercises detection and catalog switching, and the typecheck catches structural drift in en.json consumers.

Steps 3–4 require a native rebuild to take effect (they are prebuild inputs, not JS).

The Hermes Intl caveat

:::danger CI cannot catch this class of crash tsc, Jest (Node), and the web build all run on engines with full Intl. The iOS/Android runtime is Hermes, which ships Intl.NumberFormat and Intl.DateTimeFormat but not Intl.RelativeTimeFormat, Intl.ListFormat or Intl.Segmenter. An unguarded new Intl.RelativeTimeFormat(...) sails through every gate and then hard-crashes on device - this actually happened (the home "recently added" shelf crashed on device while CI was green). See the workspace CODE-HEALTH.md for the incident write-up. :::

The project rule: feature-detect any non-core Intl API and provide a fallback. The canonical example is formatRelative in src/lib/format.ts:

const rtf =
typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function'
? new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
: null;
// … rtf ? rtf.format(-n, unit) : `${n} ${unit}${n === 1 ? '' : 's'} ago`

Intl.NumberFormat and Intl.DateTimeFormat are safe to use directly (see formatBytes). If properly localised relative times/lists ever become a requirement on native, the sanctioned path is adding the @formatjs/intl-* polyfills plus locale data - not removing the guards.

All Intl formatting should take its locale from getLocale() (or accept a locale parameter defaulting to it), so number/date formatting follows the active UI language.