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:
lngandfallbackLngare bothen;supportedLngsis derived fromSUPPORTED_LANGUAGES.interpolation.escapeValue: false(React already escapes) andreturnNull: false(a missing key yields a string, nevernull).- The root layout imports
@/i18nfor its side effect; the real language is applied afterwards by theLanguageProvider- 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/:
| Code | Label |
|---|---|
en | English - the source-of-truth catalog and the runtime fallback |
es | Español |
fr | Français |
de | Deutsch |
pt | Português |
it | Italiano |
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:
- A persisted explicit preference wins (
resolveLanguage(pref)returns it unchanged). 'system'walks the device's preferred locales (Localization.getLocales(), in the user's OS-level preference order) and picks the first supportedlanguageCode.- 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.tsaugments i18next'sCustomTypeOptionswithtypeof enfromlocales/en.json, sot('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
- Add the key to
src/i18n/locales/en.jsonfirst - that updates the type union, sot()calls compile. - Use it via
useTranslation()in components (const { t } = useTranslation()) ori18n.t(...)outside React. - 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):
- Create the UI catalog:
src/i18n/locales/<code>.json, translated fromen.json(same key structure). - Register it in
src/i18n/index.ts: import the JSON, add it toresources, and add{ code, label }toSUPPORTED_LANGUAGES(the label is the language's own name - it appears untranslated in the picker). - Create the native-metadata file:
assets/locales/<code>.json. This is a different shape from the UI catalog - it holds the OS-level strings: iOSCFBundleDisplayNameandNSCameraUsageDescription(the QR-scanner permission prompt), Androidapp_name. - Register it in
app.jsonunderexpo.locales("<code>": "./assets/locales/<code>.json"). Note this list deliberately omitsen- English is the development language baked into the native projects;CFBundleAllowMixedLocalizationsis already set on iOS. - Run the full gate -
language.test.tsexercises detection and catalog switching, and the typecheck catches structural drift inen.jsonconsumers.
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.