diff --git a/TRANSLATORS.md b/TRANSLATORS.md new file mode 100644 index 0000000..3e99f21 --- /dev/null +++ b/TRANSLATORS.md @@ -0,0 +1,87 @@ +# Translating XR Gaming Plugin + +Thank you for helping translate the XR Gaming Decky plugin! This guide explains how to contribute a translation. + +## Supported Languages + +| Code | Language | File | +|---------|-----------------------|------------------------------------------| +| `en` | English (base) | `src/locales/en/translation.json` | +| `de` | German | `src/locales/de/translation.json` | +| `es` | Spanish | `src/locales/es/translation.json` | +| `fr` | French | `src/locales/fr/translation.json` | +| `it` | Italian | `src/locales/it/translation.json` | +| `ja` | Japanese | `src/locales/ja/translation.json` | +| `zh-CN` | Simplified Chinese | `src/locales/zh-CN/translation.json` | +| `ru` | Russian | `src/locales/ru/translation.json` | +| `ko` | Korean | `src/locales/ko/translation.json` | +| `pt-BR` | Portuguese (Brazil) | `src/locales/pt-BR/translation.json` | +| `pl` | Polish | `src/locales/pl/translation.json` | +| `tr` | Turkish | `src/locales/tr/translation.json` | +| `zh-TW` | Traditional Chinese | `src/locales/zh-TW/translation.json` | + +## How to Contribute a Translation + +### 1. Open the English base file + +The English file at `src/locales/en/translation.json` contains all translatable strings organized by section. Use this as your reference for what each key means. + +### 2. Find your language file + +Open the file for your language (e.g., `src/locales/de/translation.json` for German). The file starts as an empty JSON object `{}`. Any key that is missing from your language file will automatically fall back to English. + +### 3. Add translations + +Copy keys from the English file into your language file and replace the English values with your translations. You do **not** need to translate every key — untranslated keys will fall back to English. + +**Example** — German partial translation: +```json +{ + "device": { + "noDevice": "Kein Gerät verbunden", + "connected": "verbunden" + }, + "button": { + "needHelp": "Hilfe benötigt?", + "showAdvanced": "Erweiterte Einstellungen anzeigen", + "hideAdvanced": "Erweiterte Einstellungen ausblenden" + } +} +``` + +### 4. Important rules + +- **Keep the same JSON structure** (nested keys) as the English file. +- **Do not translate key names** — only translate the values (strings on the right side of `:`). +- **Preserve placeholders** like `{{time}}`, `{{count}}`, `{{units}}` — these are filled in at runtime with dynamic values. Translate the surrounding text but keep the `{{...}}` placeholders as-is. +- **Preserve HTML tags** in strings that contain them (e.g., ``, ``) — these add formatting. Only translate the surrounding text. +- **Do not change** special Unicode characters like `\u2011` (non-breaking hyphen) or `\u00a0` (non-breaking space) — keep them as-is. + +### 5. Submit your translation + +Open a pull request with your updated language file. Please test your translation if possible (see the Development section below). + +--- + +## Adding a New Language + +If your language is not listed above: + +1. Create a new directory: `src/locales//` +2. Create `src/locales//translation.json` starting with `{}` +3. Add the import and resource entry in `src/i18n.ts` +4. Open a pull request + +Use [BCP 47](https://www.ietf.org/rfc/bcp/bcp47.txt) language tags (e.g., `pt-BR`, `zh-TW`). + +--- + +## Maintaining Translations (for maintainers) + +When new strings are added to the codebase: + +1. Add the new English string(s) to `src/locales/en/translation.json` under the appropriate section. +2. Open an issue or PR noting which keys were added so translators can update their files. +3. Existing translations will continue to work — missing keys fall back to English automatically. + +No build step is needed for translation files themselves; they are bundled into the plugin at build time. diff --git a/package.json b/package.json index bbed2c6..e22f1ca 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@decky/api": "^1.1.2", + "i18next": "^25.10.10", "qrcode.react": "^4.0.1", "react-icons": "^5.3.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3441336..5927a22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@decky/api': specifier: ^1.1.2 version: 1.1.2 + i18next: + specifier: ^25.10.10 + version: 25.10.10(typescript@5.6.2) qrcode.react: specifier: ^4.0.1 version: 4.0.1(react@18.3.1) @@ -48,6 +51,10 @@ importers: packages: + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@decky/api@1.1.2': resolution: {integrity: sha512-lTMqRpHOrGTCyH2c1jJvkmWhOq2dcnX5/ioHbfCVmyQOBik1OM1BnzF1uROsnNDC5GkRvl3J/ATqYp6vhYpRqw==} @@ -189,46 +196,55 @@ packages: resolution: {integrity: sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.21.3': resolution: {integrity: sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.21.3': resolution: {integrity: sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.21.3': resolution: {integrity: sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-powerpc64le-gnu@4.21.3': resolution: {integrity: sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.21.3': resolution: {integrity: sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.21.3': resolution: {integrity: sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.21.3': resolution: {integrity: sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.21.3': resolution: {integrity: sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.21.3': resolution: {integrity: sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==} @@ -550,6 +566,14 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + i18next@25.10.10: + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -962,6 +986,8 @@ packages: snapshots: + '@babel/runtime@7.29.2': {} + '@decky/api@1.1.2': {} '@decky/rollup@1.0.1': @@ -1468,6 +1494,12 @@ snapshots: dependencies: function-bind: 1.1.2 + i18next@25.10.10(typescript@5.6.2): + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + typescript: 5.6.2 + ignore@5.3.2: {} indent-string@4.0.0: {} diff --git a/src/SupporterTierModal.tsx b/src/SupporterTierModal.tsx index 3182f21..e08b6ea 100644 --- a/src/SupporterTierModal.tsx +++ b/src/SupporterTierModal.tsx @@ -11,6 +11,7 @@ import { } from '@decky/ui'; import {FC, MutableRefObject, useEffect, useState} from "react"; import {QRCodeSVG} from "qrcode.react"; +import { t } from "./i18n"; enum SupporterTierView { NoLicense, @@ -26,26 +27,26 @@ function SupporterTierFeaturesList() { return
- Side-by-side + {t('supporterTierModal.sbsFeature')}
- Change the display distance. View 3D content. + {t('supporterTierModal.sbsFeatureDesc')}
- Smooth Follow + {t('supporterTierModal.smoothFollowFeature')}
- Display follows you. Tracks movements with smooth accelerations. + {t('supporterTierModal.smoothFollowFeatureDesc')}
- Auto re-centering + {t('supporterTierModal.autoRecenterFeature')}
- Virtual display automatically re-centers itself based on look-away distance or duration. + {t('supporterTierModal.autoRecenterFeatureDesc')}
; @@ -81,19 +82,23 @@ interface SupporterTierAboutBlurbProps { } function SupporterTierAboutRenewBlurb(props: SupporterTierAboutBlurbProps) { - return

- Your Supporter Tier access ends in {props.timeRemainingText}. Donate ${props.fundsNeeded} USD more to - renew for another year. -

; + return

; } function SupporterTierAboutEnrollBlurb(props: SupporterTierAboutBlurbProps) { return

- {props.timeRemainingText && - Your Supporter Tier trial ends in {props.timeRemainingText}. - } - Donate ${props.fundsNeeded} USD to get Supporter Tier access for 12 months, - or ${props.lifetimeFundsNeeded} USD for lifetime access. + {props.timeRemainingText && }{' '} + {t('supporterTierModal.enrollBlurb', { + fundsNeeded: props.fundsNeeded, + lifetimeFundsNeeded: props.lifetimeFundsNeeded + })}

} @@ -123,9 +128,9 @@ function SupporterTierNoLicense(props: SupporterTierStepProps) { })().catch(() => setFetchingLicense(false)); } - return + return

- Your device needs to be connected to the internet to retrieve your Supporter Tier status. + {t('supporterTierModal.offlineDesc')}

{isFetchingLicense && - Retrieving license + {t('supporterTierModal.retrievingLicense')} || - "I'm online now"} + t('supporterTierModal.onlineNow')}
@@ -194,29 +199,29 @@ function SupporterTierAbout(props: SupporterTierAboutProps) { > props.changeViewFn(SupporterTierView.Donate)} disabled={isFetchingLicense}>{props.primaryButtonLabel} {showTryNewToken && props.changeViewFn(SupporterTierView.VerifyToken)} disabled={isFetchingLicense}> - Try a new token + {t('supporterTierModal.tryNewToken')} || {isFetchingLicense && - Refreshing license + {t('supporterTierModal.refreshingLicense')} || - "I've already donated"} + t('supporterTierModal.alreadyDonated')} }
} function SupporterTierEnroll(props: SupporterTierStepProps) { - return } function SupporterTierRenew(props: SupporterTierStepProps) { - return } @@ -243,10 +248,12 @@ function SupporterTierDonate(props: SupporterTierStepProps) { } const donatedOnClick = props.confirmedToken ? fetchLicense : () => props.changeViewFn(SupporterTierView.VerifyToken); - return + return
- Donate ${fundsNeeded} USD to get Supporter Tier access for 12 months, - or ${lifetimeFundsNeeded} USD for lifetime access. + {t('supporterTierModal.enrollBlurb', { + fundsNeeded, + lifetimeFundsNeeded + })}
@@ -311,39 +318,36 @@ function SupporterTierVerifyToken(props: SupporterTierStepProps) { await props.refreshLicenseFn(); setTimeout(() => props.supporterTierModalCloseRef.current?.(), 3000); } else { - setFieldError('Token "' + token + '" is invalid, was requested from another device, ' + - 'or the server couldn\'t be reached. Please make sure your device is online, or request a new token.'); + setFieldError(t('supporterTierModal.tokenInvalid', { token })); setToken(''); } } catch (e) { setCheckingToken(false); - setFieldError('An error occurred. Please report this issue if it persists.'); + setFieldError(t('supporterTierModal.tokenError')); } } else if (token) { setFieldError(undefined); } })().catch(() => { setCheckingToken(false); - setFieldError('An error occurred. Please report this issue if it persists.'); + setFieldError(t('supporterTierModal.tokenError')); }); }, [token]); - return + return

- Enter the token that was emailed to you at the email address you use as your Ko-fi login. - If you don't receive it, double-check that you're using the correct email address, check your spam folder, - or request a new one. + {t('supporterTierModal.verifyDesc')}

setToken(newToken.currentTarget.value.toUpperCase())} description={ fieldError && {fieldError} || - isSuccess && ✔ Success! + isSuccess && ✔ {t('supporterTierModal.tokenSuccess')} } />
@@ -365,9 +369,9 @@ function SupporterTierVerifyToken(props: SupporterTierStepProps) { disabled={checkingToken || isSuccess}> {checkingToken && - Checking token + {t('supporterTierModal.checkingToken')} || - "I need a new token"} + t('supporterTierModal.needNewToken')}
@@ -391,26 +395,26 @@ function SupporterTierRequestToken(props: SupporterTierStepProps) { props.changeViewFn(SupporterTierView.VerifyToken); } catch (e) { setRequestingToken(false); - setFieldError('An error occurred. Please report this issue if it persists.'); + setFieldError(t('supporterTierModal.tokenError')); } } else { setRequestingToken(false); setFieldError(undefined); } - })().catch(() => setFieldError('An error occurred. Please report this issue if it persists.')); + })().catch(() => setFieldError(t('supporterTierModal.tokenError'))); } function isValidEmail(email: string) { return /^\w+([\.\-\+]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,})+$/.test(email) } - return + return

- Enter the same email address you use as your Ko-fi login. + {t('supporterTierModal.requestDesc')}

- { @@ -434,7 +438,7 @@ function SupporterTierRequestToken(props: SupporterTierStepProps) {
- {requestingToken && }Send verification + {requestingToken && }{t('supporterTierModal.sendVerification')}
diff --git a/src/SupporterTierStatus.tsx b/src/SupporterTierStatus.tsx index ae3f4c6..ebb2f42 100644 --- a/src/SupporterTierStatus.tsx +++ b/src/SupporterTierStatus.tsx @@ -5,6 +5,7 @@ import { RefreshLicenseResponse, SupporterTierModal } from "./SupporterTierModal import { Fragment, useRef } from "react"; import { BsFillSuitHeartFill } from "react-icons/bs"; import { BiSolidLock } from "react-icons/bi"; +import { t } from "./i18n"; export interface SupporterTierDetails { licensePresent: boolean; @@ -87,16 +88,16 @@ export function SupporterTierStatus({details, requestTokenFn, verifyTokenFn, ref flexGrow: 1 }}> Supporter Tier: - In trial + {t('supporterTier.inTrial')}
{details.trialTimeRemainingText && - Trial ends in {details.trialTimeRemainingText}
- (only tagged Supporter Tier features will be locked) + {t('supporterTier.trialEndsIn', { time: details.trialTimeRemainingText })}
+ {t('supporterTier.trialLockedNote')}
}
- Become a supporter + {t('supporterTier.becomeSupporter')} || details.active && Supporter Tier: { - details.lifetimeAccess ? "Lifetime" : "Unlocked" + details.lifetimeAccess ? t('supporterTier.lifetime') : t('supporterTier.unlocked') }
- {details.timeRemainingText ? `Access ends in ${details.timeRemainingText}` : - You rock! Thanks!} + {details.timeRemainingText ? t('supporterTier.accessEndsIn', { time: details.timeRemainingText }) : + {t('supporterTier.thankYou')}}
{details.timeRemainingText && - Renew now + {t('supporterTier.renewNow')} } || Supporter Tier: - Locked + {t('supporterTier.locked')} - Unlock now + {t('supporterTier.unlockNow')} } diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..c0cd4b1 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,57 @@ +import i18n from 'i18next'; + +import en from './locales/en/translation.json'; +import de from './locales/de/translation.json'; +import es from './locales/es/translation.json'; +import fr from './locales/fr/translation.json'; +import it from './locales/it/translation.json'; +import ja from './locales/ja/translation.json'; +import zhCN from './locales/zh-CN/translation.json'; +import ru from './locales/ru/translation.json'; +import ko from './locales/ko/translation.json'; +import ptBR from './locales/pt-BR/translation.json'; +import pl from './locales/pl/translation.json'; +import tr from './locales/tr/translation.json'; +import zhTW from './locales/zh-TW/translation.json'; + +// Normalize the browser locale to one of our supported language codes. +// i18next already strips region for simple cases (e.g. 'de-DE' → 'de'), but +// region is meaningful for Chinese (zh-CN vs zh-TW) and Portuguese (pt-BR), +// so we handle those explicitly. +function normalizeLocale(locale: string): string { + if (locale.startsWith('zh')) { + // Traditional Chinese variants: zh-TW, zh-HK, zh-MO, zh-Hant* + if (/^zh-(TW|HK|MO|Hant)/i.test(locale)) return 'zh-TW'; + // Everything else defaults to Simplified Chinese + return 'zh-CN'; + } + if (locale.startsWith('pt')) return 'pt-BR'; + // Return the base language tag (strip region); i18next handles the rest + return locale.split('-')[0]; +} + +i18n.init({ + resources: { + en: { translation: en }, + de: { translation: de }, + es: { translation: es }, + fr: { translation: fr }, + it: { translation: it }, + ja: { translation: ja }, + 'zh-CN': { translation: zhCN }, + ru: { translation: ru }, + ko: { translation: ko }, + 'pt-BR': { translation: ptBR }, + pl: { translation: pl }, + tr: { translation: tr }, + 'zh-TW': { translation: zhTW }, + }, + lng: normalizeLocale(navigator.language), + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; +export const { t } = i18n; diff --git a/src/index.tsx b/src/index.tsx index 1f8ba1c..8c5ddb7 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,7 @@ import {featureDetails, License, secondsRemaining, timeRemainingText} from "./li import {RefreshLicenseResponse} from "./SupporterTierModal"; import { useShowSupporterTierDetails, supporterTierDetails, SupporterTierStatus } from "./SupporterTierStatus"; import { SupporterTierFeatureLabel } from "./SupporterTierFeatureLabel"; +import { t } from "./i18n"; interface Config { disabled: boolean; @@ -106,91 +107,52 @@ const ManagedExternalModes: ExternalMode[] = ['virtual_display', 'sideview', 'no const SideviewPositions: SideviewPosition[] = ["center", "top_left", "top_right", "bottom_left", "bottom_right"]; const DirtyControlFlagsExpireMilliseconds = 3000; -const HeadsetModeDescriptions: {[key in HeadsetModeOption]: string} = { - "virtual_display": "Virtual display is only available in-game.", - "vr_lite": "Use Head movements to look around in-game.", - "sideview": "Display follow, sizing, and positioning.", - "disabled": "Static display with no head-tracking." -}; -const HeadsetModeOptions: HeadsetModeOption[] = Object.keys(HeadsetModeDescriptions) as HeadsetModeOption[]; - -const SideviewPositionDescriptions: {[key in SideviewPosition]: string} = { - "center": "Center", - "top_left": "Top\u00a0left", - "top_right": "Top\u00a0right", - "bottom_left": "Bottom\u00a0left", - "bottom_right": "Bottom\u00a0right" -}; +const getHeadsetModeDescriptions = (): {[key in HeadsetModeOption]: string} => ({ + "virtual_display": t('headsetMode.virtualDisplay.description'), + "vr_lite": t('headsetMode.vrLite.description'), + "sideview": t('headsetMode.sideview.description'), + "disabled": t('headsetMode.disabled.description'), +}); +const HeadsetModeOptions: HeadsetModeOption[] = ["virtual_display", "vr_lite", "sideview", "disabled"]; + +const getSideviewPositionDescriptions = (): {[key in SideviewPosition]: string} => ({ + "center": t('sideviewPosition.center'), + "top_left": t('sideviewPosition.topLeft'), + "top_right": t('sideviewPosition.topRight'), + "bottom_left": t('sideviewPosition.bottomLeft'), + "bottom_right": t('sideviewPosition.bottomRight'), +}); const HeadsetModeConfirmationTimeoutMs = 1000 -const ModeNotchLabels: NotchLabel[] = [ - { - label: "Virtual display", - notchIndex: 0 - }, - { - label: "VR\u2011Lite", - notchIndex: 1 - }, - { - label: "Follow", - notchIndex: 2 - }, - { - label: "Disabled", - notchIndex: 3 - }, +const getModeNotchLabels = (): NotchLabel[] => [ + { label: t('notch.virtualDisplay'), notchIndex: 0 }, + { label: t('notch.vrLite'), notchIndex: 1 }, + { label: t('notch.follow'), notchIndex: 2 }, + { label: t('notch.disabled'), notchIndex: 3 }, ]; -const DisplaySizeNotchLabels: NotchLabel[] = [ - { - label: "Smallest", - notchIndex: 0 - }, - { - label: "Default", - notchIndex: 3 - }, - { - label: "Biggest", - notchIndex: 8 - } +const getDisplaySizeNotchLabels = (): NotchLabel[] => [ + { label: t('notch.smallest'), notchIndex: 0 }, + { label: t('notch.default'), notchIndex: 3 }, + { label: t('notch.biggest'), notchIndex: 8 }, ]; -const SimpleDisplaySizeNotchLabels: NotchLabel[] = [ - {label: "Smallest", notchIndex: 0}, - {label: "Biggest", notchIndex: 1} +const getSimpleDisplaySizeNotchLabels = (): NotchLabel[] => [ + { label: t('notch.smallest'), notchIndex: 0 }, + { label: t('notch.biggest'), notchIndex: 1 }, ]; -const DisplayDisanceNotchLabels: NotchLabel[] = [ - { - label: "Closest", - notchIndex: 0 - }, - { - label: "Default", - notchIndex: 3 - }, - { - label: "Farthest", - notchIndex: 8 - } +const getDisplayDistanceNotchLabels = (): NotchLabel[] => [ + { label: t('notch.closest'), notchIndex: 0 }, + { label: t('notch.default'), notchIndex: 3 }, + { label: t('notch.farthest'), notchIndex: 8 }, ]; -const LookAheadNotchLabels: NotchLabel[] = [ - { - label: "Default", - notchIndex: 0 - }, - { - label: "Lower", - notchIndex: 2 - }, - { - label: "Higher", - notchIndex: 9 - } +const getLookAheadNotchLabels = (): NotchLabel[] => [ + { label: t('notch.default'), notchIndex: 0 }, + { label: t('notch.lower'), notchIndex: 2 }, + { label: t('notch.higher'), notchIndex: 9 }, ]; const FollowThresholdUpperNotchLabels: NotchLabel[] = [ @@ -218,9 +180,9 @@ const WidescreenFollowThresholdUpperNotchLabels: NotchLabel[] = [ const CentimetersPerInch = 2.54; const NormalizedSliderMin = 0.1; const NormalizedSliderMax = 2.5; -const MeasurementUnitOptions: {label: string; data: MeasurementUnits}[] = [ - {label: "Centimeters (cm)", data: "cm"}, - {label: "Inches (in)", data: "in"} +const getMeasurementUnitOptions = (): {label: string; data: MeasurementUnits}[] => [ + {label: t('measurementUnit.centimeters'), data: "cm"}, + {label: t('measurementUnit.inches'), data: "in"} ]; const clampValue = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); @@ -326,9 +288,7 @@ const Content: VFC = () => { try { if (!await waitForPendingBreezyInstall()) { if (!await call<[], boolean>("install_breezy") || !await waitForPendingBreezyInstall()) { - throw Error("There was an error during setup. Try restarting your Steam Deck. If " + - "the error persists, please file an issue in the decky-XRGaming GitHub " + - "repository."); + throw Error(t('error.setup')); } } setInstallationStatus("installed"); @@ -428,7 +388,7 @@ const Content: VFC = () => { try { const ok = await call<[], boolean>("force_reset_driver"); if (!ok) { - throw Error("Failed to restart xr-driver. Check plugin logs for details."); + throw Error(t('error.resetDriver')); } } catch (e) { setError((e as Error).message); @@ -441,7 +401,7 @@ const Content: VFC = () => { const asyncDataLoaded = !!config && !!driverState; const deviceConnected = !!driverState?.connected_device_brand && !!driverState?.connected_device_model - const deviceName = deviceConnected ? `${driverState?.connected_device_brand} ${driverState?.connected_device_model}` : "No device connected" + const deviceName = deviceConnected ? `${driverState?.connected_device_brand} ${driverState?.connected_device_model}` : t('device.noDevice') const headsetModeConfig = config?.ui_view.headset_mode ?? "disabled" const headsetMode: HeadsetModeOption = dirtyHeadsetMode ?? headsetModeConfig const isDisabled = !deviceConnected || headsetMode === "disabled" @@ -481,7 +441,7 @@ const Content: VFC = () => { const measurementUnits: MeasurementUnits = config?.measurement_units ?? "cm"; const measurementUnitsLabel = measurementUnits === "cm" ? "cm" : "in"; - const measurementUnitsMenuLabel = measurementUnits === "cm" ? "Centimeters" : "Inches"; + const measurementUnitsMenuLabel = measurementUnits === "cm" ? t('measurementUnit.centimetersShort') : t('measurementUnit.inchesShort'); const measurementSliderStep = measurementUnits === "cm" ? 1.0 : 0.5; const measurementDecimalPlaces = measurementUnits === "cm" ? 0 : 1; const fullDistanceCm = driverState?.connected_device_full_distance_cm; @@ -514,9 +474,9 @@ const Content: VFC = () => { const distanceMeasurementLabels = poseHasPosition ? buildMeasurementNotchLabels(fullDistanceCm, measurementUnits, 0.1, 2.5, 9, [0, 3, 8]) : undefined; - const displayDistanceNotchLabels = distanceMeasurementLabels ?? DisplayDisanceNotchLabels; - const displayDistanceLabel = poseHasPosition ? `Display distance (${measurementUnitsLabel})` : "Display distance"; - const displaySizeLabel = "Display size"; + const displayDistanceNotchLabels = distanceMeasurementLabels ?? getDisplayDistanceNotchLabels(); + const displayDistanceLabel = poseHasPosition ? t('display.distanceLabelWithUnits', { units: measurementUnitsLabel }) : t('display.distanceLabel'); + const displaySizeLabel = t('display.sizeLabel'); const normalizedDisplayDistance = Math.max(displayDistance, NormalizedSliderMin); const storedDisplaySize = config?.display_size ?? 1.0; const sizeMultiplier = isSizeAdjustedByDistance ? normalizedDisplayDistance : 1.0; @@ -598,13 +558,13 @@ const Content: VFC = () => { const sbsFirmwareUpdateNeeded = !driverState?.sbs_mode_supported && driverState?.firmware_update_recommended; let sbsDescription = ""; if (sbsFirmwareUpdateNeeded) { - sbsDescription = "Update your glasses' firmware to enable side-by-side mode."; + sbsDescription = t('sbs.firmwareUpdateNeeded'); } else if (!driverState?.sbs_mode_supported) { - sbsDescription = "Your glasses do not currently support side-by-side mode."; + sbsDescription = t('sbs.notSupported'); } else if (!driverState?.sbs_mode_enabled) { - sbsDescription = "Adjust display distance. View 3D content."; + sbsDescription = t('sbs.description'); } - const sbsLabel = "Enable side-by-side mode"; + const sbsLabel = t('sbs.label'); const enableSbsButton = driverState && { notchLabels={displayDistanceNotchLabels} label={displayDistanceLabel} description={ - driverState?.sbs_mode_enabled && "Adjust perceived display depth for eye comfort." || - poseHasPosition && "Display distances are approximate." + driverState?.sbs_mode_enabled && t('display.distanceDepthDesc') || + poseHasPosition && t('display.distanceApproxDesc') } step={measurementEnabledForDistance ? measurementSliderStep : 0.01} editableValue={true} @@ -662,8 +622,8 @@ const Content: VFC = () => { const joystickModeButton = { if (config) { updateConfig({ @@ -681,10 +641,10 @@ const Content: VFC = () => { const advancedSettings = [ poseHasPosition && { if (config) { const measurement_units = selection.data as MeasurementUnits; @@ -698,8 +658,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -713,8 +673,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -728,8 +688,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -745,10 +705,10 @@ const Content: VFC = () => { isVirtualDisplayMode && 0 ? "Use Default unless screen is noticeably ahead or behind your movements. May introduce jitter at higher values." : undefined} + label={t('advanced.lookAhead')} + description={(config?.look_ahead ?? 0) > 0 ? t('advanced.lookAheadDesc') : undefined} onChange={(look_ahead) => { if (config) { updateConfig({ @@ -762,8 +722,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -777,8 +737,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -794,15 +754,15 @@ const Content: VFC = () => { min={0.0} max={5.0} step={0.1} notchTicksVisible={true} notchCount={6} notchLabels={[ - {label: "Disabled", notchIndex: 0}, + {label: t('notch.disabled'), notchIndex: 0}, {label: "1.0", notchIndex: 1}, {label: "2.0", notchIndex: 2}, {label: "3.0", notchIndex: 3}, {label: "4.0", notchIndex: 4}, {label: "5.0", notchIndex: 5} ]} - label={"Dead-zone threshold (degrees)"} - description={"Stabilize movements below this angle."} + label={t('advanced.deadZone')} + description={t('advanced.deadZoneDesc')} editableValue={true} onChange={(dead_zone_threshold_deg) => { if (config) { @@ -817,8 +777,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -832,8 +792,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -846,20 +806,20 @@ const Content: VFC = () => { , writeControlFlags({recalibrate: true})} > {calibrating ? - Calibrating headset : - "Recalibrate headset" + {t('button.calibratingHeadset')} : + t('button.recalibrateHeadset') } , { if (config) { updateConfig({ @@ -871,8 +831,8 @@ const Content: VFC = () => { /> , isShaderMode && dontShowAgainKeys.length && - resetDontShowAgain()}> - Show all guides + resetDontShowAgain()}> + {t('advanced.showAllGuides')} ].filter(Boolean); @@ -903,7 +863,7 @@ const Content: VFC = () => { {deviceName} {deviceConnected && - connected + {t('device.connected')} } {deviceConnected ? : } @@ -912,11 +872,11 @@ const Content: VFC = () => { {deviceConnected && - setDirtyHeadsetMode(HeadsetModeOptions[newMode])} /> @@ -925,42 +885,42 @@ const Content: VFC = () => {
- Vulkan-only mode + {t('mode.vulkanOnly')}
- XR effects will only apply in-game + {t('mode.vulkanOnlyDesc')}
} {isOtherMode && - An external application may be using your headset data: {otherExternalModes.join(", ")}. + {t('externalMode.using')} {otherExternalModes.join(", ")}. - writeConfig({ ...config, disabled: true })} > - Disable data broadcast + {t('externalMode.disable')} || isOtherModeDisabled && - An external application may be trying to use your headset data: {otherExternalModes.join(", ")}. + {t('externalMode.trying')} {otherExternalModes.join(", ")}. - writeConfig({ ...config, disabled: false })} > - Enable data broadcast + {t('externalMode.enable')} } @@ -968,7 +928,7 @@ const Content: VFC = () => { {!isDisabled && isVrLiteMode && !isJoystickMode && { if (config) { updateConfig({ @@ -982,8 +942,8 @@ const Content: VFC = () => { {!isDisabled && isVrLiteMode && { if (config) { updateConfig({ @@ -997,8 +957,8 @@ const Content: VFC = () => { {!isDisabled && isVrLiteMode && { if (config) { updateConfig({ @@ -1011,9 +971,9 @@ const Content: VFC = () => { } {isSideviewMode && - ({ - label: SideviewPositionDescriptions[position], + label: getSideviewPositionDescriptions()[position], data: position }))} onChange={(selection) => { @@ -1029,15 +989,15 @@ const Content: VFC = () => { }).catch(e => setError(e)) } }} - menuLabel={SideviewPositionDescriptions[config?.sideview_position]} + menuLabel={getSideviewPositionDescriptions()[config?.sideview_position]} selectedOption={config?.sideview_position} /> } - description={"Display movements are more elastic"} + description={t('smoothFollow.sideviewDesc')} onChange={(sideview_smooth_follow_enabled) => { if (!smoothFollowFeature.enabled) { showSupporterTierDetailsFn(supporterTier, requestToken, verifyToken, refreshLicense); @@ -1056,8 +1016,8 @@ const Content: VFC = () => { WidescreenFollowThresholdUpperNotchLabels.length : FollowThresholdUpperNotchLabels.length} notchTicksVisible={false} - label={"Smooth follow threshold"} - description={"How closely the display follows you"} + label={t('smoothFollow.threshold')} + description={t('smoothFollow.thresholdDesc')} notchLabels={isWidescreen ? WidescreenFollowThresholdUpperNotchLabels : FollowThresholdUpperNotchLabels} @@ -1082,8 +1042,8 @@ const Content: VFC = () => { editableValue={smoothFollowEnabled} label={displaySizeLabel} notchLabels={smoothFollowEnabled ? - DisplaySizeNotchLabels : - SimpleDisplaySizeNotchLabels + getDisplaySizeNotchLabels() : + getSimpleDisplaySizeNotchLabels() } step={0.01} onChange={(rawValue) => { @@ -1104,9 +1064,9 @@ const Content: VFC = () => { } - description={"Recenter under certain conditions"} + description={t('smoothFollow.autoRecenterDesc')} onChange={(virtual_display_smooth_follow_enabled) => { if (!smoothFollowFeature.enabled) { showSupporterTierDetailsFn(supporterTier, requestToken, verifyToken, refreshLicense); @@ -1123,9 +1083,9 @@ const Content: VFC = () => { min={NormalizedSliderMin} max={NormalizedSliderMax} notchCount={9} - notchLabels={DisplaySizeNotchLabels} + notchLabels={getDisplaySizeNotchLabels()} label={displaySizeLabel} - description={sbsModeEnabled && "Display distance setting also affects perceived display size."} + description={sbsModeEnabled && t('display.sizeDistanceDesc')} step={0.01} editableValue={true} onChange={(rawValue) => { @@ -1144,20 +1104,20 @@ const Content: VFC = () => { } {is3DoFMode && writeControlFlags({recenter_screen: true})} > {calibrating ? - Calibrating headset : - "Recenter display" + {t('button.calibratingHeadset')} : + t('button.recenterDisplay') } } {is3DoFMode && { if (config) { updateConfig({ @@ -1176,8 +1136,8 @@ const Content: VFC = () => { {isVulkanOnlyMode && { if (config) { updateConfig({ @@ -1190,8 +1150,8 @@ const Content: VFC = () => { { if (config) { updateConfig({ @@ -1208,13 +1168,13 @@ const Content: VFC = () => { {!isDisabled && {!showAdvanced && advancedButtonVisible && setShowAdvanced(true)} > - Show advanced settings + {t('button.showAdvanced')} } {showAdvanced && advancedSettings} {showAdvanced && advancedButtonVisible && setShowAdvanced(false)} > - Hide advanced settings + {t('button.hideAdvanced')} } } @@ -1232,19 +1192,19 @@ const Content: VFC = () => { background: 'linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet)', WebkitBackgroundClip: 'text', color: 'transparent' - }}>Virtual display help + }}>{t('button.virtualDisplayHelp')}{t('button.virtualDisplayHelpSuffix')} || } url={"https://github.com/wheaney/decky-XRGaming#xr-gaming-plugin"} followLink={true} > - Need help? + {t('button.needHelp')} } } url={"https://discord.gg/GRQcfR5h9c"}> - News. Discussions. Help.
- Join the chat! + {t('button.discordNews')}
+ {t('button.discordJoin')}
@@ -1253,8 +1213,8 @@ const Content: VFC = () => { layout="below" onClick={() => forceResetDriver()}> {forceResettingDriver ? - Restarting driver : - "Force reset driver" + {t('button.restartingDriver')} : + t('button.forceResetDriver') } @@ -1263,7 +1223,7 @@ const Content: VFC = () => { {installationStatus == "inProgress" && - Installing... + {t('button.installing')} }
diff --git a/src/license.ts b/src/license.ts index b2d793d..1232d26 100644 --- a/src/license.ts +++ b/src/license.ts @@ -1,4 +1,4 @@ - +import i18n from './i18n'; export enum FeatureStatus { Off = "off", @@ -103,13 +103,13 @@ export function timeRemainingText(seconds?: number): string | undefined { if (!seconds) return; if (seconds < SecondsPerHour) { - return `less than an hour` + return i18n.t('license.lessThanHour'); } else if (seconds / SecondsPerHour < EndDateWarnHours) { const timeRemaining = Math.floor(seconds / SecondsPerHour); - return timeRemaining === 1 ? '1 hour' : `${timeRemaining} hours` + return timeRemaining === 1 ? i18n.t('license.hour') : i18n.t('license.hours', { count: timeRemaining }); } else if (seconds / SecondsPerDay < EndDateWarnDays) { const timeRemaining = Math.floor(seconds / SecondsPerDay); - return timeRemaining === 1 ? '1 day' : `${timeRemaining} days` + return timeRemaining === 1 ? i18n.t('license.day') : i18n.t('license.days', { count: timeRemaining }); } else { return; } @@ -118,15 +118,15 @@ export function timeRemainingText(seconds?: number): string | undefined { export function featureSubtext(license: License | undefined, featureName: string): string | undefined { const now = toSec(Date.now()); const feature = license?.features?.[featureName]; - if ((feature?.status ?? FeatureStatus.Off) === FeatureStatus.Off) return "Supporter Tier feature"; + if ((feature?.status ?? FeatureStatus.Off) === FeatureStatus.Off) return i18n.t('license.supporterFeature'); const secondsRemaining = (feature?.endDate ?? Infinity) - now; if (secondsRemaining < 0) { switch (feature?.status) { case FeatureStatus.On: - return "Supporter Tier expired"; + return i18n.t('license.supporterExpired'); case FeatureStatus.Trial: - return "Trial period expired"; + return i18n.t('license.trialExpired'); } return; } @@ -135,17 +135,17 @@ export function featureSubtext(license: License | undefined, featureName: string if (timeRemaining) { switch (feature?.status) { case FeatureStatus.On: - return `Supporter Tier: ${timeRemaining} left`; + return i18n.t('license.supporterTimeLeft', { time: timeRemaining }); case FeatureStatus.Trial: - return `Trial feature: ${timeRemaining} left`; + return i18n.t('license.trialTimeLeft', { time: timeRemaining }); } } switch (feature?.status) { case FeatureStatus.On: - return "Supporter Tier feature"; + return i18n.t('license.supporterFeature'); case FeatureStatus.Trial: - return "Supporter Tier trial feature"; + return i18n.t('license.supporterTrial'); } return; } \ No newline at end of file diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/de/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json new file mode 100644 index 0000000..f998e8f --- /dev/null +++ b/src/locales/en/translation.json @@ -0,0 +1,262 @@ +{ + "device": { + "noDevice": "No device connected", + "connected": "connected" + }, + "headsetMode": { + "virtualDisplay": { + "description": "Virtual display is only available in-game." + }, + "vrLite": { + "description": "Use Head movements to look around in-game." + }, + "sideview": { + "description": "Display follow, sizing, and positioning." + }, + "disabled": { + "description": "Static display with no head-tracking." + } + }, + "notch": { + "virtualDisplay": "Virtual display", + "vrLite": "VR\u2011Lite", + "follow": "Follow", + "disabled": "Disabled", + "smallest": "Smallest", + "default": "Default", + "biggest": "Biggest", + "closest": "Closest", + "farthest": "Farthest", + "lower": "Lower", + "higher": "Higher" + }, + "measurementUnit": { + "centimeters": "Centimeters (cm)", + "centimetersShort": "Centimeters", + "inches": "Inches (in)", + "inchesShort": "Inches" + }, + "mode": { + "vulkanOnly": "Vulkan-only mode", + "vulkanOnlyDesc": "XR effects will only apply in-game" + }, + "externalMode": { + "using": "An external application may be using your headset data:", + "disableDesc": "Disables external access to headset data", + "disable": "Disable data broadcast", + "trying": "An external application may be trying to use your headset data:", + "enableDesc": "Allows for external access to headset data", + "enable": "Enable data broadcast" + }, + "sideviewPosition": { + "center": "Center", + "topLeft": "Top\u00a0left", + "topRight": "Top\u00a0right", + "bottomLeft": "Bottom\u00a0left", + "bottomRight": "Bottom\u00a0right" + }, + "vrLite": { + "mouseSensitivity": "Mouse sensitivity", + "invertX": "Invert X-axis", + "invertXDesc": "Inverts X-axis movements in VR-Lite mode.", + "invertY": "Invert Y-axis", + "invertYDesc": "Inverts Y-axis movements in VR-Lite mode.", + "joystickMode": "Joystick mode", + "joystickModeDesc": "Try as a last resort if your game doesn't support mouse-look." + }, + "sbs": { + "firmwareUpdateNeeded": "Update your glasses' firmware to enable side-by-side mode.", + "notSupported": "Your glasses do not currently support side-by-side mode.", + "description": "Adjust display distance. View 3D content.", + "label": "Enable side-by-side mode", + "stretched": "Content is stretched", + "stretchedDesc": "Enable if your content goes from the left edge to the right edge of the screen", + "is3d": "Content is 3D", + "is3dDesc": "For when the source content is 3D SBS" + }, + "display": { + "sizeLabel": "Display size", + "sizeDistanceDesc": "Display distance setting also affects perceived display size.", + "distanceLabel": "Display distance", + "distanceLabelWithUnits": "Display distance ({{units}})", + "distanceDepthDesc": "Adjust perceived display depth for eye comfort.", + "distanceApproxDesc": "Display distances are approximate.", + "position": "Display position", + "curved": "Curved display", + "curvedDesc": "Wrap the display around your field of view." + }, + "smoothFollow": { + "sideviewLabel": "Smooth follow", + "sideviewDesc": "Display movements are more elastic", + "threshold": "Smooth follow threshold", + "thresholdDesc": "How closely the display follows you", + "autoRecenterLabel": "Automatic recentering", + "autoRecenterDesc": "Recenter under certain conditions" + }, + "button": { + "recenterDisplay": "Recenter display", + "calibratingHeadset": "Calibrating headset", + "recalibrateHeadset": "Recalibrate headset", + "recalibrateHeadsetDesc": "Or triple-tap your headset.", + "recenterDoubleDesc": "Or double-tap your headset.", + "showAdvanced": "Show advanced settings", + "hideAdvanced": "Hide advanced settings", + "forceResetDriver": "Force reset driver", + "restartingDriver": "Restarting driver", + "installing": "Installing...", + "needHelp": "Need help?", + "virtualDisplayHelp": "Virtual display", + "virtualDisplayHelpSuffix": " help", + "discordNews": "News. Discussions. Help.", + "discordJoin": "Join the chat!" + }, + "advanced": { + "distanceUnits": "Distance units", + "horizontalFollow": "Horizontal follow", + "horizontalFollowDesc": "Smooth follow will track horizontal movements.", + "verticalFollow": "Vertical follow", + "verticalFollowDesc": "Smooth follow will track vertical movements.", + "tiltRollFollow": "Tilt/roll follow", + "tiltRollFollowDesc": "Smooth follow will track roll/tilt movements.", + "lookAhead": "Movement look-ahead", + "lookAheadDesc": "Use Default unless screen is noticeably ahead or behind your movements. May introduce jitter at higher values.", + "neckSaverH": "Neck-saver horizontal", + "neckSaverHDesc": "Increase to amplify left/right neck movements.", + "neckSaverV": "Neck-saver vertical", + "neckSaverVDesc": "Increase to amplify up/down neck movements.", + "deadZone": "Dead-zone threshold (degrees)", + "deadZoneDesc": "Stabilize movements below this angle.", + "multiTap": "Multi-tap enabled", + "multiTapDesc": "Enable double-tap to recenter and triple-tap to recalibrate.", + "openTrack": "OpenTrack listener", + "openTrackDesc": "Listen for OpenTrack UDP data.", + "disableGamescope": "Disable gamescope integration", + "disableGamescopeDesc": "XR effects will only apply to Vulkan games", + "showAllGuides": "Show all guides", + "showAllGuidesDesc": "Clear your \"Don't show again\" settings." + }, + "error": { + "setup": "There was an error during setup. Try restarting your Steam Deck. If the error persists, please file an issue in the decky-XRGaming GitHub repository.", + "resetDriver": "Failed to restart xr-driver. Check plugin logs for details." + }, + "supporterTier": { + "label": "Supporter Tier:", + "inTrial": "In trial", + "trialEndsIn": "Trial ends in {{time}}", + "trialLockedNote": "(only tagged Supporter Tier features will be locked)", + "becomeSupporter": "Become a supporter", + "lifetime": "Lifetime", + "unlocked": "Unlocked", + "accessEndsIn": "Access ends in {{time}}", + "thankYou": "You rock! Thanks!", + "renewNow": "Renew now", + "locked": "Locked", + "unlockNow": "Unlock now" + }, + "supporterTierModal": { + "sbsFeature": "Side-by-side", + "sbsFeatureDesc": "Change the display distance. View 3D content.", + "smoothFollowFeature": "Smooth Follow", + "smoothFollowFeatureDesc": "Display follows you. Tracks movements with smooth accelerations.", + "autoRecenterFeature": "Auto re-centering", + "autoRecenterFeatureDesc": "Virtual display automatically re-centers itself based on look-away distance or duration.", + "renewBlurb": "Your Supporter Tier access ends in {{time}}. Donate ${{fundsNeeded}} USD more to renew for another year.", + "enrollBlurbTrial": "Your Supporter Tier trial ends in {{time}}.", + "enrollBlurb": "Donate ${{fundsNeeded}} USD to get Supporter Tier access for 12 months, or ${{lifetimeFundsNeeded}} USD for lifetime access.", + "offlineTitle": "Supporter Tier - Device offline", + "offlineDesc": "Your device needs to be connected to the internet to retrieve your Supporter Tier status.", + "retrievingLicense": "Retrieving license", + "onlineNow": "I'm online now", + "tryNewToken": "Try a new token", + "refreshingLicense": "Refreshing license", + "alreadyDonated": "I've already donated", + "enrollTitle": "Supporter Tier", + "donateNow": "Donate now", + "renewTitle": "Supporter Tier - Renewal", + "donateTitle": "Supporter Tier - Donate", + "donateQrNote": "To scan the code, you can view this dialog with your glasses unplugged.", + "okayDonated": "Okay, I've donated", + "verifyTitle": "Supporter Tier - Verify Token", + "verifyDesc": "Enter the token that was emailed to you at the email address you use as your Ko-fi login. If you don't receive it, double-check that you're using the correct email address, check your spam folder, or request a new one.", + "tokenLabel": "Token", + "tokenInvalid": "Token \"{{token}}\" is invalid, was requested from another device, or the server couldn't be reached. Please make sure your device is online, or request a new token.", + "tokenError": "An error occurred. Please report this issue if it persists.", + "checkingToken": "Checking token", + "needNewToken": "I need a new token", + "requestTitle": "Supporter Tier - Request Token", + "requestDesc": "Enter the same email address you use as your Ko-fi login.", + "emailLabel": "Email", + "sendVerification": "Send verification", + "tokenSuccess": "Success!" + }, + "license": { + "lessThanHour": "less than an hour", + "hour": "1 hour", + "hours": "{{count}} hours", + "day": "1 day", + "days": "{{count}} days", + "supporterFeature": "Supporter Tier feature", + "supporterExpired": "Supporter Tier expired", + "trialExpired": "Trial period expired", + "supporterTimeLeft": "Supporter Tier: {{time}} left", + "trialTimeLeft": "Trial feature: {{time}} left", + "supporterTrial": "Supporter Tier trial feature" + }, + "tutorial": { + "ok": "OK", + "dontShowAgain": "Don't show again", + "cancel": "Cancel", + "virtualDisplay": { + "title": "Virtual Display" + }, + "sbsMode": { + "title": "Side-by-side mode" + }, + "vd": { + "limitationsTitle": "Limitations", + "vulkanOnlyIntro": "In Vulkan-only mode, the virtual display will only work under certain conditions:", + "limitation1": "ONLY on in-game content. Not on Steam itself.", + "limitation2": "ONLY when playing a Vulkan game.", + "limitation3": "ONLY when the game is running on this device. Streaming apps won't work for now.", + "limitation4": "Vulkan games installed through other apps or launchers may not work, such as the Heroic Flatpak app.", + "blackScreen": "If you're encountering a black screen, try using the Recenter button.", + "optimizingTitle": "Optimizing Performance", + "optimizingIntro": "Virtual display works best with low input lag and higher game frame rates. Here are some recommended settings:", + "performanceSettingsInstruction": "In the Deck's ... Performance menu, flip on Disable Frame Limit and enable Allow Tearing.", + "performanceSettingsAlt": "Modifying performance settings", + "graphicsSettings": "After launching a game, go into the graphics/video settings, set the resolution to any 16:9 aspect ratio (lower is better for performance, but keep the glasses' aspect ratio), disable VSync, and tune your settings for higher performance/lower quality." + }, + "common": { + "gamePropertiesInstruction": "In the Game details view, click the Settings icon, and set the resolution to Native.", + "gamePropertiesAlt": "Setting the Game Resolution", + "displayResolutionEnableInstruction": "In Settings\u2192Display, enable the Automatically Set Resolution toggle.", + "displayResolutionAlt": "Setting the Display Resolution", + "displayResolutionSbsInstruction": "In Settings\u2192Display, disable the Automatically Set Resolution toggle, and set the resolution to the highest value, typically a 32:9 aspect ratio like 3840x1080." + }, + "sbs": { + "usageTitle": "Usage", + "usageNote": "NOTE: This mode DOES NOT add stereoscopic depth to games that don't already support it natively.", + "toggleDescVulkan": "Enabling this toggle will switch your glasses to side-by-side mode, which will make it difficult to navigate non-game content -- such as Steam. For the best experience, wait until after you've launched a game to enable SBS mode, and disable it before exiting the game.", + "toggleDescGamescope": "Enabling this toggle will switch your glasses to side-by-side mode, which will make it difficult to navigate non-game content -- such as Steam -- in some scenarios.", + "depthDesc": "This mode enables depth-based effects like moving how close or far the virtual display appears, which may relieve eye strain for users that experience that with default settings. It can also render stereoscopic 3D content in the virtual display for games that support it natively, or you can try using a tool like ReShade to add 3D to games that don't support it natively.", + "recommendedTitle": "Recommended Settings", + "recommendedIntro": "The following settings are recommended for a consistent experience across games:", + "scalingModeInstruction": "In the Deck's ... Performance menu, move the Scaling Mode slider to Stretch.", + "scalingModeAlt": "Setting Scaling Mode to Stretch", + "controlsTitle": "Controls", + "controlsEnable": "You can enable or disable SBS mode directly from the glasses by {{instructions}}. Or you can return to the plugin sidebar menu and disable it through the toggle.", + "defaultInstructions": "consulting the owner's manual", + "newControls": "You'll see some new controls in the plugin sidebar menu when SBS is enabled:", + "displayDistanceDesc": "Display distance uses stereoscopic depth perception to make the screen appear farther or closer. Since items that are closer also appear larger, you'll probably want to use the Display size setting in conjunction with this. If you experience eye strain using your glasses typically, you may find it more comfortable to move the screen closer, for example. If you're already comfortable with the default screen distance, using this may introduce eye strain.", + "stretchedControlDesc": "Content is stretched indicates that your game is being rendered using the full width of the screen. You should enable this if you followed the recommendation to set Scaling Mode to Stretch. If the screen content appears to be getting cut off or isn't lining up in each eye, you may find that toggling this fixes it.", + "is3dControlDesc": "Content is 3D indicates that the game is rendering as stereoscopic, side-by-side 3D, either natively or using a tool that adds stereoscopic depth.", + "instructions": { + "XREAL": "long-pressing the brightness/volume-up button for about 3 seconds", + "VITURE": "long-pressing the mode (short) button for about 2 seconds", + "TCL": "long-pressing the brightness-up button on the right arm", + "RayNeo": "pressing the buttons on the left and right arms of the glasses simultaneously", + "Rokid": "long-pressing the brightness (short) button for about 2 seconds" + } + } + } +} diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/es/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/fr/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/it/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/ja/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/ko/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/pl/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/pt-BR/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/ru/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/tr/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/zh-CN/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/locales/zh-TW/translation.json @@ -0,0 +1 @@ +{} diff --git a/src/tutorials.tsx b/src/tutorials.tsx index be184da..4103c36 100644 --- a/src/tutorials.tsx +++ b/src/tutorials.tsx @@ -4,6 +4,7 @@ import commonDisplayResolutionVideo from "../assets/tutorials/common/display-res import commonGamePropertiesVideo from "../assets/tutorials/common/game-properties-resolution.webp"; import vdSidebarPerformanceVideo from "../assets/tutorials/virtual_display/sidebar-performance.webp"; import sbsScalingStretchVideo from "../assets/tutorials/sbs/scaling-mode-stretch.webp"; +import { t } from "./i18n"; const videoStyle = { width: "400px", @@ -12,47 +13,34 @@ const videoStyle = { marginRight: 'auto' } -const navigationDetail = { - fontStyle: 'italic' -} - -const actionLabel = { - fontWeight: 'bold' -} - -const listItem = { - paddingBottom: 30 -} - function GamePropertiesResolutionListItem() { return
  • -

    In the Game details view, click the Settings icon, and set the resolution - to Native.

    +

    {}} noFocusRing={true}> - Setting the Game Resolution + {t('tutorial.common.gamePropertiesAlt')}

  • } function SteamDisplayResolutionListItem() { return
  • -

    In Settings->Display, enable the Automatically Set Resolution toggle.

    +

    {}} noFocusRing={true}> - Setting the Display Resolution + {t('tutorial.common.displayResolutionAlt')}

  • } function SteamDisplayResolutionGamescopeSBSListItem() { return
  • -

    In Settings->Display, disable the Automatically Set Resolution toggle, and set the resolution to the - highest value, typically a 32:9 aspect ratio like 3840x1080.

    +

  • } +const listItem = { + paddingBottom: 30 +} + type TutorialComponentProps = { deviceBrand: string, deviceModel: string @@ -62,37 +50,31 @@ type TutorialComponent = React.FC; function VirtualDisplayTutorial() { return {}} noFocusRing={true}> - -

    In Vulkan-only mode, the virtual display will only work under certain conditions:

    + +

    {t('tutorial.vd.vulkanOnlyIntro')}

      -
    • ONLY on in-game content. Not on Steam itself.
    • -
    • ONLY when playing a Vulkan game.
    • -
    • ONLY when the game is running on this device. Streaming apps won't work for now.
    • -
    • Vulkan games installed through other apps or launchers may not work, such as the Heroic Flatpak app.
    • +
    • +
    • +
    • +
    • {t('tutorial.vd.limitation4')}
    -

    If you're encountering a black screen, try using the Recenter button.

    +

    {t('tutorial.vd.blackScreen')}

    - -

    Virtual display works best with low input lag and higher game frame rates. Here are some recommended settings:

    + +

    {t('tutorial.vd.optimizingIntro')}

    1. -

      In the Deck's ... Performance menu, flip on Disable Frame Limit and enable Allow - Tearing.

      +

      {}} noFocusRing={true}> - Modifying performance settings + {t('tutorial.vd.performanceSettingsAlt')}

    2. {}} noFocusRing={true}> -

      After launching a game, go into the - graphics/video settings, set the resolution to - any 16:9 aspect ratio (lower is better for performance, but keep the glasses' - aspect ratio), disable VSync, and tune your settings for - higher performance/lower quality.

      +

    @@ -100,77 +82,58 @@ function VirtualDisplayTutorial() {
    } -const deviceBrandToSBSInstructions: {[brand: string]: string} = { - "XREAL": "long-pressing the brightness/volume-up button for about 3 seconds", - "VITURE": "long-pressing the mode (short) button for about 2 seconds", - "TCL": "long-pressing the brightness-up button on the right arm", - "RayNeo": "pressing the buttons on the left and right arms of the glasses simultaneously", - "Rokid": "long-pressing the brightness (short) button for about 2 seconds" -} - function getSBSTutorialComponent(vulkanOnlyMode: boolean) { return function SBSTutorial(props: TutorialComponentProps) { + const sbsInstructionKeys: Record = { + 'XREAL': t('tutorial.sbs.instructions.XREAL'), + 'VITURE': t('tutorial.sbs.instructions.VITURE'), + 'TCL': t('tutorial.sbs.instructions.TCL'), + 'RayNeo': t('tutorial.sbs.instructions.RayNeo'), + 'Rokid': t('tutorial.sbs.instructions.Rokid'), + }; + const sbsInstruction = sbsInstructionKeys[props.deviceBrand] ?? t('tutorial.sbs.defaultInstructions'); return - + {}} noFocusRing={true}>

    - NOTE: This mode DOES NOT add stereoscopic depth to games that don't already support it natively. + {t('tutorial.sbs.usageNote')}

    - Enabling this toggle will switch your glasses to side-by-side mode, which will make it difficult to - navigate non-game content -- such as Steam{!vulkanOnlyMode && ' -- in some scenarios'}. {vulkanOnlyMode && - 'For the best experience, wait until after you\'ve launched a game to enable SBS mode, ' + - 'and disable it before exiting the game.'} + {vulkanOnlyMode + ? t('tutorial.sbs.toggleDescVulkan') + : t('tutorial.sbs.toggleDescGamescope')}

    - This mode enables depth-based effects like moving how close or far the virtual display appears, - which may relieve eye strain for users that experience that with default settings. It can also - render stereoscopic 3D content in the virtual display for games that support it natively, or you - can try using a tool like ReShade to add 3D to games that don't support it natively. + {t('tutorial.sbs.depthDesc')}

    - -

    The following settings are recommended for a consistent experience across games:

    + +

    {t('tutorial.sbs.recommendedIntro')}

      {vulkanOnlyMode ? : } {vulkanOnlyMode &&
    1. -

      In the Deck's ... Performance menu, move the Scaling Mode slider to - Stretch.

      +

      {}} noFocusRing={true}> - Setting Scaling Mode to Stretch + {t('tutorial.sbs.scalingModeAlt')}

    2. }
    - + {}} noFocusRing={true}>

    - You can enable or disable SBS mode directly from the glasses by { - deviceBrandToSBSInstructions[props.deviceBrand] ?? 'consulting the owner\'s manual' - }. - Or you can return to the plugin sidebar menu and disable it through the toggle. + {t('tutorial.sbs.controlsEnable', { instructions: sbsInstruction })}

    - You'll see some new controls in the plugin sidebar menu when SBS is enabled: + {t('tutorial.sbs.newControls')}

      -
    • Display distance uses stereoscopic depth perception to make - the screen appear farther or closer. Since items that are closer also appear larger, you'll - probably want to use the Display size setting in - conjunction with this. If you experience eye strain using your glasses typically, you may - find it more comfortable to move the screen closer, for example. If you're already - comfortable with the default screen distance, using this may introduce eye strain.
    • - {vulkanOnlyMode &&
    • Content is stretched indicates that your game is being - rendering using the full width of the screen. You should enable this if you followed the - recommendation to set Scaling Mode to Stretch. If the screen content appears to be getting - cut off or isn't lining up in each eye, you may find that toggling this fixes it.
    • } -
    • Content is 3D indicates that the game is rendering as - stereoscopic, side-by-side 3D, either natively or using a tool that adds stereoscopic - depth.
    • +
    • + {vulkanOnlyMode &&
    • } +

    @@ -180,21 +143,21 @@ function getSBSTutorialComponent(vulkanOnlyMode: boolean) { } type tutorial = { - title: string, + titleKey: string, description?: string, component: TutorialComponent } export const tutorials: { [key: string]: tutorial } = { 'headset_mode_virtual_display_vulkan_only': { - title: 'Virtual Display', + titleKey: 'tutorial.virtualDisplay.title', component: VirtualDisplayTutorial }, 'sbs_mode_enabled_true_vulkan_only': { - title: 'Side-by-side mode', + titleKey: 'tutorial.sbsMode.title', component: getSBSTutorialComponent(true) }, 'sbs_mode_enabled_true': { - title: 'Side-by-side mode', + titleKey: 'tutorial.sbsMode.title', component: getSBSTutorialComponent(false) } } @@ -207,11 +170,11 @@ export function onChangeTutorial(tutorialKey: string, deviceBrand: string, devic const tutorial = tutorials[tutorialKey]; const TutorialComponent = tutorial.component; showModal( { onConfirm(); await setDontShowAgain(tutorialKey); diff --git a/tsconfig.json b/tsconfig.json index c2bc719..34e7158 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "noImplicitAny": true, "strict": true, "allowSyntheticDefaultImports": true, - "skipLibCheck": true + "skipLibCheck": true, + "resolveJsonModule": true }, "include": ["src"], "exclude": ["node_modules"]