From a3e673bd6854e006f4ae36e3ebfbfd6c6120e197 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Tue, 28 Jul 2026 16:46:25 +1000 Subject: [PATCH] feat(i18n): convert to opaque keys (#1848) --- .../app/shared/i18n/cdn-sandbox-locales.ts | 4 +- apps/sandbox/templates/cdn/main.ts | 2 +- packages/core/package.json | 8 +- .../core/scripts/generate-i18n-locales.ts | 83 +++++++-- packages/core/scripts/generate-i18n-types.ts | 34 ++++ packages/core/scripts/i18n-utils.ts | 5 + .../core/src/core/i18n/browser-translation.ts | 24 +-- packages/core/src/core/i18n/index.ts | 12 +- packages/core/src/core/i18n/load-locale.ts | 10 +- packages/core/src/core/i18n/locales/all.ts | 2 +- packages/core/src/core/i18n/locales/ar.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/az.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/bg.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/bn.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/bs.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/ca.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/cs.ts | 162 ++++++++++------- packages/core/src/core/i18n/locales/cy.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/da.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/de.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/el.ts | 167 +++++++++++------- packages/core/src/core/i18n/locales/en.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/es.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/et.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/eu.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/fa.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/fi.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/fr.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/gd.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/gl.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/he.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/hi.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/hr.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/hu.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/it.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/ja.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/ko.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/lv.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/mr.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/nb.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/ne.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/nl.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/nn.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/oc.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/pl.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/pt-BR.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/pt-PT.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/ro.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/ru.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/sk.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/sl.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/sr.ts | 164 ++++++++++------- packages/core/src/core/i18n/locales/sv.ts | 166 ++++++++++------- packages/core/src/core/i18n/locales/te.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/th.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/tr.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/uk.ts | 165 ++++++++++------- packages/core/src/core/i18n/locales/vi.ts | 163 ++++++++++------- packages/core/src/core/i18n/locales/zh-CN.ts | 162 ++++++++++------- packages/core/src/core/i18n/locales/zh-TW.ts | 162 ++++++++++------- .../core/src/core/i18n/params.generated.ts | 68 +++++++ packages/core/src/core/i18n/params.ts | 41 +++++ packages/core/src/core/i18n/registry.ts | 25 ++- packages/core/src/core/i18n/resolve-text.ts | 5 + .../core/src/core/i18n/resolve-translation.ts | 23 +-- .../i18n/tests/browser-translation.test.ts | 27 +-- .../src/core/i18n/tests/load-locale.test.ts | 16 +- .../core/src/core/i18n/tests/registry.test.ts | 65 ++++--- .../i18n/tests/resolve-translation.test.ts | 22 +-- .../src/core/i18n/tests/translator.test.ts | 67 +++++-- packages/core/src/core/i18n/text.ts | 12 ++ packages/core/src/core/i18n/text/airplay.ts | 14 ++ packages/core/src/core/i18n/text/buttons.ts | 29 +++ packages/core/src/core/i18n/text/captions.ts | 14 ++ packages/core/src/core/i18n/text/cast.ts | 19 ++ packages/core/src/core/i18n/text/common.ts | 14 ++ packages/core/src/core/i18n/text/errors.ts | 39 ++++ .../core/src/core/i18n/text/fullscreen.ts | 14 ++ packages/core/src/core/i18n/text/live.ts | 19 ++ packages/core/src/core/i18n/text/menu.ts | 64 +++++++ packages/core/src/core/i18n/text/pip.ts | 14 ++ packages/core/src/core/i18n/text/playback.ts | 9 + packages/core/src/core/i18n/text/seek.ts | 14 ++ packages/core/src/core/i18n/text/slider.ts | 9 + packages/core/src/core/i18n/text/status.ts | 39 ++++ packages/core/src/core/i18n/text/time.ts | 44 +++++ packages/core/src/core/i18n/text/volume.ts | 24 +++ packages/core/src/core/i18n/translate-text.ts | 9 + packages/core/src/core/i18n/translator.ts | 52 ++++-- packages/core/src/core/i18n/types.ts | 109 ------------ packages/core/src/core/i18n/utils/flatten.ts | 10 ++ packages/core/src/core/i18n/utils/index.ts | 2 + .../core/src/core/i18n/utils/interpolate.ts | 10 ++ .../ui/airplay-button/airplay-button-core.ts | 16 +- .../tests/airplay-button-core.test.ts | 17 +- .../audio-track-radio-group-core.ts | 21 +-- .../audio-track-radio-group-core.test.ts | 4 +- .../captions-button/captions-button-core.ts | 11 +- .../tests/captions-button-core.test.ts | 12 +- .../captions-radio-group-core.ts | 22 +-- .../tests/captions-radio-group-core.test.ts | 19 +- .../core/ui/cast-button/cast-button-core.ts | 15 +- .../tests/cast-button-core.test.ts | 17 +- .../core/ui/error-dialog/error-dialog-i18n.ts | 66 ++++--- .../tests/error-dialog-i18n.test.ts | 45 +++-- .../fullscreen-button-core.ts | 11 +- .../tests/fullscreen-button-core.test.ts | 12 +- .../core/src/core/ui/input-feedback/labels.ts | 35 ++-- .../ui/input-feedback/tests/labels.test.ts | 26 +-- .../core/ui/live-button/live-button-core.ts | 24 +-- .../tests/live-button-core.test.ts | 10 +- .../core/ui/mute-button/mute-button-core.ts | 11 +- .../tests/mute-button-core.test.ts | 6 +- .../src/core/ui/pip-button/pip-button-core.ts | 11 +- .../pip-button/tests/pip-button-core.test.ts | 12 +- .../core/ui/play-button/play-button-core.ts | 13 +- .../tests/play-button-core.test.ts | 10 +- .../playback-rate-button-core.ts | 11 +- .../tests/playback-rate-button-core.test.ts | 17 +- .../playback-rate-radio-group-core.ts | 11 +- .../playback-rate-radio-group-core.test.ts | 7 +- .../quality-radio-group-core.ts | 29 +-- .../tests/quality-radio-group-core.test.ts | 5 +- .../core/ui/seek-button/seek-button-core.ts | 11 +- .../tests/seek-button-core.test.ts | 20 ++- .../core/src/core/ui/slider/slider-core.ts | 5 +- .../tests/time-slider-core.test.ts | 8 +- .../core/ui/time-slider/time-slider-core.ts | 12 +- .../src/core/ui/time/tests/time-core.test.ts | 47 +++-- packages/core/src/core/ui/time/time-core.ts | 37 ++-- packages/core/src/core/ui/types.ts | 5 +- .../core/src/core/ui/utils/resolve-label.ts | 5 +- .../tests/volume-slider-core.test.ts | 6 +- .../ui/volume-slider/volume-slider-core.ts | 10 +- packages/core/tsdown.config.ts | 3 + packages/html/src/cdn/i18n.ts | 5 +- .../src/define/video/minimal-skin.tailwind.ts | 20 ++- .../html/src/define/video/minimal-skin.ts | 20 ++- .../html/src/define/video/skin.tailwind.ts | 20 ++- packages/html/src/define/video/skin.ts | 20 ++- packages/html/src/i18n/cache-key.ts | 6 + packages/html/src/i18n/index.ts | 3 + packages/html/src/i18n/provider-mixin.ts | 8 +- packages/html/src/i18n/render-text.ts | 11 ++ .../html/src/i18n/tests/create-i18n.test.ts | 96 ++++++---- packages/html/src/i18n/tests/provider.test.ts | 23 +-- .../html/src/i18n/tests/render-text.test.ts | 16 ++ packages/html/src/i18n/text-mixin.ts | 22 ++- packages/html/src/i18n/types.ts | 4 +- packages/html/src/index.ts | 1 + .../audio-track-radio-group-element.ts | 16 +- .../audio-track-radio-group-element.test.ts | 8 +- .../captions-radio-group-element.ts | 15 +- .../captions-radio-group-element.test.ts | 2 +- .../ui/error-dialog/error-dialog-element.ts | 17 +- .../tests/error-dialog-element.test.ts | 12 +- .../src/ui/live-button/live-button-element.ts | 20 ++- .../tests/live-button-element.test.ts | 88 +++++++++ packages/html/src/ui/media-button-element.ts | 12 +- packages/html/src/ui/media-ui-element.ts | 6 +- .../ui/menu/get-menu-item-setting-state.ts | 21 +-- .../html/src/ui/menu/menu-back-element.ts | 7 +- .../html/src/ui/menu/menu-item-element.ts | 4 +- .../src/ui/menu/menu-radio-group-element.ts | 10 +- .../src/ui/menu/tests/menu-element.test.ts | 10 ++ .../tests/menu-item-value-element.test.ts | 6 +- .../quality-radio-group-element.ts | 29 ++- .../tests/quality-radio-group-element.test.ts | 2 +- packages/html/src/ui/slider/slider-element.ts | 12 +- .../src/ui/tests/media-button-element.test.ts | 8 +- .../src/ui/time-slider/time-slider-element.ts | 10 +- .../src/ui/time/tests/time-element.test.ts | 29 ++- packages/html/src/ui/time/time-element.ts | 18 +- .../ui/tooltip/tests/tooltip-element.test.ts | 18 +- .../html/src/ui/tooltip/tooltip-element.ts | 6 +- .../ui/volume-slider/volume-slider-element.ts | 10 +- packages/react/src/i18n/create-i18n.tsx | 15 +- .../react/src/i18n/get-provider-root-props.ts | 30 +++- packages/react/src/i18n/index.ts | 2 + .../react/src/i18n/tests/create-i18n.test.tsx | 14 +- .../react/src/i18n/use-lazy-translations.ts | 8 +- .../react/src/i18n/use-merged-translations.ts | 16 +- packages/react/src/index.ts | 1 + .../presets/audio/minimal-skin.tailwind.tsx | 3 +- .../react/src/presets/audio/minimal-skin.tsx | 3 +- .../react/src/presets/audio/skin.tailwind.tsx | 3 +- packages/react/src/presets/audio/skin.tsx | 3 +- .../live-video/minimal-skin.tailwind.tsx | 3 +- .../src/presets/live-video/minimal-skin.tsx | 3 +- .../src/presets/live-video/skin.tailwind.tsx | 3 +- .../react/src/presets/live-video/skin.tsx | 3 +- .../presets/video/minimal-skin.tailwind.tsx | 34 ++-- .../react/src/presets/video/minimal-skin.tsx | 34 ++-- .../react/src/presets/video/skin.tailwind.tsx | 34 ++-- packages/react/src/presets/video/skin.tsx | 34 ++-- .../ui/audio-track/use-audio-track-options.ts | 4 +- .../tests/use-captions-options.test.tsx | 2 +- .../use-captions-options.ts | 7 +- packages/react/src/ui/create-media-button.tsx | 10 +- .../ui/error-dialog/error-dialog-close.tsx | 6 +- .../error-dialog/error-dialog-description.tsx | 4 +- .../ui/error-dialog/error-dialog-title.tsx | 6 +- .../error-dialog/tests/error-dialog.test.tsx | 8 +- .../react/src/ui/live-button/live-button.tsx | 11 +- .../ui/live-button/tests/live-button.test.tsx | 68 +++++++ packages/react/src/ui/menu/menu-back.tsx | 9 +- .../ui/menu/menu-item-setting-provider.tsx | 9 +- .../react/src/ui/menu/tests/menu.test.tsx | 16 ++ .../ui/play-button/tests/play-button.test.tsx | 6 +- .../tests/use-quality-options.test.tsx | 2 +- .../src/ui/quality/use-quality-options.ts | 16 +- packages/react/src/ui/slider/slider-root.tsx | 9 +- .../ui/time-slider/tests/time-slider.test.tsx | 2 +- .../src/ui/time-slider/time-slider-root.tsx | 8 +- .../react/src/ui/time/tests/time.test.tsx | 10 +- packages/react/src/ui/time/time-value.tsx | 7 +- .../ui/volume-slider/volume-slider-root.tsx | 8 +- packages/utils/src/object/flatten.ts | 33 ++++ packages/utils/src/object/index.ts | 1 + .../utils/src/object/tests/flatten.test.ts | 29 +++ site/scripts/build-ejected-skins.ts | 76 ++++---- 221 files changed, 7040 insertions(+), 4307 deletions(-) create mode 100644 packages/core/scripts/generate-i18n-types.ts create mode 100644 packages/core/scripts/i18n-utils.ts create mode 100644 packages/core/src/core/i18n/params.generated.ts create mode 100644 packages/core/src/core/i18n/params.ts create mode 100644 packages/core/src/core/i18n/resolve-text.ts create mode 100644 packages/core/src/core/i18n/text.ts create mode 100644 packages/core/src/core/i18n/text/airplay.ts create mode 100644 packages/core/src/core/i18n/text/buttons.ts create mode 100644 packages/core/src/core/i18n/text/captions.ts create mode 100644 packages/core/src/core/i18n/text/cast.ts create mode 100644 packages/core/src/core/i18n/text/common.ts create mode 100644 packages/core/src/core/i18n/text/errors.ts create mode 100644 packages/core/src/core/i18n/text/fullscreen.ts create mode 100644 packages/core/src/core/i18n/text/live.ts create mode 100644 packages/core/src/core/i18n/text/menu.ts create mode 100644 packages/core/src/core/i18n/text/pip.ts create mode 100644 packages/core/src/core/i18n/text/playback.ts create mode 100644 packages/core/src/core/i18n/text/seek.ts create mode 100644 packages/core/src/core/i18n/text/slider.ts create mode 100644 packages/core/src/core/i18n/text/status.ts create mode 100644 packages/core/src/core/i18n/text/time.ts create mode 100644 packages/core/src/core/i18n/text/volume.ts create mode 100644 packages/core/src/core/i18n/translate-text.ts delete mode 100644 packages/core/src/core/i18n/types.ts create mode 100644 packages/core/src/core/i18n/utils/flatten.ts create mode 100644 packages/core/src/core/i18n/utils/index.ts create mode 100644 packages/core/src/core/i18n/utils/interpolate.ts create mode 100644 packages/html/src/i18n/cache-key.ts create mode 100644 packages/html/src/i18n/render-text.ts create mode 100644 packages/html/src/i18n/tests/render-text.test.ts create mode 100644 packages/html/src/ui/live-button/tests/live-button-element.test.ts create mode 100644 packages/react/src/ui/live-button/tests/live-button.test.tsx create mode 100644 packages/utils/src/object/flatten.ts create mode 100644 packages/utils/src/object/tests/flatten.test.ts diff --git a/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts b/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts index 02049109..f2457235 100644 --- a/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts +++ b/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts @@ -35,8 +35,8 @@ export async function ensureCdnSandboxLocale(tag: SandboxLocaleTag): Promise ` ${propertyKey(tag.trim().replaceAll('_', '-').toLowerCase())}: () => import('./locales/${tag}'),`) .join('\n'); - return `${GENERATED_HEADER}import { findLocaleKeys, getCanonicalLocaleKey, hasRegisteredLocale } from './registry'; -import type { Translations } from './types'; + return `${GENERATED_HEADER}import { flattenTranslations } from './utils'; +import { findLocaleKeys, getCanonicalLocaleKey, hasRegisteredLocale } from './registry'; +import type { FlatTranslations, Translations } from './params'; const loaders = { ${entries} -} as const satisfies Record Promise<{ default: Partial }>>; +} as const satisfies Record Promise<{ default: Translations }>>; /** Lazy-import a shipped locale pack when the tag is not already in the registry. */ -export async function loadLocale(tag: string): Promise | undefined> { +export async function loadLocale(tag: string): Promise | undefined> { if (hasRegisteredLocale(tag)) return undefined; for (const chainTag of findLocaleKeys(tag)) { if (hasRegisteredLocale(chainTag)) return undefined; const load = loaders[getCanonicalLocaleKey(chainTag) as keyof typeof loaders]; - if (load) return (await load()).default; + if (load) return flattenTranslations((await load()).default); } return undefined; } @@ -61,7 +61,7 @@ function generateCoreAllTs(): string { const objectLines = [' en,', ...localeTags.map(objectEntry)].join('\n'); - return `${GENERATED_HEADER}import type { Translations } from '../types'; + return `${GENERATED_HEADER}import type { Translations } from '../params'; ${imports} /** Every built-in locale pack keyed by BCP 47 tag. */ @@ -86,6 +86,61 @@ function generatePlatformAllReExport(): string { `; } +function exportName(key: string): string { + const name = key.slice(key.indexOf('.') + 1); + return name === 'default' ? 'defaultText' : `${name}Text`; +} + +function stringLiteral(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`; +} + +function generateTextModule(entries: [string, string][]): string { + const namespace = entries[0][0].slice(0, entries[0][0].indexOf('.')); + const prefix = `${namespace}.`; + const exports = entries + .map( + ([key, text]) => + `export const ${exportName(key)} = {\n key: \`\${prefix}${key.slice(prefix.length)}\`,\n text: ${stringLiteral(text)},\n} as const satisfies Text;` + ) + .join('\n\n'); + + return `${GENERATED_HEADER}import type { Text } from '../text';\n\nconst prefix = '${prefix}';\n\n${exports}\n`; +} + +async function validateLocaleCompleteness(): Promise { + const englishKeys = new Set(flattenEntries(en).map(([key]) => key)); + const errors: string[] = []; + + for (const tag of localeTags) { + const { default: locale } = await import(pathToFileURL(resolve(coreLocalesDir, `${tag}.ts`)).href); + const localeKeys = new Set(flattenEntries(locale ?? {}).map(([key]) => key)); + const missing = [...englishKeys].filter((key) => !localeKeys.has(key)); + if (missing.length) errors.push(`${tag}: ${missing.join(', ')}`); + } + + if (errors.length) { + throw new Error(`Locale packs are missing English keys:\n${errors.join('\n')}`); + } +} + +function generateTextModules(): void { + rmSync(textDir, { recursive: true, force: true }); + mkdirSync(textDir, { recursive: true }); + + const namespaces = new Map(); + for (const [key, text] of flattenEntries(en)) { + const namespace = key.slice(0, key.indexOf('.')); + const entries = namespaces.get(namespace) ?? []; + entries.push([key, text]); + namespaces.set(namespace, entries); + } + + for (const [namespace, entries] of namespaces) { + writeGenerated(resolve(textDir, `${namespace}.ts`), generateTextModule(entries)); + } +} + function generatePlatformRegisterTs(tag: string): string { return `${GENERATED_HEADER}import { registerI18n } from '@videojs/core/i18n'; import translations from '@videojs/core/i18n/locales/${tag}'; @@ -139,9 +194,11 @@ function syncPlatformLocaleDir(dir: string): void { } } +await validateLocaleCompleteness(); writeGenerated(resolve(coreLocalesDir, 'all.ts'), generateCoreAllTs()); writeGenerated(resolve(coreRoot, 'src/core/i18n/load-locale.ts'), generateLoadLocaleTs()); +generateTextModules(); syncPlatformLocaleDir(htmlLocalesDir); syncPlatformLocaleDir(reactLocalesDir); -console.log('[generate-i18n-locales] Updated core all.ts, load-locale.ts, and html/react locale re-exports'); +console.log('[generate-i18n-locales] Updated locale packs, text descriptors, and platform re-exports'); diff --git a/packages/core/scripts/generate-i18n-types.ts b/packages/core/scripts/generate-i18n-types.ts new file mode 100644 index 00000000..7e58eef4 --- /dev/null +++ b/packages/core/scripts/generate-i18n-types.ts @@ -0,0 +1,34 @@ +import { writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import en from '../src/core/i18n/locales/en.ts'; +import { flattenEntries } from './i18n-utils.ts'; + +const GENERATED_HEADER = '/** Generated by packages/core/scripts/generate-i18n-types.ts — do not edit. */\n'; +const output = resolve(dirname(fileURLToPath(import.meta.url)), '../src/core/i18n/params.generated.ts'); + +function parameterNames(text: string): string[] { + return [...text.matchAll(/\{([^{}]+)\}/g)] + .map(([, name]) => name) + .filter((name, index, names) => names.indexOf(name) === index); +} + +function generate(): string { + const entries = flattenEntries(en); + const params = entries + .map(([key, text]) => { + const names = parameterNames(text); + const value = names.length ? `{ ${names.map((name) => `${name}: string | number`).join('; ')} }` : 'never'; + return ` '${key}': ${value};`; + }) + .join('\n'); + return `${GENERATED_HEADER} +/** Generated translation parameter contract from the English catalogue. */ +export interface TranslationParams { +${params} +} +`; +} + +writeFileSync(output, generate()); +console.log('[generate-i18n-types] Updated generated translation types'); diff --git a/packages/core/scripts/i18n-utils.ts b/packages/core/scripts/i18n-utils.ts new file mode 100644 index 00000000..038754fa --- /dev/null +++ b/packages/core/scripts/i18n-utils.ts @@ -0,0 +1,5 @@ +import { flatten } from '@videojs/utils/object'; + +export function flattenEntries(value: Record): [string, string][] { + return Object.entries(flatten(value)) as [string, string][]; +} diff --git a/packages/core/src/core/i18n/browser-translation.ts b/packages/core/src/core/i18n/browser-translation.ts index 09c48fbb..736619d3 100644 --- a/packages/core/src/core/i18n/browser-translation.ts +++ b/packages/core/src/core/i18n/browser-translation.ts @@ -1,6 +1,7 @@ import en from './locales/en'; -import { findLocaleKeys, getI18nTranslations, hasRegisteredLocale } from './registry'; -import type { Locale, Translations } from './types'; +import type { FlatTranslations, Locale } from './params'; +import { findLocaleKeys, hasRegisteredLocale } from './registry'; +import { flattenTranslations } from './utils'; type BrowserTranslatorAvailability = 'available' | 'downloadable' | 'downloading' | 'unavailable'; @@ -66,7 +67,7 @@ async function translateProtectingPlaceholders(translator: BrowserTranslatorInst return restoreNamedPlaceholders(translated, slots); } -const cache = new Map>(); +const cache = new Map>(); function isEnglishLocaleTag(tag: string): boolean { return tag === 'en' || tag.startsWith('en-'); @@ -89,7 +90,7 @@ export function resolveBrowserTranslationTarget(locale: string): string | undefi export function shouldAttemptBrowserTranslation( locale: Locale, loadedLazyTags: readonly string[], - translations?: Partial + translations?: Partial ): boolean { const target = resolveBrowserTranslationTarget(locale); if (!target) return false; @@ -100,8 +101,9 @@ export function shouldAttemptBrowserTranslation( return !findLocaleKeys(locale).some((tag) => !isEnglishLocaleTag(tag) && hasRegisteredLocale(tag)); } -function hasMissingEnglishTranslations(translations: Partial): boolean { - return (Object.keys(en) as (keyof Translations)[]).some((key) => translations[key] === undefined); +function hasMissingEnglishTranslations(translations: Partial): boolean { + const english = flattenTranslations(en); + return (Object.keys(english) as (keyof FlatTranslations)[]).some((key) => translations[key] === undefined); } /** @@ -111,7 +113,7 @@ function hasMissingEnglishTranslations(translations: Partial): boo export async function getBrowserTranslations( locale: string, options?: GetBrowserTranslationsOptions -): Promise> { +): Promise> { const target = resolveBrowserTranslationTarget(locale); if (!target) return {}; @@ -140,8 +142,8 @@ export async function getBrowserTranslations( notifyDownloadStart(); - const english = getI18nTranslations('en'); - const keys = Object.keys(en) as (keyof Translations)[]; + const english = flattenTranslations(en); + const keys = Object.keys(english) as (keyof FlatTranslations)[]; const translator = await Translator.create({ sourceLanguage: 'en', targetLanguage: target, @@ -160,14 +162,14 @@ export async function getBrowserTranslations( const entries = await Promise.all( keys.map(async (key) => { - const value = english[key] ?? en[key]; + const value = english[key]; if (!value) return [key, ''] as const; const translated = await translateProtectingPlaceholders(translator, value); return [key, translated] as const; }) ); - const result = Object.fromEntries(entries) as Partial; + const result = Object.fromEntries(entries) as Partial; cache.set(target, result); return result; } diff --git a/packages/core/src/core/i18n/index.ts b/packages/core/src/core/i18n/index.ts index 9a67287f..326b93d0 100644 --- a/packages/core/src/core/i18n/index.ts +++ b/packages/core/src/core/i18n/index.ts @@ -1,3 +1,5 @@ +import en from './locales/en'; + export type { GetBrowserTranslationsOptions } from './browser-translation'; export { getBrowserTranslations, @@ -8,7 +10,9 @@ export { export { loadLocale } from './load-locale'; export type { LocaleAlias } from './locales'; export { LOCALES, localeAliases } from './locales'; -export { default as translations } from './locales/en'; +export { flattenTranslations } from './utils'; +export const translations = en; +export type * from './params'; export { findLocaleKeys, getCanonicalLocaleKey, @@ -18,6 +22,8 @@ export { registerI18n, resetI18nRegistry, } from './registry'; +export { resolveText } from './resolve-text'; export { resolveTranslation } from './resolve-translation'; -export { createTranslator } from './translator'; -export type * from './types'; +export { isText, type Text, type TextParams } from './text'; +export { translateText } from './translate-text'; +export { createTranslator, type TranslationOptions, type Translator } from './translator'; diff --git a/packages/core/src/core/i18n/load-locale.ts b/packages/core/src/core/i18n/load-locale.ts index 0a58ed8e..1142ce53 100644 --- a/packages/core/src/core/i18n/load-locale.ts +++ b/packages/core/src/core/i18n/load-locale.ts @@ -1,6 +1,8 @@ /** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ + +import type { FlatTranslations, Translations } from './params'; import { findLocaleKeys, getCanonicalLocaleKey, hasRegisteredLocale } from './registry'; -import type { Translations } from './types'; +import { flattenTranslations } from './utils'; const loaders = { ar: () => import('./locales/ar'), @@ -54,15 +56,15 @@ const loaders = { 'zh-tw': () => import('./locales/zh-TW'), pt: () => import('./locales/pt'), zh: () => import('./locales/zh'), -} as const satisfies Record Promise<{ default: Partial }>>; +} as const satisfies Record Promise<{ default: Translations }>>; /** Lazy-import a shipped locale pack when the tag is not already in the registry. */ -export async function loadLocale(tag: string): Promise | undefined> { +export async function loadLocale(tag: string): Promise | undefined> { if (hasRegisteredLocale(tag)) return undefined; for (const chainTag of findLocaleKeys(tag)) { if (hasRegisteredLocale(chainTag)) return undefined; const load = loaders[getCanonicalLocaleKey(chainTag) as keyof typeof loaders]; - if (load) return (await load()).default; + if (load) return flattenTranslations((await load()).default); } return undefined; } diff --git a/packages/core/src/core/i18n/locales/all.ts b/packages/core/src/core/i18n/locales/all.ts index 9ce912da..fc19e707 100644 --- a/packages/core/src/core/i18n/locales/all.ts +++ b/packages/core/src/core/i18n/locales/all.ts @@ -1,5 +1,5 @@ /** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ -import type { Translations } from '../types'; +import type { Translations } from '../params'; import ar from './ar'; import az from './az'; import bg from './bg'; diff --git a/packages/core/src/core/i18n/locales/ar.ts b/packages/core/src/core/i18n/locales/ar.ts index 71afc6c9..f62641b3 100644 --- a/packages/core/src/core/i18n/locales/ar.ts +++ b/packages/core/src/core/i18n/locales/ar.ts @@ -1,68 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'تشغيل', - Pause: 'إيقاف', - Replay: 'إعادة التشغيل', - Mute: 'كتم', - Unmute: 'إلغاء الكتم', - 'Seek forward {seconds} seconds': 'التخطي للأمام {seconds}', - 'Seek backward {seconds} seconds': 'الرجوع للخلف {seconds}', - 'Enter fullscreen': 'ملء الشاشة', - 'Enable captions': 'تفعيل التسميات التوضيحية', - 'Disable captions': 'إيقاف التسميات التوضيحية', - 'Enter picture-in-picture': 'صورة داخل صورة', - 'Exit picture-in-picture': 'الخروج من وضع صورة داخل صورة', - 'Playing live': 'بث مباشر', - 'Seek to live edge': 'الانتقال إلى البث المباشر', - Live: 'مباشر', - 'Start casting': 'بدء الإرسال', - 'Stop casting': 'إيقاف الإرسال', - Connecting: 'جارٍ الاتصال', - Seek: 'تقديم', - 'Current time': 'الوقت الحالي', - Duration: 'المدة', - Remaining: 'الوقت المتبقي', - '{duration} remaining': 'متبقٍ {duration}', - '{duration}. Show elapsed time.': '{duration}. عرض الوقت المنقضي.', - '{duration}. Show duration.': '{duration}. عرض المدة.', - '{duration}. Show remaining time.': '{duration}. عرض الوقت المتبقي.', - 'Playback rate {rate}': 'سرعة التشغيل {rate}', - '{current} of {duration}': '{current} من {duration}', - '{percent}, muted': '{percent}، مكتوم', - Muted: 'صامت', - Volume: 'مستوى الصوت', - 'Volume {value}': 'مستوى الصوت {value}', - 'Captions on': 'الترجمة مفعّلة', - 'Captions off': 'الترجمة متوقفة', - Paused: 'متوقف مؤقتاً', - Playing: 'قيد التشغيل', - Fullscreen: 'ملء الشاشة', - 'Exit fullscreen': 'الخروج من ملء الشاشة', - 'Picture in picture': 'صورة داخل صورة', - 'Exit picture in picture': 'الخروج من صورة داخل صورة', - 'You stopped media playback before it finished.': 'لقد ألغيت تشغيل الفيديو', - 'This media could not be loaded due to a network or server issue.': 'تسبب خطأ في الشبكة بفشل تحميل الفيديو بالكامل.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'تم إيقاف تشغيل الفيديو بسبب عدم صلاحية الفيديو أو لأن الفيديو المستخدم يستخدم ميزات غير مدعومة من متصفحك.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'لا يمكن تحميل الفيديو بسبب فشل في الخادم أو الشبكة ، أو بسبب عدم إمكانية قراءة تنسيق الفيديو.', - 'This media could not be played because it could not be decrypted.': - 'الوسائط مشفرة وليس لدينا الرموز اللازمة لفك شفرتها.', - '': '', - 'Something went wrong.': 'حدث خطأ ما.', - OK: 'أغلق', - 'An unexpected error occurred.': 'حدث خطأ. يُرجى المحاولة مرة أخرى.', - Settings: 'الإعدادات', - Quality: 'الجودة', - Audio: 'الصوت', - Default: 'افتراضي', - Speed: 'السرعة', - Captions: 'التسميات التوضيحية', - 'Playback rate': 'سرعة التشغيل', - Back: 'رجوع', - Off: 'إيقاف', - Auto: 'تلقائي', - 'Auto ({label})': 'تلقائي ({label})', - Subtitles: 'الترجمة', -} as const satisfies Partial; + buttons: { + play: 'تشغيل', + pause: 'إيقاف', + replay: 'إعادة التشغيل', + mute: 'كتم', + unmute: 'إلغاء الكتم', + }, + seek: { + forward: 'التخطي للأمام {seconds}', + backward: 'الرجوع للخلف {seconds}', + }, + fullscreen: { + enter: 'ملء الشاشة', + exit: 'الخروج من ملء الشاشة', + }, + captions: { + enable: 'تفعيل التسميات التوضيحية', + disable: 'إيقاف التسميات التوضيحية', + }, + pip: { + enter: 'صورة داخل صورة', + exit: 'الخروج من وضع صورة داخل صورة', + }, + live: { + playing: 'بث مباشر', + seekToEdge: 'الانتقال إلى البث المباشر', + badge: 'مباشر', + }, + cast: { + start: 'بدء الإرسال', + stop: 'إيقاف الإرسال', + connecting: 'جارٍ الاتصال', + }, + airplay: { + start: 'بدء AirPlay', + stop: 'إيقاف AirPlay', + }, + slider: { + seek: 'تقديم', + }, + time: { + current: 'الوقت الحالي', + duration: 'المدة', + remaining: 'الوقت المتبقي', + remainingSuffix: 'متبقٍ {duration}', + showElapsed: '{duration}. عرض الوقت المنقضي.', + showDuration: '{duration}. عرض المدة.', + showRemaining: '{duration}. عرض الوقت المتبقي.', + position: '{current} من {duration}', + }, + playback: { + rate: 'سرعة التشغيل {rate}', + }, + volume: { + mutedValue: '{percent}، مكتوم', + muted: 'صامت', + label: 'مستوى الصوت', + value: 'مستوى الصوت {value}', + }, + status: { + captionsOn: 'الترجمة مفعّلة', + captionsOff: 'الترجمة متوقفة', + paused: 'متوقف مؤقتاً', + playing: 'قيد التشغيل', + fullscreen: 'ملء الشاشة', + pip: 'صورة داخل صورة', + exitPip: 'الخروج من صورة داخل صورة', + }, + errors: { + aborted: 'لقد ألغيت تشغيل الفيديو', + network: 'تسبب خطأ في الشبكة بفشل تحميل الفيديو بالكامل.', + decode: 'تم إيقاف تشغيل الفيديو بسبب عدم صلاحية الفيديو أو لأن الفيديو المستخدم يستخدم ميزات غير مدعومة من متصفحك.', + source: 'لا يمكن تحميل الفيديو بسبب فشل في الخادم أو الشبكة ، أو بسبب عدم إمكانية قراءة تنسيق الفيديو.', + encrypted: 'الوسائط مشفرة وليس لدينا الرموز اللازمة لفك شفرتها.', + title: 'حدث خطأ ما.', + unexpected: 'حدث خطأ. يُرجى المحاولة مرة أخرى.', + }, + common: { + empty: '', + ok: 'أغلق', + }, + menu: { + settings: 'الإعدادات', + quality: 'الجودة', + audio: 'الصوت', + default: 'افتراضي', + speed: 'السرعة', + captions: 'التسميات التوضيحية', + playbackRate: 'سرعة التشغيل', + back: 'رجوع', + off: 'إيقاف', + auto: 'تلقائي', + autoWithLabel: 'تلقائي ({label})', + subtitles: 'الترجمة', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/az.ts b/packages/core/src/core/i18n/locales/az.ts index 56e5985b..6a08de2c 100644 --- a/packages/core/src/core/i18n/locales/az.ts +++ b/packages/core/src/core/i18n/locales/az.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Oynat', - Pause: 'Pauza', - Replay: 'Yenidən oynat', - Mute: 'Səssizi qoş', - Unmute: 'Səssizi söndür', - 'Seek forward {seconds} seconds': '{seconds} saniyə qabağa keçin', - 'Seek backward {seconds} seconds': '{seconds} saniyə geriyə keçin', - 'Enter fullscreen': 'Tam ekran', - 'Enable captions': 'Altyazıları aktiv et', - 'Disable captions': 'Altyazıları söndür', - 'Enter picture-in-picture': 'Şəkil içində şəkil rejimi', - 'Exit picture-in-picture': 'Şəkil içində şəkil rejimindən çıxın', - 'Playing live': 'Canlı yayımda', - 'Seek to live edge': 'Canlı yayıma keç', - Live: 'Canlı', - 'Start casting': 'Yayımı başlat', - 'Stop casting': 'Yayımı durdur', - Connecting: 'Qoşulur', - Seek: 'Sürüşdür', - 'Current time': 'Cari Vaxt', - Duration: 'Müddət', - Remaining: 'Qalan vaxt', - '{duration} remaining': 'Qalan {duration}', - '{duration}. Show elapsed time.': '{duration}. Keçən vaxtı göstər.', - '{duration}. Show duration.': '{duration}. Müddəti göstər.', - '{duration}. Show remaining time.': '{duration}. Qalan vaxtı göstər.', - 'Playback rate {rate}': 'Oynatma sürəti {rate}', - '{current} of {duration}': '{current} / {duration}', - '{percent}, muted': '{percent}, səssiz', - Muted: 'Səssiz', - Volume: 'Səs', - 'Volume {value}': 'Səs {value}', - 'Captions on': 'Başlıqlar aktiv', - 'Captions off': 'Başlıqlar söndürülüb', - Paused: 'Dayandırılıb', - Playing: 'Oynadılır', - Fullscreen: 'Tam ekran', - 'Exit fullscreen': 'Tam ekrandan çıx', - 'Picture in picture': 'Şəkil içində şəkil', - 'Exit picture in picture': 'Şəkil içində şəkildən çıxın', - 'You stopped media playback before it finished.': 'Siz medianın oxudulmasını dayandırdınız', - 'This media could not be loaded due to a network or server issue.': - 'Şəbəkə xətası səbəbindən medianın endirilməsi yarıda qaldı.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Media faylının korlanması səbəbilə və ya media faylın brauzerinizin dəstəkləmədiyi funksiyalardan istifadə etdiyinə görə medianın oxudulması dayandırılıb.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Yükləmə xətası.', - 'This media could not be played because it could not be decrypted.': - 'Media faylı şifrələnib və onun şifrəsini açmaq üçün açarlar yoxdur.', - '': '', - 'Something went wrong.': 'Bir şey yanlış getdi.', - OK: 'Bağla', - 'An unexpected error occurred.': 'Xəta baş verdi. Yenidən cəhd edin.', - Settings: 'Parametrlər', - Quality: 'Keyfiyyət', - Audio: 'Səs', - Default: 'Defolt', - Speed: 'Sürət', - Captions: 'Başlıqlar', - 'Playback rate': 'Oynatma sürəti', - Back: 'Geri', - Off: 'Söndür', - Auto: 'Avtomatik', - 'Auto ({label})': 'Avtomatik ({label})', - Subtitles: 'Altyazılar', -} as const satisfies Partial; + buttons: { + play: 'Oynat', + pause: 'Pauza', + replay: 'Yenidən oynat', + mute: 'Səssizi qoş', + unmute: 'Səssizi söndür', + }, + seek: { + forward: '{seconds} saniyə qabağa keçin', + backward: '{seconds} saniyə geriyə keçin', + }, + fullscreen: { + enter: 'Tam ekran', + exit: 'Tam ekrandan çıx', + }, + captions: { + enable: 'Altyazıları aktiv et', + disable: 'Altyazıları söndür', + }, + pip: { + enter: 'Şəkil içində şəkil rejimi', + exit: 'Şəkil içində şəkil rejimindən çıxın', + }, + live: { + playing: 'Canlı yayımda', + seekToEdge: 'Canlı yayıma keç', + badge: 'Canlı', + }, + cast: { + start: 'Yayımı başlat', + stop: 'Yayımı durdur', + connecting: 'Qoşulur', + }, + airplay: { + start: 'AirPlay-i başlat', + stop: 'AirPlay-i dayandır', + }, + slider: { + seek: 'Sürüşdür', + }, + time: { + current: 'Cari Vaxt', + duration: 'Müddət', + remaining: 'Qalan vaxt', + remainingSuffix: 'Qalan {duration}', + showElapsed: '{duration}. Keçən vaxtı göstər.', + showDuration: '{duration}. Müddəti göstər.', + showRemaining: '{duration}. Qalan vaxtı göstər.', + position: '{current} / {duration}', + }, + playback: { + rate: 'Oynatma sürəti {rate}', + }, + volume: { + mutedValue: '{percent}, səssiz', + muted: 'Səssiz', + label: 'Səs', + value: 'Səs {value}', + }, + status: { + captionsOn: 'Başlıqlar aktiv', + captionsOff: 'Başlıqlar söndürülüb', + paused: 'Dayandırılıb', + playing: 'Oynadılır', + fullscreen: 'Tam ekran', + pip: 'Şəkil içində şəkil', + exitPip: 'Şəkil içində şəkildən çıxın', + }, + errors: { + aborted: 'Siz medianın oxudulmasını dayandırdınız', + network: 'Şəbəkə xətası səbəbindən medianın endirilməsi yarıda qaldı.', + decode: + 'Media faylının korlanması səbəbilə və ya media faylın brauzerinizin dəstəkləmədiyi funksiyalardan istifadə etdiyinə görə medianın oxudulması dayandırılıb.', + source: 'Yükləmə xətası.', + encrypted: 'Media faylı şifrələnib və onun şifrəsini açmaq üçün açarlar yoxdur.', + title: 'Bir şey yanlış getdi.', + unexpected: 'Xəta baş verdi. Yenidən cəhd edin.', + }, + common: { + empty: '', + ok: 'Bağla', + }, + menu: { + settings: 'Parametrlər', + quality: 'Keyfiyyət', + audio: 'Səs', + default: 'Defolt', + speed: 'Sürət', + captions: 'Başlıqlar', + playbackRate: 'Oynatma sürəti', + back: 'Geri', + off: 'Söndür', + auto: 'Avtomatik', + autoWithLabel: 'Avtomatik ({label})', + subtitles: 'Altyazılar', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/bg.ts b/packages/core/src/core/i18n/locales/bg.ts index 2b3dc38c..0d94bd16 100644 --- a/packages/core/src/core/i18n/locales/bg.ts +++ b/packages/core/src/core/i18n/locales/bg.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Възпроизвеждане', - Pause: 'Пауза', - Replay: 'Повтори', - Mute: 'Без звук', - Unmute: 'Със звук', - 'Seek forward {seconds} seconds': 'Превъртане напред с {seconds} секунди', - 'Seek backward {seconds} seconds': 'Превъртане назад с {seconds} секунди', - 'Enter fullscreen': 'Цял екран', - 'Enable captions': 'Включи субтитри', - 'Disable captions': 'Изключи субтитри', - 'Enter picture-in-picture': 'Картина в картина', - 'Exit picture-in-picture': 'Изход от картина в картина', - 'Playing live': 'На живо', - 'Seek to live edge': 'Към живото предаване', - Live: 'На живо', - 'Start casting': 'Започни излъчване', - 'Stop casting': 'Спри излъчването', - Connecting: 'Свързване', - Seek: 'Превъртане', - 'Current time': 'Текущо време', - Duration: 'Продължителност', - Remaining: 'Оставащо време', - '{duration} remaining': 'Остават {duration}', - '{duration}. Show elapsed time.': '{duration}. Показване на изминалото време.', - '{duration}. Show duration.': '{duration}. Показване на продължителността.', - '{duration}. Show remaining time.': '{duration}. Показване на оставащото време.', - 'Playback rate {rate}': 'Скорост на възпроизвеждане {rate}', - '{current} of {duration}': '{current} от {duration}', - '{percent}, muted': '{percent}, без звук', - Muted: 'Без звук', - Volume: 'Сила на звука', - 'Volume {value}': 'Сила на звука {value}', - 'Captions on': 'Субтитри включени', - 'Captions off': 'Субтитри изключени', - Paused: 'На пауза', - Playing: 'Възпроизвеждане', - Fullscreen: 'Цял екран', - 'Exit fullscreen': 'Изход от цял екран', - 'Picture in picture': 'Картина в картина', - 'Exit picture in picture': 'Изход от картина в картина', - 'You stopped media playback before it finished.': 'Спряхте възпроизвеждането на видеото', - 'This media could not be loaded due to a network or server issue.': - 'Грешка в мрежата провали изтеглянето на видеото.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Възпроизвеждането на видеото беше прекъснато заради проблем с файла или защото видеото използва опции които браузърът Ви не поддържа.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Видеото не може да бъде заредено заради проблем със сървъра или мрежата или защото този формат не е поддържан.', - 'This media could not be played because it could not be decrypted.': - 'Медията е шифрована и няма ключове за дешифриране.', - '': '', - 'Something went wrong.': 'Нещо се обърка.', - OK: 'OK', - 'An unexpected error occurred.': 'Възникна грешка. Моля, опитайте отново.', - Settings: 'Настройки', - Quality: 'Качество', - Audio: 'Аудио', - Default: 'По подразбиране', - Speed: 'Скорост', - Captions: 'Надписи', - 'Playback rate': 'Скорост на възпроизвеждане', - Back: 'Назад', - Off: 'Изкл.', - Auto: 'Авто', - 'Auto ({label})': 'Авто ({label})', - Subtitles: 'Субтитри', -} as const satisfies Partial; + buttons: { + play: 'Възпроизвеждане', + pause: 'Пауза', + replay: 'Повтори', + mute: 'Без звук', + unmute: 'Със звук', + }, + seek: { + forward: 'Превъртане напред с {seconds} секунди', + backward: 'Превъртане назад с {seconds} секунди', + }, + fullscreen: { + enter: 'Цял екран', + exit: 'Изход от цял екран', + }, + captions: { + enable: 'Включи субтитри', + disable: 'Изключи субтитри', + }, + pip: { + enter: 'Картина в картина', + exit: 'Изход от картина в картина', + }, + live: { + playing: 'На живо', + seekToEdge: 'Към живото предаване', + badge: 'На живо', + }, + cast: { + start: 'Започни излъчване', + stop: 'Спри излъчването', + connecting: 'Свързване', + }, + airplay: { + start: 'Стартиране на AirPlay', + stop: 'Спиране на AirPlay', + }, + slider: { + seek: 'Превъртане', + }, + time: { + current: 'Текущо време', + duration: 'Продължителност', + remaining: 'Оставащо време', + remainingSuffix: 'Остават {duration}', + showElapsed: '{duration}. Показване на изминалото време.', + showDuration: '{duration}. Показване на продължителността.', + showRemaining: '{duration}. Показване на оставащото време.', + position: '{current} от {duration}', + }, + playback: { + rate: 'Скорост на възпроизвеждане {rate}', + }, + volume: { + mutedValue: '{percent}, без звук', + muted: 'Без звук', + label: 'Сила на звука', + value: 'Сила на звука {value}', + }, + status: { + captionsOn: 'Субтитри включени', + captionsOff: 'Субтитри изключени', + paused: 'На пауза', + playing: 'Възпроизвеждане', + fullscreen: 'Цял екран', + pip: 'Картина в картина', + exitPip: 'Изход от картина в картина', + }, + errors: { + aborted: 'Спряхте възпроизвеждането на видеото', + network: 'Грешка в мрежата провали изтеглянето на видеото.', + decode: + 'Възпроизвеждането на видеото беше прекъснато заради проблем с файла или защото видеото използва опции които браузърът Ви не поддържа.', + source: + 'Видеото не може да бъде заредено заради проблем със сървъра или мрежата или защото този формат не е поддържан.', + encrypted: 'Медията е шифрована и няма ключове за дешифриране.', + title: 'Нещо се обърка.', + unexpected: 'Възникна грешка. Моля, опитайте отново.', + }, + common: { + empty: '', + ok: 'OK', + }, + menu: { + settings: 'Настройки', + quality: 'Качество', + audio: 'Аудио', + default: 'По подразбиране', + speed: 'Скорост', + captions: 'Надписи', + playbackRate: 'Скорост на възпроизвеждане', + back: 'Назад', + off: 'Изкл.', + auto: 'Авто', + autoWithLabel: 'Авто ({label})', + subtitles: 'Субтитри', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/bn.ts b/packages/core/src/core/i18n/locales/bn.ts index 23dbabe3..aabcdcb1 100644 --- a/packages/core/src/core/i18n/locales/bn.ts +++ b/packages/core/src/core/i18n/locales/bn.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'প্লে করুন', - Pause: 'বিরাম', - Replay: 'রিপ্লে করুন', - Mute: 'মিউট', - Unmute: 'আনমিউট', - 'Seek forward {seconds} seconds': '{seconds} সেকেন্ড এগিয়ে যান', - 'Seek backward {seconds} seconds': '{seconds} সেকেন্ড পিছিয়ে যান', - 'Enter fullscreen': 'পূর্ণ স্ক্রীন', - 'Enable captions': 'ক্যাপশন', - 'Disable captions': 'ক্যাপশন বন্ধ করুন', - 'Enter picture-in-picture': 'পিকচার-ইন-পিকচার', - 'Exit picture-in-picture': 'পিকচার-ইন-পিকচার থেকে প্রস্থান করুন', - 'Playing live': 'লাইভ চলছে', - 'Seek to live edge': 'লাইভে যান', - Live: 'লাইভ', - 'Start casting': 'কাস্টিং শুরু করুন', - 'Stop casting': 'কাস্টিং বন্ধ করুন', - Connecting: 'সংযুক্ত হচ্ছে', - Seek: 'অনুসন্ধান', - 'Current time': 'বর্তমান সময়', - Duration: 'ব্যাপ্তিকাল', - Remaining: 'অবশিষ্ট সময়', - '{duration} remaining': 'বাকি {duration}', - '{duration}. Show elapsed time.': '{duration}. অতিবাহিত সময় দেখান.', - '{duration}. Show duration.': '{duration}. সময়কাল দেখান.', - '{duration}. Show remaining time.': '{duration}. বাকি সময় দেখান.', - 'Playback rate {rate}': 'প্লেব্যাক রেট {rate}', - '{current} of {duration}': '{duration} এর মধ্যে {current}', - '{percent}, muted': '{percent}, নিঃশব্দ', - Muted: 'নিঃশব্দ', - Volume: 'ভলিউম', - 'Volume {value}': 'ভলিউম {value}', - 'Captions on': 'ক্যাপশন চালু', - 'Captions off': 'ক্যাপশন বন্ধ', - Paused: 'বিরাম', - Playing: 'চলছে', - Fullscreen: 'পূর্ণ স্ক্রীন', - 'Exit fullscreen': 'পূর্ণ স্ক্রীন থেকে বেরিয়ে আসুন', - 'Picture in picture': 'পিকচার ইন পিকচার', - 'Exit picture in picture': 'পিকচার ইন পিকচার থেকে বেরিয়ে আসুন', - 'You stopped media playback before it finished.': 'আপনি মিডিয়া প্লেব্যাক বাতিল করেছেন', - 'This media could not be loaded due to a network or server issue.': - 'একটি নেটওয়ার্ক ত্রুটির কারণে মিডিয়া ডাউনলোড আংশিকভাবে ব্যর্থ হয়েছে৷', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'মিডিয়া প্লেব্যাক একটি সমস্যার কারণে বা মিডিয়া ব্যবহার করা বৈশিষ্ট্যগুলি আপনার ব্রাউজার সমর্থন করে না বলে বাতিল করা হয়েছে৷', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'মিডিয়া লোড করা যায়নি, হয় সার্ভার বা নেটওয়ার্ক ব্যর্থ হওয়ার কারণে বা ফর্ম্যাটটি সমর্থিত নয়।', - 'This media could not be played because it could not be decrypted.': - 'মিডিয়া এনক্রিপ্ট করা হয়েছে এবং এটি ডিক্রিপ্ট করার সমাধান আমাদের কাছে নেই।', - '': '', - 'Something went wrong.': 'কিছু একটা ভুল হয়েছে।', - OK: 'বন্ধ করুন', - 'An unexpected error occurred.': 'একটি ত্রুটি ঘটেছে। আবার চেষ্টা করুন।', - Settings: 'সেটিংস', - Quality: 'গুণমান', - Audio: 'অডিও', - Default: 'ডিফল্ট', - Speed: 'গতি', - Captions: 'ক্যাপশন', - 'Playback rate': 'প্লেব্যাক রেট', - Back: 'পিছনে', - Off: 'বন্ধ', - Auto: 'স্বয়ংক্রিয়', - 'Auto ({label})': 'স্বয়ংক্রিয় ({label})', - Subtitles: 'সাবটাইটেল', -} as const satisfies Partial; + buttons: { + play: 'প্লে করুন', + pause: 'বিরাম', + replay: 'রিপ্লে করুন', + mute: 'মিউট', + unmute: 'আনমিউট', + }, + seek: { + forward: '{seconds} সেকেন্ড এগিয়ে যান', + backward: '{seconds} সেকেন্ড পিছিয়ে যান', + }, + fullscreen: { + enter: 'পূর্ণ স্ক্রীন', + exit: 'পূর্ণ স্ক্রীন থেকে বেরিয়ে আসুন', + }, + captions: { + enable: 'ক্যাপশন', + disable: 'ক্যাপশন বন্ধ করুন', + }, + pip: { + enter: 'পিকচার-ইন-পিকচার', + exit: 'পিকচার-ইন-পিকচার থেকে প্রস্থান করুন', + }, + live: { + playing: 'লাইভ চলছে', + seekToEdge: 'লাইভে যান', + badge: 'লাইভ', + }, + cast: { + start: 'কাস্টিং শুরু করুন', + stop: 'কাস্টিং বন্ধ করুন', + connecting: 'সংযুক্ত হচ্ছে', + }, + airplay: { + start: 'AirPlay শুরু করুন', + stop: 'AirPlay বন্ধ করুন', + }, + slider: { + seek: 'অনুসন্ধান', + }, + time: { + current: 'বর্তমান সময়', + duration: 'ব্যাপ্তিকাল', + remaining: 'অবশিষ্ট সময়', + remainingSuffix: 'বাকি {duration}', + showElapsed: '{duration}. অতিবাহিত সময় দেখান.', + showDuration: '{duration}. সময়কাল দেখান.', + showRemaining: '{duration}. বাকি সময় দেখান.', + position: '{duration} এর মধ্যে {current}', + }, + playback: { + rate: 'প্লেব্যাক রেট {rate}', + }, + volume: { + mutedValue: '{percent}, নিঃশব্দ', + muted: 'নিঃশব্দ', + label: 'ভলিউম', + value: 'ভলিউম {value}', + }, + status: { + captionsOn: 'ক্যাপশন চালু', + captionsOff: 'ক্যাপশন বন্ধ', + paused: 'বিরাম', + playing: 'চলছে', + fullscreen: 'পূর্ণ স্ক্রীন', + pip: 'পিকচার ইন পিকচার', + exitPip: 'পিকচার ইন পিকচার থেকে বেরিয়ে আসুন', + }, + errors: { + aborted: 'আপনি মিডিয়া প্লেব্যাক বাতিল করেছেন', + network: 'একটি নেটওয়ার্ক ত্রুটির কারণে মিডিয়া ডাউনলোড আংশিকভাবে ব্যর্থ হয়েছে৷', + decode: 'মিডিয়া প্লেব্যাক একটি সমস্যার কারণে বা মিডিয়া ব্যবহার করা বৈশিষ্ট্যগুলি আপনার ব্রাউজার সমর্থন করে না বলে বাতিল করা হয়েছে৷', + source: 'মিডিয়া লোড করা যায়নি, হয় সার্ভার বা নেটওয়ার্ক ব্যর্থ হওয়ার কারণে বা ফর্ম্যাটটি সমর্থিত নয়।', + encrypted: 'মিডিয়া এনক্রিপ্ট করা হয়েছে এবং এটি ডিক্রিপ্ট করার সমাধান আমাদের কাছে নেই।', + title: 'কিছু একটা ভুল হয়েছে।', + unexpected: 'একটি ত্রুটি ঘটেছে। আবার চেষ্টা করুন।', + }, + common: { + empty: '', + ok: 'বন্ধ করুন', + }, + menu: { + settings: 'সেটিংস', + quality: 'গুণমান', + audio: 'অডিও', + default: 'ডিফল্ট', + speed: 'গতি', + captions: 'ক্যাপশন', + playbackRate: 'প্লেব্যাক রেট', + back: 'পিছনে', + off: 'বন্ধ', + auto: 'স্বয়ংক্রিয়', + autoWithLabel: 'স্বয়ংক্রিয় ({label})', + subtitles: 'সাবটাইটেল', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/bs.ts b/packages/core/src/core/i18n/locales/bs.ts index 93056498..0c0cf147 100644 --- a/packages/core/src/core/i18n/locales/bs.ts +++ b/packages/core/src/core/i18n/locales/bs.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Pusti', - Pause: 'Pauza', - Replay: 'Ponovi', - Mute: 'Prigušen', - Unmute: 'Ne-prigušen', - 'Seek forward {seconds} seconds': 'Premotaj naprijed {seconds} sekundi', - 'Seek backward {seconds} seconds': 'Premotaj nazad {seconds} sekundi', - 'Enter fullscreen': 'Puni ekran', - 'Enable captions': 'Uključi titlove', - 'Disable captions': 'Isključi titlove', - 'Enter picture-in-picture': 'Slika u slici', - 'Exit picture-in-picture': 'Izlaz iz slike u slici', - 'Playing live': 'Reprodukcija uživo', - 'Seek to live edge': 'Idi na live', - Live: 'Uživo', - 'Start casting': 'Pokreni emitovanje', - 'Stop casting': 'Zaustavi emitovanje', - Connecting: 'Povezivanje', - Seek: 'Premotavanje', - 'Current time': 'Trenutno vrijeme', - Duration: 'Vrijeme trajanja', - Remaining: 'Preostalo vrijeme', - '{duration} remaining': 'Preostalo {duration}', - '{duration}. Show elapsed time.': '{duration}. Prikaži proteklo vrijeme.', - '{duration}. Show duration.': '{duration}. Prikaži trajanje.', - '{duration}. Show remaining time.': '{duration}. Prikaži preostalo vrijeme.', - 'Playback rate {rate}': 'Stopa reprodukcije {rate}', - '{current} of {duration}': '{current} od {duration}', - '{percent}, muted': '{percent}, isključen zvuk', - Muted: 'Isključen zvuk', - Volume: 'Glasnoća', - 'Volume {value}': 'Glasnoća {value}', - 'Captions on': 'Titlovi uključeni', - 'Captions off': 'Titlovi isključeni', - Paused: 'Pauzirano', - Playing: 'Reprodukcija', - Fullscreen: 'Puni ekran', - 'Exit fullscreen': 'Izlaz iz punog ekrana', - 'Picture in picture': 'Slika u slici', - 'Exit picture in picture': 'Izlaz iz slike u slici', - 'You stopped media playback before it finished.': 'Isključili ste reprodukciju videa.', - 'This media could not be loaded due to a network or server issue.': - 'Video se prestao preuzimati zbog greške na mreži.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Reprodukcija videa je zaustavljenja zbog greške u formatu ili zbog verzije vašeg pretraživača.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Video se ne može reproducirati zbog servera, greške u mreži ili je format ne podržan.', - 'This media could not be played because it could not be decrypted.': - 'Medij je šifriran i nema ključeva za dešifriranje.', - '': '', - 'Something went wrong.': 'Nešto je pošlo po krivu.', - OK: 'OK', - 'An unexpected error occurred.': 'Došlo je do greške. Pokušajte ponovo.', - Settings: 'Postavke', - Quality: 'Kvalitet', - Audio: 'Zvuk', - Default: 'Zadano', - Speed: 'Brzina', - Captions: 'Titlovi', - 'Playback rate': 'Brzina reprodukcije', - Back: 'Nazad', - Off: 'Isključeno', - Auto: 'Automatski', - 'Auto ({label})': 'Automatski ({label})', - Subtitles: 'Titlovi', -} as const satisfies Partial; + buttons: { + play: 'Pusti', + pause: 'Pauza', + replay: 'Ponovi', + mute: 'Prigušen', + unmute: 'Ne-prigušen', + }, + seek: { + forward: 'Premotaj naprijed {seconds} sekundi', + backward: 'Premotaj nazad {seconds} sekundi', + }, + fullscreen: { + enter: 'Puni ekran', + exit: 'Izlaz iz punog ekrana', + }, + captions: { + enable: 'Uključi titlove', + disable: 'Isključi titlove', + }, + pip: { + enter: 'Slika u slici', + exit: 'Izlaz iz slike u slici', + }, + live: { + playing: 'Reprodukcija uživo', + seekToEdge: 'Idi na live', + badge: 'Uživo', + }, + cast: { + start: 'Pokreni emitovanje', + stop: 'Zaustavi emitovanje', + connecting: 'Povezivanje', + }, + airplay: { + start: 'Pokreni AirPlay', + stop: 'Zaustavi AirPlay', + }, + slider: { + seek: 'Premotavanje', + }, + time: { + current: 'Trenutno vrijeme', + duration: 'Vrijeme trajanja', + remaining: 'Preostalo vrijeme', + remainingSuffix: 'Preostalo {duration}', + showElapsed: '{duration}. Prikaži proteklo vrijeme.', + showDuration: '{duration}. Prikaži trajanje.', + showRemaining: '{duration}. Prikaži preostalo vrijeme.', + position: '{current} od {duration}', + }, + playback: { + rate: 'Stopa reprodukcije {rate}', + }, + volume: { + mutedValue: '{percent}, isključen zvuk', + muted: 'Isključen zvuk', + label: 'Glasnoća', + value: 'Glasnoća {value}', + }, + status: { + captionsOn: 'Titlovi uključeni', + captionsOff: 'Titlovi isključeni', + paused: 'Pauzirano', + playing: 'Reprodukcija', + fullscreen: 'Puni ekran', + pip: 'Slika u slici', + exitPip: 'Izlaz iz slike u slici', + }, + errors: { + aborted: 'Isključili ste reprodukciju videa.', + network: 'Video se prestao preuzimati zbog greške na mreži.', + decode: 'Reprodukcija videa je zaustavljenja zbog greške u formatu ili zbog verzije vašeg pretraživača.', + source: 'Video se ne može reproducirati zbog servera, greške u mreži ili je format ne podržan.', + encrypted: 'Medij je šifriran i nema ključeva za dešifriranje.', + title: 'Nešto je pošlo po krivu.', + unexpected: 'Došlo je do greške. Pokušajte ponovo.', + }, + common: { + empty: '', + ok: 'OK', + }, + menu: { + settings: 'Postavke', + quality: 'Kvalitet', + audio: 'Zvuk', + default: 'Zadano', + speed: 'Brzina', + captions: 'Titlovi', + playbackRate: 'Brzina reprodukcije', + back: 'Nazad', + off: 'Isključeno', + auto: 'Automatski', + autoWithLabel: 'Automatski ({label})', + subtitles: 'Titlovi', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/ca.ts b/packages/core/src/core/i18n/locales/ca.ts index 26f43389..377751d5 100644 --- a/packages/core/src/core/i18n/locales/ca.ts +++ b/packages/core/src/core/i18n/locales/ca.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Reproduir', - Pause: 'Pausa', - Replay: 'Repetir', - Mute: 'Silenciar', - Unmute: 'Activar el so', - 'Seek forward {seconds} seconds': 'Salta endavant {seconds} segons', - 'Seek backward {seconds} seconds': 'Salta enrere {seconds} segons', - 'Enter fullscreen': 'Pantalla completa', - 'Enable captions': 'Activa subtítols', - 'Disable captions': 'Desactiva subtítols', - 'Enter picture-in-picture': 'Imatge en imatge', - 'Exit picture-in-picture': 'Sortir de la imatge en imatge', - 'Playing live': 'Reproducció en directe', - 'Seek to live edge': 'Anar al directe', - Live: 'En directe', - 'Start casting': 'Comença a emetre', - 'Stop casting': 'Atura la transmissió', - Connecting: 'Connectant', - Seek: 'Desplaçament', - 'Current time': 'Temps actual', - Duration: 'Durada', - Remaining: 'Temps restant', - '{duration} remaining': 'Queden {duration}', - '{duration}. Show elapsed time.': '{duration}. Mostra el temps transcorregut.', - '{duration}. Show duration.': '{duration}. Mostra la durada.', - '{duration}. Show remaining time.': '{duration}. Mostra el temps restant.', - 'Playback rate {rate}': 'Velocitat de reproducció {rate}', - '{current} of {duration}': '{current} de {duration}', - '{percent}, muted': '{percent}, silenciat', - Muted: 'Silenciat', - Volume: 'Volum', - 'Volume {value}': 'Volum {value}', - 'Captions on': 'Llegendes activades', - 'Captions off': 'Llegendes desactivades', - Paused: 'En pausa', - Playing: 'Reproduint', - Fullscreen: 'Pantalla completa', - 'Exit fullscreen': 'Surt de pantalla completa', - 'Picture in picture': 'Imatge en imatge', - 'Exit picture in picture': 'Surt de la imatge en imatge', - 'You stopped media playback before it finished.': 'Has interromput la reproducció del contingut', - 'This media could not be loaded due to a network or server issue.': - 'Un error de xarxa ha interromput la descàrrega del contingut.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - "La reproducció del contingut s'ha interromput a causa d'un problema de corrupció o perquè el contingut fa servir funcions que el teu navegador no suporta.", - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - "No s'ha pogut carregar el contingut, ja sigui perquè el servidor o la xarxa han fallat o perquè el format no està suportat.", - 'This media could not be played because it could not be decrypted.': - 'El contingut està xifrat i no disposem de les claus per desxifrar-lo.', - '': '', - 'Something went wrong.': 'Alguna cosa ha anat malament.', - OK: 'Tancar', - 'An unexpected error occurred.': "S'ha produït un error. Torneu-ho a intentar.", - Settings: 'Configuració', - Quality: 'Qualitat', - Audio: 'Àudio', - Default: 'Predeterminat', - Speed: 'Velocitat', - Captions: 'Subtítols', - 'Playback rate': 'Velocitat de reproducció', - Back: 'Enrere', - Off: 'Desactivat', - Auto: 'Automàtic', - 'Auto ({label})': 'Automàtic ({label})', - Subtitles: 'Subtítols', -} as const satisfies Partial; + buttons: { + play: 'Reproduir', + pause: 'Pausa', + replay: 'Repetir', + mute: 'Silenciar', + unmute: 'Activar el so', + }, + seek: { + forward: 'Salta endavant {seconds} segons', + backward: 'Salta enrere {seconds} segons', + }, + fullscreen: { + enter: 'Pantalla completa', + exit: 'Surt de pantalla completa', + }, + captions: { + enable: 'Activa subtítols', + disable: 'Desactiva subtítols', + }, + pip: { + enter: 'Imatge en imatge', + exit: 'Sortir de la imatge en imatge', + }, + live: { + playing: 'Reproducció en directe', + seekToEdge: 'Anar al directe', + badge: 'En directe', + }, + cast: { + start: 'Comença a emetre', + stop: 'Atura la transmissió', + connecting: 'Connectant', + }, + airplay: { + start: 'Inicia AirPlay', + stop: 'Atura AirPlay', + }, + slider: { + seek: 'Desplaçament', + }, + time: { + current: 'Temps actual', + duration: 'Durada', + remaining: 'Temps restant', + remainingSuffix: 'Queden {duration}', + showElapsed: '{duration}. Mostra el temps transcorregut.', + showDuration: '{duration}. Mostra la durada.', + showRemaining: '{duration}. Mostra el temps restant.', + position: '{current} de {duration}', + }, + playback: { + rate: 'Velocitat de reproducció {rate}', + }, + volume: { + mutedValue: '{percent}, silenciat', + muted: 'Silenciat', + label: 'Volum', + value: 'Volum {value}', + }, + status: { + captionsOn: 'Llegendes activades', + captionsOff: 'Llegendes desactivades', + paused: 'En pausa', + playing: 'Reproduint', + fullscreen: 'Pantalla completa', + pip: 'Imatge en imatge', + exitPip: 'Surt de la imatge en imatge', + }, + errors: { + aborted: 'Has interromput la reproducció del contingut', + network: 'Un error de xarxa ha interromput la descàrrega del contingut.', + decode: + "La reproducció del contingut s'ha interromput a causa d'un problema de corrupció o perquè el contingut fa servir funcions que el teu navegador no suporta.", + source: + "No s'ha pogut carregar el contingut, ja sigui perquè el servidor o la xarxa han fallat o perquè el format no està suportat.", + encrypted: 'El contingut està xifrat i no disposem de les claus per desxifrar-lo.', + title: 'Alguna cosa ha anat malament.', + unexpected: "S'ha produït un error. Torneu-ho a intentar.", + }, + common: { + empty: '', + ok: 'Tancar', + }, + menu: { + settings: 'Configuració', + quality: 'Qualitat', + audio: 'Àudio', + default: 'Predeterminat', + speed: 'Velocitat', + captions: 'Subtítols', + playbackRate: 'Velocitat de reproducció', + back: 'Enrere', + off: 'Desactivat', + auto: 'Automàtic', + autoWithLabel: 'Automàtic ({label})', + subtitles: 'Subtítols', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/cs.ts b/packages/core/src/core/i18n/locales/cs.ts index ca461b47..ed7c593b 100644 --- a/packages/core/src/core/i18n/locales/cs.ts +++ b/packages/core/src/core/i18n/locales/cs.ts @@ -1,67 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Přehrát', - Pause: 'Pozastavit', - Replay: 'Přehrát znovu', - Mute: 'Ztlumit', - Unmute: 'Zrušit ztlumení', - 'Seek forward {seconds} seconds': 'Posunout vpřed o {seconds} sekund', - 'Seek backward {seconds} seconds': 'Posunout zpět o {seconds} sekund', - 'Enter fullscreen': 'Celá obrazovka', - 'Enable captions': 'Zapnout titulky', - 'Disable captions': 'Vypnout titulky', - 'Enter picture-in-picture': 'Obraz v obraze', - 'Exit picture-in-picture': 'Ukončit obraz v obraze', - 'Playing live': 'Přehrává se živě', - 'Seek to live edge': 'Přejít na živé vysílání', - Live: 'Živě', - 'Start casting': 'Začít přenášet', - 'Stop casting': 'Zastavit přenos', - Connecting: 'Připojování', - Seek: 'Posun', - 'Current time': 'Aktuální čas', - Duration: 'Doba trvání', - Remaining: 'Zbývající čas', - '{duration} remaining': 'Zbývá {duration}', - '{duration}. Show elapsed time.': '{duration}. Zobrazit uplynulý čas.', - '{duration}. Show duration.': '{duration}. Zobrazit délku.', - '{duration}. Show remaining time.': '{duration}. Zobrazit zbývající čas.', - 'Playback rate {rate}': 'Rychlost přehrávání {rate}', - '{current} of {duration}': '{current} z {duration}', - '{percent}, muted': '{percent}, ztlumeno', - Muted: 'Ztlumeno', - Volume: 'Hlasitost', - 'Volume {value}': 'Hlasitost {value}', - 'Captions on': 'Popisky zapnuty', - 'Captions off': 'Popisky vypnuty', - Paused: 'Pozastaveno', - Playing: 'Přehrávání', - Fullscreen: 'Celá obrazovka', - 'Exit fullscreen': 'Ukončit celou obrazovku', - 'Picture in picture': 'Obraz v obraze', - 'Exit picture in picture': 'Ukončit obraz v obraze', - 'You stopped media playback before it finished.': 'Přehrávání videa bylo přerušeno.', - 'This media could not be loaded due to a network or server issue.': 'Video nemohlo být načteno kvůli chybě v síti.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Váš prohlížeč nepodporuje tento formát videa.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Video nemohlo být načteno, buď kvůli chybě serveru, sítě nebo proto, že daný formát není podporován.', - 'This media could not be played because it could not be decrypted.': 'Chyba při dešifrování videa.', - '': '', - 'Something went wrong.': 'Něco se pokazilo.', - OK: 'Zavřít', - 'An unexpected error occurred.': 'Došlo k chybě. Zkuste to prosím znovu.', - Settings: 'Nastavení', - Quality: 'Kvalita', - Audio: 'Zvuk', - Default: 'Výchozí', - Speed: 'Rychlost', - Captions: 'Titulky', - 'Playback rate': 'Rychlost přehrávání', - Back: 'Zpět', - Off: 'Vypnuto', - Auto: 'Automaticky', - 'Auto ({label})': 'Automaticky ({label})', - Subtitles: 'Titulky', -} as const satisfies Partial; + buttons: { + play: 'Přehrát', + pause: 'Pozastavit', + replay: 'Přehrát znovu', + mute: 'Ztlumit', + unmute: 'Zrušit ztlumení', + }, + seek: { + forward: 'Posunout vpřed o {seconds} sekund', + backward: 'Posunout zpět o {seconds} sekund', + }, + fullscreen: { + enter: 'Celá obrazovka', + exit: 'Ukončit celou obrazovku', + }, + captions: { + enable: 'Zapnout titulky', + disable: 'Vypnout titulky', + }, + pip: { + enter: 'Obraz v obraze', + exit: 'Ukončit obraz v obraze', + }, + live: { + playing: 'Přehrává se živě', + seekToEdge: 'Přejít na živé vysílání', + badge: 'Živě', + }, + cast: { + start: 'Začít přenášet', + stop: 'Zastavit přenos', + connecting: 'Připojování', + }, + airplay: { + start: 'Spustit AirPlay', + stop: 'Zastavit AirPlay', + }, + slider: { + seek: 'Posun', + }, + time: { + current: 'Aktuální čas', + duration: 'Doba trvání', + remaining: 'Zbývající čas', + remainingSuffix: 'Zbývá {duration}', + showElapsed: '{duration}. Zobrazit uplynulý čas.', + showDuration: '{duration}. Zobrazit délku.', + showRemaining: '{duration}. Zobrazit zbývající čas.', + position: '{current} z {duration}', + }, + playback: { + rate: 'Rychlost přehrávání {rate}', + }, + volume: { + mutedValue: '{percent}, ztlumeno', + muted: 'Ztlumeno', + label: 'Hlasitost', + value: 'Hlasitost {value}', + }, + status: { + captionsOn: 'Popisky zapnuty', + captionsOff: 'Popisky vypnuty', + paused: 'Pozastaveno', + playing: 'Přehrávání', + fullscreen: 'Celá obrazovka', + pip: 'Obraz v obraze', + exitPip: 'Ukončit obraz v obraze', + }, + errors: { + aborted: 'Přehrávání videa bylo přerušeno.', + network: 'Video nemohlo být načteno kvůli chybě v síti.', + decode: 'Váš prohlížeč nepodporuje tento formát videa.', + source: 'Video nemohlo být načteno, buď kvůli chybě serveru, sítě nebo proto, že daný formát není podporován.', + encrypted: 'Chyba při dešifrování videa.', + title: 'Něco se pokazilo.', + unexpected: 'Došlo k chybě. Zkuste to prosím znovu.', + }, + common: { + empty: '', + ok: 'Zavřít', + }, + menu: { + settings: 'Nastavení', + quality: 'Kvalita', + audio: 'Zvuk', + default: 'Výchozí', + speed: 'Rychlost', + captions: 'Titulky', + playbackRate: 'Rychlost přehrávání', + back: 'Zpět', + off: 'Vypnuto', + auto: 'Automaticky', + autoWithLabel: 'Automaticky ({label})', + subtitles: 'Titulky', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/cy.ts b/packages/core/src/core/i18n/locales/cy.ts index e961006f..c4762e10 100644 --- a/packages/core/src/core/i18n/locales/cy.ts +++ b/packages/core/src/core/i18n/locales/cy.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Chwarae', - Pause: 'Oedi', - Replay: 'Ailchwarae', - Mute: 'Pylu', - Unmute: 'Dad-bylu', - 'Seek forward {seconds} seconds': 'Neidla ymlaen {seconds} eiliad', - 'Seek backward {seconds} seconds': 'Neidla yn ôl {seconds} eiliad', - 'Enter fullscreen': 'Sgrîn Lawn', - 'Enable captions': 'Galluogi capsiynau', - 'Disable captions': 'Analluogi capsiynau', - 'Enter picture-in-picture': 'Llun mewn llun', - 'Exit picture-in-picture': 'Gadael llun mewn llun', - 'Playing live': 'Yn chwarae’n fyw', - 'Seek to live edge': 'Mynd i’r ymyl byw', - Live: 'Yn fyw', - 'Start casting': 'Dechrau bwrw', - 'Stop casting': 'Stopio bwrw', - Connecting: 'Cysylltu', - Seek: 'Chwilio', - 'Current time': 'Amser Cyfredol', - Duration: 'Parhad', - Remaining: 'Amser ar ôl', - '{duration} remaining': '{duration} yn weddill', - '{duration}. Show elapsed time.': '{duration}. Dangos yr amser a aeth heibio.', - '{duration}. Show duration.': '{duration}. Dangos hyd.', - '{duration}. Show remaining time.': '{duration}. Dangos yr amser sy\u0027n weddill.', - 'Playback rate {rate}': 'Cyfradd Chwarae {rate}', - '{current} of {duration}': '{current} o {duration}', - '{percent}, muted': '{percent}, mud', - Muted: 'Mud', - Volume: 'Sain', - 'Volume {value}': 'Sain {value}', - 'Captions on': 'Capsiynau ymlaen', - 'Captions off': 'Capsiynau i ffwrdd', - Paused: 'Wedi oedi', - Playing: 'Yn chwarae', - Fullscreen: 'Sgrîn lawn', - 'Exit fullscreen': 'Gadael sgrîn lawn', - 'Picture in picture': 'Llun mewn llun', - 'Exit picture in picture': 'Gadael llun mewn llun', - 'You stopped media playback before it finished.': 'Atalwyd y fideo gennych', - 'This media could not be loaded due to a network or server issue.': - 'Mae gwall rhwydwaith wedi achosi methiant lawrlwytho.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - "Atalwyd y fideo oherwydd problem llygredd data neu oherwydd nid yw'ch porwr yn cefnogi nodweddion penodol o'r fideo.", - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - "Ni lwythodd y fideo, oherwydd methiant gweinydd neu rwydwaith, neu achos nid yw'r system yn cefnogi'r fformat.", - 'This media could not be played because it could not be decrypted.': - "Mae'r fideo wedi ei amgryptio ac nid oes allweddion gennym.", - '': '', - 'Something went wrong.': "Aeth rhywbeth o'i le.", - OK: 'Cau', - 'An unexpected error occurred.': 'Digwyddodd gwall. Ceisiwch eto.', - Settings: 'Gosodiadau', - Quality: 'Ansawdd', - Audio: 'Sain', - Default: 'Rhagosodedig', - Speed: 'Cyflymder', - Captions: 'Capsiynau', - 'Playback rate': 'Cyfradd chwarae', - Back: 'Yn ôl', - Off: 'I ffwrdd', - Auto: 'Awtomatig', - 'Auto ({label})': 'Awtomatig ({label})', - Subtitles: 'Isdeitlau', -} as const satisfies Partial; + buttons: { + play: 'Chwarae', + pause: 'Oedi', + replay: 'Ailchwarae', + mute: 'Pylu', + unmute: 'Dad-bylu', + }, + seek: { + forward: 'Neidla ymlaen {seconds} eiliad', + backward: 'Neidla yn ôl {seconds} eiliad', + }, + fullscreen: { + enter: 'Sgrîn Lawn', + exit: 'Gadael sgrîn lawn', + }, + captions: { + enable: 'Galluogi capsiynau', + disable: 'Analluogi capsiynau', + }, + pip: { + enter: 'Llun mewn llun', + exit: 'Gadael llun mewn llun', + }, + live: { + playing: 'Yn chwarae’n fyw', + seekToEdge: 'Mynd i’r ymyl byw', + badge: 'Yn fyw', + }, + cast: { + start: 'Dechrau bwrw', + stop: 'Stopio bwrw', + connecting: 'Cysylltu', + }, + airplay: { + start: 'Cychwyn AirPlay', + stop: 'Stopio AirPlay', + }, + slider: { + seek: 'Chwilio', + }, + time: { + current: 'Amser Cyfredol', + duration: 'Parhad', + remaining: 'Amser ar ôl', + remainingSuffix: '{duration} yn weddill', + showElapsed: '{duration}. Dangos yr amser a aeth heibio.', + showDuration: '{duration}. Dangos hyd.', + showRemaining: "{duration}. Dangos yr amser sy'n weddill.", + position: '{current} o {duration}', + }, + playback: { + rate: 'Cyfradd Chwarae {rate}', + }, + volume: { + mutedValue: '{percent}, mud', + muted: 'Mud', + label: 'Sain', + value: 'Sain {value}', + }, + status: { + captionsOn: 'Capsiynau ymlaen', + captionsOff: 'Capsiynau i ffwrdd', + paused: 'Wedi oedi', + playing: 'Yn chwarae', + fullscreen: 'Sgrîn lawn', + pip: 'Llun mewn llun', + exitPip: 'Gadael llun mewn llun', + }, + errors: { + aborted: 'Atalwyd y fideo gennych', + network: 'Mae gwall rhwydwaith wedi achosi methiant lawrlwytho.', + decode: + "Atalwyd y fideo oherwydd problem llygredd data neu oherwydd nid yw'ch porwr yn cefnogi nodweddion penodol o'r fideo.", + source: + "Ni lwythodd y fideo, oherwydd methiant gweinydd neu rwydwaith, neu achos nid yw'r system yn cefnogi'r fformat.", + encrypted: "Mae'r fideo wedi ei amgryptio ac nid oes allweddion gennym.", + title: "Aeth rhywbeth o'i le.", + unexpected: 'Digwyddodd gwall. Ceisiwch eto.', + }, + common: { + empty: '', + ok: 'Cau', + }, + menu: { + settings: 'Gosodiadau', + quality: 'Ansawdd', + audio: 'Sain', + default: 'Rhagosodedig', + speed: 'Cyflymder', + captions: 'Capsiynau', + playbackRate: 'Cyfradd chwarae', + back: 'Yn ôl', + off: 'I ffwrdd', + auto: 'Awtomatig', + autoWithLabel: 'Awtomatig ({label})', + subtitles: 'Isdeitlau', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/da.ts b/packages/core/src/core/i18n/locales/da.ts index db41937d..bd32c907 100644 --- a/packages/core/src/core/i18n/locales/da.ts +++ b/packages/core/src/core/i18n/locales/da.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Afspil', - Pause: 'Pause', - Replay: 'Afspil igen', - Mute: 'Uden lyd', - Unmute: 'Med lyd', - 'Seek forward {seconds} seconds': 'Spring {seconds} sekunder frem', - 'Seek backward {seconds} seconds': 'Spring {seconds} sekunder tilbage', - 'Enter fullscreen': 'Fuldskærm', - 'Enable captions': 'Aktivér undertekster', - 'Disable captions': 'Deaktiver undertekster', - 'Enter picture-in-picture': 'Billede-i-billede', - 'Exit picture-in-picture': 'Afslut billede-i-billede', - 'Playing live': 'Afspiller live', - 'Seek to live edge': 'Gå til live', - Live: 'Live', - 'Start casting': 'Start afsendelse', - 'Stop casting': 'Stop afsendelse', - Connecting: 'Forbinder', - Seek: 'Spol', - 'Current time': 'Aktuel tid', - Duration: 'Varighed', - Remaining: 'Resterende tid', - '{duration} remaining': '{duration} tilbage', - '{duration}. Show elapsed time.': '{duration}. Vis forløbet tid.', - '{duration}. Show duration.': '{duration}. Vis varighed.', - '{duration}. Show remaining time.': '{duration}. Vis resterende tid.', - 'Playback rate {rate}': 'Afspilningsrate {rate}', - '{current} of {duration}': '{current} af {duration}', - '{percent}, muted': '{percent}, lydløs', - Muted: 'Lydløs', - Volume: 'Lydstyrke', - 'Volume {value}': 'Lydstyrke {value}', - 'Captions on': 'Undertekster til', - 'Captions off': 'Undertekster fra', - Paused: 'Pauseret', - Playing: 'Afspiller', - Fullscreen: 'Fuldskærm', - 'Exit fullscreen': 'Luk fuldskærm', - 'Picture in picture': 'Billede i billede', - 'Exit picture in picture': 'Afslut billede i billede', - 'You stopped media playback before it finished.': 'Du afbrød videoafspilningen.', - 'This media could not be loaded due to a network or server issue.': - 'En netværksfejl fik download af videoen til at fejle.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Videoafspilningen blev afbrudt på grund af ødelagte data eller fordi videoen benyttede faciliteter som din browser ikke understøtter.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Videoen kunne ikke indlæses, enten fordi serveren eller netværket fejlede, eller fordi formatet ikke er understøttet.', - 'This media could not be played because it could not be decrypted.': - 'Mediet er krypteret, og der er ingen nøgler til at dekryptere det.', - '': '', - 'Something went wrong.': 'Noget gik galt.', - OK: 'OK', - 'An unexpected error occurred.': 'Der opstod en fejl. Prøv igen.', - Settings: 'Indstillinger', - Quality: 'Kvalitet', - Audio: 'Lyd', - Default: 'Standard', - Speed: 'Hastighed', - Captions: 'Undertekster', - 'Playback rate': 'Afspilningshastighed', - Back: 'Tilbage', - Off: 'Fra', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Undertekster', -} as const satisfies Partial; + buttons: { + play: 'Afspil', + pause: 'Pause', + replay: 'Afspil igen', + mute: 'Uden lyd', + unmute: 'Med lyd', + }, + seek: { + forward: 'Spring {seconds} sekunder frem', + backward: 'Spring {seconds} sekunder tilbage', + }, + fullscreen: { + enter: 'Fuldskærm', + exit: 'Luk fuldskærm', + }, + captions: { + enable: 'Aktivér undertekster', + disable: 'Deaktiver undertekster', + }, + pip: { + enter: 'Billede-i-billede', + exit: 'Afslut billede-i-billede', + }, + live: { + playing: 'Afspiller live', + seekToEdge: 'Gå til live', + badge: 'Live', + }, + cast: { + start: 'Start afsendelse', + stop: 'Stop afsendelse', + connecting: 'Forbinder', + }, + airplay: { + start: 'Start AirPlay', + stop: 'Stop AirPlay', + }, + slider: { + seek: 'Spol', + }, + time: { + current: 'Aktuel tid', + duration: 'Varighed', + remaining: 'Resterende tid', + remainingSuffix: '{duration} tilbage', + showElapsed: '{duration}. Vis forløbet tid.', + showDuration: '{duration}. Vis varighed.', + showRemaining: '{duration}. Vis resterende tid.', + position: '{current} af {duration}', + }, + playback: { + rate: 'Afspilningsrate {rate}', + }, + volume: { + mutedValue: '{percent}, lydløs', + muted: 'Lydløs', + label: 'Lydstyrke', + value: 'Lydstyrke {value}', + }, + status: { + captionsOn: 'Undertekster til', + captionsOff: 'Undertekster fra', + paused: 'Pauseret', + playing: 'Afspiller', + fullscreen: 'Fuldskærm', + pip: 'Billede i billede', + exitPip: 'Afslut billede i billede', + }, + errors: { + aborted: 'Du afbrød videoafspilningen.', + network: 'En netværksfejl fik download af videoen til at fejle.', + decode: + 'Videoafspilningen blev afbrudt på grund af ødelagte data eller fordi videoen benyttede faciliteter som din browser ikke understøtter.', + source: + 'Videoen kunne ikke indlæses, enten fordi serveren eller netværket fejlede, eller fordi formatet ikke er understøttet.', + encrypted: 'Mediet er krypteret, og der er ingen nøgler til at dekryptere det.', + title: 'Noget gik galt.', + unexpected: 'Der opstod en fejl. Prøv igen.', + }, + common: { + empty: '', + ok: 'OK', + }, + menu: { + settings: 'Indstillinger', + quality: 'Kvalitet', + audio: 'Lyd', + default: 'Standard', + speed: 'Hastighed', + captions: 'Undertekster', + playbackRate: 'Afspilningshastighed', + back: 'Tilbage', + off: 'Fra', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Undertekster', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/de.ts b/packages/core/src/core/i18n/locales/de.ts index cbdf1b22..15a90c6c 100644 --- a/packages/core/src/core/i18n/locales/de.ts +++ b/packages/core/src/core/i18n/locales/de.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Wiedergabe', - Pause: 'Pause', - Replay: 'Erneut abspielen', - Mute: 'Stumm schalten', - Unmute: 'Ton einschalten', - 'Seek forward {seconds} seconds': '{seconds} Sekunden vorwärts', - 'Seek backward {seconds} seconds': '{seconds} Sekunden zurück', - 'Enter fullscreen': 'Vollbild', - 'Enable captions': 'Untertitel einschalten', - 'Disable captions': 'Untertitel ausschalten', - 'Enter picture-in-picture': 'Bild-im-Bild-Modus', - 'Exit picture-in-picture': 'Bild-im-Bild-Modus beenden', - 'Playing live': 'Wird live wiedergegeben', - 'Seek to live edge': 'Zum Live-Rand springen', - Live: 'Live', - 'Start casting': 'Übertragung starten', - 'Stop casting': 'Übertragung beenden', - Connecting: 'Verbinden', - Seek: 'Spule', - 'Current time': 'Aktueller Zeitpunkt', - Duration: 'Dauer', - Remaining: 'Verbleibende Zeit', - '{duration} remaining': 'Noch {duration}', - '{duration}. Show elapsed time.': '{duration}. Verstrichene Zeit anzeigen.', - '{duration}. Show duration.': '{duration}. Dauer anzeigen.', - '{duration}. Show remaining time.': '{duration}. Verbleibende Zeit anzeigen.', - 'Playback rate {rate}': 'Wiedergabegeschwindigkeit {rate}', - '{current} of {duration}': '{current} von {duration}', - '{percent}, muted': '{percent}, stummgeschaltet', - Muted: 'Stummgeschaltet', - Volume: 'Lautstärke', - 'Volume {value}': 'Lautstärke {value}', - 'Captions on': 'Untertitel ein', - 'Captions off': 'Untertitel aus', - Paused: 'Pausiert', - Playing: 'Wird wiedergegeben', - Fullscreen: 'Vollbild', - 'Exit fullscreen': 'Vollbild beenden', - 'Picture in picture': 'Bild-in-Bild', - 'Exit picture in picture': 'Bild-in-Bild beenden', - 'You stopped media playback before it finished.': 'Sie haben die Videowiedergabe abgebrochen.', - 'This media could not be loaded due to a network or server issue.': - 'Der Videodownload ist aufgrund eines Netzwerkfehlers fehlgeschlagen.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Die Videowiedergabe wurde entweder wegen eines Problems mit einem beschädigten Video oder wegen verwendeten Funktionen, die vom Browser nicht unterstützt werden, abgebrochen.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Das Video konnte nicht geladen werden, da entweder ein Server- oder Netzwerkfehler auftrat oder das Format nicht unterstützt wird.', - 'This media could not be played because it could not be decrypted.': - 'Die Entschlüsselungsschlüssel für den verschlüsselten Medieninhalt sind nicht verfügbar.', - '': '', - 'Something went wrong.': 'Etwas ist schiefgelaufen.', - OK: 'Schließen', - 'An unexpected error occurred.': 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', - Settings: 'Einstellungen', - Quality: 'Qualität', - Audio: 'Ton', - Default: 'Standard', - Speed: 'Geschwindigkeit', - Captions: 'Untertitel', - 'Playback rate': 'Wiedergabegeschwindigkeit', - Back: 'Zurück', - Off: 'Aus', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Untertitel', -} as const satisfies Partial; + buttons: { + play: 'Wiedergabe', + pause: 'Pause', + replay: 'Erneut abspielen', + mute: 'Stumm schalten', + unmute: 'Ton einschalten', + }, + seek: { + forward: '{seconds} Sekunden vorwärts', + backward: '{seconds} Sekunden zurück', + }, + fullscreen: { + enter: 'Vollbild', + exit: 'Vollbild beenden', + }, + captions: { + enable: 'Untertitel einschalten', + disable: 'Untertitel ausschalten', + }, + pip: { + enter: 'Bild-im-Bild-Modus', + exit: 'Bild-im-Bild-Modus beenden', + }, + live: { + playing: 'Wird live wiedergegeben', + seekToEdge: 'Zum Live-Rand springen', + badge: 'Live', + }, + cast: { + start: 'Übertragung starten', + stop: 'Übertragung beenden', + connecting: 'Verbinden', + }, + airplay: { + start: 'AirPlay starten', + stop: 'AirPlay stoppen', + }, + slider: { + seek: 'Spule', + }, + time: { + current: 'Aktueller Zeitpunkt', + duration: 'Dauer', + remaining: 'Verbleibende Zeit', + remainingSuffix: 'Noch {duration}', + showElapsed: '{duration}. Verstrichene Zeit anzeigen.', + showDuration: '{duration}. Dauer anzeigen.', + showRemaining: '{duration}. Verbleibende Zeit anzeigen.', + position: '{current} von {duration}', + }, + playback: { + rate: 'Wiedergabegeschwindigkeit {rate}', + }, + volume: { + mutedValue: '{percent}, stummgeschaltet', + muted: 'Stummgeschaltet', + label: 'Lautstärke', + value: 'Lautstärke {value}', + }, + status: { + captionsOn: 'Untertitel ein', + captionsOff: 'Untertitel aus', + paused: 'Pausiert', + playing: 'Wird wiedergegeben', + fullscreen: 'Vollbild', + pip: 'Bild-in-Bild', + exitPip: 'Bild-in-Bild beenden', + }, + errors: { + aborted: 'Sie haben die Videowiedergabe abgebrochen.', + network: 'Der Videodownload ist aufgrund eines Netzwerkfehlers fehlgeschlagen.', + decode: + 'Die Videowiedergabe wurde entweder wegen eines Problems mit einem beschädigten Video oder wegen verwendeten Funktionen, die vom Browser nicht unterstützt werden, abgebrochen.', + source: + 'Das Video konnte nicht geladen werden, da entweder ein Server- oder Netzwerkfehler auftrat oder das Format nicht unterstützt wird.', + encrypted: 'Die Entschlüsselungsschlüssel für den verschlüsselten Medieninhalt sind nicht verfügbar.', + title: 'Etwas ist schiefgelaufen.', + unexpected: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + }, + common: { + empty: '', + ok: 'Schließen', + }, + menu: { + settings: 'Einstellungen', + quality: 'Qualität', + audio: 'Ton', + default: 'Standard', + speed: 'Geschwindigkeit', + captions: 'Untertitel', + playbackRate: 'Wiedergabegeschwindigkeit', + back: 'Zurück', + off: 'Aus', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Untertitel', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/el.ts b/packages/core/src/core/i18n/locales/el.ts index bab33940..a2c760ce 100644 --- a/packages/core/src/core/i18n/locales/el.ts +++ b/packages/core/src/core/i18n/locales/el.ts @@ -1,69 +1,102 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Aναπαραγωγή', - Pause: 'Παύση', - Replay: 'Επανάληψη', - Mute: 'Σίγαση', - Unmute: 'Kατάργηση σίγασης', - 'Seek forward {seconds} seconds': 'Μεταβείτε μπροστά {seconds} δευτερόλεπτα', - 'Seek backward {seconds} seconds': 'Μεταβείτε πίσω {seconds} δευτερόλεπτα', - 'Enter fullscreen': 'Πλήρης οθόνη', - 'Enable captions': 'Ενεργοποίηση υποτίτλων', - 'Disable captions': 'Απενεργοποίηση υποτίτλων', - 'Enter picture-in-picture': 'Εικόνα-μέσα-σε-Εικόνα', - 'Exit picture-in-picture': 'Έξοδος από την Εικόνα-μέσα-σε-Εικόνα', - 'Playing live': 'Αναπαραγωγή ζωντανά', - 'Seek to live edge': 'Μετάβαση στο ζωντανό', - Live: 'Ζωντανά', - 'Start casting': 'Έναρξη μετάδοσης', - 'Stop casting': 'Διακοπή μετάδοσης', - Connecting: 'Σύνδεση', - Seek: 'Μετακίνηση', - 'Current time': 'Τρέχων χρόνος', - Duration: 'Συνολικός χρόνος', - Remaining: 'Υπολοιπόμενος χρόνος', - '{duration} remaining': 'Απομένουν {duration}', - '{duration}. Show elapsed time.': '{duration}. Εμφάνιση χρόνου που πέρασε.', - '{duration}. Show duration.': '{duration}. Εμφάνιση διάρκειας.', - '{duration}. Show remaining time.': '{duration}. Εμφάνιση υπολειπόμενου χρόνου.', - 'Playback rate {rate}': 'Ρυθμός αναπαραγωγής {rate}', - '{current} of {duration}': '{current} από {duration}', - '{percent}, muted': '{percent}, σε σίγαση', - Muted: 'Σε σίγαση', - Volume: 'Ένταση', - 'Volume {value}': 'Ένταση {value}', - 'Captions on': 'Λεζάντες ενεργές', - 'Captions off': 'Λεζάντες ανενεργές', - Paused: 'Σε παύση', - Playing: 'Αναπαραγωγή', - Fullscreen: 'Πλήρης οθόνη', - 'Exit fullscreen': 'Έξοδος από πλήρη οθόνη', - 'Picture in picture': 'Εικόνα μέσα σε εικόνα', - 'Exit picture in picture': 'Έξοδος από εικόνα μέσα σε εικόνα', - 'You stopped media playback before it finished.': 'Ακυρώσατε την αναπαραγωγή', - 'This media could not be loaded due to a network or server issue.': - 'Ένα σφάλμα δικτύου προκάλεσε την αποτυχία μεταφόρτωσης του αρχείου προς αναπαραγωγή.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Η αναπαραγωγή ακυρώθηκε είτε λόγω κατεστραμμένου αρχείου, είτε γιατί το αρχείο απαιτεί λειτουργίες που δεν υποστηρίζονται από το πρόγραμμα περιήγησης που χρησιμοποιείτε.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Το αρχείο προς αναπαραγωγή δεν ήταν δυνατό να φορτωθεί είτε γιατί υπήρξε σφάλμα στον διακομιστή ή το δίκτυο, είτε γιατί ο τύπος του αρχείου δεν υποστηρίζεται.', - 'This media could not be played because it could not be decrypted.': - 'Το αρχείο προς αναπαραγωγή είναι κρυπτογραφημένo και δεν υπάρχουν τα απαραίτητα κλειδιά αποκρυπτογράφησης.', - '': '', - 'Something went wrong.': 'Κάτι πήγε στραβά.', - OK: 'Κλείσιμο', - 'An unexpected error occurred.': 'Παρουσιάστηκε σφάλμα. Δοκιμάστε ξανά.', - Settings: 'Ρυθμίσεις', - Quality: 'Ποιότητα', - Audio: 'Ήχος', - Default: 'Προεπιλογή', - Speed: 'Ταχύτητα', - Captions: 'Λεζάντες', - 'Playback rate': 'Ρυθμός αναπαραγωγής', - Back: 'Πίσω', - Off: 'Απενεργοποίηση', - Auto: 'Αυτόματα', - 'Auto ({label})': 'Αυτόματα ({label})', - Subtitles: 'Υπότιτλοι', -} as const satisfies Partial; + buttons: { + play: 'Aναπαραγωγή', + pause: 'Παύση', + replay: 'Επανάληψη', + mute: 'Σίγαση', + unmute: 'Kατάργηση σίγασης', + }, + seek: { + forward: 'Μεταβείτε μπροστά {seconds} δευτερόλεπτα', + backward: 'Μεταβείτε πίσω {seconds} δευτερόλεπτα', + }, + fullscreen: { + enter: 'Πλήρης οθόνη', + exit: 'Έξοδος από πλήρη οθόνη', + }, + captions: { + enable: 'Ενεργοποίηση υποτίτλων', + disable: 'Απενεργοποίηση υποτίτλων', + }, + pip: { + enter: 'Εικόνα-μέσα-σε-Εικόνα', + exit: 'Έξοδος από την Εικόνα-μέσα-σε-Εικόνα', + }, + live: { + playing: 'Αναπαραγωγή ζωντανά', + seekToEdge: 'Μετάβαση στο ζωντανό', + badge: 'Ζωντανά', + }, + cast: { + start: 'Έναρξη μετάδοσης', + stop: 'Διακοπή μετάδοσης', + connecting: 'Σύνδεση', + }, + airplay: { + start: 'Έναρξη AirPlay', + stop: 'Διακοπή AirPlay', + }, + slider: { + seek: 'Μετακίνηση', + }, + time: { + current: 'Τρέχων χρόνος', + duration: 'Συνολικός χρόνος', + remaining: 'Υπολοιπόμενος χρόνος', + remainingSuffix: 'Απομένουν {duration}', + showElapsed: '{duration}. Εμφάνιση χρόνου που πέρασε.', + showDuration: '{duration}. Εμφάνιση διάρκειας.', + showRemaining: '{duration}. Εμφάνιση υπολειπόμενου χρόνου.', + position: '{current} από {duration}', + }, + playback: { + rate: 'Ρυθμός αναπαραγωγής {rate}', + }, + volume: { + mutedValue: '{percent}, σε σίγαση', + muted: 'Σε σίγαση', + label: 'Ένταση', + value: 'Ένταση {value}', + }, + status: { + captionsOn: 'Λεζάντες ενεργές', + captionsOff: 'Λεζάντες ανενεργές', + paused: 'Σε παύση', + playing: 'Αναπαραγωγή', + fullscreen: 'Πλήρης οθόνη', + pip: 'Εικόνα μέσα σε εικόνα', + exitPip: 'Έξοδος από εικόνα μέσα σε εικόνα', + }, + errors: { + aborted: 'Ακυρώσατε την αναπαραγωγή', + network: 'Ένα σφάλμα δικτύου προκάλεσε την αποτυχία μεταφόρτωσης του αρχείου προς αναπαραγωγή.', + decode: + 'Η αναπαραγωγή ακυρώθηκε είτε λόγω κατεστραμμένου αρχείου, είτε γιατί το αρχείο απαιτεί λειτουργίες που δεν υποστηρίζονται από το πρόγραμμα περιήγησης που χρησιμοποιείτε.', + source: + 'Το αρχείο προς αναπαραγωγή δεν ήταν δυνατό να φορτωθεί είτε γιατί υπήρξε σφάλμα στον διακομιστή ή το δίκτυο, είτε γιατί ο τύπος του αρχείου δεν υποστηρίζεται.', + encrypted: + 'Το αρχείο προς αναπαραγωγή είναι κρυπτογραφημένo και δεν υπάρχουν τα απαραίτητα κλειδιά αποκρυπτογράφησης.', + title: 'Κάτι πήγε στραβά.', + unexpected: 'Παρουσιάστηκε σφάλμα. Δοκιμάστε ξανά.', + }, + common: { + empty: '', + ok: 'Κλείσιμο', + }, + menu: { + settings: 'Ρυθμίσεις', + quality: 'Ποιότητα', + audio: 'Ήχος', + default: 'Προεπιλογή', + speed: 'Ταχύτητα', + captions: 'Λεζάντες', + playbackRate: 'Ρυθμός αναπαραγωγής', + back: 'Πίσω', + off: 'Απενεργοποίηση', + auto: 'Αυτόματα', + autoWithLabel: 'Αυτόματα ({label})', + subtitles: 'Υπότιτλοι', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/en.ts b/packages/core/src/core/i18n/locales/en.ts index c3d5787f..384fb874 100644 --- a/packages/core/src/core/i18n/locales/en.ts +++ b/packages/core/src/core/i18n/locales/en.ts @@ -1,70 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; -/** Default English layer — registered when `@videojs/core/i18n/registry` (or this package entry) is loaded. */ export default { - Play: 'Play', - Pause: 'Pause', - Replay: 'Replay', - Mute: 'Mute', - Unmute: 'Unmute', - 'Seek forward {seconds} seconds': 'Seek forward {seconds} seconds', - 'Seek backward {seconds} seconds': 'Seek backward {seconds} seconds', - 'Enter fullscreen': 'Enter fullscreen', - 'Enable captions': 'Enable captions', - 'Disable captions': 'Disable captions', - 'Enter picture-in-picture': 'Enter picture-in-picture', - 'Exit picture-in-picture': 'Exit picture-in-picture', - 'Playing live': 'Playing live', - 'Seek to live edge': 'Seek to live edge', - Live: 'Live', - 'Start casting': 'Start casting', - 'Stop casting': 'Stop casting', - Connecting: 'Connecting', - Seek: 'Seek', - 'Current time': 'Current time', - Duration: 'Duration', - Remaining: 'Remaining', - '{duration} remaining': '{duration} remaining', - '{duration}. Show elapsed time.': '{duration}. Show elapsed time.', - '{duration}. Show duration.': '{duration}. Show duration.', - '{duration}. Show remaining time.': '{duration}. Show remaining time.', - 'Playback rate {rate}': 'Playback rate {rate}', - '{current} of {duration}': '{current} of {duration}', - '{percent}, muted': '{percent}, muted', - Muted: 'Muted', - Volume: 'Volume', - 'Volume {value}': 'Volume {value}', - 'Captions on': 'Captions on', - 'Captions off': 'Captions off', - Paused: 'Paused', - Playing: 'Playing', - Fullscreen: 'Fullscreen', - 'Exit fullscreen': 'Exit fullscreen', - 'Picture in picture': 'Picture in picture', - 'Exit picture in picture': 'Exit picture in picture', - 'You stopped media playback before it finished.': 'You stopped media playback before it finished.', - 'This media could not be loaded due to a network or server issue.': - 'This media could not be loaded due to a network or server issue.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'This media could not be played. It may be corrupted, or your browser may not support its format.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.', - 'This media could not be played because it could not be decrypted.': - 'This media could not be played because it could not be decrypted.', - '': '', - 'Something went wrong.': 'Something went wrong.', - OK: 'OK', - 'An unexpected error occurred.': 'An unexpected error occurred.', - Settings: 'Settings', - Quality: 'Quality', - Audio: 'Audio', - Default: 'Default', - Speed: 'Speed', - Captions: 'Captions', - 'Playback rate': 'Playback rate', - Back: 'Back', - Off: 'Off', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Subtitles', + buttons: { + play: 'Play', + pause: 'Pause', + replay: 'Replay', + mute: 'Mute', + unmute: 'Unmute', + }, + seek: { + forward: 'Seek forward {seconds} seconds', + backward: 'Seek backward {seconds} seconds', + }, + fullscreen: { + enter: 'Enter fullscreen', + exit: 'Exit fullscreen', + }, + captions: { + enable: 'Enable captions', + disable: 'Disable captions', + }, + pip: { + enter: 'Enter picture-in-picture', + exit: 'Exit picture-in-picture', + }, + live: { + playing: 'Playing live', + seekToEdge: 'Seek to live edge', + badge: 'Live', + }, + cast: { + start: 'Start casting', + stop: 'Stop casting', + connecting: 'Connecting', + }, + airplay: { + start: 'Start AirPlay', + stop: 'Stop AirPlay', + }, + slider: { + seek: 'Seek', + }, + time: { + current: 'Current time', + duration: 'Duration', + remaining: 'Remaining', + remainingSuffix: '{duration} remaining', + showElapsed: '{duration}. Show elapsed time.', + showDuration: '{duration}. Show duration.', + showRemaining: '{duration}. Show remaining time.', + position: '{current} of {duration}', + }, + playback: { + rate: 'Playback rate {rate}', + }, + volume: { + mutedValue: '{percent}, muted', + muted: 'Muted', + label: 'Volume', + value: 'Volume {value}', + }, + status: { + captionsOn: 'Captions on', + captionsOff: 'Captions off', + paused: 'Paused', + playing: 'Playing', + fullscreen: 'Fullscreen', + pip: 'Picture in picture', + exitPip: 'Exit picture in picture', + }, + errors: { + aborted: 'You stopped media playback before it finished.', + network: 'This media could not be loaded due to a network or server issue.', + decode: 'This media could not be played. It may be corrupted, or your browser may not support its format.', + source: 'This media could not be loaded. It may be unavailable, or your browser may not support its format.', + encrypted: 'This media could not be played because it could not be decrypted.', + title: 'Something went wrong.', + unexpected: 'An unexpected error occurred.', + }, + common: { + empty: '', + ok: 'OK', + }, + menu: { + settings: 'Settings', + quality: 'Quality', + audio: 'Audio', + default: 'Default', + speed: 'Speed', + captions: 'Captions', + playbackRate: 'Playback rate', + back: 'Back', + off: 'Off', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Subtitles', + }, } as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/es.ts b/packages/core/src/core/i18n/locales/es.ts index 779c324f..1a46f564 100644 --- a/packages/core/src/core/i18n/locales/es.ts +++ b/packages/core/src/core/i18n/locales/es.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Reproducir', - Pause: 'Pausa', - Replay: 'Volver a reproducir', - Mute: 'Desactivar el sonido', - Unmute: 'Activar el sonido', - 'Seek forward {seconds} seconds': 'Avanza {seconds} segundos', - 'Seek backward {seconds} seconds': 'Retrocede {seconds} segundos', - 'Enter fullscreen': 'Pantalla completa', - 'Enable captions': 'Activar subtítulos', - 'Disable captions': 'Desactivar subtítulos', - 'Enter picture-in-picture': 'Imagen sobre imagen', - 'Exit picture-in-picture': 'Salir de imagen sobre imagen', - 'Playing live': 'Reproduciendo en directo', - 'Seek to live edge': 'Ir al directo', - Live: 'Directo', - 'Start casting': 'Iniciar transmisión', - 'Stop casting': 'Detener transmisión', - Connecting: 'Conectando', - Seek: 'Buscar', - 'Current time': 'Tiempo reproducido', - Duration: 'Duración total', - Remaining: 'Tiempo restante', - '{duration} remaining': 'Quedan {duration}', - '{duration}. Show elapsed time.': '{duration}. Mostrar tiempo transcurrido.', - '{duration}. Show duration.': '{duration}. Mostrar duración.', - '{duration}. Show remaining time.': '{duration}. Mostrar tiempo restante.', - 'Playback rate {rate}': 'Velocidad de reproducción {rate}', - '{current} of {duration}': '{current} de {duration}', - '{percent}, muted': '{percent}, silenciado', - Muted: 'Silenciado', - Volume: 'Volumen', - 'Volume {value}': 'Volumen {value}', - 'Captions on': 'Subtítulos activados', - 'Captions off': 'Subtítulos desactivados', - Paused: 'En pausa', - Playing: 'Reproduciendo', - Fullscreen: 'Pantalla completa', - 'Exit fullscreen': 'Salir de pantalla completa', - 'Picture in picture': 'Imagen en imagen', - 'Exit picture in picture': 'Salir de imagen en imagen', - 'You stopped media playback before it finished.': 'Ha interrumpido la reproducción del vídeo.', - 'This media could not be loaded due to a network or server issue.': - 'Un error de red ha interrumpido la descarga del vídeo.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'La reproducción de vídeo se ha interrumpido por un problema de corrupción de datos o porque el vídeo precisa funciones que su navegador no ofrece.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'No se ha podido cargar el vídeo debido a un fallo de red o del servidor o porque el formato es incompatible.', - 'This media could not be played because it could not be decrypted.': - 'El material audiovisual está cifrado y no tenemos las claves para descifrarlo.', - '': '', - 'Something went wrong.': 'Algo ha salido mal.', - OK: 'Cerrar', - 'An unexpected error occurred.': 'Se ha producido un error. Inténtalo de nuevo.', - Settings: 'Configuración', - Quality: 'Calidad', - Audio: 'Audio', - Default: 'Predeterminado', - Speed: 'Velocidad', - Captions: 'Subtítulos', - 'Playback rate': 'Velocidad de reproducción', - Back: 'Atrás', - Off: 'Desactivado', - Auto: 'Automático', - 'Auto ({label})': 'Automático ({label})', - Subtitles: 'Subtítulos', -} as const satisfies Partial; + buttons: { + play: 'Reproducir', + pause: 'Pausa', + replay: 'Volver a reproducir', + mute: 'Desactivar el sonido', + unmute: 'Activar el sonido', + }, + seek: { + forward: 'Avanza {seconds} segundos', + backward: 'Retrocede {seconds} segundos', + }, + fullscreen: { + enter: 'Pantalla completa', + exit: 'Salir de pantalla completa', + }, + captions: { + enable: 'Activar subtítulos', + disable: 'Desactivar subtítulos', + }, + pip: { + enter: 'Imagen sobre imagen', + exit: 'Salir de imagen sobre imagen', + }, + live: { + playing: 'Reproduciendo en directo', + seekToEdge: 'Ir al directo', + badge: 'Directo', + }, + cast: { + start: 'Iniciar transmisión', + stop: 'Detener transmisión', + connecting: 'Conectando', + }, + airplay: { + start: 'Iniciar AirPlay', + stop: 'Detener AirPlay', + }, + slider: { + seek: 'Buscar', + }, + time: { + current: 'Tiempo reproducido', + duration: 'Duración total', + remaining: 'Tiempo restante', + remainingSuffix: 'Quedan {duration}', + showElapsed: '{duration}. Mostrar tiempo transcurrido.', + showDuration: '{duration}. Mostrar duración.', + showRemaining: '{duration}. Mostrar tiempo restante.', + position: '{current} de {duration}', + }, + playback: { + rate: 'Velocidad de reproducción {rate}', + }, + volume: { + mutedValue: '{percent}, silenciado', + muted: 'Silenciado', + label: 'Volumen', + value: 'Volumen {value}', + }, + status: { + captionsOn: 'Subtítulos activados', + captionsOff: 'Subtítulos desactivados', + paused: 'En pausa', + playing: 'Reproduciendo', + fullscreen: 'Pantalla completa', + pip: 'Imagen en imagen', + exitPip: 'Salir de imagen en imagen', + }, + errors: { + aborted: 'Ha interrumpido la reproducción del vídeo.', + network: 'Un error de red ha interrumpido la descarga del vídeo.', + decode: + 'La reproducción de vídeo se ha interrumpido por un problema de corrupción de datos o porque el vídeo precisa funciones que su navegador no ofrece.', + source: + 'No se ha podido cargar el vídeo debido a un fallo de red o del servidor o porque el formato es incompatible.', + encrypted: 'El material audiovisual está cifrado y no tenemos las claves para descifrarlo.', + title: 'Algo ha salido mal.', + unexpected: 'Se ha producido un error. Inténtalo de nuevo.', + }, + common: { + empty: '', + ok: 'Cerrar', + }, + menu: { + settings: 'Configuración', + quality: 'Calidad', + audio: 'Audio', + default: 'Predeterminado', + speed: 'Velocidad', + captions: 'Subtítulos', + playbackRate: 'Velocidad de reproducción', + back: 'Atrás', + off: 'Desactivado', + auto: 'Automático', + autoWithLabel: 'Automático ({label})', + subtitles: 'Subtítulos', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/et.ts b/packages/core/src/core/i18n/locales/et.ts index c4bf9c4c..fcebda09 100644 --- a/packages/core/src/core/i18n/locales/et.ts +++ b/packages/core/src/core/i18n/locales/et.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Esita', - Pause: 'Paus', - Replay: 'Esita uuesti', - Mute: 'Vaigista', - Unmute: 'Lõpeta vaigistus', - 'Seek forward {seconds} seconds': 'Liigu edasi {seconds} sekundit', - 'Seek backward {seconds} seconds': 'Liigu tagasi {seconds} sekundit', - 'Enter fullscreen': 'Täisekraan', - 'Enable captions': 'Lülita subtiitrid sisse', - 'Disable captions': 'Lülita subtiitrid välja', - 'Enter picture-in-picture': 'Pilt pildis', - 'Exit picture-in-picture': 'Välju funktsioonist pilt pildis', - 'Playing live': 'Mängib reaalajas', - 'Seek to live edge': 'Mine otseülekande äärele', - Live: 'Otse', - 'Start casting': 'Alusta ülekandmist', - 'Stop casting': 'Lõpeta ülekandmine', - Connecting: 'Ühendumine', - Seek: 'Kerimine', - 'Current time': 'Praegune aeg', - Duration: 'Kestus', - Remaining: 'Järelejäänud aeg', - '{duration} remaining': 'Jäänud {duration}', - '{duration}. Show elapsed time.': '{duration}. Kuva möödunud aeg.', - '{duration}. Show duration.': '{duration}. Kuva kestus.', - '{duration}. Show remaining time.': '{duration}. Kuva järelejäänud aeg.', - 'Playback rate {rate}': 'Taasesituse kiirus {rate}', - '{current} of {duration}': '{current} / {duration}', - '{percent}, muted': '{percent}, vaigistatud', - Muted: 'Vaigistatud', - Volume: 'Helitugevus', - 'Volume {value}': 'Helitugevus {value}', - 'Captions on': 'Pealdised sees', - 'Captions off': 'Pealdised väljas', - Paused: 'Pausitud', - Playing: 'Esitamine', - Fullscreen: 'Täisekraan', - 'Exit fullscreen': 'Välju täisekraanist', - 'Picture in picture': 'Pilt pildis', - 'Exit picture in picture': 'Välju funktsioonist pilt pildis', - 'You stopped media playback before it finished.': 'Katkestasid taasesituse', - 'This media could not be loaded due to a network or server issue.': - 'Võrguvea tõttu nurjus meediumifaili allalaadimine poole pealt.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Meediumifaili taasesitamine katkestati, kuna fail on rikutud või see kasutab funktsiooni, mida sinu brauser ei toeta.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Seda meediumifaili ei õnnestunud laadida, kuna serveris või võrgus esines tõrge või kuna vormingut ei toetata.', - 'This media could not be played because it could not be decrypted.': - 'See meediumifail on krüpteeritud ja meil pole dekrüpteerimiseks vajalikku võtit.', - '': '', - 'Something went wrong.': 'Midagi läks valesti.', - OK: 'Sule', - 'An unexpected error occurred.': 'Esines viga. Palun proovige uuesti.', - Settings: 'Seaded', - Quality: 'Kvaliteet', - Audio: 'Heli', - Default: 'Vaikimisi', - Speed: 'Kiirus', - Captions: 'Subtiitrid', - 'Playback rate': 'Taasesituse kiirus', - Back: 'Tagasi', - Off: 'Väljas', - Auto: 'Automaatne', - 'Auto ({label})': 'Automaatne ({label})', - Subtitles: 'Subtiitrid', -} as const satisfies Partial; + buttons: { + play: 'Esita', + pause: 'Paus', + replay: 'Esita uuesti', + mute: 'Vaigista', + unmute: 'Lõpeta vaigistus', + }, + seek: { + forward: 'Liigu edasi {seconds} sekundit', + backward: 'Liigu tagasi {seconds} sekundit', + }, + fullscreen: { + enter: 'Täisekraan', + exit: 'Välju täisekraanist', + }, + captions: { + enable: 'Lülita subtiitrid sisse', + disable: 'Lülita subtiitrid välja', + }, + pip: { + enter: 'Pilt pildis', + exit: 'Välju funktsioonist pilt pildis', + }, + live: { + playing: 'Mängib reaalajas', + seekToEdge: 'Mine otseülekande äärele', + badge: 'Otse', + }, + cast: { + start: 'Alusta ülekandmist', + stop: 'Lõpeta ülekandmine', + connecting: 'Ühendumine', + }, + airplay: { + start: 'Käivita AirPlay', + stop: 'Peata AirPlay', + }, + slider: { + seek: 'Kerimine', + }, + time: { + current: 'Praegune aeg', + duration: 'Kestus', + remaining: 'Järelejäänud aeg', + remainingSuffix: 'Jäänud {duration}', + showElapsed: '{duration}. Kuva möödunud aeg.', + showDuration: '{duration}. Kuva kestus.', + showRemaining: '{duration}. Kuva järelejäänud aeg.', + position: '{current} / {duration}', + }, + playback: { + rate: 'Taasesituse kiirus {rate}', + }, + volume: { + mutedValue: '{percent}, vaigistatud', + muted: 'Vaigistatud', + label: 'Helitugevus', + value: 'Helitugevus {value}', + }, + status: { + captionsOn: 'Pealdised sees', + captionsOff: 'Pealdised väljas', + paused: 'Pausitud', + playing: 'Esitamine', + fullscreen: 'Täisekraan', + pip: 'Pilt pildis', + exitPip: 'Välju funktsioonist pilt pildis', + }, + errors: { + aborted: 'Katkestasid taasesituse', + network: 'Võrguvea tõttu nurjus meediumifaili allalaadimine poole pealt.', + decode: + 'Meediumifaili taasesitamine katkestati, kuna fail on rikutud või see kasutab funktsiooni, mida sinu brauser ei toeta.', + source: + 'Seda meediumifaili ei õnnestunud laadida, kuna serveris või võrgus esines tõrge või kuna vormingut ei toetata.', + encrypted: 'See meediumifail on krüpteeritud ja meil pole dekrüpteerimiseks vajalikku võtit.', + title: 'Midagi läks valesti.', + unexpected: 'Esines viga. Palun proovige uuesti.', + }, + common: { + empty: '', + ok: 'Sule', + }, + menu: { + settings: 'Seaded', + quality: 'Kvaliteet', + audio: 'Heli', + default: 'Vaikimisi', + speed: 'Kiirus', + captions: 'Subtiitrid', + playbackRate: 'Taasesituse kiirus', + back: 'Tagasi', + off: 'Väljas', + auto: 'Automaatne', + autoWithLabel: 'Automaatne ({label})', + subtitles: 'Subtiitrid', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/eu.ts b/packages/core/src/core/i18n/locales/eu.ts index 55d93255..f7d51501 100644 --- a/packages/core/src/core/i18n/locales/eu.ts +++ b/packages/core/src/core/i18n/locales/eu.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Hasi', - Pause: 'Gelditu', - Replay: 'Berriz hasi', - Mute: 'Ixildu', - Unmute: 'Soinua jarri', - 'Seek forward {seconds} seconds': 'Joan aurrera {seconds} segundo', - 'Seek backward {seconds} seconds': 'Joan atzera {seconds} segundo', - 'Enter fullscreen': 'Pantaila osoa', - 'Enable captions': 'Aktibatu azpitituluak', - 'Disable captions': 'Desaktibatu azpitituluak', - 'Enter picture-in-picture': 'Irudiz-irudi', - 'Exit picture-in-picture': 'Irten irudiz-irudiztik', - 'Playing live': 'Zuzenean erreproduzitzen', - 'Seek to live edge': 'Zuzeneko ertzeraino joan', - Live: 'Zuzenean', - 'Start casting': 'Hasi emankizuna', - 'Stop casting': 'Gelditu emankizuna', - Connecting: 'Konektatzen', - Seek: 'Bilatu', - 'Current time': 'Uneko denbora', - Duration: 'Iraupena', - Remaining: 'Gelditzen den denbora', - '{duration} remaining': 'Geratzen den {duration}', - '{duration}. Show elapsed time.': '{duration}. Erakutsi igarotako denbora.', - '{duration}. Show duration.': '{duration}. Erakutsi iraupena.', - '{duration}. Show remaining time.': '{duration}. Erakutsi geratzen den denbora.', - 'Playback rate {rate}': 'Abiadura {rate}', - '{current} of {duration}': '{current} / {duration}', - '{percent}, muted': '{percent}, isilarazia', - Muted: 'Isilarazia', - Volume: 'Bolumena', - 'Volume {value}': 'Bolumena {value}', - 'Captions on': 'Oharrak aktibo', - 'Captions off': 'Oharrak ez aktibo', - Paused: 'Geldituta', - Playing: 'Erreproduzitzen', - Fullscreen: 'Pantaila osoa', - 'Exit fullscreen': 'Irten pantaila osotik', - 'Picture in picture': 'Irudiz irudi', - 'Exit picture in picture': 'Irten irudiz irudiztik', - 'You stopped media playback before it finished.': 'Bertan behera utzi duzu', - 'This media could not be loaded due to a network or server issue.': - 'Sare errore batek deskargak huts egitea eragin du.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Bertan behera gelditu da fitxategia ondo ez dagoelako edo zure nabigatzailean erabili ezin diren ezaugarriak dituelako.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Media ezin izan da kargatu, zerbitzariak edo sareak huts egin duelako edo formatu horretako media erabili ezin delako.', - 'This media could not be played because it could not be decrypted.': - 'Media zifratuta dago eta ez ditugu beharrezko gakoak.', - '': '', - 'Something went wrong.': 'Zerbait gaizki joan da.', - OK: 'Itxi', - 'An unexpected error occurred.': 'Errore bat gertatu da. Saiatu berriro.', - Settings: 'Ezarpenak', - Quality: 'Kalitatea', - Audio: 'Audioa', - Default: 'Lehenetsia', - Speed: 'Abiadura', - Captions: 'Azpitituluak', - 'Playback rate': 'Erreprodukzio-abiadura', - Back: 'Atzera', - Off: 'Desaktibatuta', - Auto: 'Automatikoa', - 'Auto ({label})': 'Automatikoa ({label})', - Subtitles: 'Azpitituluak', -} as const satisfies Partial; + buttons: { + play: 'Hasi', + pause: 'Gelditu', + replay: 'Berriz hasi', + mute: 'Ixildu', + unmute: 'Soinua jarri', + }, + seek: { + forward: 'Joan aurrera {seconds} segundo', + backward: 'Joan atzera {seconds} segundo', + }, + fullscreen: { + enter: 'Pantaila osoa', + exit: 'Irten pantaila osotik', + }, + captions: { + enable: 'Aktibatu azpitituluak', + disable: 'Desaktibatu azpitituluak', + }, + pip: { + enter: 'Irudiz-irudi', + exit: 'Irten irudiz-irudiztik', + }, + live: { + playing: 'Zuzenean erreproduzitzen', + seekToEdge: 'Zuzeneko ertzeraino joan', + badge: 'Zuzenean', + }, + cast: { + start: 'Hasi emankizuna', + stop: 'Gelditu emankizuna', + connecting: 'Konektatzen', + }, + airplay: { + start: 'Hasi AirPlay', + stop: 'Gelditu AirPlay', + }, + slider: { + seek: 'Bilatu', + }, + time: { + current: 'Uneko denbora', + duration: 'Iraupena', + remaining: 'Gelditzen den denbora', + remainingSuffix: 'Geratzen den {duration}', + showElapsed: '{duration}. Erakutsi igarotako denbora.', + showDuration: '{duration}. Erakutsi iraupena.', + showRemaining: '{duration}. Erakutsi geratzen den denbora.', + position: '{current} / {duration}', + }, + playback: { + rate: 'Abiadura {rate}', + }, + volume: { + mutedValue: '{percent}, isilarazia', + muted: 'Isilarazia', + label: 'Bolumena', + value: 'Bolumena {value}', + }, + status: { + captionsOn: 'Oharrak aktibo', + captionsOff: 'Oharrak ez aktibo', + paused: 'Geldituta', + playing: 'Erreproduzitzen', + fullscreen: 'Pantaila osoa', + pip: 'Irudiz irudi', + exitPip: 'Irten irudiz irudiztik', + }, + errors: { + aborted: 'Bertan behera utzi duzu', + network: 'Sare errore batek deskargak huts egitea eragin du.', + decode: + 'Bertan behera gelditu da fitxategia ondo ez dagoelako edo zure nabigatzailean erabili ezin diren ezaugarriak dituelako.', + source: + 'Media ezin izan da kargatu, zerbitzariak edo sareak huts egin duelako edo formatu horretako media erabili ezin delako.', + encrypted: 'Media zifratuta dago eta ez ditugu beharrezko gakoak.', + title: 'Zerbait gaizki joan da.', + unexpected: 'Errore bat gertatu da. Saiatu berriro.', + }, + common: { + empty: '', + ok: 'Itxi', + }, + menu: { + settings: 'Ezarpenak', + quality: 'Kalitatea', + audio: 'Audioa', + default: 'Lehenetsia', + speed: 'Abiadura', + captions: 'Azpitituluak', + playbackRate: 'Erreprodukzio-abiadura', + back: 'Atzera', + off: 'Desaktibatuta', + auto: 'Automatikoa', + autoWithLabel: 'Automatikoa ({label})', + subtitles: 'Azpitituluak', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/fa.ts b/packages/core/src/core/i18n/locales/fa.ts index ff1ffabb..1d280ae3 100644 --- a/packages/core/src/core/i18n/locales/fa.ts +++ b/packages/core/src/core/i18n/locales/fa.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'پخش', - Pause: 'توقف', - Replay: 'پخش مجدد', - Mute: 'بی‌صدا', - Unmute: 'صدادار', - 'Seek forward {seconds} seconds': '{seconds} ثانیه بعد', - 'Seek backward {seconds} seconds': '{seconds} ثانیه قبل', - 'Enter fullscreen': 'تمام‌صفحه', - 'Enable captions': 'فعال‌سازی زیرنویس', - 'Disable captions': 'غیرفعال‌سازی زیرنویس', - 'Enter picture-in-picture': 'تصویر در تصویر', - 'Exit picture-in-picture': 'خروج از حالت تصویر در تصویر', - 'Playing live': 'پخش زنده', - 'Seek to live edge': 'رفتن به پخش زنده', - Live: 'زنده', - 'Start casting': 'شروع پخش به تلویزیون', - 'Stop casting': 'توقف پخش به تلویزیون', - Connecting: 'در حال اتصال', - Seek: 'جستجو', - 'Current time': 'زمان فعلی', - Duration: 'مدت', - Remaining: 'زمان باقی‌مانده', - '{duration} remaining': '{duration} باقی‌مانده', - '{duration}. Show elapsed time.': '{duration}. نمایش زمان سپری‌شده.', - '{duration}. Show duration.': '{duration}. نمایش مدت.', - '{duration}. Show remaining time.': '{duration}. نمایش زمان باقی‌مانده.', - 'Playback rate {rate}': 'سرعت پخش {rate}', - '{current} of {duration}': '{current} از {duration}', - '{percent}, muted': '{percent}, بی‌صدا', - Muted: 'بی‌صدا', - Volume: 'صدا', - 'Volume {value}': 'صدا {value}', - 'Captions on': 'توضیحات روشن', - 'Captions off': 'توضیحات خاموش', - Paused: 'متوقف شده', - Playing: 'در حال پخش', - Fullscreen: 'تمام‌صفحه', - 'Exit fullscreen': 'خروج از تمام‌صفحه', - 'Picture in picture': 'تصویر در تصویر', - 'Exit picture in picture': 'خروج از حالت تصویر در تصویر', - 'You stopped media playback before it finished.': 'شما پخش رسانه را قطع نمودید', - 'This media could not be loaded due to a network or server issue.': - 'وقوع مشکلی در شبکه باعث اختلال در دانلود رسانه شد.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'پخش رسانه به‌علت اشکال در آن یا عدم پشتیبانی مرورگر شما قطع شد.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'رسانه قابل بارگیری نیست. ممکن است مشکلی در شبکه یا سرور رخ داده باشد یا قالب رسانه در دستگاه شما پشتیبانی نشود.', - 'This media could not be played because it could not be decrypted.': - 'این رسانه رمزنگاری شده‌است و کلیدهای رمزگشایی آن موجود نیست.', - '': '', - 'Something went wrong.': 'مشکلی پیش آمد.', - OK: 'بستن', - 'An unexpected error occurred.': 'خطایی رخ داد. لطفاً دوباره امتحان کنید.', - Settings: 'تنظیمات', - Quality: 'کیفیت', - Audio: 'صدا', - Default: 'پیش‌فرض', - Speed: 'سرعت', - Captions: 'زیرنویس‌ها', - 'Playback rate': 'سرعت پخش', - Back: 'بازگشت', - Off: 'خاموش', - Auto: 'خودکار', - 'Auto ({label})': 'خودکار ({label})', - Subtitles: 'زیرنویس‌ها', -} as const satisfies Partial; + buttons: { + play: 'پخش', + pause: 'توقف', + replay: 'پخش مجدد', + mute: 'بی‌صدا', + unmute: 'صدادار', + }, + seek: { + forward: '{seconds} ثانیه بعد', + backward: '{seconds} ثانیه قبل', + }, + fullscreen: { + enter: 'تمام‌صفحه', + exit: 'خروج از تمام‌صفحه', + }, + captions: { + enable: 'فعال‌سازی زیرنویس', + disable: 'غیرفعال‌سازی زیرنویس', + }, + pip: { + enter: 'تصویر در تصویر', + exit: 'خروج از حالت تصویر در تصویر', + }, + live: { + playing: 'پخش زنده', + seekToEdge: 'رفتن به پخش زنده', + badge: 'زنده', + }, + cast: { + start: 'شروع پخش به تلویزیون', + stop: 'توقف پخش به تلویزیون', + connecting: 'در حال اتصال', + }, + airplay: { + start: 'شروع AirPlay', + stop: 'توقف AirPlay', + }, + slider: { + seek: 'جستجو', + }, + time: { + current: 'زمان فعلی', + duration: 'مدت', + remaining: 'زمان باقی‌مانده', + remainingSuffix: '{duration} باقی‌مانده', + showElapsed: '{duration}. نمایش زمان سپری‌شده.', + showDuration: '{duration}. نمایش مدت.', + showRemaining: '{duration}. نمایش زمان باقی‌مانده.', + position: '{current} از {duration}', + }, + playback: { + rate: 'سرعت پخش {rate}', + }, + volume: { + mutedValue: '{percent}, بی‌صدا', + muted: 'بی‌صدا', + label: 'صدا', + value: 'صدا {value}', + }, + status: { + captionsOn: 'توضیحات روشن', + captionsOff: 'توضیحات خاموش', + paused: 'متوقف شده', + playing: 'در حال پخش', + fullscreen: 'تمام‌صفحه', + pip: 'تصویر در تصویر', + exitPip: 'خروج از حالت تصویر در تصویر', + }, + errors: { + aborted: 'شما پخش رسانه را قطع نمودید', + network: 'وقوع مشکلی در شبکه باعث اختلال در دانلود رسانه شد.', + decode: 'پخش رسانه به‌علت اشکال در آن یا عدم پشتیبانی مرورگر شما قطع شد.', + source: + 'رسانه قابل بارگیری نیست. ممکن است مشکلی در شبکه یا سرور رخ داده باشد یا قالب رسانه در دستگاه شما پشتیبانی نشود.', + encrypted: 'این رسانه رمزنگاری شده‌است و کلیدهای رمزگشایی آن موجود نیست.', + title: 'مشکلی پیش آمد.', + unexpected: 'خطایی رخ داد. لطفاً دوباره امتحان کنید.', + }, + common: { + empty: '', + ok: 'بستن', + }, + menu: { + settings: 'تنظیمات', + quality: 'کیفیت', + audio: 'صدا', + default: 'پیش‌فرض', + speed: 'سرعت', + captions: 'زیرنویس‌ها', + playbackRate: 'سرعت پخش', + back: 'بازگشت', + off: 'خاموش', + auto: 'خودکار', + autoWithLabel: 'خودکار ({label})', + subtitles: 'زیرنویس‌ها', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/fi.ts b/packages/core/src/core/i18n/locales/fi.ts index 912c5bd7..ad1270aa 100644 --- a/packages/core/src/core/i18n/locales/fi.ts +++ b/packages/core/src/core/i18n/locales/fi.ts @@ -1,67 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Toista', - Pause: 'Keskeytä toisto', - Replay: 'Toista uudelleen', - Mute: 'Mykistä', - Unmute: 'Poista mykistys', - 'Seek forward {seconds} seconds': 'Hyppää eteenpäin {seconds} sekuntia', - 'Seek backward {seconds} seconds': 'Hyppää taaksepäin {seconds} sekuntia', - 'Enter fullscreen': 'Koko näytön tila', - 'Enable captions': 'Ota tekstitykset käyttöön', - 'Disable captions': 'Poista tekstitykset käytöstä', - 'Enter picture-in-picture': 'Kuva kuvassa -tila', - 'Exit picture-in-picture': 'Poistu kuva kuvassa -tilasta', - 'Playing live': 'Toistetaan livenä', - 'Seek to live edge': 'Siirry liveen', - Live: 'Live', - 'Start casting': 'Aloita lähetys', - 'Stop casting': 'Lopeta lähetys', - Connecting: 'Yhdistetään', - Seek: 'Kelaa', - 'Current time': 'Tämänhetkinen aika', - Duration: 'Kokonaiskesto', - Remaining: 'Jäljellä oleva aika', - '{duration} remaining': '{duration} jäljellä', - '{duration}. Show elapsed time.': '{duration}. Näytä kulunut aika.', - '{duration}. Show duration.': '{duration}. Näytä kesto.', - '{duration}. Show remaining time.': '{duration}. Näytä jäljellä oleva aika.', - 'Playback rate {rate}': 'Toistonopeus {rate}', - '{current} of {duration}': '{current} / {duration}', - '{percent}, muted': '{percent}, mykistetty', - Muted: 'Mykistetty', - Volume: 'Äänenvoimakkuus', - 'Volume {value}': 'Äänenvoimakkuus {value}', - 'Captions on': 'Tekstitys päällä', - 'Captions off': 'Tekstitys pois', - Paused: 'Keskeytetty', - Playing: 'Toistetaan', - Fullscreen: 'Koko näyttö', - 'Exit fullscreen': 'Poistu koko näytöltä', - 'Picture in picture': 'Kuva kuvassa', - 'Exit picture in picture': 'Poistu kuva kuvassa -tilasta', - 'You stopped media playback before it finished.': 'Olet keskeyttänyt videotoiston', - 'This media could not be loaded due to a network or server issue.': 'Verkkovirhe keskeytti videon latauksen.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Videon toisto keskeytyi, koska videotiedosto on vioittunut tai käyttää toimintoja, joita selaimesi ei tue.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Videon lataus ei onnistunut joko palvelin- tai verkkovirheestä tai väärästä formaatista johtuen.', - 'This media could not be played because it could not be decrypted.': 'Media on salattu eikä siihen ole purkuavaimia.', - '': '', - 'Something went wrong.': 'Jotain meni pieleen.', - OK: 'OK', - 'An unexpected error occurred.': 'Tapahtui virhe. Yritä uudelleen.', - Settings: 'Asetukset', - Quality: 'Laatu', - Audio: 'Ääni', - Default: 'Oletus', - Speed: 'Nopeus', - Captions: 'Tekstitykset', - 'Playback rate': 'Toistonopeus', - Back: 'Takaisin', - Off: 'Pois', - Auto: 'Automaattinen', - 'Auto ({label})': 'Automaattinen ({label})', - Subtitles: 'Tekstitykset', -} as const satisfies Partial; + buttons: { + play: 'Toista', + pause: 'Keskeytä toisto', + replay: 'Toista uudelleen', + mute: 'Mykistä', + unmute: 'Poista mykistys', + }, + seek: { + forward: 'Hyppää eteenpäin {seconds} sekuntia', + backward: 'Hyppää taaksepäin {seconds} sekuntia', + }, + fullscreen: { + enter: 'Koko näytön tila', + exit: 'Poistu koko näytöltä', + }, + captions: { + enable: 'Ota tekstitykset käyttöön', + disable: 'Poista tekstitykset käytöstä', + }, + pip: { + enter: 'Kuva kuvassa -tila', + exit: 'Poistu kuva kuvassa -tilasta', + }, + live: { + playing: 'Toistetaan livenä', + seekToEdge: 'Siirry liveen', + badge: 'Live', + }, + cast: { + start: 'Aloita lähetys', + stop: 'Lopeta lähetys', + connecting: 'Yhdistetään', + }, + airplay: { + start: 'Käynnistä AirPlay', + stop: 'Pysäytä AirPlay', + }, + slider: { + seek: 'Kelaa', + }, + time: { + current: 'Tämänhetkinen aika', + duration: 'Kokonaiskesto', + remaining: 'Jäljellä oleva aika', + remainingSuffix: '{duration} jäljellä', + showElapsed: '{duration}. Näytä kulunut aika.', + showDuration: '{duration}. Näytä kesto.', + showRemaining: '{duration}. Näytä jäljellä oleva aika.', + position: '{current} / {duration}', + }, + playback: { + rate: 'Toistonopeus {rate}', + }, + volume: { + mutedValue: '{percent}, mykistetty', + muted: 'Mykistetty', + label: 'Äänenvoimakkuus', + value: 'Äänenvoimakkuus {value}', + }, + status: { + captionsOn: 'Tekstitys päällä', + captionsOff: 'Tekstitys pois', + paused: 'Keskeytetty', + playing: 'Toistetaan', + fullscreen: 'Koko näyttö', + pip: 'Kuva kuvassa', + exitPip: 'Poistu kuva kuvassa -tilasta', + }, + errors: { + aborted: 'Olet keskeyttänyt videotoiston', + network: 'Verkkovirhe keskeytti videon latauksen.', + decode: + 'Videon toisto keskeytyi, koska videotiedosto on vioittunut tai käyttää toimintoja, joita selaimesi ei tue.', + source: 'Videon lataus ei onnistunut joko palvelin- tai verkkovirheestä tai väärästä formaatista johtuen.', + encrypted: 'Media on salattu eikä siihen ole purkuavaimia.', + title: 'Jotain meni pieleen.', + unexpected: 'Tapahtui virhe. Yritä uudelleen.', + }, + common: { + empty: '', + ok: 'OK', + }, + menu: { + settings: 'Asetukset', + quality: 'Laatu', + audio: 'Ääni', + default: 'Oletus', + speed: 'Nopeus', + captions: 'Tekstitykset', + playbackRate: 'Toistonopeus', + back: 'Takaisin', + off: 'Pois', + auto: 'Automaattinen', + autoWithLabel: 'Automaattinen ({label})', + subtitles: 'Tekstitykset', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/fr.ts b/packages/core/src/core/i18n/locales/fr.ts index a43f11b4..ccb5c67f 100644 --- a/packages/core/src/core/i18n/locales/fr.ts +++ b/packages/core/src/core/i18n/locales/fr.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Lecture', - Pause: 'Pause', - Replay: 'Revoir', - Mute: 'Mettre en sourdine', - Unmute: 'Activer le son', - 'Seek forward {seconds} seconds': 'Avancer de {seconds} secondes', - 'Seek backward {seconds} seconds': 'Reculer de {seconds} secondes', - 'Enter fullscreen': 'Plein écran', - 'Enable captions': 'Activer les sous-titres', - 'Disable captions': 'Désactiver les sous-titres', - 'Enter picture-in-picture': "Image dans l'image", - 'Exit picture-in-picture': "Quitter le mode image dans l'image", - 'Playing live': 'Lecture en direct', - 'Seek to live edge': 'Aller au direct', - Live: 'En direct', - 'Start casting': 'Démarrer la diffusion', - 'Stop casting': 'Arrêter la diffusion', - Connecting: 'Connexion', - Seek: 'Barre de lecture', - 'Current time': 'Temps actuel', - Duration: 'Durée', - Remaining: 'Temps restant', - '{duration} remaining': 'Il reste {duration}', - '{duration}. Show elapsed time.': '{duration}. Afficher le temps écoulé.', - '{duration}. Show duration.': '{duration}. Afficher la durée.', - '{duration}. Show remaining time.': '{duration}. Afficher le temps restant.', - 'Playback rate {rate}': 'Vitesse de lecture {rate}', - '{current} of {duration}': '{current} de {duration}', - '{percent}, muted': '{percent}, son coupé', - Muted: 'Son coupé', - Volume: 'Niveau de volume', - 'Volume {value}': 'Niveau de volume {value}', - 'Captions on': 'Sous-titres activés', - 'Captions off': 'Sous-titres désactivés', - Paused: 'En pause', - Playing: 'Lecture en cours', - Fullscreen: 'Plein écran', - 'Exit fullscreen': 'Quitter le plein écran', - 'Picture in picture': 'Image dans l’image', - 'Exit picture in picture': 'Quitter l’image dans l’image', - 'You stopped media playback before it finished.': 'Vous avez interrompu la lecture de la vidéo.', - 'This media could not be loaded due to a network or server issue.': - 'Une erreur de réseau a interrompu le téléchargement de la vidéo.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - "La lecture de la vidéo a été interrompue à cause d'un problème de corruption ou parce que la vidéo utilise des fonctionnalités non prises en charge par votre navigateur.", - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - "Cette vidéo n'a pas pu être chargée, soit parce que le serveur ou le réseau a échoué ou parce que le format n'est pas reconnu.", - 'This media could not be played because it could not be decrypted.': - "Le média est chiffré et nous n'avons pas les clés pour le déchiffrer.", - '': '', - 'Something went wrong.': 'Une erreur s’est produite.', - OK: 'Fermer', - 'An unexpected error occurred.': 'Une erreur s’est produite. Veuillez réessayer.', - Settings: 'Paramètres', - Quality: 'Qualité', - Audio: 'Audio', - Default: 'Par défaut', - Speed: 'Vitesse', - Captions: 'Sous-titres', - 'Playback rate': 'Vitesse de lecture', - Back: 'Retour', - Off: 'Désactivé', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Sous-titres', -} as const satisfies Partial; + buttons: { + play: 'Lecture', + pause: 'Pause', + replay: 'Revoir', + mute: 'Mettre en sourdine', + unmute: 'Activer le son', + }, + seek: { + forward: 'Avancer de {seconds} secondes', + backward: 'Reculer de {seconds} secondes', + }, + fullscreen: { + enter: 'Plein écran', + exit: 'Quitter le plein écran', + }, + captions: { + enable: 'Activer les sous-titres', + disable: 'Désactiver les sous-titres', + }, + pip: { + enter: "Image dans l'image", + exit: "Quitter le mode image dans l'image", + }, + live: { + playing: 'Lecture en direct', + seekToEdge: 'Aller au direct', + badge: 'En direct', + }, + cast: { + start: 'Démarrer la diffusion', + stop: 'Arrêter la diffusion', + connecting: 'Connexion', + }, + airplay: { + start: 'Démarrer AirPlay', + stop: 'Arrêter AirPlay', + }, + slider: { + seek: 'Barre de lecture', + }, + time: { + current: 'Temps actuel', + duration: 'Durée', + remaining: 'Temps restant', + remainingSuffix: 'Il reste {duration}', + showElapsed: '{duration}. Afficher le temps écoulé.', + showDuration: '{duration}. Afficher la durée.', + showRemaining: '{duration}. Afficher le temps restant.', + position: '{current} de {duration}', + }, + playback: { + rate: 'Vitesse de lecture {rate}', + }, + volume: { + mutedValue: '{percent}, son coupé', + muted: 'Son coupé', + label: 'Niveau de volume', + value: 'Niveau de volume {value}', + }, + status: { + captionsOn: 'Sous-titres activés', + captionsOff: 'Sous-titres désactivés', + paused: 'En pause', + playing: 'Lecture en cours', + fullscreen: 'Plein écran', + pip: 'Image dans l’image', + exitPip: 'Quitter l’image dans l’image', + }, + errors: { + aborted: 'Vous avez interrompu la lecture de la vidéo.', + network: 'Une erreur de réseau a interrompu le téléchargement de la vidéo.', + decode: + "La lecture de la vidéo a été interrompue à cause d'un problème de corruption ou parce que la vidéo utilise des fonctionnalités non prises en charge par votre navigateur.", + source: + "Cette vidéo n'a pas pu être chargée, soit parce que le serveur ou le réseau a échoué ou parce que le format n'est pas reconnu.", + encrypted: "Le média est chiffré et nous n'avons pas les clés pour le déchiffrer.", + title: 'Une erreur s’est produite.', + unexpected: 'Une erreur s’est produite. Veuillez réessayer.', + }, + common: { + empty: '', + ok: 'Fermer', + }, + menu: { + settings: 'Paramètres', + quality: 'Qualité', + audio: 'Audio', + default: 'Par défaut', + speed: 'Vitesse', + captions: 'Sous-titres', + playbackRate: 'Vitesse de lecture', + back: 'Retour', + off: 'Désactivé', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Sous-titres', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/gd.ts b/packages/core/src/core/i18n/locales/gd.ts index 769b1dd0..0ff13681 100644 --- a/packages/core/src/core/i18n/locales/gd.ts +++ b/packages/core/src/core/i18n/locales/gd.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Cluich', - Pause: 'Cuir ’na stad', - Replay: 'Cluich a-rithist', - Mute: 'Mùch', - Unmute: 'Dì-mhùch', - 'Seek forward {seconds} seconds': 'Gluais air adhart {seconds} diog', - 'Seek backward {seconds} seconds': 'Gluais air ais {seconds} diog', - 'Enter fullscreen': 'Làn-sgrìn', - 'Enable captions': 'Cuir capsaidean air', - 'Disable captions': 'Toir capsaidean dheth', - 'Enter picture-in-picture': 'Dealbh beag anns a’ dealbh mhòr', - 'Exit picture-in-picture': 'Fàg dealbh beag anns a’ dealbh mhòr', - 'Playing live': 'A’ cluich beò', - 'Seek to live edge': 'Tèarmann gu beò', - Live: 'Beò', - 'Start casting': 'Tòisich air tar-chur', - 'Stop casting': 'Cuir stad air tar-chur', - Connecting: 'A’ ceangal', - Seek: 'Lorg', - 'Current time': 'An ùine làithreach', - Duration: 'Faide', - Remaining: 'An ùine air fhàgail', - '{duration} remaining': '{duration} air fhàgail', - '{duration}. Show elapsed time.': '{duration}. Seall an ùine a chaidh seachad.', - '{duration}. Show duration.': '{duration}. Seall an ùine iomlan.', - '{duration}. Show remaining time.': '{duration}. Seall an ùine air fhàgail.', - 'Playback rate {rate}': 'Reat cluich {rate}', - '{current} of {duration}': '{current} à {duration}', - '{percent}, muted': '{percent}, air mùchadh', - Muted: 'Air mùchadh', - Volume: 'Àirde na fuaime', - 'Volume {value}': 'Àirde na fuaime {value}', - 'Captions on': 'Caipseanan air', - 'Captions off': 'Caipseanan dheth', - Paused: 'Air stad', - Playing: 'A’ cluich', - Fullscreen: 'Làn-sgrìn', - 'Exit fullscreen': 'Fàg làn-sgrìn', - 'Picture in picture': 'Dealbh beag anns a’ dealbh mhòr', - 'Exit picture in picture': 'Fàg dealbh beag', - 'You stopped media playback before it finished.': 'Sguir thu de chluich a’ mheadhain', - 'This media could not be loaded due to a network or server issue.': - 'Cha deach leinn an còrr dhen mheadhan a luchdadh a-nuas ri linn mearachd lìonraidh.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Sguir sinn de chluich a’ mheadhain – dh’fhaoidte gu bheil e coirbte no gu bheil gleus aig a’ mheadhan nach cuir am brabhsair taic ris.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Cha b’ urrainn dhuinn am meadhan a luchdadh – dh’fhaoidte gun do dh’fhàillig leis an fhrithealaiche no an lìonra no nach cuir sinn taic ris an fhòrmat.', - 'This media could not be played because it could not be decrypted.': - 'Tha am meadhan crioptaichte ’s chan eil iuchair dì-chrioptachaidh againn dha.', - '': '', - 'Something went wrong.': 'Chaidh rudeigin ceàrr.', - OK: 'Dùin', - 'An unexpected error occurred.': 'Thachair mearachd. Feuch ris a-rithist.', - Settings: 'Roghainnean', - Quality: 'Càileachd', - Audio: 'Fuaim', - Default: 'Bunaiteach', - Speed: 'Astar', - Captions: 'Caipseanan', - 'Playback rate': 'Reat cluich', - Back: 'Air ais', - Off: 'Dheth', - Auto: 'Fèin-obrachail', - 'Auto ({label})': 'Fèin-obrachail ({label})', - Subtitles: 'Fo-thiotalan', -} as const satisfies Partial; + buttons: { + play: 'Cluich', + pause: 'Cuir ’na stad', + replay: 'Cluich a-rithist', + mute: 'Mùch', + unmute: 'Dì-mhùch', + }, + seek: { + forward: 'Gluais air adhart {seconds} diog', + backward: 'Gluais air ais {seconds} diog', + }, + fullscreen: { + enter: 'Làn-sgrìn', + exit: 'Fàg làn-sgrìn', + }, + captions: { + enable: 'Cuir capsaidean air', + disable: 'Toir capsaidean dheth', + }, + pip: { + enter: 'Dealbh beag anns a’ dealbh mhòr', + exit: 'Fàg dealbh beag anns a’ dealbh mhòr', + }, + live: { + playing: 'A’ cluich beò', + seekToEdge: 'Tèarmann gu beò', + badge: 'Beò', + }, + cast: { + start: 'Tòisich air tar-chur', + stop: 'Cuir stad air tar-chur', + connecting: 'A’ ceangal', + }, + airplay: { + start: 'Tòisich AirPlay', + stop: 'Cuir stad air AirPlay', + }, + slider: { + seek: 'Lorg', + }, + time: { + current: 'An ùine làithreach', + duration: 'Faide', + remaining: 'An ùine air fhàgail', + remainingSuffix: '{duration} air fhàgail', + showElapsed: '{duration}. Seall an ùine a chaidh seachad.', + showDuration: '{duration}. Seall an ùine iomlan.', + showRemaining: '{duration}. Seall an ùine air fhàgail.', + position: '{current} à {duration}', + }, + playback: { + rate: 'Reat cluich {rate}', + }, + volume: { + mutedValue: '{percent}, air mùchadh', + muted: 'Air mùchadh', + label: 'Àirde na fuaime', + value: 'Àirde na fuaime {value}', + }, + status: { + captionsOn: 'Caipseanan air', + captionsOff: 'Caipseanan dheth', + paused: 'Air stad', + playing: 'A’ cluich', + fullscreen: 'Làn-sgrìn', + pip: 'Dealbh beag anns a’ dealbh mhòr', + exitPip: 'Fàg dealbh beag', + }, + errors: { + aborted: 'Sguir thu de chluich a’ mheadhain', + network: 'Cha deach leinn an còrr dhen mheadhan a luchdadh a-nuas ri linn mearachd lìonraidh.', + decode: + 'Sguir sinn de chluich a’ mheadhain – dh’fhaoidte gu bheil e coirbte no gu bheil gleus aig a’ mheadhan nach cuir am brabhsair taic ris.', + source: + 'Cha b’ urrainn dhuinn am meadhan a luchdadh – dh’fhaoidte gun do dh’fhàillig leis an fhrithealaiche no an lìonra no nach cuir sinn taic ris an fhòrmat.', + encrypted: 'Tha am meadhan crioptaichte ’s chan eil iuchair dì-chrioptachaidh againn dha.', + title: 'Chaidh rudeigin ceàrr.', + unexpected: 'Thachair mearachd. Feuch ris a-rithist.', + }, + common: { + empty: '', + ok: 'Dùin', + }, + menu: { + settings: 'Roghainnean', + quality: 'Càileachd', + audio: 'Fuaim', + default: 'Bunaiteach', + speed: 'Astar', + captions: 'Caipseanan', + playbackRate: 'Reat cluich', + back: 'Air ais', + off: 'Dheth', + auto: 'Fèin-obrachail', + autoWithLabel: 'Fèin-obrachail ({label})', + subtitles: 'Fo-thiotalan', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/gl.ts b/packages/core/src/core/i18n/locales/gl.ts index 4c49b074..27d25400 100644 --- a/packages/core/src/core/i18n/locales/gl.ts +++ b/packages/core/src/core/i18n/locales/gl.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Reproducir', - Pause: 'Pausa', - Replay: 'Repetir', - Mute: 'Silenciar', - Unmute: 'Son activado', - 'Seek forward {seconds} seconds': 'Avanzar {seconds} segundos', - 'Seek backward {seconds} seconds': 'Retroceder {seconds} segundos', - 'Enter fullscreen': 'Pantalla completa', - 'Enable captions': 'Activar subtítulos', - 'Disable captions': 'Desactivar subtítulos', - 'Enter picture-in-picture': 'Imaxe en imaxe', - 'Exit picture-in-picture': 'Saír de imaxe en imaxe', - 'Playing live': 'Reproducindo en directo', - 'Seek to live edge': 'Ir ao directo', - Live: 'En directo', - 'Start casting': 'Iniciar emisión', - 'Stop casting': 'Deter emisión', - Connecting: 'Conectando', - Seek: 'Buscar', - 'Current time': 'Tempo reproducido', - Duration: 'Duración', - Remaining: 'Tempo restante', - '{duration} remaining': 'Quedan {duration}', - '{duration}. Show elapsed time.': '{duration}. Amosar tempo transcorrido.', - '{duration}. Show duration.': '{duration}. Amosar duración.', - '{duration}. Show remaining time.': '{duration}. Amosar tempo restante.', - 'Playback rate {rate}': 'Velocidade de reprodución {rate}', - '{current} of {duration}': '{current} de {duration}', - '{percent}, muted': '{percent}, silenciado', - Muted: 'Silenciado', - Volume: 'Nivel do volume', - 'Volume {value}': 'Nivel do volume {value}', - 'Captions on': 'Subtítulos activados', - 'Captions off': 'Subtítulos desactivados', - Paused: 'En pausa', - Playing: 'Reproducindo', - Fullscreen: 'Pantalla completa', - 'Exit fullscreen': 'Saír da pantalla completa', - 'Picture in picture': 'Imaxe en imaxe', - 'Exit picture in picture': 'Saír de imaxe en imaxe', - 'You stopped media playback before it finished.': 'Vostede interrompeu a reprodución do medio.', - 'This media could not be loaded due to a network or server issue.': - 'Un erro de rede interrompeu a descarga do medio.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Interrompeuse a reprodución do medio por mor dun problema de estragamento dos datos ou porque o medio precisa funcións que o seu navegador non ofrece.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Non foi posíbel cargar o medio por mor dun fallo de rede ou do servidor ou porque o formato non é compatíbel.', - 'This media could not be played because it could not be decrypted.': - 'O medio está cifrado e non temos as chaves para descifralo.', - '': '', - 'Something went wrong.': 'Algo saíu mal.', - OK: 'Pechar', - 'An unexpected error occurred.': 'Produciuse un erro. Por favor, ténteo de novo.', - Settings: 'Axustes', - Quality: 'Calidade', - Audio: 'Son', - Default: 'Predeterminado', - Speed: 'Velocidade', - Captions: 'Subtítulos para xordos', - 'Playback rate': 'Velocidade de reprodución', - Back: 'Atrás', - Off: 'Desactivado', - Auto: 'Automático', - 'Auto ({label})': 'Automático ({label})', - Subtitles: 'Subtítulos', -} as const satisfies Partial; + buttons: { + play: 'Reproducir', + pause: 'Pausa', + replay: 'Repetir', + mute: 'Silenciar', + unmute: 'Son activado', + }, + seek: { + forward: 'Avanzar {seconds} segundos', + backward: 'Retroceder {seconds} segundos', + }, + fullscreen: { + enter: 'Pantalla completa', + exit: 'Saír da pantalla completa', + }, + captions: { + enable: 'Activar subtítulos', + disable: 'Desactivar subtítulos', + }, + pip: { + enter: 'Imaxe en imaxe', + exit: 'Saír de imaxe en imaxe', + }, + live: { + playing: 'Reproducindo en directo', + seekToEdge: 'Ir ao directo', + badge: 'En directo', + }, + cast: { + start: 'Iniciar emisión', + stop: 'Deter emisión', + connecting: 'Conectando', + }, + airplay: { + start: 'Iniciar AirPlay', + stop: 'Deter AirPlay', + }, + slider: { + seek: 'Buscar', + }, + time: { + current: 'Tempo reproducido', + duration: 'Duración', + remaining: 'Tempo restante', + remainingSuffix: 'Quedan {duration}', + showElapsed: '{duration}. Amosar tempo transcorrido.', + showDuration: '{duration}. Amosar duración.', + showRemaining: '{duration}. Amosar tempo restante.', + position: '{current} de {duration}', + }, + playback: { + rate: 'Velocidade de reprodución {rate}', + }, + volume: { + mutedValue: '{percent}, silenciado', + muted: 'Silenciado', + label: 'Nivel do volume', + value: 'Nivel do volume {value}', + }, + status: { + captionsOn: 'Subtítulos activados', + captionsOff: 'Subtítulos desactivados', + paused: 'En pausa', + playing: 'Reproducindo', + fullscreen: 'Pantalla completa', + pip: 'Imaxe en imaxe', + exitPip: 'Saír de imaxe en imaxe', + }, + errors: { + aborted: 'Vostede interrompeu a reprodución do medio.', + network: 'Un erro de rede interrompeu a descarga do medio.', + decode: + 'Interrompeuse a reprodución do medio por mor dun problema de estragamento dos datos ou porque o medio precisa funcións que o seu navegador non ofrece.', + source: + 'Non foi posíbel cargar o medio por mor dun fallo de rede ou do servidor ou porque o formato non é compatíbel.', + encrypted: 'O medio está cifrado e non temos as chaves para descifralo.', + title: 'Algo saíu mal.', + unexpected: 'Produciuse un erro. Por favor, ténteo de novo.', + }, + common: { + empty: '', + ok: 'Pechar', + }, + menu: { + settings: 'Axustes', + quality: 'Calidade', + audio: 'Son', + default: 'Predeterminado', + speed: 'Velocidade', + captions: 'Subtítulos para xordos', + playbackRate: 'Velocidade de reprodución', + back: 'Atrás', + off: 'Desactivado', + auto: 'Automático', + autoWithLabel: 'Automático ({label})', + subtitles: 'Subtítulos', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/he.ts b/packages/core/src/core/i18n/locales/he.ts index 00a2b287..cf943a57 100644 --- a/packages/core/src/core/i18n/locales/he.ts +++ b/packages/core/src/core/i18n/locales/he.ts @@ -1,68 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'נַגֵּן', - Pause: 'השהה', - Replay: 'נַגֵּן שוב', - Mute: 'השתק', - Unmute: 'בטל השתקה', - 'Seek forward {seconds} seconds': 'דילוג קדימה {seconds} שניות', - 'Seek backward {seconds} seconds': 'דילוג אחורה {seconds} שניות', - 'Enter fullscreen': 'מסך מלא', - 'Enable captions': 'הפעל כתוביות', - 'Disable captions': 'כבה כתוביות', - 'Enter picture-in-picture': 'תמונה בתוך תמונה', - 'Exit picture-in-picture': 'יציאה מתמונה בתוך תמונה', - 'Playing live': 'משדר חי', - 'Seek to live edge': 'עבור לשידור חי', - Live: 'שידור חי', - 'Start casting': 'התחל שידור', - 'Stop casting': 'עצור שידור', - Connecting: 'מתחבר', - Seek: 'חיפוש', - 'Current time': 'זמן נוכחי', - Duration: 'זמן כולל', - Remaining: 'זמן נותר', - '{duration} remaining': 'נותרו {duration}', - '{duration}. Show elapsed time.': '{duration}. הצג זמן שחלף.', - '{duration}. Show duration.': '{duration}. הצג משך.', - '{duration}. Show remaining time.': '{duration}. הצג זמן שנותר.', - 'Playback rate {rate}': 'קצב ניגון {rate}', - '{current} of {duration}': '{current} מתוך {duration}', - '{percent}, muted': '{percent}, מושתק', - Muted: 'מושתק', - Volume: 'עוצמת קול', - 'Volume {value}': 'עוצמת קול {value}', - 'Captions on': 'כיתובים פועלים', - 'Captions off': 'כיתובים כבויים', - Paused: 'מושהה', - Playing: 'מתנגן', - Fullscreen: 'מסך מלא', - 'Exit fullscreen': 'יציאה ממסך מלא', - 'Picture in picture': 'תמונה בתוך תמונה', - 'Exit picture in picture': 'יציאה מתמונה בתוך תמונה', - 'You stopped media playback before it finished.': 'ביטלת את השמעת המדיה', - 'This media could not be loaded due to a network or server issue.': 'שגיאת רשת גרמה להורדת המדיה להיכשל באמצע.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'השמעת המדיה בוטלה בשל בעית השחטת מידע או מכיוון שהמדיה עשתה שימוש בתכונות שהדפדפן שלך לא תמך בהן.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'לא ניתן לטעון את המדיה, או מכיוון שהרשת או השרת כשלו או מכיוון שהפורמט אינו נתמך.', - 'This media could not be played because it could not be decrypted.': - 'המדיה מוצפנת ואין בידינו את המפתח כדי לפענח אותה.', - '': '', - 'Something went wrong.': 'אירעה שגיאה.', - OK: 'סְגוֹר', - 'An unexpected error occurred.': 'אירעה שגיאה. אנא נסה שוב.', - Settings: 'הגדרות', - Quality: 'איכות', - Audio: 'שמע', - Default: 'ברירת מחדל', - Speed: 'מהירות', - Captions: 'כתוביות', - 'Playback rate': 'קצב ניגון', - Back: 'חזרה', - Off: 'כבוי', - Auto: 'אוטומטי', - 'Auto ({label})': 'אוטומטי ({label})', - Subtitles: 'כתוביות', -} as const satisfies Partial; + buttons: { + play: 'נַגֵּן', + pause: 'השהה', + replay: 'נַגֵּן שוב', + mute: 'השתק', + unmute: 'בטל השתקה', + }, + seek: { + forward: 'דילוג קדימה {seconds} שניות', + backward: 'דילוג אחורה {seconds} שניות', + }, + fullscreen: { + enter: 'מסך מלא', + exit: 'יציאה ממסך מלא', + }, + captions: { + enable: 'הפעל כתוביות', + disable: 'כבה כתוביות', + }, + pip: { + enter: 'תמונה בתוך תמונה', + exit: 'יציאה מתמונה בתוך תמונה', + }, + live: { + playing: 'משדר חי', + seekToEdge: 'עבור לשידור חי', + badge: 'שידור חי', + }, + cast: { + start: 'התחל שידור', + stop: 'עצור שידור', + connecting: 'מתחבר', + }, + airplay: { + start: 'הפעלת AirPlay', + stop: 'עצירת AirPlay', + }, + slider: { + seek: 'חיפוש', + }, + time: { + current: 'זמן נוכחי', + duration: 'זמן כולל', + remaining: 'זמן נותר', + remainingSuffix: 'נותרו {duration}', + showElapsed: '{duration}. הצג זמן שחלף.', + showDuration: '{duration}. הצג משך.', + showRemaining: '{duration}. הצג זמן שנותר.', + position: '{current} מתוך {duration}', + }, + playback: { + rate: 'קצב ניגון {rate}', + }, + volume: { + mutedValue: '{percent}, מושתק', + muted: 'מושתק', + label: 'עוצמת קול', + value: 'עוצמת קול {value}', + }, + status: { + captionsOn: 'כיתובים פועלים', + captionsOff: 'כיתובים כבויים', + paused: 'מושהה', + playing: 'מתנגן', + fullscreen: 'מסך מלא', + pip: 'תמונה בתוך תמונה', + exitPip: 'יציאה מתמונה בתוך תמונה', + }, + errors: { + aborted: 'ביטלת את השמעת המדיה', + network: 'שגיאת רשת גרמה להורדת המדיה להיכשל באמצע.', + decode: 'השמעת המדיה בוטלה בשל בעית השחטת מידע או מכיוון שהמדיה עשתה שימוש בתכונות שהדפדפן שלך לא תמך בהן.', + source: 'לא ניתן לטעון את המדיה, או מכיוון שהרשת או השרת כשלו או מכיוון שהפורמט אינו נתמך.', + encrypted: 'המדיה מוצפנת ואין בידינו את המפתח כדי לפענח אותה.', + title: 'אירעה שגיאה.', + unexpected: 'אירעה שגיאה. אנא נסה שוב.', + }, + common: { + empty: '', + ok: 'סְגוֹר', + }, + menu: { + settings: 'הגדרות', + quality: 'איכות', + audio: 'שמע', + default: 'ברירת מחדל', + speed: 'מהירות', + captions: 'כתוביות', + playbackRate: 'קצב ניגון', + back: 'חזרה', + off: 'כבוי', + auto: 'אוטומטי', + autoWithLabel: 'אוטומטי ({label})', + subtitles: 'כתוביות', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/hi.ts b/packages/core/src/core/i18n/locales/hi.ts index 57c70684..2faecfcf 100644 --- a/packages/core/src/core/i18n/locales/hi.ts +++ b/packages/core/src/core/i18n/locales/hi.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'चलाएँ', - Pause: 'रोकें', - Replay: 'फिर से चलाएँ', - Mute: 'म्यूट करें', - Unmute: 'अनम्यूट करें', - 'Seek forward {seconds} seconds': '{seconds} सेकंड आगे बढ़ें', - 'Seek backward {seconds} seconds': '{seconds} सेकंड पीछे जाएं', - 'Enter fullscreen': 'फ़ुल स्क्रीन', - 'Enable captions': 'कैप्शन चालू करें', - 'Disable captions': 'कैप्शन बंद करें', - 'Enter picture-in-picture': 'पिक्चर-इन-पिक्चर', - 'Exit picture-in-picture': 'पिक्चर-इन-पिक्चर से बाहर निकलें', - 'Playing live': 'लाइव चल रहा है', - 'Seek to live edge': 'लाइव पर जाएँ', - Live: 'लाइव', - 'Start casting': 'कास्टिंग शुरू करें', - 'Stop casting': 'कास्टिंग बंद करें', - Connecting: 'कनेक्ट हो रहा है', - Seek: 'खोजें', - 'Current time': 'वर्तमान समय', - Duration: 'अवधि', - Remaining: 'शेष समय', - '{duration} remaining': '{duration} शेष', - '{duration}. Show elapsed time.': '{duration}. बीता समय दिखाएँ.', - '{duration}. Show duration.': '{duration}. अवधि दिखाएँ.', - '{duration}. Show remaining time.': '{duration}. शेष समय दिखाएँ.', - 'Playback rate {rate}': 'चलाने की दर {rate}', - '{current} of {duration}': '{duration} में से {current}', - '{percent}, muted': '{percent}, म्यूट', - Muted: 'म्यूट', - Volume: 'वॉल्यूम', - 'Volume {value}': 'वॉल्यूम {value}', - 'Captions on': 'कैप्शन चालू', - 'Captions off': 'कैप्शन बंद', - Paused: 'रोका गया', - Playing: 'चल रहा है', - Fullscreen: 'पूर्ण स्क्रीन', - 'Exit fullscreen': 'पूर्ण स्क्रीन से बाहर', - 'Picture in picture': 'पिक्चर में पिक्चर', - 'Exit picture in picture': 'पिक्चर में पिक्चर से बाहर', - 'You stopped media playback before it finished.': 'आपने मीडिया प्लेबैक को रोक दिया', - 'This media could not be loaded due to a network or server issue.': - 'एक नेटवर्क त्रुटि के कारण मीडिया डाउनलोड आंशिक रूप से विफल हो गया।', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'मीडिया प्लेबैक निरस्त कर दिया गया, कारण: दूषण की समस्या या मीडिया ने उन सुविधाओं का उपयोग किया था जिनका आपके ब्राउज़र ने समर्थन नहीं किया।', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'मीडिया लोड नहीं किया जा सका, या तो सर्वर या नेटवर्क विफल होने के कारण या प्रारूप समर्थित नहीं होने के कारण।', - 'This media could not be played because it could not be decrypted.': - 'मीडिया एन्क्रिप्टेड है और हमारे पास इसे डिक्रिप्ट करने की चाबी नहीं है।', - '': '', - 'Something went wrong.': 'कुछ गलत हुआ।', - OK: 'बंद करें', - 'An unexpected error occurred.': 'एक त्रुटि हुई। कृपया पुनः प्रयास करें।', - Settings: 'सेटिंग्स', - Quality: 'गुणवत्ता', - Audio: 'ऑडियो', - Default: 'डिफ़ॉल्ट', - Speed: 'गति', - Captions: 'कैप्शन', - 'Playback rate': 'प्लेबैक दर', - Back: 'वापस', - Off: 'बंद', - Auto: 'ऑटो', - 'Auto ({label})': 'ऑटो ({label})', - Subtitles: 'उपशीर्षक', -} as const satisfies Partial; + buttons: { + play: 'चलाएँ', + pause: 'रोकें', + replay: 'फिर से चलाएँ', + mute: 'म्यूट करें', + unmute: 'अनम्यूट करें', + }, + seek: { + forward: '{seconds} सेकंड आगे बढ़ें', + backward: '{seconds} सेकंड पीछे जाएं', + }, + fullscreen: { + enter: 'फ़ुल स्क्रीन', + exit: 'पूर्ण स्क्रीन से बाहर', + }, + captions: { + enable: 'कैप्शन चालू करें', + disable: 'कैप्शन बंद करें', + }, + pip: { + enter: 'पिक्चर-इन-पिक्चर', + exit: 'पिक्चर-इन-पिक्चर से बाहर निकलें', + }, + live: { + playing: 'लाइव चल रहा है', + seekToEdge: 'लाइव पर जाएँ', + badge: 'लाइव', + }, + cast: { + start: 'कास्टिंग शुरू करें', + stop: 'कास्टिंग बंद करें', + connecting: 'कनेक्ट हो रहा है', + }, + airplay: { + start: 'AirPlay शुरू करें', + stop: 'AirPlay रोकें', + }, + slider: { + seek: 'खोजें', + }, + time: { + current: 'वर्तमान समय', + duration: 'अवधि', + remaining: 'शेष समय', + remainingSuffix: '{duration} शेष', + showElapsed: '{duration}. बीता समय दिखाएँ.', + showDuration: '{duration}. अवधि दिखाएँ.', + showRemaining: '{duration}. शेष समय दिखाएँ.', + position: '{duration} में से {current}', + }, + playback: { + rate: 'चलाने की दर {rate}', + }, + volume: { + mutedValue: '{percent}, म्यूट', + muted: 'म्यूट', + label: 'वॉल्यूम', + value: 'वॉल्यूम {value}', + }, + status: { + captionsOn: 'कैप्शन चालू', + captionsOff: 'कैप्शन बंद', + paused: 'रोका गया', + playing: 'चल रहा है', + fullscreen: 'पूर्ण स्क्रीन', + pip: 'पिक्चर में पिक्चर', + exitPip: 'पिक्चर में पिक्चर से बाहर', + }, + errors: { + aborted: 'आपने मीडिया प्लेबैक को रोक दिया', + network: 'एक नेटवर्क त्रुटि के कारण मीडिया डाउनलोड आंशिक रूप से विफल हो गया।', + decode: + 'मीडिया प्लेबैक निरस्त कर दिया गया, कारण: दूषण की समस्या या मीडिया ने उन सुविधाओं का उपयोग किया था जिनका आपके ब्राउज़र ने समर्थन नहीं किया।', + source: 'मीडिया लोड नहीं किया जा सका, या तो सर्वर या नेटवर्क विफल होने के कारण या प्रारूप समर्थित नहीं होने के कारण।', + encrypted: 'मीडिया एन्क्रिप्टेड है और हमारे पास इसे डिक्रिप्ट करने की चाबी नहीं है।', + title: 'कुछ गलत हुआ।', + unexpected: 'एक त्रुटि हुई। कृपया पुनः प्रयास करें।', + }, + common: { + empty: '', + ok: 'बंद करें', + }, + menu: { + settings: 'सेटिंग्स', + quality: 'गुणवत्ता', + audio: 'ऑडियो', + default: 'डिफ़ॉल्ट', + speed: 'गति', + captions: 'कैप्शन', + playbackRate: 'प्लेबैक दर', + back: 'वापस', + off: 'बंद', + auto: 'ऑटो', + autoWithLabel: 'ऑटो ({label})', + subtitles: 'उपशीर्षक', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/hr.ts b/packages/core/src/core/i18n/locales/hr.ts index e7d8a81e..4293679f 100644 --- a/packages/core/src/core/i18n/locales/hr.ts +++ b/packages/core/src/core/i18n/locales/hr.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Pusti', - Pause: 'Pauza', - Replay: 'Ponovi', - Mute: 'Prigušen', - Unmute: 'Ne-prigušen', - 'Seek forward {seconds} seconds': 'Preskoči naprijed {seconds} sekundi', - 'Seek backward {seconds} seconds': 'Preskoči unatrag {seconds} sekundi', - 'Enter fullscreen': 'Puni ekran', - 'Enable captions': 'Uključi titlove', - 'Disable captions': 'Isključi titlove', - 'Enter picture-in-picture': 'Slika u slici', - 'Exit picture-in-picture': 'Izađi iz slike u slici', - 'Playing live': 'Reprodukcija uživo', - 'Seek to live edge': 'Prijeđi na live', - Live: 'Uživo', - 'Start casting': 'Pokreni emitiranje', - 'Stop casting': 'Zaustavi emitiranje', - Connecting: 'Povezivanje', - Seek: 'Premotavanje', - 'Current time': 'Trenutno vrijeme', - Duration: 'Vrijeme trajanja', - Remaining: 'Preostalo vrijeme', - '{duration} remaining': 'Preostalo {duration}', - '{duration}. Show elapsed time.': '{duration}. Prikaži proteklo vrijeme.', - '{duration}. Show duration.': '{duration}. Prikaži trajanje.', - '{duration}. Show remaining time.': '{duration}. Prikaži preostalo vrijeme.', - 'Playback rate {rate}': 'Stopa reprodukcije {rate}', - '{current} of {duration}': '{current} od {duration}', - '{percent}, muted': '{percent}, utišano', - Muted: 'Utišano', - Volume: 'Glasnoća', - 'Volume {value}': 'Glasnoća {value}', - 'Captions on': 'Titlovi uključeni', - 'Captions off': 'Titlovi isključeni', - Paused: 'Pauzirano', - Playing: 'Reproducira se', - Fullscreen: 'Cijeli zaslon', - 'Exit fullscreen': 'Izađi iz cijelog zaslona', - 'Picture in picture': 'Slika u slici', - 'Exit picture in picture': 'Izađi iz slike u slici', - 'You stopped media playback before it finished.': 'Isključili ste reprodukciju videa.', - 'This media could not be loaded due to a network or server issue.': - 'Video se prestao preuzimati zbog greške na mreži.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Reprodukcija videa je zaustavljenja zbog greške u formatu ili zbog verzije vašeg pretraživača.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Video se ne može reproducirati zbog servera, greške u mreži ili je format ne podržan.', - 'This media could not be played because it could not be decrypted.': - 'Medij je šifriran i nema ključeva za dešifriranje.', - '': '', - 'Something went wrong.': 'Nešto je pošlo po zlu.', - OK: 'Zatvori', - 'An unexpected error occurred.': 'Došlo je do pogreške. Pokušajte ponovo.', - Settings: 'Postavke', - Quality: 'Kvaliteta', - Audio: 'Zvuk', - Default: 'Zadano', - Speed: 'Brzina', - Captions: 'Titlovi', - 'Playback rate': 'Brzina reprodukcije', - Back: 'Natrag', - Off: 'Isključeno', - Auto: 'Automatski', - 'Auto ({label})': 'Automatski ({label})', - Subtitles: 'Titlovi', -} as const satisfies Partial; + buttons: { + play: 'Pusti', + pause: 'Pauza', + replay: 'Ponovi', + mute: 'Prigušen', + unmute: 'Ne-prigušen', + }, + seek: { + forward: 'Preskoči naprijed {seconds} sekundi', + backward: 'Preskoči unatrag {seconds} sekundi', + }, + fullscreen: { + enter: 'Puni ekran', + exit: 'Izađi iz cijelog zaslona', + }, + captions: { + enable: 'Uključi titlove', + disable: 'Isključi titlove', + }, + pip: { + enter: 'Slika u slici', + exit: 'Izađi iz slike u slici', + }, + live: { + playing: 'Reprodukcija uživo', + seekToEdge: 'Prijeđi na live', + badge: 'Uživo', + }, + cast: { + start: 'Pokreni emitiranje', + stop: 'Zaustavi emitiranje', + connecting: 'Povezivanje', + }, + airplay: { + start: 'Pokreni AirPlay', + stop: 'Zaustavi AirPlay', + }, + slider: { + seek: 'Premotavanje', + }, + time: { + current: 'Trenutno vrijeme', + duration: 'Vrijeme trajanja', + remaining: 'Preostalo vrijeme', + remainingSuffix: 'Preostalo {duration}', + showElapsed: '{duration}. Prikaži proteklo vrijeme.', + showDuration: '{duration}. Prikaži trajanje.', + showRemaining: '{duration}. Prikaži preostalo vrijeme.', + position: '{current} od {duration}', + }, + playback: { + rate: 'Stopa reprodukcije {rate}', + }, + volume: { + mutedValue: '{percent}, utišano', + muted: 'Utišano', + label: 'Glasnoća', + value: 'Glasnoća {value}', + }, + status: { + captionsOn: 'Titlovi uključeni', + captionsOff: 'Titlovi isključeni', + paused: 'Pauzirano', + playing: 'Reproducira se', + fullscreen: 'Cijeli zaslon', + pip: 'Slika u slici', + exitPip: 'Izađi iz slike u slici', + }, + errors: { + aborted: 'Isključili ste reprodukciju videa.', + network: 'Video se prestao preuzimati zbog greške na mreži.', + decode: 'Reprodukcija videa je zaustavljenja zbog greške u formatu ili zbog verzije vašeg pretraživača.', + source: 'Video se ne može reproducirati zbog servera, greške u mreži ili je format ne podržan.', + encrypted: 'Medij je šifriran i nema ključeva za dešifriranje.', + title: 'Nešto je pošlo po zlu.', + unexpected: 'Došlo je do pogreške. Pokušajte ponovo.', + }, + common: { + empty: '', + ok: 'Zatvori', + }, + menu: { + settings: 'Postavke', + quality: 'Kvaliteta', + audio: 'Zvuk', + default: 'Zadano', + speed: 'Brzina', + captions: 'Titlovi', + playbackRate: 'Brzina reprodukcije', + back: 'Natrag', + off: 'Isključeno', + auto: 'Automatski', + autoWithLabel: 'Automatski ({label})', + subtitles: 'Titlovi', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/hu.ts b/packages/core/src/core/i18n/locales/hu.ts index d5e2f725..80f067bf 100644 --- a/packages/core/src/core/i18n/locales/hu.ts +++ b/packages/core/src/core/i18n/locales/hu.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Lejátszás', - Pause: 'Szünet', - Replay: 'Visszajátszás', - Mute: 'Némítás', - Unmute: 'Némítás kikapcsolva', - 'Seek forward {seconds} seconds': 'Ugrás előre {seconds} másodpercet', - 'Seek backward {seconds} seconds': 'Ugrás vissza {seconds} másodpercet', - 'Enter fullscreen': 'Teljes képernyő', - 'Enable captions': 'Feliratok bekapcsolása', - 'Disable captions': 'Feliratok kikapcsolása', - 'Enter picture-in-picture': 'Kép a képben', - 'Exit picture-in-picture': 'Kilépés kép a képben módból', - 'Playing live': 'Élő adás', - 'Seek to live edge': 'Ugrás az élő adáshoz', - Live: 'Élő', - 'Start casting': 'Vetítés indítása', - 'Stop casting': 'Vetítés leállítása', - Connecting: 'Csatlakozás', - Seek: 'Teke', - 'Current time': 'Aktuális időpont', - Duration: 'Hossz', - Remaining: 'Hátralévő idő', - '{duration} remaining': '{duration} van hátra', - '{duration}. Show elapsed time.': '{duration}. Eltelt idő megjelenítése.', - '{duration}. Show duration.': '{duration}. Időtartam megjelenítése.', - '{duration}. Show remaining time.': '{duration}. Hátralévő idő megjelenítése.', - 'Playback rate {rate}': 'Lejátszási sebesség {rate}', - '{current} of {duration}': '{current} / {duration}', - '{percent}, muted': '{percent}, némítva', - Muted: 'Némítva', - Volume: 'Hangerő', - 'Volume {value}': 'Hangerő {value}', - 'Captions on': 'Feliratok bekapcsolva', - 'Captions off': 'Feliratok kikapcsolva', - Paused: 'Szüneteltetve', - Playing: 'Lejátszás', - Fullscreen: 'Teljes képernyő', - 'Exit fullscreen': 'Kilépés teljes képernyőből', - 'Picture in picture': 'Kép a képben', - 'Exit picture in picture': 'Kilépés kép a képben módból', - 'You stopped media playback before it finished.': 'Leállította a lejátszást', - 'This media could not be loaded due to a network or server issue.': - 'Hálózati hiba miatt a videó részlegesen töltődött le.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'A lejátszás adatsérülés miatt leállt, vagy a videó egyes tulajdonságait a böngészője nem támogatja.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'A videó nem tölthető be hálózati vagy kiszolgálói hiba miatt, vagy a formátuma nem támogatott.', - 'This media could not be played because it could not be decrypted.': - 'A média titkosítva van és nincsenek kulcsok a visszafejtéshez.', - '': '', - 'Something went wrong.': 'Valami hiba történt.', - OK: 'Bezárás', - 'An unexpected error occurred.': 'Hiba történt. Kérjük, próbálja újra.', - Settings: 'Beállítások', - Quality: 'Minőség', - Audio: 'Hang', - Default: 'Alapértelmezett', - Speed: 'Sebesség', - Captions: 'Feliratok', - 'Playback rate': 'Lejátszási sebesség', - Back: 'Vissza', - Off: 'Ki', - Auto: 'Automatikus', - 'Auto ({label})': 'Automatikus ({label})', - Subtitles: 'Feliratok', -} as const satisfies Partial; + buttons: { + play: 'Lejátszás', + pause: 'Szünet', + replay: 'Visszajátszás', + mute: 'Némítás', + unmute: 'Némítás kikapcsolva', + }, + seek: { + forward: 'Ugrás előre {seconds} másodpercet', + backward: 'Ugrás vissza {seconds} másodpercet', + }, + fullscreen: { + enter: 'Teljes képernyő', + exit: 'Kilépés teljes képernyőből', + }, + captions: { + enable: 'Feliratok bekapcsolása', + disable: 'Feliratok kikapcsolása', + }, + pip: { + enter: 'Kép a képben', + exit: 'Kilépés kép a képben módból', + }, + live: { + playing: 'Élő adás', + seekToEdge: 'Ugrás az élő adáshoz', + badge: 'Élő', + }, + cast: { + start: 'Vetítés indítása', + stop: 'Vetítés leállítása', + connecting: 'Csatlakozás', + }, + airplay: { + start: 'AirPlay indítása', + stop: 'AirPlay leállítása', + }, + slider: { + seek: 'Teke', + }, + time: { + current: 'Aktuális időpont', + duration: 'Hossz', + remaining: 'Hátralévő idő', + remainingSuffix: '{duration} van hátra', + showElapsed: '{duration}. Eltelt idő megjelenítése.', + showDuration: '{duration}. Időtartam megjelenítése.', + showRemaining: '{duration}. Hátralévő idő megjelenítése.', + position: '{current} / {duration}', + }, + playback: { + rate: 'Lejátszási sebesség {rate}', + }, + volume: { + mutedValue: '{percent}, némítva', + muted: 'Némítva', + label: 'Hangerő', + value: 'Hangerő {value}', + }, + status: { + captionsOn: 'Feliratok bekapcsolva', + captionsOff: 'Feliratok kikapcsolva', + paused: 'Szüneteltetve', + playing: 'Lejátszás', + fullscreen: 'Teljes képernyő', + pip: 'Kép a képben', + exitPip: 'Kilépés kép a képben módból', + }, + errors: { + aborted: 'Leállította a lejátszást', + network: 'Hálózati hiba miatt a videó részlegesen töltődött le.', + decode: 'A lejátszás adatsérülés miatt leállt, vagy a videó egyes tulajdonságait a böngészője nem támogatja.', + source: 'A videó nem tölthető be hálózati vagy kiszolgálói hiba miatt, vagy a formátuma nem támogatott.', + encrypted: 'A média titkosítva van és nincsenek kulcsok a visszafejtéshez.', + title: 'Valami hiba történt.', + unexpected: 'Hiba történt. Kérjük, próbálja újra.', + }, + common: { + empty: '', + ok: 'Bezárás', + }, + menu: { + settings: 'Beállítások', + quality: 'Minőség', + audio: 'Hang', + default: 'Alapértelmezett', + speed: 'Sebesség', + captions: 'Feliratok', + playbackRate: 'Lejátszási sebesség', + back: 'Vissza', + off: 'Ki', + auto: 'Automatikus', + autoWithLabel: 'Automatikus ({label})', + subtitles: 'Feliratok', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/it.ts b/packages/core/src/core/i18n/locales/it.ts index 0608d45f..25e6b0a0 100644 --- a/packages/core/src/core/i18n/locales/it.ts +++ b/packages/core/src/core/i18n/locales/it.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Riproduci', - Pause: 'Pausa', - Replay: 'Riproduci di nuovo', - Mute: 'Disattiva l’audio', - Unmute: 'Attiva l’audio', - 'Seek forward {seconds} seconds': 'Avanti {seconds} secondi', - 'Seek backward {seconds} seconds': 'Indietro {seconds} secondi', - 'Enter fullscreen': 'Schermo intero', - 'Enable captions': 'Attiva sottotitoli', - 'Disable captions': 'Disattiva sottotitoli', - 'Enter picture-in-picture': 'Picture-in-Picture', - 'Exit picture-in-picture': 'Esci dalla modalità Picture-in-Picture', - 'Playing live': 'Riproduzione in diretta', - 'Seek to live edge': 'Vai al live', - Live: 'In diretta', - 'Start casting': 'Avvia trasmissione', - 'Stop casting': 'Interrompi trasmissione', - Connecting: 'Connessione', - Seek: 'Scorrimento', - 'Current time': 'Orario attuale', - Duration: 'Durata', - Remaining: 'Tempo rimanente', - '{duration} remaining': 'Restano {duration}', - '{duration}. Show elapsed time.': '{duration}. Mostra tempo trascorso.', - '{duration}. Show duration.': '{duration}. Mostra durata.', - '{duration}. Show remaining time.': '{duration}. Mostra tempo rimanente.', - 'Playback rate {rate}': 'Velocità di riproduzione {rate}', - '{current} of {duration}': '{current} di {duration}', - '{percent}, muted': '{percent}, audio disattivato', - Muted: 'Audio disattivato', - Volume: 'Livello del volume', - 'Volume {value}': 'Livello del volume {value}', - 'Captions on': 'Sottotitoli attivi', - 'Captions off': 'Sottotitoli disattivi', - Paused: 'In pausa', - Playing: 'In riproduzione', - Fullscreen: 'Schermo intero', - 'Exit fullscreen': 'Esci da schermo intero', - 'Picture in picture': 'Picture-in-picture', - 'Exit picture in picture': 'Esci dalla modalità Picture-in-picture', - 'You stopped media playback before it finished.': 'La riproduzione del contenuto multimediale è stata interrotta.', - 'This media could not be loaded due to a network or server issue.': - 'Il download del contenuto multimediale è stato interrotto a causa di un problema rete.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'La riproduzione del contenuto multimediale è stata interrotta a causa di un file danneggiato o per l’utilizzo di impostazioni non supportate dal browser.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Il contenuto multimediale non può essere caricato a causa di un errore nel server o nella rete o perché il formato non viene supportato.', - 'This media could not be played because it could not be decrypted.': - 'Il contenuto multimediale è criptato e non disponiamo delle chiavi per decifrarlo.', - '': '', - 'Something went wrong.': 'Qualcosa è andato storto.', - OK: 'Chiudi', - 'An unexpected error occurred.': 'Si è verificato un errore. Riprova.', - Settings: 'Impostazioni', - Quality: 'Qualità', - Audio: 'Audio', - Default: 'Predefinito', - Speed: 'Velocità', - Captions: 'Sottotitoli', - 'Playback rate': 'Velocità di riproduzione', - Back: 'Indietro', - Off: 'Disattivato', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Sottotitoli', -} as const satisfies Partial; + buttons: { + play: 'Riproduci', + pause: 'Pausa', + replay: 'Riproduci di nuovo', + mute: 'Disattiva l’audio', + unmute: 'Attiva l’audio', + }, + seek: { + forward: 'Avanti {seconds} secondi', + backward: 'Indietro {seconds} secondi', + }, + fullscreen: { + enter: 'Schermo intero', + exit: 'Esci da schermo intero', + }, + captions: { + enable: 'Attiva sottotitoli', + disable: 'Disattiva sottotitoli', + }, + pip: { + enter: 'Picture-in-Picture', + exit: 'Esci dalla modalità Picture-in-Picture', + }, + live: { + playing: 'Riproduzione in diretta', + seekToEdge: 'Vai al live', + badge: 'In diretta', + }, + cast: { + start: 'Avvia trasmissione', + stop: 'Interrompi trasmissione', + connecting: 'Connessione', + }, + airplay: { + start: 'Avvia AirPlay', + stop: 'Arresta AirPlay', + }, + slider: { + seek: 'Scorrimento', + }, + time: { + current: 'Orario attuale', + duration: 'Durata', + remaining: 'Tempo rimanente', + remainingSuffix: 'Restano {duration}', + showElapsed: '{duration}. Mostra tempo trascorso.', + showDuration: '{duration}. Mostra durata.', + showRemaining: '{duration}. Mostra tempo rimanente.', + position: '{current} di {duration}', + }, + playback: { + rate: 'Velocità di riproduzione {rate}', + }, + volume: { + mutedValue: '{percent}, audio disattivato', + muted: 'Audio disattivato', + label: 'Livello del volume', + value: 'Livello del volume {value}', + }, + status: { + captionsOn: 'Sottotitoli attivi', + captionsOff: 'Sottotitoli disattivi', + paused: 'In pausa', + playing: 'In riproduzione', + fullscreen: 'Schermo intero', + pip: 'Picture-in-picture', + exitPip: 'Esci dalla modalità Picture-in-picture', + }, + errors: { + aborted: 'La riproduzione del contenuto multimediale è stata interrotta.', + network: 'Il download del contenuto multimediale è stato interrotto a causa di un problema rete.', + decode: + 'La riproduzione del contenuto multimediale è stata interrotta a causa di un file danneggiato o per l’utilizzo di impostazioni non supportate dal browser.', + source: + 'Il contenuto multimediale non può essere caricato a causa di un errore nel server o nella rete o perché il formato non viene supportato.', + encrypted: 'Il contenuto multimediale è criptato e non disponiamo delle chiavi per decifrarlo.', + title: 'Qualcosa è andato storto.', + unexpected: 'Si è verificato un errore. Riprova.', + }, + common: { + empty: '', + ok: 'Chiudi', + }, + menu: { + settings: 'Impostazioni', + quality: 'Qualità', + audio: 'Audio', + default: 'Predefinito', + speed: 'Velocità', + captions: 'Sottotitoli', + playbackRate: 'Velocità di riproduzione', + back: 'Indietro', + off: 'Disattivato', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Sottotitoli', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/ja.ts b/packages/core/src/core/i18n/locales/ja.ts index dffe307a..18ed0d23 100644 --- a/packages/core/src/core/i18n/locales/ja.ts +++ b/packages/core/src/core/i18n/locales/ja.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: '再生', - Pause: '一時停止', - Replay: 'もう一度見る', - Mute: 'ミュート', - Unmute: 'サウンドをオン', - 'Seek forward {seconds} seconds': '{seconds}秒進む', - 'Seek backward {seconds} seconds': '{seconds}秒戻る', - 'Enter fullscreen': 'フルスクリーン', - 'Enable captions': '字幕を表示', - 'Disable captions': '字幕を非表示', - 'Enter picture-in-picture': 'ピクチャーインピクチャー', - 'Exit picture-in-picture': 'ピクチャーインピクチャー機能の終了', - 'Playing live': 'ライブ再生中', - 'Seek to live edge': 'ライブ位置へ移動', - Live: 'ライブ', - 'Start casting': 'キャスト開始', - 'Stop casting': 'キャスト停止', - Connecting: '接続中', - Seek: 'シーク', - 'Current time': '現在の時間', - Duration: '長さ', - Remaining: '残りの時間', - '{duration} remaining': '残り {duration}', - '{duration}. Show elapsed time.': '{duration}. 経過時間を表示.', - '{duration}. Show duration.': '{duration}. 再生時間を表示.', - '{duration}. Show remaining time.': '{duration}. 残り時間を表示.', - 'Playback rate {rate}': '再生レート {rate}', - '{current} of {duration}': '{duration}の{current}', - '{percent}, muted': '{percent}、ミュート', - Muted: 'ミュート', - Volume: '音量', - 'Volume {value}': '音量 {value}', - 'Captions on': '字幕オン', - 'Captions off': '字幕オフ', - Paused: '一時停止', - Playing: '再生中', - Fullscreen: '全画面表示', - 'Exit fullscreen': '全画面表示解除', - 'Picture in picture': 'ピクチャーインピクチャー表示', - 'Exit picture in picture': 'ピクチャーインピクチャー表示解除', - 'You stopped media playback before it finished.': '動画再生を中止しました', - 'This media could not be loaded due to a network or server issue.': - 'ネットワーク エラーにより動画のダウンロードが途中で失敗しました', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - '破損の問題、またはお使いのブラウザがサポートしていない機能が動画に使用されていたため、動画の再生が中止されました', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'サーバーまたはネットワークのエラー、またはフォーマットがサポートされていないため、動画をロードできませんでした', - 'This media could not be played because it could not be decrypted.': - 'メディアは暗号化されており、解読するためのキーがありません。', - '': '', - 'Something went wrong.': '問題が発生しました。', - OK: '閉じる', - 'An unexpected error occurred.': 'エラーが発生しました。再度お試しください。', - Settings: '設定', - Quality: '画質', - Audio: '音声', - Default: 'デフォルト', - Speed: '速度', - Captions: 'キャプション', - 'Playback rate': '再生速度', - Back: '戻る', - Off: 'オフ', - Auto: '自動', - 'Auto ({label})': '自動 ({label})', - Subtitles: '字幕', -} as const satisfies Partial; + buttons: { + play: '再生', + pause: '一時停止', + replay: 'もう一度見る', + mute: 'ミュート', + unmute: 'サウンドをオン', + }, + seek: { + forward: '{seconds}秒進む', + backward: '{seconds}秒戻る', + }, + fullscreen: { + enter: 'フルスクリーン', + exit: '全画面表示解除', + }, + captions: { + enable: '字幕を表示', + disable: '字幕を非表示', + }, + pip: { + enter: 'ピクチャーインピクチャー', + exit: 'ピクチャーインピクチャー機能の終了', + }, + live: { + playing: 'ライブ再生中', + seekToEdge: 'ライブ位置へ移動', + badge: 'ライブ', + }, + cast: { + start: 'キャスト開始', + stop: 'キャスト停止', + connecting: '接続中', + }, + airplay: { + start: 'AirPlayを開始', + stop: 'AirPlayを停止', + }, + slider: { + seek: 'シーク', + }, + time: { + current: '現在の時間', + duration: '長さ', + remaining: '残りの時間', + remainingSuffix: '残り {duration}', + showElapsed: '{duration}. 経過時間を表示.', + showDuration: '{duration}. 再生時間を表示.', + showRemaining: '{duration}. 残り時間を表示.', + position: '{duration}の{current}', + }, + playback: { + rate: '再生レート {rate}', + }, + volume: { + mutedValue: '{percent}、ミュート', + muted: 'ミュート', + label: '音量', + value: '音量 {value}', + }, + status: { + captionsOn: '字幕オン', + captionsOff: '字幕オフ', + paused: '一時停止', + playing: '再生中', + fullscreen: '全画面表示', + pip: 'ピクチャーインピクチャー表示', + exitPip: 'ピクチャーインピクチャー表示解除', + }, + errors: { + aborted: '動画再生を中止しました', + network: 'ネットワーク エラーにより動画のダウンロードが途中で失敗しました', + decode: + '破損の問題、またはお使いのブラウザがサポートしていない機能が動画に使用されていたため、動画の再生が中止されました', + source: + 'サーバーまたはネットワークのエラー、またはフォーマットがサポートされていないため、動画をロードできませんでした', + encrypted: 'メディアは暗号化されており、解読するためのキーがありません。', + title: '問題が発生しました。', + unexpected: 'エラーが発生しました。再度お試しください。', + }, + common: { + empty: '', + ok: '閉じる', + }, + menu: { + settings: '設定', + quality: '画質', + audio: '音声', + default: 'デフォルト', + speed: '速度', + captions: 'キャプション', + playbackRate: '再生速度', + back: '戻る', + off: 'オフ', + auto: '自動', + autoWithLabel: '自動 ({label})', + subtitles: '字幕', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/ko.ts b/packages/core/src/core/i18n/locales/ko.ts index 64cae8f0..4615308c 100644 --- a/packages/core/src/core/i18n/locales/ko.ts +++ b/packages/core/src/core/i18n/locales/ko.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: '재생', - Pause: '일시중지', - Replay: '다시 재생', - Mute: '음소거', - Unmute: '소리 활성화하기', - 'Seek forward {seconds} seconds': '{seconds}초 앞으로', - 'Seek backward {seconds} seconds': '{seconds}초 뒤로', - 'Enter fullscreen': '전체 화면', - 'Enable captions': '자막 켜기', - 'Disable captions': '자막 끄기', - 'Enter picture-in-picture': '화면 속 화면', - 'Exit picture-in-picture': '화면 속 화면 종료', - 'Playing live': '라이브 재생 중', - 'Seek to live edge': '라이브 지점으로 이동', - Live: '라이브', - 'Start casting': '전송 시작', - 'Stop casting': '전송 중지', - Connecting: '연결 중', - Seek: '탐색', - 'Current time': '현재 시간', - Duration: '지정 기간', - Remaining: '남은 시간', - '{duration} remaining': '{duration} 남음', - '{duration}. Show elapsed time.': '{duration}. 경과 시간 표시.', - '{duration}. Show duration.': '{duration}. 길이 표시.', - '{duration}. Show remaining time.': '{duration}. 남은 시간 표시.', - 'Playback rate {rate}': '재생 속도 {rate}', - '{current} of {duration}': '{duration} 중 {current}', - '{percent}, muted': '{percent}, 음소거', - Muted: '음소거', - Volume: '볼륨', - 'Volume {value}': '볼륨 {value}', - 'Captions on': '자막 켜짐', - 'Captions off': '자막 꺼짐', - Paused: '일시정지', - Playing: '재생 중', - Fullscreen: '전체 화면', - 'Exit fullscreen': '전체 화면 종료', - 'Picture in picture': '화면 속 화면', - 'Exit picture in picture': '화면 속 화면 종료', - 'You stopped media playback before it finished.': '비디오 재생을 취소했습니다.', - 'This media could not be loaded due to a network or server issue.': - '네트워크 오류로 인하여 비디오 일부를 다운로드하지 못 했습니다.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - '비디오 재생이 취소됐습니다. 비디오가 손상되었거나 비디오가 사용하는 기능을 브라우저에서 지원하지 않는 것 같습니다.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - '비디오를 로드할 수 없습니다. 서버 혹은 네트워크 오류 때문이거나 지원되지 않는 형식 때문일 수 있습니다.', - 'This media could not be played because it could not be decrypted.': - '미디어는 암호화되어 있으며 이를 해독할 키를 갖고 있지 않습니다.', - '': '', - 'Something went wrong.': '문제가 발생했습니다.', - OK: '닫기', - 'An unexpected error occurred.': '오류가 발생했습니다. 다시 시도해 주세요.', - Settings: '설정', - Quality: '품질', - Audio: '오디오', - Default: '기본값', - Speed: '속도', - Captions: '캡션', - 'Playback rate': '재생 속도', - Back: '뒤로', - Off: '끄기', - Auto: '자동', - 'Auto ({label})': '자동 ({label})', - Subtitles: '자막', -} as const satisfies Partial; + buttons: { + play: '재생', + pause: '일시중지', + replay: '다시 재생', + mute: '음소거', + unmute: '소리 활성화하기', + }, + seek: { + forward: '{seconds}초 앞으로', + backward: '{seconds}초 뒤로', + }, + fullscreen: { + enter: '전체 화면', + exit: '전체 화면 종료', + }, + captions: { + enable: '자막 켜기', + disable: '자막 끄기', + }, + pip: { + enter: '화면 속 화면', + exit: '화면 속 화면 종료', + }, + live: { + playing: '라이브 재생 중', + seekToEdge: '라이브 지점으로 이동', + badge: '라이브', + }, + cast: { + start: '전송 시작', + stop: '전송 중지', + connecting: '연결 중', + }, + airplay: { + start: 'AirPlay 시작', + stop: 'AirPlay 중지', + }, + slider: { + seek: '탐색', + }, + time: { + current: '현재 시간', + duration: '지정 기간', + remaining: '남은 시간', + remainingSuffix: '{duration} 남음', + showElapsed: '{duration}. 경과 시간 표시.', + showDuration: '{duration}. 길이 표시.', + showRemaining: '{duration}. 남은 시간 표시.', + position: '{duration} 중 {current}', + }, + playback: { + rate: '재생 속도 {rate}', + }, + volume: { + mutedValue: '{percent}, 음소거', + muted: '음소거', + label: '볼륨', + value: '볼륨 {value}', + }, + status: { + captionsOn: '자막 켜짐', + captionsOff: '자막 꺼짐', + paused: '일시정지', + playing: '재생 중', + fullscreen: '전체 화면', + pip: '화면 속 화면', + exitPip: '화면 속 화면 종료', + }, + errors: { + aborted: '비디오 재생을 취소했습니다.', + network: '네트워크 오류로 인하여 비디오 일부를 다운로드하지 못 했습니다.', + decode: + '비디오 재생이 취소됐습니다. 비디오가 손상되었거나 비디오가 사용하는 기능을 브라우저에서 지원하지 않는 것 같습니다.', + source: '비디오를 로드할 수 없습니다. 서버 혹은 네트워크 오류 때문이거나 지원되지 않는 형식 때문일 수 있습니다.', + encrypted: '미디어는 암호화되어 있으며 이를 해독할 키를 갖고 있지 않습니다.', + title: '문제가 발생했습니다.', + unexpected: '오류가 발생했습니다. 다시 시도해 주세요.', + }, + common: { + empty: '', + ok: '닫기', + }, + menu: { + settings: '설정', + quality: '품질', + audio: '오디오', + default: '기본값', + speed: '속도', + captions: '캡션', + playbackRate: '재생 속도', + back: '뒤로', + off: '끄기', + auto: '자동', + autoWithLabel: '자동 ({label})', + subtitles: '자막', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/lv.ts b/packages/core/src/core/i18n/locales/lv.ts index 0570f244..be296b37 100644 --- a/packages/core/src/core/i18n/locales/lv.ts +++ b/packages/core/src/core/i18n/locales/lv.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Atskaņot', - Pause: 'Pauzēt', - Replay: 'Atkārtot', - Mute: 'Izslēgt skaņu', - Unmute: 'Ieslēgt skaņu', - 'Seek forward {seconds} seconds': 'Pārtīt uz priekšu {seconds} sekundes', - 'Seek backward {seconds} seconds': 'Pārtīt atpakaļ {seconds} sekundes', - 'Enter fullscreen': 'Pilnekrāna režīms', - 'Enable captions': 'Ieslēgt parakstus', - 'Disable captions': 'Izslēgt parakstus', - 'Enter picture-in-picture': 'Attēls attēlā', - 'Exit picture-in-picture': 'Iziet no attēls attēlā', - 'Playing live': 'Tiešraide', - 'Seek to live edge': 'Pāriet uz tiešraidi', - Live: 'Tiešraide', - 'Start casting': 'Sākt pārraidīšanu', - 'Stop casting': 'Beigt pārraidīšanu', - Connecting: 'Savienošanās', - Seek: 'Meklēt', - 'Current time': 'Esošais laiks', - Duration: 'Ilgums', - Remaining: 'Atlikušais laiks', - '{duration} remaining': 'Atlicis {duration}', - '{duration}. Show elapsed time.': '{duration}. Rādīt pagājušo laiku.', - '{duration}. Show duration.': '{duration}. Rādīt ilgumu.', - '{duration}. Show remaining time.': '{duration}. Rādīt atlikušo laiku.', - 'Playback rate {rate}': 'Atskaņošanas ātrums {rate}', - '{current} of {duration}': '{current} no {duration}', - '{percent}, muted': '{percent}, izslēgts', - Muted: 'Skaņa izslēgta', - Volume: 'Skaļums', - 'Volume {value}': 'Skaļums {value}', - 'Captions on': 'Paraksti ieslēgti', - 'Captions off': 'Paraksti izslēgti', - Paused: 'Pauzēts', - Playing: 'Atskaņo', - Fullscreen: 'Pilnekrāna režīms', - 'Exit fullscreen': 'Iziet no pilnekrāna', - 'Picture in picture': 'Attēls attēlā', - 'Exit picture in picture': 'Iziet no attēls attēlā', - 'You stopped media playback before it finished.': 'Atskaņošana atcelta', - 'This media could not be loaded due to a network or server issue.': - 'Tīkla kļūdas dēļ, multivides lejupielāde neizdevās.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Atskaņošana tika pārtraukta tīkla kļūmes dēļ vai pārlūkprogrammas iespēju trūkuma dēļ.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Neizdevās ielādēt multividi, iespējams severa, vai tīkla kļūmes dēļ, vai neatbalstīta formāta dēļ.', - 'This media could not be played because it could not be decrypted.': - 'Multividi nevar atskaņot, jo tas ir kriptēts un nav pieejama dekriptēšanas atslēga.', - '': '', - 'Something went wrong.': 'Kaut kas nogāja greizi.', - OK: 'Aizvērt', - 'An unexpected error occurred.': 'Radās kļūda. Lūdzu, mēģiniet vēlreiz.', - Settings: 'Iestatījumi', - Quality: 'Kvalitāte', - Audio: 'Skaņa', - Default: 'Noklusējuma', - Speed: 'Ātrums', - Captions: 'Subtitri', - 'Playback rate': 'Atskaņošanas ātrums', - Back: 'Atpakaļ', - Off: 'Izslēgts', - Auto: 'Automātiski', - 'Auto ({label})': 'Automātiski ({label})', - Subtitles: 'Subtitri', -} as const satisfies Partial; + buttons: { + play: 'Atskaņot', + pause: 'Pauzēt', + replay: 'Atkārtot', + mute: 'Izslēgt skaņu', + unmute: 'Ieslēgt skaņu', + }, + seek: { + forward: 'Pārtīt uz priekšu {seconds} sekundes', + backward: 'Pārtīt atpakaļ {seconds} sekundes', + }, + fullscreen: { + enter: 'Pilnekrāna režīms', + exit: 'Iziet no pilnekrāna', + }, + captions: { + enable: 'Ieslēgt parakstus', + disable: 'Izslēgt parakstus', + }, + pip: { + enter: 'Attēls attēlā', + exit: 'Iziet no attēls attēlā', + }, + live: { + playing: 'Tiešraide', + seekToEdge: 'Pāriet uz tiešraidi', + badge: 'Tiešraide', + }, + cast: { + start: 'Sākt pārraidīšanu', + stop: 'Beigt pārraidīšanu', + connecting: 'Savienošanās', + }, + airplay: { + start: 'Sākt AirPlay', + stop: 'Apturēt AirPlay', + }, + slider: { + seek: 'Meklēt', + }, + time: { + current: 'Esošais laiks', + duration: 'Ilgums', + remaining: 'Atlikušais laiks', + remainingSuffix: 'Atlicis {duration}', + showElapsed: '{duration}. Rādīt pagājušo laiku.', + showDuration: '{duration}. Rādīt ilgumu.', + showRemaining: '{duration}. Rādīt atlikušo laiku.', + position: '{current} no {duration}', + }, + playback: { + rate: 'Atskaņošanas ātrums {rate}', + }, + volume: { + mutedValue: '{percent}, izslēgts', + muted: 'Skaņa izslēgta', + label: 'Skaļums', + value: 'Skaļums {value}', + }, + status: { + captionsOn: 'Paraksti ieslēgti', + captionsOff: 'Paraksti izslēgti', + paused: 'Pauzēts', + playing: 'Atskaņo', + fullscreen: 'Pilnekrāna režīms', + pip: 'Attēls attēlā', + exitPip: 'Iziet no attēls attēlā', + }, + errors: { + aborted: 'Atskaņošana atcelta', + network: 'Tīkla kļūdas dēļ, multivides lejupielāde neizdevās.', + decode: 'Atskaņošana tika pārtraukta tīkla kļūmes dēļ vai pārlūkprogrammas iespēju trūkuma dēļ.', + source: 'Neizdevās ielādēt multividi, iespējams severa, vai tīkla kļūmes dēļ, vai neatbalstīta formāta dēļ.', + encrypted: 'Multividi nevar atskaņot, jo tas ir kriptēts un nav pieejama dekriptēšanas atslēga.', + title: 'Kaut kas nogāja greizi.', + unexpected: 'Radās kļūda. Lūdzu, mēģiniet vēlreiz.', + }, + common: { + empty: '', + ok: 'Aizvērt', + }, + menu: { + settings: 'Iestatījumi', + quality: 'Kvalitāte', + audio: 'Skaņa', + default: 'Noklusējuma', + speed: 'Ātrums', + captions: 'Subtitri', + playbackRate: 'Atskaņošanas ātrums', + back: 'Atpakaļ', + off: 'Izslēgts', + auto: 'Automātiski', + autoWithLabel: 'Automātiski ({label})', + subtitles: 'Subtitri', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/mr.ts b/packages/core/src/core/i18n/locales/mr.ts index bad69a7e..863e1e22 100644 --- a/packages/core/src/core/i18n/locales/mr.ts +++ b/packages/core/src/core/i18n/locales/mr.ts @@ -1,68 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'वाजवा', - Pause: 'थांबा', - Replay: 'पुन्हा वाजवा', - Mute: 'म्यूट करा', - Unmute: 'अनम्यूट करा', - 'Seek forward {seconds} seconds': 'पुढे जा {seconds} सेकंद', - 'Seek backward {seconds} seconds': 'मागे जा {seconds} सेकंद', - 'Enter fullscreen': 'संपूर्ण पडदा', - 'Enable captions': 'मथळे', - 'Disable captions': 'मथळे बंद', - 'Enter picture-in-picture': 'पिक्चर-इन-पिक्चर', - 'Exit picture-in-picture': 'पिक्चर-इन-पिक्चरमधून बाहेर पडा', - 'Playing live': 'थेट प्रसारण सुरू आहे', - 'Seek to live edge': 'थेट प्रसारणाकडे जा', - Live: 'थेट प्रसारण', - 'Start casting': 'कास्टिंग सुरू करा', - 'Stop casting': 'कास्टिंग थांबवा', - Connecting: 'कनेक्ट होत आहे', - Seek: 'शोध', - 'Current time': 'वर्तमान वेळ', - Duration: 'कालावधी', - Remaining: 'उर्वरित वेळ', - '{duration} remaining': '{duration} उर्वरित', - '{duration}. Show elapsed time.': '{duration}. गेलेला वेळ दाखवा.', - '{duration}. Show duration.': '{duration}. कालावधी दाखवा.', - '{duration}. Show remaining time.': '{duration}. उरलेला वेळ दाखवा.', - 'Playback rate {rate}': 'प्लेबॅक दर {rate}', - '{current} of {duration}': '{duration} पैकी {current}', - '{percent}, muted': '{percent}, म्यूट केलेले', - Muted: 'म्यूट केलेले', - Volume: 'आवाज', - 'Volume {value}': 'आवाज {value}', - 'Captions on': 'मथळे चालू', - 'Captions off': 'मथळे बंद', - Paused: 'थांबलेले', - Playing: 'वाजत आहे', - Fullscreen: 'संपूर्ण पडदा', - 'Exit fullscreen': 'संपूर्ण पडद्यातून बाहेर', - 'Picture in picture': 'पिक्चरमध्ये पिक्चर', - 'Exit picture in picture': 'पिक्चरमध्ये पिक्चरमधून बाहेर', - 'You stopped media playback before it finished.': 'तुम्ही मीडिया प्लेबॅक रद्द केला', - 'This media could not be loaded due to a network or server issue.': 'नेटवर्क त्रुटीमुळे मीडिया डाउनलोड अर्ध्यात अयशस्वी झाला.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'मीडिया प्लेबॅक भ्रष्टाचाराच्या समस्येमुळे किंवा मीडियाने वापरलेल्या वैशिष्ट्यांमुळे तुमचा ब्राउझर सपोर्ट करत नसल्यामुळे रद्द करण्यात आला.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'मीडिया लोड करता आला नाही, एकतर सर्व्हर किंवा नेटवर्क अयशस्वी झाल्यामुळे किंवा फॉरमॅट समर्थित नसल्यामुळे.', - 'This media could not be played because it could not be decrypted.': - 'मीडिया एन्क्रिप्ट केलेला आहे आणि तो डिक्रिप्ट करण्यासाठी आमच्याकडे कळा नाहीत.', - '': '', - 'Something went wrong.': 'काहीतरी चुकले.', - OK: 'बंद', - 'An unexpected error occurred.': 'एक त्रुटी आली. कृपया पुन्हा प्रयत्न करा.', - Settings: 'सेटिंग्ज', - Quality: 'गुणवत्ता', - Audio: 'ऑडिओ', - Default: 'डीफॉल्ट', - Speed: 'गती', - Captions: 'मथळे', - 'Playback rate': 'प्लेबॅक दर', - Back: 'मागे', - Off: 'बंद', - Auto: 'स्वयंचलित', - 'Auto ({label})': 'स्वयंचलित ({label})', - Subtitles: 'उपशीर्षके', -} as const satisfies Partial; + buttons: { + play: 'वाजवा', + pause: 'थांबा', + replay: 'पुन्हा वाजवा', + mute: 'म्यूट करा', + unmute: 'अनम्यूट करा', + }, + seek: { + forward: 'पुढे जा {seconds} सेकंद', + backward: 'मागे जा {seconds} सेकंद', + }, + fullscreen: { + enter: 'संपूर्ण पडदा', + exit: 'संपूर्ण पडद्यातून बाहेर', + }, + captions: { + enable: 'मथळे', + disable: 'मथळे बंद', + }, + pip: { + enter: 'पिक्चर-इन-पिक्चर', + exit: 'पिक्चर-इन-पिक्चरमधून बाहेर पडा', + }, + live: { + playing: 'थेट प्रसारण सुरू आहे', + seekToEdge: 'थेट प्रसारणाकडे जा', + badge: 'थेट प्रसारण', + }, + cast: { + start: 'कास्टिंग सुरू करा', + stop: 'कास्टिंग थांबवा', + connecting: 'कनेक्ट होत आहे', + }, + airplay: { + start: 'AirPlay सुरू करा', + stop: 'AirPlay थांबवा', + }, + slider: { + seek: 'शोध', + }, + time: { + current: 'वर्तमान वेळ', + duration: 'कालावधी', + remaining: 'उर्वरित वेळ', + remainingSuffix: '{duration} उर्वरित', + showElapsed: '{duration}. गेलेला वेळ दाखवा.', + showDuration: '{duration}. कालावधी दाखवा.', + showRemaining: '{duration}. उरलेला वेळ दाखवा.', + position: '{duration} पैकी {current}', + }, + playback: { + rate: 'प्लेबॅक दर {rate}', + }, + volume: { + mutedValue: '{percent}, म्यूट केलेले', + muted: 'म्यूट केलेले', + label: 'आवाज', + value: 'आवाज {value}', + }, + status: { + captionsOn: 'मथळे चालू', + captionsOff: 'मथळे बंद', + paused: 'थांबलेले', + playing: 'वाजत आहे', + fullscreen: 'संपूर्ण पडदा', + pip: 'पिक्चरमध्ये पिक्चर', + exitPip: 'पिक्चरमध्ये पिक्चरमधून बाहेर', + }, + errors: { + aborted: 'तुम्ही मीडिया प्लेबॅक रद्द केला', + network: 'नेटवर्क त्रुटीमुळे मीडिया डाउनलोड अर्ध्यात अयशस्वी झाला.', + decode: + 'मीडिया प्लेबॅक भ्रष्टाचाराच्या समस्येमुळे किंवा मीडियाने वापरलेल्या वैशिष्ट्यांमुळे तुमचा ब्राउझर सपोर्ट करत नसल्यामुळे रद्द करण्यात आला.', + source: 'मीडिया लोड करता आला नाही, एकतर सर्व्हर किंवा नेटवर्क अयशस्वी झाल्यामुळे किंवा फॉरमॅट समर्थित नसल्यामुळे.', + encrypted: 'मीडिया एन्क्रिप्ट केलेला आहे आणि तो डिक्रिप्ट करण्यासाठी आमच्याकडे कळा नाहीत.', + title: 'काहीतरी चुकले.', + unexpected: 'एक त्रुटी आली. कृपया पुन्हा प्रयत्न करा.', + }, + common: { + empty: '', + ok: 'बंद', + }, + menu: { + settings: 'सेटिंग्ज', + quality: 'गुणवत्ता', + audio: 'ऑडिओ', + default: 'डीफॉल्ट', + speed: 'गती', + captions: 'मथळे', + playbackRate: 'प्लेबॅक दर', + back: 'मागे', + off: 'बंद', + auto: 'स्वयंचलित', + autoWithLabel: 'स्वयंचलित ({label})', + subtitles: 'उपशीर्षके', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/nb.ts b/packages/core/src/core/i18n/locales/nb.ts index e9f9f981..3bed59b0 100644 --- a/packages/core/src/core/i18n/locales/nb.ts +++ b/packages/core/src/core/i18n/locales/nb.ts @@ -1,68 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Spill', - Pause: 'Pause', - Replay: 'Spill om igjen', - Mute: 'Lyd av', - Unmute: 'Lyd på', - 'Seek forward {seconds} seconds': 'Hopp frem {seconds} sekunder', - 'Seek backward {seconds} seconds': 'Hopp tilbake {seconds} sekunder', - 'Enter fullscreen': 'Fullskjerm', - 'Enable captions': 'Slå på teksting', - 'Disable captions': 'Slå av teksting', - 'Enter picture-in-picture': 'Bilde-i-bilde', - 'Exit picture-in-picture': 'Avslutt bilde-i-bilde', - 'Playing live': 'Spiller live', - 'Seek to live edge': 'Gå til live', - Live: 'Direkte', - 'Start casting': 'Start sending', - 'Stop casting': 'Stopp sending', - Connecting: 'Kobler til', - Seek: 'Spol', - 'Current time': 'Aktuell tid', - Duration: 'Varighet', - Remaining: 'Gjenstående tid', - '{duration} remaining': '{duration} igjen', - '{duration}. Show elapsed time.': '{duration}. Vis avspilt tid.', - '{duration}. Show duration.': '{duration}. Vis varighet.', - '{duration}. Show remaining time.': '{duration}. Vis gjenstående tid.', - 'Playback rate {rate}': 'Avspillingshastighet {rate}', - '{current} of {duration}': '{current} av {duration}', - '{percent}, muted': '{percent}, dempet', - Muted: 'Dempet', - Volume: 'Volum', - 'Volume {value}': 'Volum {value}', - 'Captions on': 'Teksting på', - 'Captions off': 'Teksting av', - Paused: 'Satt på pause', - Playing: 'Spiller', - Fullscreen: 'Fullskjerm', - 'Exit fullscreen': 'Avslutt fullskjerm', - 'Picture in picture': 'Bilde i bilde', - 'Exit picture in picture': 'Avslutt bilde i bilde', - 'You stopped media playback before it finished.': 'Du avbrøt avspillingen.', - 'This media could not be loaded due to a network or server issue.': 'En nettverksfeil avbrøt nedlasting av videoen.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Videoavspillingen ble avbrudt på grunn av ødelagte data eller fordi videoen ville gjøre noe som nettleseren din ikke har støtte for.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Videoen kunne ikke lastes ned, på grunn av nettverksfeil eller serverfeil, eller fordi formatet ikke er støttet.', - 'This media could not be played because it could not be decrypted.': - 'Mediefilen er kryptert og vi mangler nøkler for å dekryptere den.', - '': '', - 'Something went wrong.': 'Noe gikk galt.', - OK: 'Lukk', - 'An unexpected error occurred.': 'En feil oppstod. Vennligst prøv igjen.', - Settings: 'Innstillinger', - Quality: 'Kvalitet', - Audio: 'Lyd', - Default: 'Standard', - Speed: 'Hastighet', - Captions: 'Teksting', - 'Playback rate': 'Avspillingshastighet', - Back: 'Tilbake', - Off: 'Av', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Undertekster', -} as const satisfies Partial; + buttons: { + play: 'Spill', + pause: 'Pause', + replay: 'Spill om igjen', + mute: 'Lyd av', + unmute: 'Lyd på', + }, + seek: { + forward: 'Hopp frem {seconds} sekunder', + backward: 'Hopp tilbake {seconds} sekunder', + }, + fullscreen: { + enter: 'Fullskjerm', + exit: 'Avslutt fullskjerm', + }, + captions: { + enable: 'Slå på teksting', + disable: 'Slå av teksting', + }, + pip: { + enter: 'Bilde-i-bilde', + exit: 'Avslutt bilde-i-bilde', + }, + live: { + playing: 'Spiller live', + seekToEdge: 'Gå til live', + badge: 'Direkte', + }, + cast: { + start: 'Start sending', + stop: 'Stopp sending', + connecting: 'Kobler til', + }, + airplay: { + start: 'Start AirPlay', + stop: 'Stopp AirPlay', + }, + slider: { + seek: 'Spol', + }, + time: { + current: 'Aktuell tid', + duration: 'Varighet', + remaining: 'Gjenstående tid', + remainingSuffix: '{duration} igjen', + showElapsed: '{duration}. Vis avspilt tid.', + showDuration: '{duration}. Vis varighet.', + showRemaining: '{duration}. Vis gjenstående tid.', + position: '{current} av {duration}', + }, + playback: { + rate: 'Avspillingshastighet {rate}', + }, + volume: { + mutedValue: '{percent}, dempet', + muted: 'Dempet', + label: 'Volum', + value: 'Volum {value}', + }, + status: { + captionsOn: 'Teksting på', + captionsOff: 'Teksting av', + paused: 'Satt på pause', + playing: 'Spiller', + fullscreen: 'Fullskjerm', + pip: 'Bilde i bilde', + exitPip: 'Avslutt bilde i bilde', + }, + errors: { + aborted: 'Du avbrøt avspillingen.', + network: 'En nettverksfeil avbrøt nedlasting av videoen.', + decode: + 'Videoavspillingen ble avbrudt på grunn av ødelagte data eller fordi videoen ville gjøre noe som nettleseren din ikke har støtte for.', + source: + 'Videoen kunne ikke lastes ned, på grunn av nettverksfeil eller serverfeil, eller fordi formatet ikke er støttet.', + encrypted: 'Mediefilen er kryptert og vi mangler nøkler for å dekryptere den.', + title: 'Noe gikk galt.', + unexpected: 'En feil oppstod. Vennligst prøv igjen.', + }, + common: { + empty: '', + ok: 'Lukk', + }, + menu: { + settings: 'Innstillinger', + quality: 'Kvalitet', + audio: 'Lyd', + default: 'Standard', + speed: 'Hastighet', + captions: 'Teksting', + playbackRate: 'Avspillingshastighet', + back: 'Tilbake', + off: 'Av', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Undertekster', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/ne.ts b/packages/core/src/core/i18n/locales/ne.ts index 970d9373..4271e201 100644 --- a/packages/core/src/core/i18n/locales/ne.ts +++ b/packages/core/src/core/i18n/locales/ne.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'चलाउनु', - Pause: 'रोक्नु', - Replay: 'फेरि चलाउनु', - Mute: 'म्यूट गर्नुहोस्', - Unmute: 'अनम्यूट गर्नुहोस्', - 'Seek forward {seconds} seconds': '{seconds} सेकेन्ड अगाडि सार्नुहोस्', - 'Seek backward {seconds} seconds': '{seconds} सेकेन्ड पछाडि सार्नुहोस्', - 'Enter fullscreen': 'पूर्ण स्क्रिन', - 'Enable captions': 'क्याप्शन', - 'Disable captions': 'क्याप्शन बंद', - 'Enter picture-in-picture': 'पिक्चर-इन-पिक्चर', - 'Exit picture-in-picture': 'पिक्चर-इन-पिक्चरबाट बाहिर निस्कनुहोस्', - 'Playing live': 'लाइभ चलिरहेको छ', - 'Seek to live edge': 'लाइभमा जानुहोस्', - Live: 'लाइव', - 'Start casting': 'कास्टिंग सुरू गर्नुहोस्', - 'Stop casting': 'कास्टिंग रोक्नुहोस्', - Connecting: 'जडान हुँदैछ', - Seek: 'खोज', - 'Current time': 'हालको समय', - Duration: 'अवधि', - Remaining: 'बाँकी समय', - '{duration} remaining': '{duration} बाँकी', - '{duration}. Show elapsed time.': '{duration}. बितेको समय देखाउनुहोस्.', - '{duration}. Show duration.': '{duration}. अवधि देखाउनुहोस्.', - '{duration}. Show remaining time.': '{duration}. बाँकी समय देखाउनुहोस्.', - 'Playback rate {rate}': 'प्लेब्याक दर {rate}', - '{current} of {duration}': '{duration} मध्ये {current}', - '{percent}, muted': '{percent}, म्यूट', - Muted: 'म्यूट', - Volume: 'भोल्युम', - 'Volume {value}': 'भोल्युम {value}', - 'Captions on': 'क्याप्शन चालू', - 'Captions off': 'क्याप्शन बंद', - Paused: 'रोकिएको', - Playing: 'चलिरहेको', - Fullscreen: 'पूर्ण स्क्रिन', - 'Exit fullscreen': 'पूर्ण स्क्रिनबाट बाहिर', - 'Picture in picture': 'पिक्चर इन पिक्चर', - 'Exit picture in picture': 'पिक्चर इन पिक्चरबाट बाहिर', - 'You stopped media playback before it finished.': 'तपाईंले मिडिया प्लेब्याक रद्द गर्नुभयो', - 'This media could not be loaded due to a network or server issue.': - 'नेटवर्क त्रुटिले मिडिया डाउनलोडलाई आधा मार्गमा असफल गर्यो।', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'मिडिया प्लेब्याक अवरुद्ध गरियो, कारण मिडिया दूषित भयो वा तपाईंको ब्राउजरले समर्थन नगरेको सुविधाहरू प्रयोग गर्यो।', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'मिडिया लोड गर्न सकिएन, नेटवर्क वा सर्भर विफल भयो वा त्यसको प्रारूप समर्थित छैन।', - 'This media could not be played because it could not be decrypted.': - 'मिडिया एन्क्रिप्ट गरिएको छ र हामीसँग डिक्रिप्ट गर्ने कुञ्जीहरू छैनन्।', - '': '', - 'Something went wrong.': 'केही गलत भयो।', - OK: 'बन्द गर्नुहोस्', - 'An unexpected error occurred.': 'एउटा त्रुटि भयो। कृपया पुन: प्रयास गर्नुहोस्।', - Settings: 'सेटिङहरू', - Quality: 'गुणस्तर', - Audio: 'अडियो', - Default: 'पूर्वनिर्धारित', - Speed: 'गति', - Captions: 'क्याप्शन', - 'Playback rate': 'प्लेब्याक दर', - Back: 'पछाडि', - Off: 'बन्द', - Auto: 'स्वतः', - 'Auto ({label})': 'स्वतः ({label})', - Subtitles: 'उपशीर्षक', -} as const satisfies Partial; + buttons: { + play: 'चलाउनु', + pause: 'रोक्नु', + replay: 'फेरि चलाउनु', + mute: 'म्यूट गर्नुहोस्', + unmute: 'अनम्यूट गर्नुहोस्', + }, + seek: { + forward: '{seconds} सेकेन्ड अगाडि सार्नुहोस्', + backward: '{seconds} सेकेन्ड पछाडि सार्नुहोस्', + }, + fullscreen: { + enter: 'पूर्ण स्क्रिन', + exit: 'पूर्ण स्क्रिनबाट बाहिर', + }, + captions: { + enable: 'क्याप्शन', + disable: 'क्याप्शन बंद', + }, + pip: { + enter: 'पिक्चर-इन-पिक्चर', + exit: 'पिक्चर-इन-पिक्चरबाट बाहिर निस्कनुहोस्', + }, + live: { + playing: 'लाइभ चलिरहेको छ', + seekToEdge: 'लाइभमा जानुहोस्', + badge: 'लाइव', + }, + cast: { + start: 'कास्टिंग सुरू गर्नुहोस्', + stop: 'कास्टिंग रोक्नुहोस्', + connecting: 'जडान हुँदैछ', + }, + airplay: { + start: 'AirPlay सुरु गर्नुहोस्', + stop: 'AirPlay रोक्नुहोस्', + }, + slider: { + seek: 'खोज', + }, + time: { + current: 'हालको समय', + duration: 'अवधि', + remaining: 'बाँकी समय', + remainingSuffix: '{duration} बाँकी', + showElapsed: '{duration}. बितेको समय देखाउनुहोस्.', + showDuration: '{duration}. अवधि देखाउनुहोस्.', + showRemaining: '{duration}. बाँकी समय देखाउनुहोस्.', + position: '{duration} मध्ये {current}', + }, + playback: { + rate: 'प्लेब्याक दर {rate}', + }, + volume: { + mutedValue: '{percent}, म्यूट', + muted: 'म्यूट', + label: 'भोल्युम', + value: 'भोल्युम {value}', + }, + status: { + captionsOn: 'क्याप्शन चालू', + captionsOff: 'क्याप्शन बंद', + paused: 'रोकिएको', + playing: 'चलिरहेको', + fullscreen: 'पूर्ण स्क्रिन', + pip: 'पिक्चर इन पिक्चर', + exitPip: 'पिक्चर इन पिक्चरबाट बाहिर', + }, + errors: { + aborted: 'तपाईंले मिडिया प्लेब्याक रद्द गर्नुभयो', + network: 'नेटवर्क त्रुटिले मिडिया डाउनलोडलाई आधा मार्गमा असफल गर्यो।', + decode: 'मिडिया प्लेब्याक अवरुद्ध गरियो, कारण मिडिया दूषित भयो वा तपाईंको ब्राउजरले समर्थन नगरेको सुविधाहरू प्रयोग गर्यो।', + source: 'मिडिया लोड गर्न सकिएन, नेटवर्क वा सर्भर विफल भयो वा त्यसको प्रारूप समर्थित छैन।', + encrypted: 'मिडिया एन्क्रिप्ट गरिएको छ र हामीसँग डिक्रिप्ट गर्ने कुञ्जीहरू छैनन्।', + title: 'केही गलत भयो।', + unexpected: 'एउटा त्रुटि भयो। कृपया पुन: प्रयास गर्नुहोस्।', + }, + common: { + empty: '', + ok: 'बन्द गर्नुहोस्', + }, + menu: { + settings: 'सेटिङहरू', + quality: 'गुणस्तर', + audio: 'अडियो', + default: 'पूर्वनिर्धारित', + speed: 'गति', + captions: 'क्याप्शन', + playbackRate: 'प्लेब्याक दर', + back: 'पछाडि', + off: 'बन्द', + auto: 'स्वतः', + autoWithLabel: 'स्वतः ({label})', + subtitles: 'उपशीर्षक', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/nl.ts b/packages/core/src/core/i18n/locales/nl.ts index 845fe948..e30f376c 100644 --- a/packages/core/src/core/i18n/locales/nl.ts +++ b/packages/core/src/core/i18n/locales/nl.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Afspelen', - Pause: 'Pauzeren', - Replay: 'Opnieuw afspelen', - Mute: 'Dempen', - Unmute: 'Dempen uit', - 'Seek forward {seconds} seconds': '{seconds} seconden vooruit', - 'Seek backward {seconds} seconds': '{seconds} seconden terug', - 'Enter fullscreen': 'Volledig scherm', - 'Enable captions': 'Ondertiteling inschakelen', - 'Disable captions': 'Ondertiteling uitschakelen', - 'Enter picture-in-picture': 'Picture-in-Picture', - 'Exit picture-in-picture': 'Picture-in-Picture uit', - 'Playing live': 'Speelt live', - 'Seek to live edge': 'Ga naar live', - Live: 'Live', - 'Start casting': 'Casten starten', - 'Stop casting': 'Casten stoppen', - Connecting: 'Verbinden', - Seek: 'Spoelen', - 'Current time': 'Huidige tijd', - Duration: 'Tijdsduur', - Remaining: 'Resterende tijd', - '{duration} remaining': 'Nog {duration}', - '{duration}. Show elapsed time.': '{duration}. Verstreken tijd tonen.', - '{duration}. Show duration.': '{duration}. Duur tonen.', - '{duration}. Show remaining time.': '{duration}. Resterende tijd tonen.', - 'Playback rate {rate}': 'Afspeelsnelheid {rate}', - '{current} of {duration}': '{current} van {duration}', - '{percent}, muted': '{percent}, gedempt', - Muted: 'Gedempt', - Volume: 'Geluidsniveau', - 'Volume {value}': 'Geluidsniveau {value}', - 'Captions on': 'Ondertiteling aan', - 'Captions off': 'Ondertiteling uit', - Paused: 'Gepauzeerd', - Playing: 'Wordt afgespeeld', - Fullscreen: 'Volledig scherm', - 'Exit fullscreen': 'Volledig scherm verlaten', - 'Picture in picture': 'Beeld-in-beeld', - 'Exit picture in picture': 'Beeld-in-beeld verlaten', - 'You stopped media playback before it finished.': 'U heeft het afspelen van de media afgebroken', - 'This media could not be loaded due to a network or server issue.': - 'Een netwerkfout heeft ervoor gezorgd dat het downloaden van de media is mislukt.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Het afspelen van de media werd afgebroken vanwege een corruptieprobleem of omdat de uw browser de gebruikte mediafuncties niet ondersteund.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'De media kon niet worden geladen, doordat de server of het netwerk faalde of doordat het formaat niet wordt ondersteund.', - 'This media could not be played because it could not be decrypted.': - 'De media is gecodeerd en we hebben niet de sleutels om het te decoderen.', - '': '', - 'Something went wrong.': 'Er is iets misgegaan.', - OK: 'Sluiten', - 'An unexpected error occurred.': 'Er is een fout opgetreden. Probeer het opnieuw.', - Settings: 'Instellingen', - Quality: 'Kwaliteit', - Audio: 'Geluid', - Default: 'Standaard', - Speed: 'Snelheid', - Captions: 'Ondertiteling', - 'Playback rate': 'Afspeelsnelheid', - Back: 'Terug', - Off: 'Uit', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Ondertiteling', -} as const satisfies Partial; + buttons: { + play: 'Afspelen', + pause: 'Pauzeren', + replay: 'Opnieuw afspelen', + mute: 'Dempen', + unmute: 'Dempen uit', + }, + seek: { + forward: '{seconds} seconden vooruit', + backward: '{seconds} seconden terug', + }, + fullscreen: { + enter: 'Volledig scherm', + exit: 'Volledig scherm verlaten', + }, + captions: { + enable: 'Ondertiteling inschakelen', + disable: 'Ondertiteling uitschakelen', + }, + pip: { + enter: 'Picture-in-Picture', + exit: 'Picture-in-Picture uit', + }, + live: { + playing: 'Speelt live', + seekToEdge: 'Ga naar live', + badge: 'Live', + }, + cast: { + start: 'Casten starten', + stop: 'Casten stoppen', + connecting: 'Verbinden', + }, + airplay: { + start: 'AirPlay starten', + stop: 'AirPlay stoppen', + }, + slider: { + seek: 'Spoelen', + }, + time: { + current: 'Huidige tijd', + duration: 'Tijdsduur', + remaining: 'Resterende tijd', + remainingSuffix: 'Nog {duration}', + showElapsed: '{duration}. Verstreken tijd tonen.', + showDuration: '{duration}. Duur tonen.', + showRemaining: '{duration}. Resterende tijd tonen.', + position: '{current} van {duration}', + }, + playback: { + rate: 'Afspeelsnelheid {rate}', + }, + volume: { + mutedValue: '{percent}, gedempt', + muted: 'Gedempt', + label: 'Geluidsniveau', + value: 'Geluidsniveau {value}', + }, + status: { + captionsOn: 'Ondertiteling aan', + captionsOff: 'Ondertiteling uit', + paused: 'Gepauzeerd', + playing: 'Wordt afgespeeld', + fullscreen: 'Volledig scherm', + pip: 'Beeld-in-beeld', + exitPip: 'Beeld-in-beeld verlaten', + }, + errors: { + aborted: 'U heeft het afspelen van de media afgebroken', + network: 'Een netwerkfout heeft ervoor gezorgd dat het downloaden van de media is mislukt.', + decode: + 'Het afspelen van de media werd afgebroken vanwege een corruptieprobleem of omdat de uw browser de gebruikte mediafuncties niet ondersteund.', + source: + 'De media kon niet worden geladen, doordat de server of het netwerk faalde of doordat het formaat niet wordt ondersteund.', + encrypted: 'De media is gecodeerd en we hebben niet de sleutels om het te decoderen.', + title: 'Er is iets misgegaan.', + unexpected: 'Er is een fout opgetreden. Probeer het opnieuw.', + }, + common: { + empty: '', + ok: 'Sluiten', + }, + menu: { + settings: 'Instellingen', + quality: 'Kwaliteit', + audio: 'Geluid', + default: 'Standaard', + speed: 'Snelheid', + captions: 'Ondertiteling', + playbackRate: 'Afspeelsnelheid', + back: 'Terug', + off: 'Uit', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Ondertiteling', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/nn.ts b/packages/core/src/core/i18n/locales/nn.ts index 8acb3e99..82c0beae 100644 --- a/packages/core/src/core/i18n/locales/nn.ts +++ b/packages/core/src/core/i18n/locales/nn.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Spel', - Pause: 'Pause', - Replay: 'Spel om att', - Mute: 'Lyd av', - Unmute: 'Lyd på', - 'Seek forward {seconds} seconds': 'Hopp fram {seconds} sekund', - 'Seek backward {seconds} seconds': 'Hopp tilbake {seconds} sekund', - 'Enter fullscreen': 'Fullskjerm', - 'Enable captions': 'Slå på teksting', - 'Disable captions': 'Slå av teksting', - 'Enter picture-in-picture': 'Bilete-i-bilete', - 'Exit picture-in-picture': 'Avslutt bilete-i-bilete', - 'Playing live': 'Spelar live', - 'Seek to live edge': 'Hopp til live', - Live: 'Direkte', - 'Start casting': 'Start sending', - 'Stop casting': 'Stopp sending', - Connecting: 'Koplar til', - Seek: 'Spol', - 'Current time': 'Aktuell tid', - Duration: 'Varigheit', - Remaining: 'Tid attende', - '{duration} remaining': '{duration} att', - '{duration}. Show elapsed time.': '{duration}. Vis avspelt tid.', - '{duration}. Show duration.': '{duration}. Vis lengd.', - '{duration}. Show remaining time.': '{duration}. Vis tid att.', - 'Playback rate {rate}': 'Avspelingshastigheit {rate}', - '{current} of {duration}': '{current} av {duration}', - '{percent}, muted': '{percent}, dempa', - Muted: 'Dempa', - Volume: 'Volum', - 'Volume {value}': 'Volum {value}', - 'Captions on': 'Teksting på', - 'Captions off': 'Teksting av', - Paused: 'Satt på pause', - Playing: 'Spelar', - Fullscreen: 'Fullskjerm', - 'Exit fullscreen': 'Stenga fullskjerm', - 'Picture in picture': 'Bilete i bilete', - 'Exit picture in picture': 'Avslutt bilete i bilete', - 'You stopped media playback before it finished.': 'Du avbraut avspelinga.', - 'This media could not be loaded due to a network or server issue.': - 'Ein nettverksfeil avbraut nedlasting av videoen.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Videoavspelinga blei broten på grunn av øydelagde data eller av di videoen ville gjera noe som nettlesaren din ikkje stodar.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Videoen kunne ikkje lastas ned, på grunn av ein nettverksfeil eller serverfeil, eller av di formatet ikkje er stoda.', - 'This media could not be played because it could not be decrypted.': - 'Mediefila er kryptert og vi manglar nyklar for å dekryptere ho.', - '': '', - 'Something went wrong.': 'Noko gjekk gale.', - OK: 'Lukk', - 'An unexpected error occurred.': 'Det oppstod ein feil. Ver venleg prøv igjen.', - Settings: 'Innstillingar', - Quality: 'Kvalitet', - Audio: 'Lyd', - Default: 'Standard', - Speed: 'Fart', - Captions: 'Teksting', - 'Playback rate': 'Avspelingshastigheit', - Back: 'Tilbake', - Off: 'Av', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Undertekstar', -} as const satisfies Partial; + buttons: { + play: 'Spel', + pause: 'Pause', + replay: 'Spel om att', + mute: 'Lyd av', + unmute: 'Lyd på', + }, + seek: { + forward: 'Hopp fram {seconds} sekund', + backward: 'Hopp tilbake {seconds} sekund', + }, + fullscreen: { + enter: 'Fullskjerm', + exit: 'Stenga fullskjerm', + }, + captions: { + enable: 'Slå på teksting', + disable: 'Slå av teksting', + }, + pip: { + enter: 'Bilete-i-bilete', + exit: 'Avslutt bilete-i-bilete', + }, + live: { + playing: 'Spelar live', + seekToEdge: 'Hopp til live', + badge: 'Direkte', + }, + cast: { + start: 'Start sending', + stop: 'Stopp sending', + connecting: 'Koplar til', + }, + airplay: { + start: 'Start AirPlay', + stop: 'Stopp AirPlay', + }, + slider: { + seek: 'Spol', + }, + time: { + current: 'Aktuell tid', + duration: 'Varigheit', + remaining: 'Tid attende', + remainingSuffix: '{duration} att', + showElapsed: '{duration}. Vis avspelt tid.', + showDuration: '{duration}. Vis lengd.', + showRemaining: '{duration}. Vis tid att.', + position: '{current} av {duration}', + }, + playback: { + rate: 'Avspelingshastigheit {rate}', + }, + volume: { + mutedValue: '{percent}, dempa', + muted: 'Dempa', + label: 'Volum', + value: 'Volum {value}', + }, + status: { + captionsOn: 'Teksting på', + captionsOff: 'Teksting av', + paused: 'Satt på pause', + playing: 'Spelar', + fullscreen: 'Fullskjerm', + pip: 'Bilete i bilete', + exitPip: 'Avslutt bilete i bilete', + }, + errors: { + aborted: 'Du avbraut avspelinga.', + network: 'Ein nettverksfeil avbraut nedlasting av videoen.', + decode: + 'Videoavspelinga blei broten på grunn av øydelagde data eller av di videoen ville gjera noe som nettlesaren din ikkje stodar.', + source: + 'Videoen kunne ikkje lastas ned, på grunn av ein nettverksfeil eller serverfeil, eller av di formatet ikkje er stoda.', + encrypted: 'Mediefila er kryptert og vi manglar nyklar for å dekryptere ho.', + title: 'Noko gjekk gale.', + unexpected: 'Det oppstod ein feil. Ver venleg prøv igjen.', + }, + common: { + empty: '', + ok: 'Lukk', + }, + menu: { + settings: 'Innstillingar', + quality: 'Kvalitet', + audio: 'Lyd', + default: 'Standard', + speed: 'Fart', + captions: 'Teksting', + playbackRate: 'Avspelingshastigheit', + back: 'Tilbake', + off: 'Av', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Undertekstar', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/oc.ts b/packages/core/src/core/i18n/locales/oc.ts index f607729b..d0a46cba 100644 --- a/packages/core/src/core/i18n/locales/oc.ts +++ b/packages/core/src/core/i18n/locales/oc.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Lectura', - Pause: 'Pausa', - Replay: 'Tornar legir', - Mute: 'Copar lo son', - Unmute: 'Restablir lo son', - 'Seek forward {seconds} seconds': 'Avançar de {seconds} segondas', - 'Seek backward {seconds} seconds': 'Recular de {seconds} segondas', - 'Enter fullscreen': 'Ecran complèt', - 'Enable captions': 'Activar los subtítols', - 'Disable captions': 'Desactivar los subtítols', - 'Enter picture-in-picture': 'Vidèo incrustada', - 'Exit picture-in-picture': 'Sortir de la vidèo incrustada', - 'Playing live': 'Lectura dirècta', - 'Seek to live edge': 'Anar al dirècte', - Live: 'Dirècte', - 'Start casting': 'Anar en dirècte', - 'Stop casting': 'Aturar la difusion', - Connecting: 'Connexion en cors', - Seek: 'Desfilament', - 'Current time': 'Durada passada', - Duration: 'Durada', - Remaining: 'Temps restant', - '{duration} remaining': 'Demòra {duration}', - '{duration}. Show elapsed time.': '{duration}. Afichar lo temps passat.', - '{duration}. Show duration.': '{duration}. Afichar la durada.', - '{duration}. Show remaining time.': '{duration}. Afichar lo temps que demòra.', - 'Playback rate {rate}': 'Velocitat de lectura {rate}', - '{current} of {duration}': '{current} sus {duration}', - '{percent}, muted': '{percent}, silenciat', - Muted: 'Silenciat', - Volume: 'Volum', - 'Volume {value}': 'Volum {value}', - 'Captions on': 'Legendas activadas', - 'Captions off': 'Legendas desactivadas', - Paused: 'En pausa', - Playing: 'En lectura', - Fullscreen: 'Ecran complèt', - 'Exit fullscreen': "Sortir de l'ecran complèt", - 'Picture in picture': 'Vidèo incrustada', - 'Exit picture in picture': 'Sortir de la vidèo incrustada', - 'You stopped media playback before it finished.': 'Avètz copat la lectura del mèdia.', - 'This media could not be loaded due to a network or server issue.': - 'Una error de ret a provocat un fracàs del telecargament.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - "La lectura del mèdia es copada a causa d'un problèma de corrupcion o perque lo mèdia utiliza de foncionalitats pas suportadas pel navigador.", - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Lo mèdia a pas pogut èsser cargat, siá perque lo servidor o lo ret a fracassat siá perque lo format es pas compatible.', - 'This media could not be played because it could not be decrypted.': - 'Lo mèdia es chifrat e avèm pas las claus per lo deschifrar.', - '': '', - 'Something went wrong.': "Quaucarèn s'es mal passat.", - OK: 'Tampar', - 'An unexpected error occurred.': "Una error s'es produsida. Provatz d'un autre còp.", - Settings: 'Paramètres', - Quality: 'Qualitat', - Audio: 'Àudio', - Default: 'Per defaut', - Speed: 'Velocitat', - Captions: 'Legendas', - 'Playback rate': 'Velocitat de lectura', - Back: 'Retorn', - Off: 'Desactivat', - Auto: 'Automatic', - 'Auto ({label})': 'Automatic ({label})', - Subtitles: 'Sostítols', -} as const satisfies Partial; + buttons: { + play: 'Lectura', + pause: 'Pausa', + replay: 'Tornar legir', + mute: 'Copar lo son', + unmute: 'Restablir lo son', + }, + seek: { + forward: 'Avançar de {seconds} segondas', + backward: 'Recular de {seconds} segondas', + }, + fullscreen: { + enter: 'Ecran complèt', + exit: "Sortir de l'ecran complèt", + }, + captions: { + enable: 'Activar los subtítols', + disable: 'Desactivar los subtítols', + }, + pip: { + enter: 'Vidèo incrustada', + exit: 'Sortir de la vidèo incrustada', + }, + live: { + playing: 'Lectura dirècta', + seekToEdge: 'Anar al dirècte', + badge: 'Dirècte', + }, + cast: { + start: 'Anar en dirècte', + stop: 'Aturar la difusion', + connecting: 'Connexion en cors', + }, + airplay: { + start: 'Aviar AirPlay', + stop: 'Arrestar AirPlay', + }, + slider: { + seek: 'Desfilament', + }, + time: { + current: 'Durada passada', + duration: 'Durada', + remaining: 'Temps restant', + remainingSuffix: 'Demòra {duration}', + showElapsed: '{duration}. Afichar lo temps passat.', + showDuration: '{duration}. Afichar la durada.', + showRemaining: '{duration}. Afichar lo temps que demòra.', + position: '{current} sus {duration}', + }, + playback: { + rate: 'Velocitat de lectura {rate}', + }, + volume: { + mutedValue: '{percent}, silenciat', + muted: 'Silenciat', + label: 'Volum', + value: 'Volum {value}', + }, + status: { + captionsOn: 'Legendas activadas', + captionsOff: 'Legendas desactivadas', + paused: 'En pausa', + playing: 'En lectura', + fullscreen: 'Ecran complèt', + pip: 'Vidèo incrustada', + exitPip: 'Sortir de la vidèo incrustada', + }, + errors: { + aborted: 'Avètz copat la lectura del mèdia.', + network: 'Una error de ret a provocat un fracàs del telecargament.', + decode: + "La lectura del mèdia es copada a causa d'un problèma de corrupcion o perque lo mèdia utiliza de foncionalitats pas suportadas pel navigador.", + source: + 'Lo mèdia a pas pogut èsser cargat, siá perque lo servidor o lo ret a fracassat siá perque lo format es pas compatible.', + encrypted: 'Lo mèdia es chifrat e avèm pas las claus per lo deschifrar.', + title: "Quaucarèn s'es mal passat.", + unexpected: "Una error s'es produsida. Provatz d'un autre còp.", + }, + common: { + empty: '', + ok: 'Tampar', + }, + menu: { + settings: 'Paramètres', + quality: 'Qualitat', + audio: 'Àudio', + default: 'Per defaut', + speed: 'Velocitat', + captions: 'Legendas', + playbackRate: 'Velocitat de lectura', + back: 'Retorn', + off: 'Desactivat', + auto: 'Automatic', + autoWithLabel: 'Automatic ({label})', + subtitles: 'Sostítols', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/pl.ts b/packages/core/src/core/i18n/locales/pl.ts index 667ff685..b41243c5 100644 --- a/packages/core/src/core/i18n/locales/pl.ts +++ b/packages/core/src/core/i18n/locales/pl.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Odtwórz', - Pause: 'Wstrzymaj', - Replay: 'Odtwórz ponownie', - Mute: 'Wycisz', - Unmute: 'Wyłącz wyciszenie', - 'Seek forward {seconds} seconds': 'Przewiń do przodu o {seconds} s', - 'Seek backward {seconds} seconds': 'Przewiń do tyłu o {seconds} s', - 'Enter fullscreen': 'Pełny ekran', - 'Enable captions': 'Włącz napisy', - 'Disable captions': 'Wyłącz napisy', - 'Enter picture-in-picture': 'Obraz w obrazie', - 'Exit picture-in-picture': 'Wyjdź z trybu obraz w obrazie', - 'Playing live': 'Odtwarzanie na żywo', - 'Seek to live edge': 'Przejdź na transmisję na żywo', - Live: 'Na żywo', - 'Start casting': 'Rozpocznij przesyłanie', - 'Stop casting': 'Zatrzymaj przesyłanie', - Connecting: 'Łączenie', - Seek: 'Przewijanie', - 'Current time': 'Aktualny czas', - Duration: 'Czas trwania', - Remaining: 'Pozostały czas', - '{duration} remaining': 'Pozostało {duration}', - '{duration}. Show elapsed time.': '{duration}. Pokaż upływ czasu.', - '{duration}. Show duration.': '{duration}. Pokaż czas trwania.', - '{duration}. Show remaining time.': '{duration}. Pokaż pozostały czas.', - 'Playback rate {rate}': 'Prędkość odtwarzania {rate}', - '{current} of {duration}': '{current} z {duration}', - '{percent}, muted': '{percent}, wyciszono', - Muted: 'Wyciszono', - Volume: 'Głośność', - 'Volume {value}': 'Głośność {value}', - 'Captions on': 'Napisy włączone', - 'Captions off': 'Napisy wyłączone', - Paused: 'Wstrzymano', - Playing: 'Odtwarzanie', - Fullscreen: 'Pełny ekran', - 'Exit fullscreen': 'Wyjdź z pełnego ekranu', - 'Picture in picture': 'Obraz w obrazie', - 'Exit picture in picture': 'Wyjdź z obrazu w obrazie', - 'You stopped media playback before it finished.': 'Odtwarzanie zostało przerwane', - 'This media could not be loaded due to a network or server issue.': - 'Błąd sieci spowodował częściowe niepowodzenie pobierania materiału wideo.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Odtwarzanie materiału wideo zostało przerwane z powodu uszkodzonego pliku wideo lub z powodu użycia funkcji multimediów nieobsługiwanych przez Twoją przeglądarkę.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Materiał wideo nie może zostać załadowany, ponieważ wystąpił problem z serwerem lub siecią albo format materiału wideo nie jest obsługiwany', - 'This media could not be played because it could not be decrypted.': - 'Materiał jest zaszyfrowany, a nie mamy kluczy do jego odszyfrowania.', - '': '', - 'Something went wrong.': 'Coś poszło nie tak.', - OK: 'Zamknij', - 'An unexpected error occurred.': 'Wystąpił błąd. Spróbuj ponownie.', - Settings: 'Ustawienia', - Quality: 'Jakość', - Audio: 'Dźwięk', - Default: 'Domyślne', - Speed: 'Szybkość', - Captions: 'Napisy', - 'Playback rate': 'Prędkość odtwarzania', - Back: 'Wstecz', - Off: 'Wyłączone', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Napisy', -} as const satisfies Partial; + buttons: { + play: 'Odtwórz', + pause: 'Wstrzymaj', + replay: 'Odtwórz ponownie', + mute: 'Wycisz', + unmute: 'Wyłącz wyciszenie', + }, + seek: { + forward: 'Przewiń do przodu o {seconds} s', + backward: 'Przewiń do tyłu o {seconds} s', + }, + fullscreen: { + enter: 'Pełny ekran', + exit: 'Wyjdź z pełnego ekranu', + }, + captions: { + enable: 'Włącz napisy', + disable: 'Wyłącz napisy', + }, + pip: { + enter: 'Obraz w obrazie', + exit: 'Wyjdź z trybu obraz w obrazie', + }, + live: { + playing: 'Odtwarzanie na żywo', + seekToEdge: 'Przejdź na transmisję na żywo', + badge: 'Na żywo', + }, + cast: { + start: 'Rozpocznij przesyłanie', + stop: 'Zatrzymaj przesyłanie', + connecting: 'Łączenie', + }, + airplay: { + start: 'Uruchom AirPlay', + stop: 'Zatrzymaj AirPlay', + }, + slider: { + seek: 'Przewijanie', + }, + time: { + current: 'Aktualny czas', + duration: 'Czas trwania', + remaining: 'Pozostały czas', + remainingSuffix: 'Pozostało {duration}', + showElapsed: '{duration}. Pokaż upływ czasu.', + showDuration: '{duration}. Pokaż czas trwania.', + showRemaining: '{duration}. Pokaż pozostały czas.', + position: '{current} z {duration}', + }, + playback: { + rate: 'Prędkość odtwarzania {rate}', + }, + volume: { + mutedValue: '{percent}, wyciszono', + muted: 'Wyciszono', + label: 'Głośność', + value: 'Głośność {value}', + }, + status: { + captionsOn: 'Napisy włączone', + captionsOff: 'Napisy wyłączone', + paused: 'Wstrzymano', + playing: 'Odtwarzanie', + fullscreen: 'Pełny ekran', + pip: 'Obraz w obrazie', + exitPip: 'Wyjdź z obrazu w obrazie', + }, + errors: { + aborted: 'Odtwarzanie zostało przerwane', + network: 'Błąd sieci spowodował częściowe niepowodzenie pobierania materiału wideo.', + decode: + 'Odtwarzanie materiału wideo zostało przerwane z powodu uszkodzonego pliku wideo lub z powodu użycia funkcji multimediów nieobsługiwanych przez Twoją przeglądarkę.', + source: + 'Materiał wideo nie może zostać załadowany, ponieważ wystąpił problem z serwerem lub siecią albo format materiału wideo nie jest obsługiwany', + encrypted: 'Materiał jest zaszyfrowany, a nie mamy kluczy do jego odszyfrowania.', + title: 'Coś poszło nie tak.', + unexpected: 'Wystąpił błąd. Spróbuj ponownie.', + }, + common: { + empty: '', + ok: 'Zamknij', + }, + menu: { + settings: 'Ustawienia', + quality: 'Jakość', + audio: 'Dźwięk', + default: 'Domyślne', + speed: 'Szybkość', + captions: 'Napisy', + playbackRate: 'Prędkość odtwarzania', + back: 'Wstecz', + off: 'Wyłączone', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Napisy', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/pt-BR.ts b/packages/core/src/core/i18n/locales/pt-BR.ts index 7bc54ad9..224f3483 100644 --- a/packages/core/src/core/i18n/locales/pt-BR.ts +++ b/packages/core/src/core/i18n/locales/pt-BR.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Tocar', - Pause: 'Pausar', - Replay: 'Tocar novamente', - Mute: 'Mudo', - Unmute: 'Ativar o som', - 'Seek forward {seconds} seconds': 'Avançar {seconds} segundos', - 'Seek backward {seconds} seconds': 'Retroceder {seconds} segundos', - 'Enter fullscreen': 'Tela Cheia', - 'Enable captions': 'Ativar legendas', - 'Disable captions': 'Desativar legendas', - 'Enter picture-in-picture': 'Picture-in-Picture', - 'Exit picture-in-picture': 'Sair de Picture-in-Picture', - 'Playing live': 'Reproduzindo ao vivo', - 'Seek to live edge': 'Ir para o ao vivo', - Live: 'Ao vivo', - 'Start casting': 'Iniciar transmissão', - 'Stop casting': 'Parar transmissão', - Connecting: 'Conectando', - Seek: 'Buscar', - 'Current time': 'Tempo', - Duration: 'Duração', - Remaining: 'Tempo Restante', - '{duration} remaining': 'Restam {duration}', - '{duration}. Show elapsed time.': '{duration}. Mostrar tempo decorrido.', - '{duration}. Show duration.': '{duration}. Mostrar duração.', - '{duration}. Show remaining time.': '{duration}. Mostrar tempo restante.', - 'Playback rate {rate}': 'Velocidade {rate}', - '{current} of {duration}': '{current} de {duration}', - '{percent}, muted': '{percent}, silenciado', - Muted: 'Silenciado', - Volume: 'Nível de volume', - 'Volume {value}': 'Nível de volume {value}', - 'Captions on': 'Legendas ativadas', - 'Captions off': 'Legendas desativadas', - Paused: 'Pausado', - Playing: 'Reproduzindo', - Fullscreen: 'Tela cheia', - 'Exit fullscreen': 'Sair da tela cheia', - 'Picture in picture': 'Picture-in-picture', - 'Exit picture in picture': 'Sair do picture-in-picture', - 'You stopped media playback before it finished.': 'Você parou a execução do vídeo.', - 'This media could not be loaded due to a network or server issue.': - 'Um erro na rede causou falha durante o download da mídia.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'A reprodução foi interrompida devido à um problema de mídia corrompida ou porque a mídia utiliza funções que seu navegador não suporta.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'A mídia não pode ser carregada, por uma falha de rede ou servidor ou o formato não é suportado.', - 'This media could not be played because it could not be decrypted.': - 'A mídia está criptografada e não temos as chaves para descriptografar.', - '': '', - 'Something went wrong.': 'Algo deu errado.', - OK: 'Fechar', - 'An unexpected error occurred.': 'Ocorreu um erro. Tente novamente.', - Settings: 'Configurações', - Quality: 'Qualidade', - Audio: 'Áudio', - Default: 'Padrão', - Speed: 'Velocidade', - Captions: 'Legendas', - 'Playback rate': 'Velocidade de reprodução', - Back: 'Voltar', - Off: 'Desativado', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Legendas', -} as const satisfies Partial; + buttons: { + play: 'Tocar', + pause: 'Pausar', + replay: 'Tocar novamente', + mute: 'Mudo', + unmute: 'Ativar o som', + }, + seek: { + forward: 'Avançar {seconds} segundos', + backward: 'Retroceder {seconds} segundos', + }, + fullscreen: { + enter: 'Tela Cheia', + exit: 'Sair da tela cheia', + }, + captions: { + enable: 'Ativar legendas', + disable: 'Desativar legendas', + }, + pip: { + enter: 'Picture-in-Picture', + exit: 'Sair de Picture-in-Picture', + }, + live: { + playing: 'Reproduzindo ao vivo', + seekToEdge: 'Ir para o ao vivo', + badge: 'Ao vivo', + }, + cast: { + start: 'Iniciar transmissão', + stop: 'Parar transmissão', + connecting: 'Conectando', + }, + airplay: { + start: 'Iniciar AirPlay', + stop: 'Parar AirPlay', + }, + slider: { + seek: 'Buscar', + }, + time: { + current: 'Tempo', + duration: 'Duração', + remaining: 'Tempo Restante', + remainingSuffix: 'Restam {duration}', + showElapsed: '{duration}. Mostrar tempo decorrido.', + showDuration: '{duration}. Mostrar duração.', + showRemaining: '{duration}. Mostrar tempo restante.', + position: '{current} de {duration}', + }, + playback: { + rate: 'Velocidade {rate}', + }, + volume: { + mutedValue: '{percent}, silenciado', + muted: 'Silenciado', + label: 'Nível de volume', + value: 'Nível de volume {value}', + }, + status: { + captionsOn: 'Legendas ativadas', + captionsOff: 'Legendas desativadas', + paused: 'Pausado', + playing: 'Reproduzindo', + fullscreen: 'Tela cheia', + pip: 'Picture-in-picture', + exitPip: 'Sair do picture-in-picture', + }, + errors: { + aborted: 'Você parou a execução do vídeo.', + network: 'Um erro na rede causou falha durante o download da mídia.', + decode: + 'A reprodução foi interrompida devido à um problema de mídia corrompida ou porque a mídia utiliza funções que seu navegador não suporta.', + source: 'A mídia não pode ser carregada, por uma falha de rede ou servidor ou o formato não é suportado.', + encrypted: 'A mídia está criptografada e não temos as chaves para descriptografar.', + title: 'Algo deu errado.', + unexpected: 'Ocorreu um erro. Tente novamente.', + }, + common: { + empty: '', + ok: 'Fechar', + }, + menu: { + settings: 'Configurações', + quality: 'Qualidade', + audio: 'Áudio', + default: 'Padrão', + speed: 'Velocidade', + captions: 'Legendas', + playbackRate: 'Velocidade de reprodução', + back: 'Voltar', + off: 'Desativado', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Legendas', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/pt-PT.ts b/packages/core/src/core/i18n/locales/pt-PT.ts index b4fba2a0..b1480c95 100644 --- a/packages/core/src/core/i18n/locales/pt-PT.ts +++ b/packages/core/src/core/i18n/locales/pt-PT.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Reproduzir', - Pause: 'Pausar', - Replay: 'Reiniciar', - Mute: 'Desativar som', - Unmute: 'Ativar som', - 'Seek forward {seconds} seconds': 'Avançar {seconds} segundos', - 'Seek backward {seconds} seconds': 'Recuar {seconds} segundos', - 'Enter fullscreen': 'Ecrã inteiro', - 'Enable captions': 'Ativar legendas', - 'Disable captions': 'Desativar legendas', - 'Enter picture-in-picture': 'Imagem em imagem', - 'Exit picture-in-picture': 'Sair de imagem em imagem', - 'Playing live': 'A reproduzir em direto', - 'Seek to live edge': 'Ir para o em direto', - Live: 'Em direto', - 'Start casting': 'Iniciar transmissão', - 'Stop casting': 'Parar transmissão', - Connecting: 'A ligar', - Seek: 'Procurar', - 'Current time': 'Tempo Atual', - Duration: 'Duração', - Remaining: 'Tempo Restante', - '{duration} remaining': 'Restam {duration}', - '{duration}. Show elapsed time.': '{duration}. Mostrar tempo decorrido.', - '{duration}. Show duration.': '{duration}. Mostrar duração.', - '{duration}. Show remaining time.': '{duration}. Mostrar tempo restante.', - 'Playback rate {rate}': 'Velocidade de reprodução {rate}', - '{current} of {duration}': '{current} de {duration}', - '{percent}, muted': '{percent}, sem som', - Muted: 'Sem som', - Volume: 'Nível de volume', - 'Volume {value}': 'Nível de volume {value}', - 'Captions on': 'Legendas ativas', - 'Captions off': 'Legendas desativadas', - Paused: 'Em pausa', - Playing: 'A reproduzir', - Fullscreen: 'Ecrã inteiro', - 'Exit fullscreen': 'Sair de ecrã inteiro', - 'Picture in picture': 'Imagem em imagem', - 'Exit picture in picture': 'Sair de imagem em imagem', - 'You stopped media playback before it finished.': 'Parou a reprodução do vídeo.', - 'This media could not be loaded due to a network or server issue.': - 'Um erro na rede fez o vídeo falhar parcialmente.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'A reprodução foi interrompida por um problema com o vídeo ou porque o formato não é compatível com o seu navegador.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'O vídeo não pode ser carregado, ou porque houve um problema na rede ou no servidor, ou porque o formato do vídeo não é compatível.', - 'This media could not be played because it could not be decrypted.': - 'O vídeo está encriptado e não há uma chave para o desencriptar.', - '': '', - 'Something went wrong.': 'Algo correu mal.', - OK: 'Fechar', - 'An unexpected error occurred.': 'Ocorreu um erro. Por favor tente novamente.', - Settings: 'Definições', - Quality: 'Qualidade', - Audio: 'Áudio', - Default: 'Predefinição', - Speed: 'Velocidade', - Captions: 'Legendas', - 'Playback rate': 'Velocidade de reprodução', - Back: 'Voltar', - Off: 'Desativado', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Legendas', -} as const satisfies Partial; + buttons: { + play: 'Reproduzir', + pause: 'Pausar', + replay: 'Reiniciar', + mute: 'Desativar som', + unmute: 'Ativar som', + }, + seek: { + forward: 'Avançar {seconds} segundos', + backward: 'Recuar {seconds} segundos', + }, + fullscreen: { + enter: 'Ecrã inteiro', + exit: 'Sair de ecrã inteiro', + }, + captions: { + enable: 'Ativar legendas', + disable: 'Desativar legendas', + }, + pip: { + enter: 'Imagem em imagem', + exit: 'Sair de imagem em imagem', + }, + live: { + playing: 'A reproduzir em direto', + seekToEdge: 'Ir para o em direto', + badge: 'Em direto', + }, + cast: { + start: 'Iniciar transmissão', + stop: 'Parar transmissão', + connecting: 'A ligar', + }, + airplay: { + start: 'Iniciar AirPlay', + stop: 'Parar AirPlay', + }, + slider: { + seek: 'Procurar', + }, + time: { + current: 'Tempo Atual', + duration: 'Duração', + remaining: 'Tempo Restante', + remainingSuffix: 'Restam {duration}', + showElapsed: '{duration}. Mostrar tempo decorrido.', + showDuration: '{duration}. Mostrar duração.', + showRemaining: '{duration}. Mostrar tempo restante.', + position: '{current} de {duration}', + }, + playback: { + rate: 'Velocidade de reprodução {rate}', + }, + volume: { + mutedValue: '{percent}, sem som', + muted: 'Sem som', + label: 'Nível de volume', + value: 'Nível de volume {value}', + }, + status: { + captionsOn: 'Legendas ativas', + captionsOff: 'Legendas desativadas', + paused: 'Em pausa', + playing: 'A reproduzir', + fullscreen: 'Ecrã inteiro', + pip: 'Imagem em imagem', + exitPip: 'Sair de imagem em imagem', + }, + errors: { + aborted: 'Parou a reprodução do vídeo.', + network: 'Um erro na rede fez o vídeo falhar parcialmente.', + decode: + 'A reprodução foi interrompida por um problema com o vídeo ou porque o formato não é compatível com o seu navegador.', + source: + 'O vídeo não pode ser carregado, ou porque houve um problema na rede ou no servidor, ou porque o formato do vídeo não é compatível.', + encrypted: 'O vídeo está encriptado e não há uma chave para o desencriptar.', + title: 'Algo correu mal.', + unexpected: 'Ocorreu um erro. Por favor tente novamente.', + }, + common: { + empty: '', + ok: 'Fechar', + }, + menu: { + settings: 'Definições', + quality: 'Qualidade', + audio: 'Áudio', + default: 'Predefinição', + speed: 'Velocidade', + captions: 'Legendas', + playbackRate: 'Velocidade de reprodução', + back: 'Voltar', + off: 'Desativado', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Legendas', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/ro.ts b/packages/core/src/core/i18n/locales/ro.ts index 2bbcee39..01ae23f4 100644 --- a/packages/core/src/core/i18n/locales/ro.ts +++ b/packages/core/src/core/i18n/locales/ro.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Redare', - Pause: 'Pauză', - Replay: 'Reluare', - Mute: 'Fără sunet', - Unmute: 'Cu sunet', - 'Seek forward {seconds} seconds': 'Salt înainte {seconds} secunde', - 'Seek backward {seconds} seconds': 'Salt înapoi {seconds} secunde', - 'Enter fullscreen': 'Ecran complet', - 'Enable captions': 'Activează subtitrările', - 'Disable captions': 'Dezactivează subtitrările', - 'Enter picture-in-picture': 'Imagine în imagine', - 'Exit picture-in-picture': 'Închidere imagine în imagine', - 'Playing live': 'Redare în direct', - 'Seek to live edge': 'Salt la direct', - Live: 'În direct', - 'Start casting': 'Pornire transmisie', - 'Stop casting': 'Oprire transmisie', - Connecting: 'Se conectează', - Seek: 'Derulare', - 'Current time': 'Ora curentă', - Duration: 'Durată', - Remaining: 'Timp rămas', - '{duration} remaining': 'Mai rămân {duration}', - '{duration}. Show elapsed time.': '{duration}. Afișează timpul scurs.', - '{duration}. Show duration.': '{duration}. Afișează durata.', - '{duration}. Show remaining time.': '{duration}. Afișează timpul rămas.', - 'Playback rate {rate}': 'Rată de redare {rate}', - '{current} of {duration}': '{current} din {duration}', - '{percent}, muted': '{percent}, mut', - Muted: 'Mut', - Volume: 'Volum', - 'Volume {value}': 'Volum {value}', - 'Captions on': 'Subtitrări activate', - 'Captions off': 'Subtitrări dezactivate', - Paused: 'Pauză', - Playing: 'Se redă', - Fullscreen: 'Ecran complet', - 'Exit fullscreen': 'Ieșire ecran complet', - 'Picture in picture': 'Imagine în imagine', - 'Exit picture in picture': 'Închidere imagine în imagine', - 'You stopped media playback before it finished.': 'Ați abandonat redarea media', - 'This media could not be loaded due to a network or server issue.': - 'O eroare de rețea a provocat eșecul descărcării conținutului media în timpul procesului.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Redarea media a fost întreruptă din cauza conținutului corupt sau din cauza faptului că acest conținut media folosește funcții pe care browserul dvs. nu le acceptă.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Conținutul media nu a putut fi încărcat, fie pentru că serverul sau rețeaua a eșuat, fie pentru că formatul nu este acceptat.', - 'This media could not be played because it could not be decrypted.': - 'Conținutul media este criptat și nu avem cheile pentru decriptare.', - '': '', - 'Something went wrong.': 'Ceva a mers greșit.', - OK: 'Închidere', - 'An unexpected error occurred.': 'A apărut o eroare. Vă rugăm să încercați din nou.', - Settings: 'Setări', - Quality: 'Calitate', - Audio: 'Sunet', - Default: 'Implicit', - Speed: 'Viteză', - Captions: 'Subtitrări', - 'Playback rate': 'Rată de redare', - Back: 'Înapoi', - Off: 'Dezactivat', - Auto: 'Automat', - 'Auto ({label})': 'Automat ({label})', - Subtitles: 'Subtitrări', -} as const satisfies Partial; + buttons: { + play: 'Redare', + pause: 'Pauză', + replay: 'Reluare', + mute: 'Fără sunet', + unmute: 'Cu sunet', + }, + seek: { + forward: 'Salt înainte {seconds} secunde', + backward: 'Salt înapoi {seconds} secunde', + }, + fullscreen: { + enter: 'Ecran complet', + exit: 'Ieșire ecran complet', + }, + captions: { + enable: 'Activează subtitrările', + disable: 'Dezactivează subtitrările', + }, + pip: { + enter: 'Imagine în imagine', + exit: 'Închidere imagine în imagine', + }, + live: { + playing: 'Redare în direct', + seekToEdge: 'Salt la direct', + badge: 'În direct', + }, + cast: { + start: 'Pornire transmisie', + stop: 'Oprire transmisie', + connecting: 'Se conectează', + }, + airplay: { + start: 'Pornește AirPlay', + stop: 'Oprește AirPlay', + }, + slider: { + seek: 'Derulare', + }, + time: { + current: 'Ora curentă', + duration: 'Durată', + remaining: 'Timp rămas', + remainingSuffix: 'Mai rămân {duration}', + showElapsed: '{duration}. Afișează timpul scurs.', + showDuration: '{duration}. Afișează durata.', + showRemaining: '{duration}. Afișează timpul rămas.', + position: '{current} din {duration}', + }, + playback: { + rate: 'Rată de redare {rate}', + }, + volume: { + mutedValue: '{percent}, mut', + muted: 'Mut', + label: 'Volum', + value: 'Volum {value}', + }, + status: { + captionsOn: 'Subtitrări activate', + captionsOff: 'Subtitrări dezactivate', + paused: 'Pauză', + playing: 'Se redă', + fullscreen: 'Ecran complet', + pip: 'Imagine în imagine', + exitPip: 'Închidere imagine în imagine', + }, + errors: { + aborted: 'Ați abandonat redarea media', + network: 'O eroare de rețea a provocat eșecul descărcării conținutului media în timpul procesului.', + decode: + 'Redarea media a fost întreruptă din cauza conținutului corupt sau din cauza faptului că acest conținut media folosește funcții pe care browserul dvs. nu le acceptă.', + source: + 'Conținutul media nu a putut fi încărcat, fie pentru că serverul sau rețeaua a eșuat, fie pentru că formatul nu este acceptat.', + encrypted: 'Conținutul media este criptat și nu avem cheile pentru decriptare.', + title: 'Ceva a mers greșit.', + unexpected: 'A apărut o eroare. Vă rugăm să încercați din nou.', + }, + common: { + empty: '', + ok: 'Închidere', + }, + menu: { + settings: 'Setări', + quality: 'Calitate', + audio: 'Sunet', + default: 'Implicit', + speed: 'Viteză', + captions: 'Subtitrări', + playbackRate: 'Rată de redare', + back: 'Înapoi', + off: 'Dezactivat', + auto: 'Automat', + autoWithLabel: 'Automat ({label})', + subtitles: 'Subtitrări', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/ru.ts b/packages/core/src/core/i18n/locales/ru.ts index 1fdafa09..762f7549 100644 --- a/packages/core/src/core/i18n/locales/ru.ts +++ b/packages/core/src/core/i18n/locales/ru.ts @@ -1,68 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Воспроизвести', - Pause: 'Приостановить', - Replay: 'Воспроизвести снова', - Mute: 'Без звука', - Unmute: 'Со звуком', - 'Seek forward {seconds} seconds': 'На {seconds} секунд вперед', - 'Seek backward {seconds} seconds': 'На {seconds} секунд назад', - 'Enter fullscreen': 'Полноэкранный режим', - 'Enable captions': 'Включить субтитры', - 'Disable captions': 'Отключить субтитры', - 'Enter picture-in-picture': 'Картинка в картинке', - 'Exit picture-in-picture': 'Закрыть картинку в картинке', - 'Playing live': 'Прямой эфир', - 'Seek to live edge': 'Перейти к прямому эфиру', - Live: 'Прямой эфир', - 'Start casting': 'Начать трансляцию', - 'Stop casting': 'Остановить трансляцию', - Connecting: 'Подключение', - Seek: 'Перемотка', - 'Current time': 'Текущее время', - Duration: 'Продолжительность', - Remaining: 'Оставшееся время', - '{duration} remaining': 'Осталось {duration}', - '{duration}. Show elapsed time.': '{duration}. Показать прошедшее время.', - '{duration}. Show duration.': '{duration}. Показать длительность.', - '{duration}. Show remaining time.': '{duration}. Показать оставшееся время.', - 'Playback rate {rate}': 'Скорость воспроизведения {rate}', - '{current} of {duration}': '{current} из {duration}', - '{percent}, muted': '{percent}, без звука', - Muted: 'Без звука', - Volume: 'Громкость', - 'Volume {value}': 'Громкость {value}', - 'Captions on': 'Субтитры включены', - 'Captions off': 'Субтитры выключены', - Paused: 'На паузе', - Playing: 'Воспроизведение', - Fullscreen: 'Полноэкранный режим', - 'Exit fullscreen': 'Выйти из полноэкранного режима', - 'Picture in picture': 'Картинка в картинке', - 'Exit picture in picture': 'Выйти из режима «картинка в картинке»', - 'You stopped media playback before it finished.': 'Вы прервали воспроизведение видео', - 'This media could not be loaded due to a network or server issue.': 'Ошибка сети вызвала сбой во время загрузки.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Воспроизведение прервано из-за повреждения либо в связи с тем, что видео использует функции, неподдерживаемые вашим браузером.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Не удалось загрузить видео из-за сетевого или серверного сбоя либо неподдерживаемого формата видео.', - 'This media could not be played because it could not be decrypted.': - 'Видео зашифровано, а у нас нет ключей для его расшифровки.', - '': '', - 'Something went wrong.': 'Что-то пошло не так.', - OK: 'Закрыть', - 'An unexpected error occurred.': 'Произошла ошибка. Попробуйте снова.', - Settings: 'Настройки', - Quality: 'Качество', - Audio: 'Аудио', - Default: 'По умолчанию', - Speed: 'Скорость', - Captions: 'Субтитры', - 'Playback rate': 'Скорость воспроизведения', - Back: 'Назад', - Off: 'Выкл.', - Auto: 'Авто', - 'Auto ({label})': 'Авто ({label})', - Subtitles: 'Субтитры', -} as const satisfies Partial; + buttons: { + play: 'Воспроизвести', + pause: 'Приостановить', + replay: 'Воспроизвести снова', + mute: 'Без звука', + unmute: 'Со звуком', + }, + seek: { + forward: 'На {seconds} секунд вперед', + backward: 'На {seconds} секунд назад', + }, + fullscreen: { + enter: 'Полноэкранный режим', + exit: 'Выйти из полноэкранного режима', + }, + captions: { + enable: 'Включить субтитры', + disable: 'Отключить субтитры', + }, + pip: { + enter: 'Картинка в картинке', + exit: 'Закрыть картинку в картинке', + }, + live: { + playing: 'Прямой эфир', + seekToEdge: 'Перейти к прямому эфиру', + badge: 'Прямой эфир', + }, + cast: { + start: 'Начать трансляцию', + stop: 'Остановить трансляцию', + connecting: 'Подключение', + }, + airplay: { + start: 'Запустить AirPlay', + stop: 'Остановить AirPlay', + }, + slider: { + seek: 'Перемотка', + }, + time: { + current: 'Текущее время', + duration: 'Продолжительность', + remaining: 'Оставшееся время', + remainingSuffix: 'Осталось {duration}', + showElapsed: '{duration}. Показать прошедшее время.', + showDuration: '{duration}. Показать длительность.', + showRemaining: '{duration}. Показать оставшееся время.', + position: '{current} из {duration}', + }, + playback: { + rate: 'Скорость воспроизведения {rate}', + }, + volume: { + mutedValue: '{percent}, без звука', + muted: 'Без звука', + label: 'Громкость', + value: 'Громкость {value}', + }, + status: { + captionsOn: 'Субтитры включены', + captionsOff: 'Субтитры выключены', + paused: 'На паузе', + playing: 'Воспроизведение', + fullscreen: 'Полноэкранный режим', + pip: 'Картинка в картинке', + exitPip: 'Выйти из режима «картинка в картинке»', + }, + errors: { + aborted: 'Вы прервали воспроизведение видео', + network: 'Ошибка сети вызвала сбой во время загрузки.', + decode: + 'Воспроизведение прервано из-за повреждения либо в связи с тем, что видео использует функции, неподдерживаемые вашим браузером.', + source: 'Не удалось загрузить видео из-за сетевого или серверного сбоя либо неподдерживаемого формата видео.', + encrypted: 'Видео зашифровано, а у нас нет ключей для его расшифровки.', + title: 'Что-то пошло не так.', + unexpected: 'Произошла ошибка. Попробуйте снова.', + }, + common: { + empty: '', + ok: 'Закрыть', + }, + menu: { + settings: 'Настройки', + quality: 'Качество', + audio: 'Аудио', + default: 'По умолчанию', + speed: 'Скорость', + captions: 'Субтитры', + playbackRate: 'Скорость воспроизведения', + back: 'Назад', + off: 'Выкл.', + auto: 'Авто', + autoWithLabel: 'Авто ({label})', + subtitles: 'Субтитры', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/sk.ts b/packages/core/src/core/i18n/locales/sk.ts index e93e3528..fa4a8de5 100644 --- a/packages/core/src/core/i18n/locales/sk.ts +++ b/packages/core/src/core/i18n/locales/sk.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Prehrať', - Pause: 'Pozastaviť', - Replay: 'Prehrať znova', - Mute: 'Stlmiť', - Unmute: 'Zrušiť stlmenie', - 'Seek forward {seconds} seconds': 'Posunúť dopredu o {seconds} s', - 'Seek backward {seconds} seconds': 'Posunúť dozadu o {seconds} s', - 'Enter fullscreen': 'Režim celej obrazovky', - 'Enable captions': 'Zapnúť titulky', - 'Disable captions': 'Vypnúť titulky', - 'Enter picture-in-picture': 'Obraz v obraze', - 'Exit picture-in-picture': 'Zavrieť obraz v obraze', - 'Playing live': 'Prehráva sa naživo', - 'Seek to live edge': 'Prejsť na živé vysielanie', - Live: 'Naživo', - 'Start casting': 'Spustiť prenos', - 'Stop casting': 'Zastaviť prenos', - Connecting: 'Pripájam', - Seek: 'Posun', - 'Current time': 'Aktuálny čas', - Duration: 'Čas trvania', - Remaining: 'Zostávajúci čas', - '{duration} remaining': 'Zostáva {duration}', - '{duration}. Show elapsed time.': '{duration}. Zobraziť uplynulý čas.', - '{duration}. Show duration.': '{duration}. Zobraziť trvanie.', - '{duration}. Show remaining time.': '{duration}. Zobraziť zostávajúci čas.', - 'Playback rate {rate}': 'Rýchlosť prehrávania {rate}', - '{current} of {duration}': '{current} z {duration}', - '{percent}, muted': '{percent}, stlmené', - Muted: 'Stlmené', - Volume: 'Hlasitosť', - 'Volume {value}': 'Hlasitosť {value}', - 'Captions on': 'Popisky zapnuté', - 'Captions off': 'Popisky vypnuté', - Paused: 'Pozastavené', - Playing: 'Prehráva sa', - Fullscreen: 'Celá obrazovka', - 'Exit fullscreen': 'Zavrieť celú obrazovku', - 'Picture in picture': 'Obraz v obraze', - 'Exit picture in picture': 'Zavrieť obraz v obraze', - 'You stopped media playback before it finished.': 'Prerušili ste prehrávanie', - 'This media could not be loaded due to a network or server issue.': - 'Sťahovanie súboru bolo zrušené pre chybu na sieti.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Prehrávanie súboru bolo prerušené pre poškodené dáta, alebo súbor používa vlastnosti, ktoré váš prehliadač nepodporuje.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Súbor sa nepodarilo načítať pre chybu servera, sieťového pripojenia, alebo je formát súboru nepodporovaný.', - 'This media could not be played because it could not be decrypted.': - 'Súbor je zašifrovaný a nie je k dispozícii kľúč na rozšifrovanie.', - '': '', - 'Something went wrong.': 'Niečo sa pokazilo.', - OK: 'Zatvoriť', - 'An unexpected error occurred.': 'Vyskytla sa chyba. Skúste to znova.', - Settings: 'Nastavenia', - Quality: 'Kvalita', - Audio: 'Zvuk', - Default: 'Predvolené', - Speed: 'Rýchlosť', - Captions: 'Titulky', - 'Playback rate': 'Rýchlosť prehrávania', - Back: 'Späť', - Off: 'Vypnuté', - Auto: 'Automaticky', - 'Auto ({label})': 'Automaticky ({label})', - Subtitles: 'Titulky', -} as const satisfies Partial; + buttons: { + play: 'Prehrať', + pause: 'Pozastaviť', + replay: 'Prehrať znova', + mute: 'Stlmiť', + unmute: 'Zrušiť stlmenie', + }, + seek: { + forward: 'Posunúť dopredu o {seconds} s', + backward: 'Posunúť dozadu o {seconds} s', + }, + fullscreen: { + enter: 'Režim celej obrazovky', + exit: 'Zavrieť celú obrazovku', + }, + captions: { + enable: 'Zapnúť titulky', + disable: 'Vypnúť titulky', + }, + pip: { + enter: 'Obraz v obraze', + exit: 'Zavrieť obraz v obraze', + }, + live: { + playing: 'Prehráva sa naživo', + seekToEdge: 'Prejsť na živé vysielanie', + badge: 'Naživo', + }, + cast: { + start: 'Spustiť prenos', + stop: 'Zastaviť prenos', + connecting: 'Pripájam', + }, + airplay: { + start: 'Spustiť AirPlay', + stop: 'Zastaviť AirPlay', + }, + slider: { + seek: 'Posun', + }, + time: { + current: 'Aktuálny čas', + duration: 'Čas trvania', + remaining: 'Zostávajúci čas', + remainingSuffix: 'Zostáva {duration}', + showElapsed: '{duration}. Zobraziť uplynulý čas.', + showDuration: '{duration}. Zobraziť trvanie.', + showRemaining: '{duration}. Zobraziť zostávajúci čas.', + position: '{current} z {duration}', + }, + playback: { + rate: 'Rýchlosť prehrávania {rate}', + }, + volume: { + mutedValue: '{percent}, stlmené', + muted: 'Stlmené', + label: 'Hlasitosť', + value: 'Hlasitosť {value}', + }, + status: { + captionsOn: 'Popisky zapnuté', + captionsOff: 'Popisky vypnuté', + paused: 'Pozastavené', + playing: 'Prehráva sa', + fullscreen: 'Celá obrazovka', + pip: 'Obraz v obraze', + exitPip: 'Zavrieť obraz v obraze', + }, + errors: { + aborted: 'Prerušili ste prehrávanie', + network: 'Sťahovanie súboru bolo zrušené pre chybu na sieti.', + decode: + 'Prehrávanie súboru bolo prerušené pre poškodené dáta, alebo súbor používa vlastnosti, ktoré váš prehliadač nepodporuje.', + source: + 'Súbor sa nepodarilo načítať pre chybu servera, sieťového pripojenia, alebo je formát súboru nepodporovaný.', + encrypted: 'Súbor je zašifrovaný a nie je k dispozícii kľúč na rozšifrovanie.', + title: 'Niečo sa pokazilo.', + unexpected: 'Vyskytla sa chyba. Skúste to znova.', + }, + common: { + empty: '', + ok: 'Zatvoriť', + }, + menu: { + settings: 'Nastavenia', + quality: 'Kvalita', + audio: 'Zvuk', + default: 'Predvolené', + speed: 'Rýchlosť', + captions: 'Titulky', + playbackRate: 'Rýchlosť prehrávania', + back: 'Späť', + off: 'Vypnuté', + auto: 'Automaticky', + autoWithLabel: 'Automaticky ({label})', + subtitles: 'Titulky', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/sl.ts b/packages/core/src/core/i18n/locales/sl.ts index f7a1227f..5e3895b9 100644 --- a/packages/core/src/core/i18n/locales/sl.ts +++ b/packages/core/src/core/i18n/locales/sl.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Predvajaj', - Pause: 'Začasno ustavi', - Replay: 'Predvajaj ponovno', - Mute: 'Izključi zvok', - Unmute: 'Vključi zvok', - 'Seek forward {seconds} seconds': 'Preskoči naprej {seconds} sekund', - 'Seek backward {seconds} seconds': 'Preskoči nazaj {seconds} sekund', - 'Enter fullscreen': 'Celozaslonski prikaz', - 'Enable captions': 'Vklopi podnapise', - 'Disable captions': 'Izklopi podnapise', - 'Enter picture-in-picture': 'Slika v sliki', - 'Exit picture-in-picture': 'Izhod iz slike v sliki', - 'Playing live': 'Predvajanje v živo', - 'Seek to live edge': 'Skoči na live', - Live: 'V živo', - 'Start casting': 'Začni predvajanje na zaslonu', - 'Stop casting': 'Ustavi predvajanje na zaslonu', - Connecting: 'Povezovanje', - Seek: 'Premikanje', - 'Current time': 'Trenutni čas', - Duration: 'Trajanje', - Remaining: 'Preostali čas', - '{duration} remaining': 'Preostane {duration}', - '{duration}. Show elapsed time.': '{duration}. Prikaži pretekli čas.', - '{duration}. Show duration.': '{duration}. Prikaži trajanje.', - '{duration}. Show remaining time.': '{duration}. Prikaži preostali čas.', - 'Playback rate {rate}': 'Hitrost predvajanja {rate}', - '{current} of {duration}': '{current} od {duration}', - '{percent}, muted': '{percent}, izklopljeno', - Muted: 'Izklopljeno', - Volume: 'Glasnost', - 'Volume {value}': 'Glasnost {value}', - 'Captions on': 'Zvočni zapis vklopljen', - 'Captions off': 'Zvočni zapis izklopljen', - Paused: 'Začasno ustavljeno', - Playing: 'Predvaja', - Fullscreen: 'Celozaslonski prikaz', - 'Exit fullscreen': 'Izhod iz celozaslonskega prikaza', - 'Picture in picture': 'Slika v sliki', - 'Exit picture in picture': 'Izhod iz slike v sliki', - 'You stopped media playback before it finished.': 'Prekinili ste predvajanje.', - 'This media could not be loaded due to a network or server issue.': - 'Prenos multimedijske datoteke ni uspel zaradi napake v omrežju.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Predvajanje datoteke je bilo prekinjeno zaradi napak v datoteki ali ker uporablja funkcije, ki jih brskalnik ne podpira.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Multimedijske datoteke ni bilo mogoče naložiti zaradi napake na strežniku oziroma omrežju ali ker ta oblika ni podprta.', - 'This media could not be played because it could not be decrypted.': - 'Datoteka je šifrirana in predvajalnik nima ključev za njeno dešifriranje.', - '': '', - 'Something went wrong.': 'Nekaj je šlo narobe.', - OK: 'Zapri', - 'An unexpected error occurred.': 'Prišlo je do napake. Poskusite znova.', - Settings: 'Nastavitve', - Quality: 'Kakovost', - Audio: 'Zvok', - Default: 'Privzeto', - Speed: 'Hitrost', - Captions: 'Podnapisi', - 'Playback rate': 'Hitrost predvajanja', - Back: 'Nazaj', - Off: 'Izklopljeno', - Auto: 'Samodejno', - 'Auto ({label})': 'Samodejno ({label})', - Subtitles: 'Podnapisi', -} as const satisfies Partial; + buttons: { + play: 'Predvajaj', + pause: 'Začasno ustavi', + replay: 'Predvajaj ponovno', + mute: 'Izključi zvok', + unmute: 'Vključi zvok', + }, + seek: { + forward: 'Preskoči naprej {seconds} sekund', + backward: 'Preskoči nazaj {seconds} sekund', + }, + fullscreen: { + enter: 'Celozaslonski prikaz', + exit: 'Izhod iz celozaslonskega prikaza', + }, + captions: { + enable: 'Vklopi podnapise', + disable: 'Izklopi podnapise', + }, + pip: { + enter: 'Slika v sliki', + exit: 'Izhod iz slike v sliki', + }, + live: { + playing: 'Predvajanje v živo', + seekToEdge: 'Skoči na live', + badge: 'V živo', + }, + cast: { + start: 'Začni predvajanje na zaslonu', + stop: 'Ustavi predvajanje na zaslonu', + connecting: 'Povezovanje', + }, + airplay: { + start: 'Zaženi AirPlay', + stop: 'Ustavi AirPlay', + }, + slider: { + seek: 'Premikanje', + }, + time: { + current: 'Trenutni čas', + duration: 'Trajanje', + remaining: 'Preostali čas', + remainingSuffix: 'Preostane {duration}', + showElapsed: '{duration}. Prikaži pretekli čas.', + showDuration: '{duration}. Prikaži trajanje.', + showRemaining: '{duration}. Prikaži preostali čas.', + position: '{current} od {duration}', + }, + playback: { + rate: 'Hitrost predvajanja {rate}', + }, + volume: { + mutedValue: '{percent}, izklopljeno', + muted: 'Izklopljeno', + label: 'Glasnost', + value: 'Glasnost {value}', + }, + status: { + captionsOn: 'Zvočni zapis vklopljen', + captionsOff: 'Zvočni zapis izklopljen', + paused: 'Začasno ustavljeno', + playing: 'Predvaja', + fullscreen: 'Celozaslonski prikaz', + pip: 'Slika v sliki', + exitPip: 'Izhod iz slike v sliki', + }, + errors: { + aborted: 'Prekinili ste predvajanje.', + network: 'Prenos multimedijske datoteke ni uspel zaradi napake v omrežju.', + decode: + 'Predvajanje datoteke je bilo prekinjeno zaradi napak v datoteki ali ker uporablja funkcije, ki jih brskalnik ne podpira.', + source: + 'Multimedijske datoteke ni bilo mogoče naložiti zaradi napake na strežniku oziroma omrežju ali ker ta oblika ni podprta.', + encrypted: 'Datoteka je šifrirana in predvajalnik nima ključev za njeno dešifriranje.', + title: 'Nekaj je šlo narobe.', + unexpected: 'Prišlo je do napake. Poskusite znova.', + }, + common: { + empty: '', + ok: 'Zapri', + }, + menu: { + settings: 'Nastavitve', + quality: 'Kakovost', + audio: 'Zvok', + default: 'Privzeto', + speed: 'Hitrost', + captions: 'Podnapisi', + playbackRate: 'Hitrost predvajanja', + back: 'Nazaj', + off: 'Izklopljeno', + auto: 'Samodejno', + autoWithLabel: 'Samodejno ({label})', + subtitles: 'Podnapisi', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/sr.ts b/packages/core/src/core/i18n/locales/sr.ts index 8de007a1..b781a9cd 100644 --- a/packages/core/src/core/i18n/locales/sr.ts +++ b/packages/core/src/core/i18n/locales/sr.ts @@ -1,69 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Pusti', - Pause: 'Pauza', - Replay: 'Ponovi', - Mute: 'Utišaj', - Unmute: 'Poništi utišavanje', - 'Seek forward {seconds} seconds': 'Premotaj unapred {seconds} sekundi', - 'Seek backward {seconds} seconds': 'Premotaj unazad {seconds} sekundi', - 'Enter fullscreen': 'Pun ekran', - 'Enable captions': 'Uključi titlove', - 'Disable captions': 'Isključi titlove', - 'Enter picture-in-picture': 'Slika u slici', - 'Exit picture-in-picture': 'Izađi iz slike u slici', - 'Playing live': 'Reprodukcija uživo', - 'Seek to live edge': 'Idi na live', - Live: 'Uživo', - 'Start casting': 'Počni emitovanje', - 'Stop casting': 'Zaustavi emitovanje', - Connecting: 'Povezivanje', - Seek: 'Premotavanje', - 'Current time': 'Trenutno vreme', - Duration: 'Vreme trajanja', - Remaining: 'Preostalo vreme', - '{duration} remaining': 'Preostalo {duration}', - '{duration}. Show elapsed time.': '{duration}. Prikaži proteklo vreme.', - '{duration}. Show duration.': '{duration}. Prikaži trajanje.', - '{duration}. Show remaining time.': '{duration}. Prikaži preostalo vreme.', - 'Playback rate {rate}': 'Stopa reprodukcije {rate}', - '{current} of {duration}': '{current} od {duration}', - '{percent}, muted': '{percent}, utišano', - Muted: 'Utišano', - Volume: 'Jačina zvuka', - 'Volume {value}': 'Jačina zvuka {value}', - 'Captions on': 'Titlovi uključeni', - 'Captions off': 'Titlovi isključeni', - Paused: 'Pauzirano', - Playing: 'Reprodukuje se', - Fullscreen: 'Pun ekran', - 'Exit fullscreen': 'Izađi iz punog ekrana', - 'Picture in picture': 'Slika u slici', - 'Exit picture in picture': 'Izađi iz slike u slici', - 'You stopped media playback before it finished.': 'Isključili ste reprodukciju videa.', - 'This media could not be loaded due to a network or server issue.': - 'Video se prestao preuzimati zbog greške na mreži.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Reprodukcija videa je zaustavljena zbog greške u formatu ili zbog verzije vašeg pretraživača.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Video se ne može reproducirati zbog servera, greške u mreži ili format nije podržan.', - 'This media could not be played because it could not be decrypted.': - 'Medij je šifrovan i nema ključeva za dešifrovanje.', - '': '', - 'Something went wrong.': 'Nešto je pošlo po zlu.', - OK: 'Zatvori', - 'An unexpected error occurred.': 'Došlo je do greške. Molimo pokušajte ponovo.', - Settings: 'Podešavanja', - Quality: 'Kvalitet', - Audio: 'Zvuk', - Default: 'Подразумевано', - Speed: 'Brzina', - Captions: 'Titlovi', - 'Playback rate': 'Stopa reprodukcije', - Back: 'Nazad', - Off: 'Isključeno', - Auto: 'Automatski', - 'Auto ({label})': 'Automatski ({label})', - Subtitles: 'Titlovi', -} as const satisfies Partial; + buttons: { + play: 'Pusti', + pause: 'Pauza', + replay: 'Ponovi', + mute: 'Utišaj', + unmute: 'Poništi utišavanje', + }, + seek: { + forward: 'Premotaj unapred {seconds} sekundi', + backward: 'Premotaj unazad {seconds} sekundi', + }, + fullscreen: { + enter: 'Pun ekran', + exit: 'Izađi iz punog ekrana', + }, + captions: { + enable: 'Uključi titlove', + disable: 'Isključi titlove', + }, + pip: { + enter: 'Slika u slici', + exit: 'Izađi iz slike u slici', + }, + live: { + playing: 'Reprodukcija uživo', + seekToEdge: 'Idi na live', + badge: 'Uživo', + }, + cast: { + start: 'Počni emitovanje', + stop: 'Zaustavi emitovanje', + connecting: 'Povezivanje', + }, + airplay: { + start: 'Покрени AirPlay', + stop: 'Заустави AirPlay', + }, + slider: { + seek: 'Premotavanje', + }, + time: { + current: 'Trenutno vreme', + duration: 'Vreme trajanja', + remaining: 'Preostalo vreme', + remainingSuffix: 'Preostalo {duration}', + showElapsed: '{duration}. Prikaži proteklo vreme.', + showDuration: '{duration}. Prikaži trajanje.', + showRemaining: '{duration}. Prikaži preostalo vreme.', + position: '{current} od {duration}', + }, + playback: { + rate: 'Stopa reprodukcije {rate}', + }, + volume: { + mutedValue: '{percent}, utišano', + muted: 'Utišano', + label: 'Jačina zvuka', + value: 'Jačina zvuka {value}', + }, + status: { + captionsOn: 'Titlovi uključeni', + captionsOff: 'Titlovi isključeni', + paused: 'Pauzirano', + playing: 'Reprodukuje se', + fullscreen: 'Pun ekran', + pip: 'Slika u slici', + exitPip: 'Izađi iz slike u slici', + }, + errors: { + aborted: 'Isključili ste reprodukciju videa.', + network: 'Video se prestao preuzimati zbog greške na mreži.', + decode: 'Reprodukcija videa je zaustavljena zbog greške u formatu ili zbog verzije vašeg pretraživača.', + source: 'Video se ne može reproducirati zbog servera, greške u mreži ili format nije podržan.', + encrypted: 'Medij je šifrovan i nema ključeva za dešifrovanje.', + title: 'Nešto je pošlo po zlu.', + unexpected: 'Došlo je do greške. Molimo pokušajte ponovo.', + }, + common: { + empty: '', + ok: 'Zatvori', + }, + menu: { + settings: 'Podešavanja', + quality: 'Kvalitet', + audio: 'Zvuk', + default: 'Подразумевано', + speed: 'Brzina', + captions: 'Titlovi', + playbackRate: 'Stopa reprodukcije', + back: 'Nazad', + off: 'Isključeno', + auto: 'Automatski', + autoWithLabel: 'Automatski ({label})', + subtitles: 'Titlovi', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/sv.ts b/packages/core/src/core/i18n/locales/sv.ts index 19d2807c..b86e5d1e 100644 --- a/packages/core/src/core/i18n/locales/sv.ts +++ b/packages/core/src/core/i18n/locales/sv.ts @@ -1,69 +1,101 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Spela', - Pause: 'Pausa', - Replay: 'Spela upp igen', - Mute: 'Ljud av', - Unmute: 'Ljud på', - 'Seek forward {seconds} seconds': 'Hoppa framåt {seconds} sekunder', - 'Seek backward {seconds} seconds': 'Hoppa bakåt {seconds} sekunder', - 'Enter fullscreen': 'Fullskärm', - 'Enable captions': 'Aktivera textning', - 'Disable captions': 'Inaktivera textning', - 'Enter picture-in-picture': 'Bild-i-bild', - 'Exit picture-in-picture': 'Avsluta bild-i-bild', - 'Playing live': 'Spelar live', - 'Seek to live edge': 'Gå till live', - Live: 'Live', - 'Start casting': 'Starta casting', - 'Stop casting': 'Stoppa casting', - Connecting: 'Ansluter', - Seek: 'Spola', - 'Current time': 'Aktuell tid', - Duration: 'Total tid', - Remaining: 'Återstående tid', - '{duration} remaining': '{duration} kvar', - '{duration}. Show elapsed time.': '{duration}. Visa förfluten tid.', - '{duration}. Show duration.': '{duration}. Visa längd.', - '{duration}. Show remaining time.': '{duration}. Visa återstående tid.', - 'Playback rate {rate}': 'Uppspelningshastighet {rate}', - '{current} of {duration}': '{current} av {duration}', - '{percent}, muted': '{percent}, tystat', - Muted: 'Tystat', - Volume: 'Volym', - 'Volume {value}': 'Volym {value}', - 'Captions on': 'Text på', - 'Captions off': 'Text av', - Paused: 'Pausad', - Playing: 'Spelar', - Fullscreen: 'Fullskärm', - 'Exit fullscreen': 'Avsluta fullskärm', - 'Picture in picture': 'Bild i bild', - 'Exit picture in picture': 'Avsluta bild i bild', - 'You stopped media playback before it finished.': 'Du har avbrutit videouppspelningen.', - 'This media could not be loaded due to a network or server issue.': - 'Ett nätverksfel gjorde att nedladdningen av videon avbröts.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Uppspelningen avbröts på grund av att videon är skadad, eller också för att videon använder funktioner som din webbläsare inte stöder.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Det gick inte att ladda videon, antingen på grund av ett server- eller nätverksfel, eller för att formatet inte stöds.', - 'This media could not be played because it could not be decrypted.': - 'Mediat är krypterat och vi har inte nycklarna för att dekryptera det.', - '': '', - 'Something went wrong.': 'Något gick fel.', - OK: 'Stäng', - 'An unexpected error occurred.': 'Ett fel uppstod. Försök igen.', - Settings: 'Inställningar', - Quality: 'Kvalitet', - Audio: 'Ljud', - Default: 'Standard', - Speed: 'Hastighet', - Captions: 'Textning', - 'Playback rate': 'Uppspelningshastighet', - Back: 'Tillbaka', - Off: 'Av', - Auto: 'Auto', - 'Auto ({label})': 'Auto ({label})', - Subtitles: 'Undertexter', -} as const satisfies Partial; + buttons: { + play: 'Spela', + pause: 'Pausa', + replay: 'Spela upp igen', + mute: 'Ljud av', + unmute: 'Ljud på', + }, + seek: { + forward: 'Hoppa framåt {seconds} sekunder', + backward: 'Hoppa bakåt {seconds} sekunder', + }, + fullscreen: { + enter: 'Fullskärm', + exit: 'Avsluta fullskärm', + }, + captions: { + enable: 'Aktivera textning', + disable: 'Inaktivera textning', + }, + pip: { + enter: 'Bild-i-bild', + exit: 'Avsluta bild-i-bild', + }, + live: { + playing: 'Spelar live', + seekToEdge: 'Gå till live', + badge: 'Live', + }, + cast: { + start: 'Starta casting', + stop: 'Stoppa casting', + connecting: 'Ansluter', + }, + airplay: { + start: 'Starta AirPlay', + stop: 'Stoppa AirPlay', + }, + slider: { + seek: 'Spola', + }, + time: { + current: 'Aktuell tid', + duration: 'Total tid', + remaining: 'Återstående tid', + remainingSuffix: '{duration} kvar', + showElapsed: '{duration}. Visa förfluten tid.', + showDuration: '{duration}. Visa längd.', + showRemaining: '{duration}. Visa återstående tid.', + position: '{current} av {duration}', + }, + playback: { + rate: 'Uppspelningshastighet {rate}', + }, + volume: { + mutedValue: '{percent}, tystat', + muted: 'Tystat', + label: 'Volym', + value: 'Volym {value}', + }, + status: { + captionsOn: 'Text på', + captionsOff: 'Text av', + paused: 'Pausad', + playing: 'Spelar', + fullscreen: 'Fullskärm', + pip: 'Bild i bild', + exitPip: 'Avsluta bild i bild', + }, + errors: { + aborted: 'Du har avbrutit videouppspelningen.', + network: 'Ett nätverksfel gjorde att nedladdningen av videon avbröts.', + decode: + 'Uppspelningen avbröts på grund av att videon är skadad, eller också för att videon använder funktioner som din webbläsare inte stöder.', + source: + 'Det gick inte att ladda videon, antingen på grund av ett server- eller nätverksfel, eller för att formatet inte stöds.', + encrypted: 'Mediat är krypterat och vi har inte nycklarna för att dekryptera det.', + title: 'Något gick fel.', + unexpected: 'Ett fel uppstod. Försök igen.', + }, + common: { + empty: '', + ok: 'Stäng', + }, + menu: { + settings: 'Inställningar', + quality: 'Kvalitet', + audio: 'Ljud', + default: 'Standard', + speed: 'Hastighet', + captions: 'Textning', + playbackRate: 'Uppspelningshastighet', + back: 'Tillbaka', + off: 'Av', + auto: 'Auto', + autoWithLabel: 'Auto ({label})', + subtitles: 'Undertexter', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/te.ts b/packages/core/src/core/i18n/locales/te.ts index 86d072ff..006256f0 100644 --- a/packages/core/src/core/i18n/locales/te.ts +++ b/packages/core/src/core/i18n/locales/te.ts @@ -1,68 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'ప్లే', - Pause: 'పాజ్', - Replay: 'రీప్లే', - Mute: 'మ్యూట్', - Unmute: 'అన్మ్యూట్ చేయండి', - 'Seek forward {seconds} seconds': '{seconds} సెకన్లు ముందుకు', - 'Seek backward {seconds} seconds': '{seconds} సెకన్లు వెనుకకు', - 'Enter fullscreen': 'పూర్తి స్క్రీన్', - 'Enable captions': 'శీర్షికలు', - 'Disable captions': 'శీర్షికలు ఆఫ్ చేయండి', - 'Enter picture-in-picture': 'పిక్చర్-ఇన్-పిక్చర్', - 'Exit picture-in-picture': 'పిక్చర్-ఇన్-పిక్చర్ నుండి నిష్క్రమించండి', - 'Playing live': 'లైవ్‌లో ప్లే అవుతోంది', - 'Seek to live edge': 'లైవ్‌కు వెళ్లండి', - Live: 'లైవ్', - 'Start casting': 'కాస్టింగ్ ప్రారంభించండి', - 'Stop casting': 'కాస్టింగ్ ఆపండి', - Connecting: 'కనెక్ట్ అవుతోంది', - Seek: 'శోధించు', - 'Current time': 'ప్రస్తుత సమయం', - Duration: 'వ్యవధి', - Remaining: 'మిగిలిన సమయం', - '{duration} remaining': '{duration} మిగిలి', - '{duration}. Show elapsed time.': '{duration}. గడిచిన సమయం చూపు.', - '{duration}. Show duration.': '{duration}. వ్యవధి చూపు.', - '{duration}. Show remaining time.': '{duration}. మిగిలిన సమయం చూపు.', - 'Playback rate {rate}': 'ప్లేబ్యాక్ రేట్ {rate}', - '{current} of {duration}': '{current} యొక్క {duration}', - '{percent}, muted': '{percent}, మ్యూట్ చేయబడింది', - Muted: 'మ్యూట్ చేయబడింది', - Volume: 'వాల్యూమ్', - 'Volume {value}': 'వాల్యూమ్ {value}', - 'Captions on': 'శీర్షికలు ఆన్', - 'Captions off': 'శీర్షికలు ఆఫ్', - Paused: 'పాజ్ చేయబడింది', - Playing: 'ప్లే అవుతోంది', - Fullscreen: 'పూర్తి స్క్రీన్', - 'Exit fullscreen': 'పూర్తి స్క్రీన్ నుండి నిష్క్రమించండి', - 'Picture in picture': 'పిక్చర్ ఇన్ పిక్చర్', - 'Exit picture in picture': 'పిక్చర్ ఇన్ పిక్చర్ నుండి నిష్క్రమించండి', - 'You stopped media playback before it finished.': 'మీరు మీడియా ప్లేబ్యాక్‌ను రద్దు చేశారు', - 'This media could not be loaded due to a network or server issue.': 'నెట్‌వర్క్ లోపం వలన మీడియా డౌన్‌లోడ్ విఫలమైంది.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'అవినీతి సమస్య కారణంగా లేదా మీ బ్రౌజర్ మద్దతు ఇవ్వని లక్షణాలను మీడియా ఉపయోగించినందున మీడియా ప్లేబ్యాక్ నిలిపివేయబడింది.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'సర్వర్ లేదా నెట్‌వర్క్ విఫలమైనందున లేదా ఫార్మాట్‌కు మద్దతు లేనందున మీడియాను లోడ్ చేయడం సాధ్యం కాలేదు.', - 'This media could not be played because it could not be decrypted.': - 'మీడియా గుప్తీకరించబడింది మరియు దానిని డీక్రిప్ట్ చేయడానికి మాకు కీలు లేవు.', - '': '', - 'Something went wrong.': 'ఏదో తప్పు జరిగింది.', - OK: 'మూసివేయండి', - 'An unexpected error occurred.': 'ఒక లోపం సంభవించింది. దయచేసి మళ్ళీ ప్రయత్నించండి.', - Settings: 'సెట్టింగ్‌లు', - Quality: 'నాణ్యత', - Audio: 'ఆడియో', - Default: 'డిఫాల్ట్', - Speed: 'వేగం', - Captions: 'శీర్షికలు', - 'Playback rate': 'ప్లేబ్యాక్ రేట్', - Back: 'వెనుకకు', - Off: 'ఆఫ్', - Auto: 'ఆటో', - 'Auto ({label})': 'ఆటో ({label})', - Subtitles: 'ఉపశీర్షికలు', -} as const satisfies Partial; + buttons: { + play: 'ప్లే', + pause: 'పాజ్', + replay: 'రీప్లే', + mute: 'మ్యూట్', + unmute: 'అన్మ్యూట్ చేయండి', + }, + seek: { + forward: '{seconds} సెకన్లు ముందుకు', + backward: '{seconds} సెకన్లు వెనుకకు', + }, + fullscreen: { + enter: 'పూర్తి స్క్రీన్', + exit: 'పూర్తి స్క్రీన్ నుండి నిష్క్రమించండి', + }, + captions: { + enable: 'శీర్షికలు', + disable: 'శీర్షికలు ఆఫ్ చేయండి', + }, + pip: { + enter: 'పిక్చర్-ఇన్-పిక్చర్', + exit: 'పిక్చర్-ఇన్-పిక్చర్ నుండి నిష్క్రమించండి', + }, + live: { + playing: 'లైవ్‌లో ప్లే అవుతోంది', + seekToEdge: 'లైవ్‌కు వెళ్లండి', + badge: 'లైవ్', + }, + cast: { + start: 'కాస్టింగ్ ప్రారంభించండి', + stop: 'కాస్టింగ్ ఆపండి', + connecting: 'కనెక్ట్ అవుతోంది', + }, + airplay: { + start: 'AirPlay ప్రారంభించండి', + stop: 'AirPlay ఆపండి', + }, + slider: { + seek: 'శోధించు', + }, + time: { + current: 'ప్రస్తుత సమయం', + duration: 'వ్యవధి', + remaining: 'మిగిలిన సమయం', + remainingSuffix: '{duration} మిగిలి', + showElapsed: '{duration}. గడిచిన సమయం చూపు.', + showDuration: '{duration}. వ్యవధి చూపు.', + showRemaining: '{duration}. మిగిలిన సమయం చూపు.', + position: '{current} యొక్క {duration}', + }, + playback: { + rate: 'ప్లేబ్యాక్ రేట్ {rate}', + }, + volume: { + mutedValue: '{percent}, మ్యూట్ చేయబడింది', + muted: 'మ్యూట్ చేయబడింది', + label: 'వాల్యూమ్', + value: 'వాల్యూమ్ {value}', + }, + status: { + captionsOn: 'శీర్షికలు ఆన్', + captionsOff: 'శీర్షికలు ఆఫ్', + paused: 'పాజ్ చేయబడింది', + playing: 'ప్లే అవుతోంది', + fullscreen: 'పూర్తి స్క్రీన్', + pip: 'పిక్చర్ ఇన్ పిక్చర్', + exitPip: 'పిక్చర్ ఇన్ పిక్చర్ నుండి నిష్క్రమించండి', + }, + errors: { + aborted: 'మీరు మీడియా ప్లేబ్యాక్‌ను రద్దు చేశారు', + network: 'నెట్‌వర్క్ లోపం వలన మీడియా డౌన్‌లోడ్ విఫలమైంది.', + decode: 'అవినీతి సమస్య కారణంగా లేదా మీ బ్రౌజర్ మద్దతు ఇవ్వని లక్షణాలను మీడియా ఉపయోగించినందున మీడియా ప్లేబ్యాక్ నిలిపివేయబడింది.', + source: 'సర్వర్ లేదా నెట్‌వర్క్ విఫలమైనందున లేదా ఫార్మాట్‌కు మద్దతు లేనందున మీడియాను లోడ్ చేయడం సాధ్యం కాలేదు.', + encrypted: 'మీడియా గుప్తీకరించబడింది మరియు దానిని డీక్రిప్ట్ చేయడానికి మాకు కీలు లేవు.', + title: 'ఏదో తప్పు జరిగింది.', + unexpected: 'ఒక లోపం సంభవించింది. దయచేసి మళ్ళీ ప్రయత్నించండి.', + }, + common: { + empty: '', + ok: 'మూసివేయండి', + }, + menu: { + settings: 'సెట్టింగ్‌లు', + quality: 'నాణ్యత', + audio: 'ఆడియో', + default: 'డిఫాల్ట్', + speed: 'వేగం', + captions: 'శీర్షికలు', + playbackRate: 'ప్లేబ్యాక్ రేట్', + back: 'వెనుకకు', + off: 'ఆఫ్', + auto: 'ఆటో', + autoWithLabel: 'ఆటో ({label})', + subtitles: 'ఉపశీర్షికలు', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/th.ts b/packages/core/src/core/i18n/locales/th.ts index c44bfdc2..51a0c851 100644 --- a/packages/core/src/core/i18n/locales/th.ts +++ b/packages/core/src/core/i18n/locales/th.ts @@ -1,68 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'เล่น', - Pause: 'หยุดชั่วคราว', - Replay: 'เล่นซ้ำ', - Mute: 'ปิดเสียง', - Unmute: 'ยกเลิกการปิดเสียง', - 'Seek forward {seconds} seconds': 'ข้ามไปข้างหน้า {seconds} วินาที', - 'Seek backward {seconds} seconds': 'ข้ามไปข้างหลัง {seconds} วินาที', - 'Enter fullscreen': 'แบบเต็มหน้าจอ', - 'Enable captions': 'เปิดคำบรรยาย', - 'Disable captions': 'ปิดคำบรรยาย', - 'Enter picture-in-picture': 'การเล่นภาพควบคู่', - 'Exit picture-in-picture': 'ออกจากการเล่นภาพควบคู่', - 'Playing live': 'กำลังถ่ายทอดสด', - 'Seek to live edge': 'ไปยังจุดถ่ายทอดสด', - Live: 'ถ่ายทอดสด', - 'Start casting': 'เริ่มแคสต์', - 'Stop casting': 'หยุดแคสต์', - Connecting: 'กำลังเชื่อมต่อ', - Seek: 'ค้นหา', - 'Current time': 'เวลาปัจจุบัน', - Duration: 'ระยะเวลา', - Remaining: 'เวลาที่เหลือ', - '{duration} remaining': 'เหลือ {duration}', - '{duration}. Show elapsed time.': '{duration}. แสดงเวลาที่ผ่านไป.', - '{duration}. Show duration.': '{duration}. แสดงระยะเวลา.', - '{duration}. Show remaining time.': '{duration}. แสดงเวลาที่เหลือ.', - 'Playback rate {rate}': 'อัตราการเล่น {rate}', - '{current} of {duration}': '{current} ของ {duration}', - '{percent}, muted': '{percent}, ปิดเสียง', - Muted: 'ปิดเสียงแล้ว', - Volume: 'ระดับเสียง', - 'Volume {value}': 'ระดับเสียง {value}', - 'Captions on': 'เปิดคำอธิบายภาพ', - 'Captions off': 'ปิดคำอธิบายภาพ', - Paused: 'หยุดชั่วคราว', - Playing: 'กำลังเล่น', - Fullscreen: 'เต็มหน้าจอ', - 'Exit fullscreen': 'ออกจากเต็มหน้าจอ', - 'Picture in picture': 'ภาพซ้อนภาพ', - 'Exit picture in picture': 'ออกจากภาพซ้อนภาพ', - 'You stopped media playback before it finished.': 'คุณยกเลิกการเล่นสื่อแล้ว', - 'This media could not be loaded due to a network or server issue.': - 'ข้อผิดพลาดของเครือข่ายทำให้การดาวน์โหลดสื่อไม่สำเร็จเป็นบางส่วน', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'การเล่นสื่อถูกยกเลิกเนื่องจากปัญหาเกี่ยวกับความเสียหาย หรือเนื่องจากสื่อใช้ฟีเจอร์ที่เบราว์เซอร์ของคุณไม่รองรับ', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'ไม่สามารถโหลดสื่อได้ โดยอาจเป็นเพราะเซิร์ฟเวอร์หรือเครือข่ายล้มเหลว หรือเพราะรูปแบบไม่ได้รับการรองรับ', - 'This media could not be played because it could not be decrypted.': 'สื่อถูกเข้ารหัสลับแล้ว และเราไม่มีคีย์ที่จะถอดรหัสลับดังกล่าว', - '': '', - 'Something went wrong.': 'เกิดข้อผิดพลาด', - OK: 'ปิด', - 'An unexpected error occurred.': 'เกิดข้อผิดพลาด กรุณาลองอีกครั้ง', - Settings: 'การตั้งค่า', - Quality: 'คุณภาพ', - Audio: 'เสียง', - Default: 'ค่าเริ่มต้น', - Speed: 'ความเร็ว', - Captions: 'คำบรรยาย', - 'Playback rate': 'อัตราการเล่น', - Back: 'กลับ', - Off: 'ปิด', - Auto: 'อัตโนมัติ', - 'Auto ({label})': 'อัตโนมัติ ({label})', - Subtitles: 'คำบรรยาย', -} as const satisfies Partial; + buttons: { + play: 'เล่น', + pause: 'หยุดชั่วคราว', + replay: 'เล่นซ้ำ', + mute: 'ปิดเสียง', + unmute: 'ยกเลิกการปิดเสียง', + }, + seek: { + forward: 'ข้ามไปข้างหน้า {seconds} วินาที', + backward: 'ข้ามไปข้างหลัง {seconds} วินาที', + }, + fullscreen: { + enter: 'แบบเต็มหน้าจอ', + exit: 'ออกจากเต็มหน้าจอ', + }, + captions: { + enable: 'เปิดคำบรรยาย', + disable: 'ปิดคำบรรยาย', + }, + pip: { + enter: 'การเล่นภาพควบคู่', + exit: 'ออกจากการเล่นภาพควบคู่', + }, + live: { + playing: 'กำลังถ่ายทอดสด', + seekToEdge: 'ไปยังจุดถ่ายทอดสด', + badge: 'ถ่ายทอดสด', + }, + cast: { + start: 'เริ่มแคสต์', + stop: 'หยุดแคสต์', + connecting: 'กำลังเชื่อมต่อ', + }, + airplay: { + start: 'เริ่ม AirPlay', + stop: 'หยุด AirPlay', + }, + slider: { + seek: 'ค้นหา', + }, + time: { + current: 'เวลาปัจจุบัน', + duration: 'ระยะเวลา', + remaining: 'เวลาที่เหลือ', + remainingSuffix: 'เหลือ {duration}', + showElapsed: '{duration}. แสดงเวลาที่ผ่านไป.', + showDuration: '{duration}. แสดงระยะเวลา.', + showRemaining: '{duration}. แสดงเวลาที่เหลือ.', + position: '{current} ของ {duration}', + }, + playback: { + rate: 'อัตราการเล่น {rate}', + }, + volume: { + mutedValue: '{percent}, ปิดเสียง', + muted: 'ปิดเสียงแล้ว', + label: 'ระดับเสียง', + value: 'ระดับเสียง {value}', + }, + status: { + captionsOn: 'เปิดคำอธิบายภาพ', + captionsOff: 'ปิดคำอธิบายภาพ', + paused: 'หยุดชั่วคราว', + playing: 'กำลังเล่น', + fullscreen: 'เต็มหน้าจอ', + pip: 'ภาพซ้อนภาพ', + exitPip: 'ออกจากภาพซ้อนภาพ', + }, + errors: { + aborted: 'คุณยกเลิกการเล่นสื่อแล้ว', + network: 'ข้อผิดพลาดของเครือข่ายทำให้การดาวน์โหลดสื่อไม่สำเร็จเป็นบางส่วน', + decode: 'การเล่นสื่อถูกยกเลิกเนื่องจากปัญหาเกี่ยวกับความเสียหาย หรือเนื่องจากสื่อใช้ฟีเจอร์ที่เบราว์เซอร์ของคุณไม่รองรับ', + source: 'ไม่สามารถโหลดสื่อได้ โดยอาจเป็นเพราะเซิร์ฟเวอร์หรือเครือข่ายล้มเหลว หรือเพราะรูปแบบไม่ได้รับการรองรับ', + encrypted: 'สื่อถูกเข้ารหัสลับแล้ว และเราไม่มีคีย์ที่จะถอดรหัสลับดังกล่าว', + title: 'เกิดข้อผิดพลาด', + unexpected: 'เกิดข้อผิดพลาด กรุณาลองอีกครั้ง', + }, + common: { + empty: '', + ok: 'ปิด', + }, + menu: { + settings: 'การตั้งค่า', + quality: 'คุณภาพ', + audio: 'เสียง', + default: 'ค่าเริ่มต้น', + speed: 'ความเร็ว', + captions: 'คำบรรยาย', + playbackRate: 'อัตราการเล่น', + back: 'กลับ', + off: 'ปิด', + auto: 'อัตโนมัติ', + autoWithLabel: 'อัตโนมัติ ({label})', + subtitles: 'คำบรรยาย', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/tr.ts b/packages/core/src/core/i18n/locales/tr.ts index 59160445..7b323c37 100644 --- a/packages/core/src/core/i18n/locales/tr.ts +++ b/packages/core/src/core/i18n/locales/tr.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Oynat', - Pause: 'Duraklat', - Replay: 'Yeniden Oynat', - Mute: 'Sessiz', - Unmute: 'Sesi Aç', - 'Seek forward {seconds} seconds': '{seconds} saniye ileri sar', - 'Seek backward {seconds} seconds': '{seconds} saniye geri sar', - 'Enter fullscreen': 'Tam Ekran', - 'Enable captions': 'Altyazıları aç', - 'Disable captions': 'Altyazıları kapat', - 'Enter picture-in-picture': 'Mini oynatıcı', - 'Exit picture-in-picture': 'Mini oynatıcıdan çık', - 'Playing live': 'Canlı oynatılıyor', - 'Seek to live edge': 'Canlıya git', - Live: 'Canlı', - 'Start casting': 'Yansıtmayı başlat', - 'Stop casting': 'Yansıtmayı durdur', - Connecting: 'Bağlanıyor', - Seek: 'Ara', - 'Current time': 'Süre', - Duration: 'Toplam Süre', - Remaining: 'Kalan Süre', - '{duration} remaining': '{duration} kaldı', - '{duration}. Show elapsed time.': '{duration}. Geçen süreyi göster.', - '{duration}. Show duration.': '{duration}. Süreyi göster.', - '{duration}. Show remaining time.': '{duration}. Kalan süreyi göster.', - 'Playback rate {rate}': 'Oynatma Hızı {rate}', - '{current} of {duration}': '{current} / {duration}', - '{percent}, muted': '{percent}, sessiz', - Muted: 'Sessiz', - Volume: 'Ses', - 'Volume {value}': 'Ses {value}', - 'Captions on': 'Altyazılar açık', - 'Captions off': 'Altyazılar kapalı', - Paused: 'Duraklatıldı', - Playing: 'Oynatılıyor', - Fullscreen: 'Tam ekran', - 'Exit fullscreen': 'Tam ekrandan çık', - 'Picture in picture': 'Resim içinde resim', - 'Exit picture in picture': 'Resim içinde resimden çık', - 'You stopped media playback before it finished.': 'Medyayı oynatmayı iptal ettiniz', - 'This media could not be loaded due to a network or server issue.': - 'Medya indirme işleminin kısmen başarısız olmasına neden olan bir ağ sorunu oluştu.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Medya oynatma, bir bozulma sorunu nedeniyle veya medya, tarayıcınızın desteklemediği özellikleri kullandığı için durduruldu.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Sunucu veya ağ hatasından ya da biçim desteklenmediğinden medya yüklenemedi.', - 'This media could not be played because it could not be decrypted.': - 'Medya, şifrelenmiş bir kaynaktan geliyor ve oynatmak için gerekli anahtar bulunamadı.', - '': '', - 'Something went wrong.': 'Bir şeyler ters gitti.', - OK: 'Kapat', - 'An unexpected error occurred.': 'Bir hata oluştu. Lütfen tekrar deneyin.', - Settings: 'Ayarlar', - Quality: 'Kalite', - Audio: 'Ses', - Default: 'Varsayılan', - Speed: 'Hız', - Captions: 'Altyazılar', - 'Playback rate': 'Oynatma hızı', - Back: 'Geri', - Off: 'Kapalı', - Auto: 'Otomatik', - 'Auto ({label})': 'Otomatik ({label})', - Subtitles: 'Altyazılar', -} as const satisfies Partial; + buttons: { + play: 'Oynat', + pause: 'Duraklat', + replay: 'Yeniden Oynat', + mute: 'Sessiz', + unmute: 'Sesi Aç', + }, + seek: { + forward: '{seconds} saniye ileri sar', + backward: '{seconds} saniye geri sar', + }, + fullscreen: { + enter: 'Tam Ekran', + exit: 'Tam ekrandan çık', + }, + captions: { + enable: 'Altyazıları aç', + disable: 'Altyazıları kapat', + }, + pip: { + enter: 'Mini oynatıcı', + exit: 'Mini oynatıcıdan çık', + }, + live: { + playing: 'Canlı oynatılıyor', + seekToEdge: 'Canlıya git', + badge: 'Canlı', + }, + cast: { + start: 'Yansıtmayı başlat', + stop: 'Yansıtmayı durdur', + connecting: 'Bağlanıyor', + }, + airplay: { + start: "AirPlay'i başlat", + stop: "AirPlay'i durdur", + }, + slider: { + seek: 'Ara', + }, + time: { + current: 'Süre', + duration: 'Toplam Süre', + remaining: 'Kalan Süre', + remainingSuffix: '{duration} kaldı', + showElapsed: '{duration}. Geçen süreyi göster.', + showDuration: '{duration}. Süreyi göster.', + showRemaining: '{duration}. Kalan süreyi göster.', + position: '{current} / {duration}', + }, + playback: { + rate: 'Oynatma Hızı {rate}', + }, + volume: { + mutedValue: '{percent}, sessiz', + muted: 'Sessiz', + label: 'Ses', + value: 'Ses {value}', + }, + status: { + captionsOn: 'Altyazılar açık', + captionsOff: 'Altyazılar kapalı', + paused: 'Duraklatıldı', + playing: 'Oynatılıyor', + fullscreen: 'Tam ekran', + pip: 'Resim içinde resim', + exitPip: 'Resim içinde resimden çık', + }, + errors: { + aborted: 'Medyayı oynatmayı iptal ettiniz', + network: 'Medya indirme işleminin kısmen başarısız olmasına neden olan bir ağ sorunu oluştu.', + decode: + 'Medya oynatma, bir bozulma sorunu nedeniyle veya medya, tarayıcınızın desteklemediği özellikleri kullandığı için durduruldu.', + source: 'Sunucu veya ağ hatasından ya da biçim desteklenmediğinden medya yüklenemedi.', + encrypted: 'Medya, şifrelenmiş bir kaynaktan geliyor ve oynatmak için gerekli anahtar bulunamadı.', + title: 'Bir şeyler ters gitti.', + unexpected: 'Bir hata oluştu. Lütfen tekrar deneyin.', + }, + common: { + empty: '', + ok: 'Kapat', + }, + menu: { + settings: 'Ayarlar', + quality: 'Kalite', + audio: 'Ses', + default: 'Varsayılan', + speed: 'Hız', + captions: 'Altyazılar', + playbackRate: 'Oynatma hızı', + back: 'Geri', + off: 'Kapalı', + auto: 'Otomatik', + autoWithLabel: 'Otomatik ({label})', + subtitles: 'Altyazılar', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/uk.ts b/packages/core/src/core/i18n/locales/uk.ts index 02356625..9073e0a6 100644 --- a/packages/core/src/core/i18n/locales/uk.ts +++ b/packages/core/src/core/i18n/locales/uk.ts @@ -1,69 +1,100 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Відтворити', - Pause: 'Призупинити', - Replay: 'Відтворити знову', - Mute: 'Без звуку', - Unmute: 'Зі звуком', - 'Seek forward {seconds} seconds': 'Перемотати вперед на {seconds} с', - 'Seek backward {seconds} seconds': 'Перемотати назад на {seconds} с', - 'Enter fullscreen': 'Повноекранний режим', - 'Enable captions': 'Увімкнути субтитри', - 'Disable captions': 'Вимкнути субтитри', - 'Enter picture-in-picture': 'Зображення в зображенні', - 'Exit picture-in-picture': 'Вийти із режиму зображення в зображенні', - 'Playing live': 'Прямий ефір', - 'Seek to live edge': 'Перейти до прямого ефіру', - Live: 'На живо', - 'Start casting': 'Почати трансляцію', - 'Stop casting': 'Зупинити трансляцію', - Connecting: 'Підключення', - Seek: 'Перемотка', - 'Current time': 'Поточний час', - Duration: 'Тривалість', - Remaining: 'Час, що залишився', - '{duration} remaining': 'Залишилось {duration}', - '{duration}. Show elapsed time.': '{duration}. Показати минулий час.', - '{duration}. Show duration.': '{duration}. Показати тривалість.', - '{duration}. Show remaining time.': '{duration}. Показати час, що залишився.', - 'Playback rate {rate}': 'Швидкість відтворення {rate}', - '{current} of {duration}': '{current} з {duration}', - '{percent}, muted': '{percent}, вимкнено', - Muted: 'Вимкнено', - Volume: 'Гучність', - 'Volume {value}': 'Гучність {value}', - 'Captions on': 'Підписи увімкнено', - 'Captions off': 'Підписи вимкнено', - Paused: 'На паузі', - Playing: 'Відтворення', - Fullscreen: 'Повноекранний режим', - 'Exit fullscreen': 'Вийти з повноекранного режиму', - 'Picture in picture': 'Зображення в зображенні', - 'Exit picture in picture': 'Вийти із режиму зображення в зображенні', - 'You stopped media playback before it finished.': 'Ви припинили відтворення відео', - 'This media could not be loaded due to a network or server issue.': - 'Помилка мережі викликала збій під час завантаження відео.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - "Відтворення відео було припинено через пошкодження або у зв'язку з тим, що відео використовує функції, які не підтримуються вашим браузером.", - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Неможливо завантажити відео через мережевий чи серверний збій або формат не підтримується.', - 'This media could not be played because it could not be decrypted.': - 'Відео в зашифрованому вигляді, і ми не маємо ключі для розшифровки.', - '': '', - 'Something went wrong.': 'Щось пішло не так.', - OK: 'Закрити', - 'An unexpected error occurred.': 'Сталася помилка. Будь ласка, спробуйте ще раз.', - Settings: 'Налаштування', - Quality: 'Якість', - Audio: 'Аудіо', - Default: 'За замовчуванням', - Speed: 'Швидкість', - Captions: 'Субтитри', - 'Playback rate': 'Швидкість відтворення', - Back: 'Назад', - Off: 'Вимкнено', - Auto: 'Авто', - 'Auto ({label})': 'Авто ({label})', - Subtitles: 'Субтитри', -} as const satisfies Partial; + buttons: { + play: 'Відтворити', + pause: 'Призупинити', + replay: 'Відтворити знову', + mute: 'Без звуку', + unmute: 'Зі звуком', + }, + seek: { + forward: 'Перемотати вперед на {seconds} с', + backward: 'Перемотати назад на {seconds} с', + }, + fullscreen: { + enter: 'Повноекранний режим', + exit: 'Вийти з повноекранного режиму', + }, + captions: { + enable: 'Увімкнути субтитри', + disable: 'Вимкнути субтитри', + }, + pip: { + enter: 'Зображення в зображенні', + exit: 'Вийти із режиму зображення в зображенні', + }, + live: { + playing: 'Прямий ефір', + seekToEdge: 'Перейти до прямого ефіру', + badge: 'На живо', + }, + cast: { + start: 'Почати трансляцію', + stop: 'Зупинити трансляцію', + connecting: 'Підключення', + }, + airplay: { + start: 'Запустити AirPlay', + stop: 'Зупинити AirPlay', + }, + slider: { + seek: 'Перемотка', + }, + time: { + current: 'Поточний час', + duration: 'Тривалість', + remaining: 'Час, що залишився', + remainingSuffix: 'Залишилось {duration}', + showElapsed: '{duration}. Показати минулий час.', + showDuration: '{duration}. Показати тривалість.', + showRemaining: '{duration}. Показати час, що залишився.', + position: '{current} з {duration}', + }, + playback: { + rate: 'Швидкість відтворення {rate}', + }, + volume: { + mutedValue: '{percent}, вимкнено', + muted: 'Вимкнено', + label: 'Гучність', + value: 'Гучність {value}', + }, + status: { + captionsOn: 'Підписи увімкнено', + captionsOff: 'Підписи вимкнено', + paused: 'На паузі', + playing: 'Відтворення', + fullscreen: 'Повноекранний режим', + pip: 'Зображення в зображенні', + exitPip: 'Вийти із режиму зображення в зображенні', + }, + errors: { + aborted: 'Ви припинили відтворення відео', + network: 'Помилка мережі викликала збій під час завантаження відео.', + decode: + "Відтворення відео було припинено через пошкодження або у зв'язку з тим, що відео використовує функції, які не підтримуються вашим браузером.", + source: 'Неможливо завантажити відео через мережевий чи серверний збій або формат не підтримується.', + encrypted: 'Відео в зашифрованому вигляді, і ми не маємо ключі для розшифровки.', + title: 'Щось пішло не так.', + unexpected: 'Сталася помилка. Будь ласка, спробуйте ще раз.', + }, + common: { + empty: '', + ok: 'Закрити', + }, + menu: { + settings: 'Налаштування', + quality: 'Якість', + audio: 'Аудіо', + default: 'За замовчуванням', + speed: 'Швидкість', + captions: 'Субтитри', + playbackRate: 'Швидкість відтворення', + back: 'Назад', + off: 'Вимкнено', + auto: 'Авто', + autoWithLabel: 'Авто ({label})', + subtitles: 'Субтитри', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/vi.ts b/packages/core/src/core/i18n/locales/vi.ts index 93b11bef..90ee36ff 100644 --- a/packages/core/src/core/i18n/locales/vi.ts +++ b/packages/core/src/core/i18n/locales/vi.ts @@ -1,68 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: 'Phát', - Pause: 'Tạm dừng', - Replay: 'Phát lại', - Mute: 'Tắt tiếng', - Unmute: 'Bật âm thanh', - 'Seek forward {seconds} seconds': 'Tua tới {seconds} giây', - 'Seek backward {seconds} seconds': 'Tua lại {seconds} giây', - 'Enter fullscreen': 'Toàn màn hình', - 'Enable captions': 'Bật phụ đề', - 'Disable captions': 'Tắt chú thích', - 'Enter picture-in-picture': 'Màn hình trong màn hình', - 'Exit picture-in-picture': 'Thoát màn hình trong màn hình', - 'Playing live': 'Đang phát trực tiếp', - 'Seek to live edge': 'Tua tới trực tiếp', - Live: 'Trực tiếp', - 'Start casting': 'Bắt đầu truyền phát', - 'Stop casting': 'Dừng truyền phát', - Connecting: 'Đang kết nối', - Seek: 'Tua', - 'Current time': 'Thời gian hiện tại', - Duration: 'Độ dài', - Remaining: 'Thời gian còn lại', - '{duration} remaining': 'Còn {duration}', - '{duration}. Show elapsed time.': '{duration}. Hiển thị thời gian đã phát.', - '{duration}. Show duration.': '{duration}. Hiển thị thời lượng.', - '{duration}. Show remaining time.': '{duration}. Hiển thị thời gian còn lại.', - 'Playback rate {rate}': 'Tỉ lệ phát lại {rate}', - '{current} of {duration}': '{current} của {duration}', - '{percent}, muted': '{percent}, đã tắt tiếng', - Muted: 'Đã tắt tiếng', - Volume: 'Âm lượng', - 'Volume {value}': 'Âm lượng {value}', - 'Captions on': 'Bật chú thích', - 'Captions off': 'Tắt chú thích', - Paused: 'Đã tạm dừng', - Playing: 'Đang phát', - Fullscreen: 'Toàn màn hình', - 'Exit fullscreen': 'Thoát toàn màn hình', - 'Picture in picture': 'Màn hình trong màn hình', - 'Exit picture in picture': 'Thoát màn hình trong màn hình', - 'You stopped media playback before it finished.': 'Bạn đã hủy việc phát lại media.', - 'This media could not be loaded due to a network or server issue.': 'Một lỗi mạng dẫn đến việc tải media bị lỗi.', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - 'Phát media đã bị hủy do một sai lỗi hoặc media sử dụng những tính năng trình duyệt không hỗ trợ.', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - 'Video không tải được, mạng hay server có lỗi hoặc định dạng không được hỗ trợ.', - 'This media could not be played because it could not be decrypted.': - 'Media đã được mã hóa và chúng tôi không có khóa để giải mã.', - '': '', - 'Something went wrong.': 'Đã xảy ra lỗi.', - OK: 'Đóng', - 'An unexpected error occurred.': 'Đã xảy ra lỗi. Vui lòng thử lại.', - Settings: 'Cài đặt', - Quality: 'Chất lượng', - Audio: 'Âm thanh', - Default: 'Mặc định', - Speed: 'Tốc độ', - Captions: 'Phụ đề', - 'Playback rate': 'Tốc độ phát lại', - Back: 'Quay lại', - Off: 'Tắt', - Auto: 'Tự động', - 'Auto ({label})': 'Tự động ({label})', - Subtitles: 'Phụ đề', -} as const satisfies Partial; + buttons: { + play: 'Phát', + pause: 'Tạm dừng', + replay: 'Phát lại', + mute: 'Tắt tiếng', + unmute: 'Bật âm thanh', + }, + seek: { + forward: 'Tua tới {seconds} giây', + backward: 'Tua lại {seconds} giây', + }, + fullscreen: { + enter: 'Toàn màn hình', + exit: 'Thoát toàn màn hình', + }, + captions: { + enable: 'Bật phụ đề', + disable: 'Tắt chú thích', + }, + pip: { + enter: 'Màn hình trong màn hình', + exit: 'Thoát màn hình trong màn hình', + }, + live: { + playing: 'Đang phát trực tiếp', + seekToEdge: 'Tua tới trực tiếp', + badge: 'Trực tiếp', + }, + cast: { + start: 'Bắt đầu truyền phát', + stop: 'Dừng truyền phát', + connecting: 'Đang kết nối', + }, + airplay: { + start: 'Bắt đầu AirPlay', + stop: 'Dừng AirPlay', + }, + slider: { + seek: 'Tua', + }, + time: { + current: 'Thời gian hiện tại', + duration: 'Độ dài', + remaining: 'Thời gian còn lại', + remainingSuffix: 'Còn {duration}', + showElapsed: '{duration}. Hiển thị thời gian đã phát.', + showDuration: '{duration}. Hiển thị thời lượng.', + showRemaining: '{duration}. Hiển thị thời gian còn lại.', + position: '{current} của {duration}', + }, + playback: { + rate: 'Tỉ lệ phát lại {rate}', + }, + volume: { + mutedValue: '{percent}, đã tắt tiếng', + muted: 'Đã tắt tiếng', + label: 'Âm lượng', + value: 'Âm lượng {value}', + }, + status: { + captionsOn: 'Bật chú thích', + captionsOff: 'Tắt chú thích', + paused: 'Đã tạm dừng', + playing: 'Đang phát', + fullscreen: 'Toàn màn hình', + pip: 'Màn hình trong màn hình', + exitPip: 'Thoát màn hình trong màn hình', + }, + errors: { + aborted: 'Bạn đã hủy việc phát lại media.', + network: 'Một lỗi mạng dẫn đến việc tải media bị lỗi.', + decode: 'Phát media đã bị hủy do một sai lỗi hoặc media sử dụng những tính năng trình duyệt không hỗ trợ.', + source: 'Video không tải được, mạng hay server có lỗi hoặc định dạng không được hỗ trợ.', + encrypted: 'Media đã được mã hóa và chúng tôi không có khóa để giải mã.', + title: 'Đã xảy ra lỗi.', + unexpected: 'Đã xảy ra lỗi. Vui lòng thử lại.', + }, + common: { + empty: '', + ok: 'Đóng', + }, + menu: { + settings: 'Cài đặt', + quality: 'Chất lượng', + audio: 'Âm thanh', + default: 'Mặc định', + speed: 'Tốc độ', + captions: 'Phụ đề', + playbackRate: 'Tốc độ phát lại', + back: 'Quay lại', + off: 'Tắt', + auto: 'Tự động', + autoWithLabel: 'Tự động ({label})', + subtitles: 'Phụ đề', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/zh-CN.ts b/packages/core/src/core/i18n/locales/zh-CN.ts index 2f0a53c1..1c43b227 100644 --- a/packages/core/src/core/i18n/locales/zh-CN.ts +++ b/packages/core/src/core/i18n/locales/zh-CN.ts @@ -1,67 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: '播放', - Pause: '暂停', - Replay: '重新播放', - Mute: '静音', - Unmute: '开启音效', - 'Seek forward {seconds} seconds': '快进 {seconds} 秒', - 'Seek backward {seconds} seconds': '快退 {seconds} 秒', - 'Enter fullscreen': '全屏', - 'Enable captions': '开启字幕', - 'Disable captions': '关闭字幕', - 'Enter picture-in-picture': '画中画', - 'Exit picture-in-picture': '退出画中画', - 'Playing live': '正在直播', - 'Seek to live edge': '跳转到直播', - Live: '直播', - 'Start casting': '开始投屏', - 'Stop casting': '停止投屏', - Connecting: '正在连接', - Seek: '定位', - 'Current time': '当前时间', - Duration: '时长', - Remaining: '剩余时间', - '{duration} remaining': '剩余 {duration}', - '{duration}. Show elapsed time.': '{duration}. 显示已播放时间.', - '{duration}. Show duration.': '{duration}. 显示时长.', - '{duration}. Show remaining time.': '{duration}. 显示剩余时间.', - 'Playback rate {rate}': '播放速度 {rate}', - '{current} of {duration}': '{current},总时长 {duration}', - '{percent}, muted': '{percent},已静音', - Muted: '已静音', - Volume: '音量', - 'Volume {value}': '音量 {value}', - 'Captions on': '字幕已开启', - 'Captions off': '字幕已关闭', - Paused: '已暂停', - Playing: '正在播放', - Fullscreen: '全屏', - 'Exit fullscreen': '退出全屏', - 'Picture in picture': '画中画', - 'Exit picture in picture': '退出画中画', - 'You stopped media playback before it finished.': '视频播放被终止', - 'This media could not be loaded due to a network or server issue.': '网络错误导致视频下载中途失败。', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - '由于视频文件损坏或是该视频使用了你的浏览器不支持的功能,播放终止。', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - '视频因格式不支持或者服务器或网络的问题无法加载。', - 'This media could not be played because it could not be decrypted.': '视频已加密,无法解密。', - '': '', - 'Something went wrong.': '出现问题。', - OK: '关闭', - 'An unexpected error occurred.': '发生错误,请重试。', - Settings: '设置', - Quality: '画质', - Audio: '音频', - Default: '默认', - Speed: '速度', - Captions: '字幕', - 'Playback rate': '播放速度', - Back: '返回', - Off: '关闭', - Auto: '自动', - 'Auto ({label})': '自动({label})', - Subtitles: '字幕', -} as const satisfies Partial; + buttons: { + play: '播放', + pause: '暂停', + replay: '重新播放', + mute: '静音', + unmute: '开启音效', + }, + seek: { + forward: '快进 {seconds} 秒', + backward: '快退 {seconds} 秒', + }, + fullscreen: { + enter: '全屏', + exit: '退出全屏', + }, + captions: { + enable: '开启字幕', + disable: '关闭字幕', + }, + pip: { + enter: '画中画', + exit: '退出画中画', + }, + live: { + playing: '正在直播', + seekToEdge: '跳转到直播', + badge: '直播', + }, + cast: { + start: '开始投屏', + stop: '停止投屏', + connecting: '正在连接', + }, + airplay: { + start: '启动 AirPlay', + stop: '停止 AirPlay', + }, + slider: { + seek: '定位', + }, + time: { + current: '当前时间', + duration: '时长', + remaining: '剩余时间', + remainingSuffix: '剩余 {duration}', + showElapsed: '{duration}. 显示已播放时间.', + showDuration: '{duration}. 显示时长.', + showRemaining: '{duration}. 显示剩余时间.', + position: '{current},总时长 {duration}', + }, + playback: { + rate: '播放速度 {rate}', + }, + volume: { + mutedValue: '{percent},已静音', + muted: '已静音', + label: '音量', + value: '音量 {value}', + }, + status: { + captionsOn: '字幕已开启', + captionsOff: '字幕已关闭', + paused: '已暂停', + playing: '正在播放', + fullscreen: '全屏', + pip: '画中画', + exitPip: '退出画中画', + }, + errors: { + aborted: '视频播放被终止', + network: '网络错误导致视频下载中途失败。', + decode: '由于视频文件损坏或是该视频使用了你的浏览器不支持的功能,播放终止。', + source: '视频因格式不支持或者服务器或网络的问题无法加载。', + encrypted: '视频已加密,无法解密。', + title: '出现问题。', + unexpected: '发生错误,请重试。', + }, + common: { + empty: '', + ok: '关闭', + }, + menu: { + settings: '设置', + quality: '画质', + audio: '音频', + default: '默认', + speed: '速度', + captions: '字幕', + playbackRate: '播放速度', + back: '返回', + off: '关闭', + auto: '自动', + autoWithLabel: '自动({label})', + subtitles: '字幕', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/locales/zh-TW.ts b/packages/core/src/core/i18n/locales/zh-TW.ts index 289f3545..bafe7518 100644 --- a/packages/core/src/core/i18n/locales/zh-TW.ts +++ b/packages/core/src/core/i18n/locales/zh-TW.ts @@ -1,67 +1,99 @@ -import type { Translations } from '../types'; +import type { Translations } from '../params'; export default { - Play: '播放', - Pause: '暫停', - Replay: '重播', - Mute: '靜音', - Unmute: '開啟音效', - 'Seek forward {seconds} seconds': '快轉 {seconds} 秒', - 'Seek backward {seconds} seconds': '倒轉 {seconds} 秒', - 'Enter fullscreen': '全螢幕', - 'Enable captions': '開啟字幕', - 'Disable captions': '關閉字幕', - 'Enter picture-in-picture': '子母畫面', - 'Exit picture-in-picture': '離開子母畫面', - 'Playing live': '正在直播', - 'Seek to live edge': '跳轉至直播', - Live: '直播', - 'Start casting': '開始投屏', - 'Stop casting': '停止投屏', - Connecting: '連線中', - Seek: '定位', - 'Current time': '目前時間', - Duration: '總共時間', - Remaining: '剩餘時間', - '{duration} remaining': '剩餘 {duration}', - '{duration}. Show elapsed time.': '{duration}. 顯示已播放時間.', - '{duration}. Show duration.': '{duration}. 顯示時長.', - '{duration}. Show remaining time.': '{duration}. 顯示剩餘時間.', - 'Playback rate {rate}': '播放速率 {rate}', - '{current} of {duration}': '{current},總時長 {duration}', - '{percent}, muted': '{percent},已靜音', - Muted: '已靜音', - Volume: '音量', - 'Volume {value}': '音量 {value}', - 'Captions on': '字幕已開啟', - 'Captions off': '字幕已關閉', - Paused: '已暫停', - Playing: '正在播放', - Fullscreen: '全螢幕', - 'Exit fullscreen': '退出全螢幕', - 'Picture in picture': '子母畫面', - 'Exit picture in picture': '離開子母畫面', - 'You stopped media playback before it finished.': '影片播放已終止', - 'This media could not be loaded due to a network or server issue.': '網路錯誤導致影片下載失敗。', - 'This media could not be played. It may be corrupted, or your browser may not support its format.': - '由於影片檔案損毀或是該影片使用了您的瀏覽器不支援的功能,已終止播放媒體。', - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': - '因格式不支援、伺服器或網路的問題無法載入媒體。', - 'This media could not be played because it could not be decrypted.': '媒體已加密,無法解密。', - '': '', - 'Something went wrong.': '發生問題。', - OK: '關閉', - 'An unexpected error occurred.': '發生錯誤,請重試。', - Settings: '設定', - Quality: '畫質', - Audio: '音訊', - Default: '預設', - Speed: '速度', - Captions: '字幕', - 'Playback rate': '播放速率', - Back: '返回', - Off: '關閉', - Auto: '自動', - 'Auto ({label})': '自動({label})', - Subtitles: '字幕', -} as const satisfies Partial; + buttons: { + play: '播放', + pause: '暫停', + replay: '重播', + mute: '靜音', + unmute: '開啟音效', + }, + seek: { + forward: '快轉 {seconds} 秒', + backward: '倒轉 {seconds} 秒', + }, + fullscreen: { + enter: '全螢幕', + exit: '退出全螢幕', + }, + captions: { + enable: '開啟字幕', + disable: '關閉字幕', + }, + pip: { + enter: '子母畫面', + exit: '離開子母畫面', + }, + live: { + playing: '正在直播', + seekToEdge: '跳轉至直播', + badge: '直播', + }, + cast: { + start: '開始投屏', + stop: '停止投屏', + connecting: '連線中', + }, + airplay: { + start: '啟動 AirPlay', + stop: '停止 AirPlay', + }, + slider: { + seek: '定位', + }, + time: { + current: '目前時間', + duration: '總共時間', + remaining: '剩餘時間', + remainingSuffix: '剩餘 {duration}', + showElapsed: '{duration}. 顯示已播放時間.', + showDuration: '{duration}. 顯示時長.', + showRemaining: '{duration}. 顯示剩餘時間.', + position: '{current},總時長 {duration}', + }, + playback: { + rate: '播放速率 {rate}', + }, + volume: { + mutedValue: '{percent},已靜音', + muted: '已靜音', + label: '音量', + value: '音量 {value}', + }, + status: { + captionsOn: '字幕已開啟', + captionsOff: '字幕已關閉', + paused: '已暫停', + playing: '正在播放', + fullscreen: '全螢幕', + pip: '子母畫面', + exitPip: '離開子母畫面', + }, + errors: { + aborted: '影片播放已終止', + network: '網路錯誤導致影片下載失敗。', + decode: '由於影片檔案損毀或是該影片使用了您的瀏覽器不支援的功能,已終止播放媒體。', + source: '因格式不支援、伺服器或網路的問題無法載入媒體。', + encrypted: '媒體已加密,無法解密。', + title: '發生問題。', + unexpected: '發生錯誤,請重試。', + }, + common: { + empty: '', + ok: '關閉', + }, + menu: { + settings: '設定', + quality: '畫質', + audio: '音訊', + default: '預設', + speed: '速度', + captions: '字幕', + playbackRate: '播放速率', + back: '返回', + off: '關閉', + auto: '自動', + autoWithLabel: '自動({label})', + subtitles: '字幕', + }, +} as const satisfies Translations; diff --git a/packages/core/src/core/i18n/params.generated.ts b/packages/core/src/core/i18n/params.generated.ts new file mode 100644 index 00000000..c96d5f5b --- /dev/null +++ b/packages/core/src/core/i18n/params.generated.ts @@ -0,0 +1,68 @@ +/** Generated by packages/core/scripts/generate-i18n-types.ts — do not edit. */ + +/** Generated translation parameter contract from the English catalogue. */ +export interface TranslationParams { + 'buttons.play': never; + 'buttons.pause': never; + 'buttons.replay': never; + 'buttons.mute': never; + 'buttons.unmute': never; + 'seek.forward': { seconds: string | number }; + 'seek.backward': { seconds: string | number }; + 'fullscreen.enter': never; + 'fullscreen.exit': never; + 'captions.enable': never; + 'captions.disable': never; + 'pip.enter': never; + 'pip.exit': never; + 'live.playing': never; + 'live.seekToEdge': never; + 'live.badge': never; + 'cast.start': never; + 'cast.stop': never; + 'cast.connecting': never; + 'airplay.start': never; + 'airplay.stop': never; + 'slider.seek': never; + 'time.current': never; + 'time.duration': never; + 'time.remaining': never; + 'time.remainingSuffix': { duration: string | number }; + 'time.showElapsed': { duration: string | number }; + 'time.showDuration': { duration: string | number }; + 'time.showRemaining': { duration: string | number }; + 'time.position': { current: string | number; duration: string | number }; + 'playback.rate': { rate: string | number }; + 'volume.mutedValue': { percent: string | number }; + 'volume.muted': never; + 'volume.label': never; + 'volume.value': { value: string | number }; + 'status.captionsOn': never; + 'status.captionsOff': never; + 'status.paused': never; + 'status.playing': never; + 'status.fullscreen': never; + 'status.pip': never; + 'status.exitPip': never; + 'errors.aborted': never; + 'errors.network': never; + 'errors.decode': never; + 'errors.source': never; + 'errors.encrypted': never; + 'errors.title': never; + 'errors.unexpected': never; + 'common.empty': never; + 'common.ok': never; + 'menu.settings': never; + 'menu.quality': never; + 'menu.audio': never; + 'menu.default': never; + 'menu.speed': never; + 'menu.captions': never; + 'menu.playbackRate': never; + 'menu.back': never; + 'menu.off': never; + 'menu.auto': never; + 'menu.autoWithLabel': { label: string | number }; + 'menu.subtitles': never; +} diff --git a/packages/core/src/core/i18n/params.ts b/packages/core/src/core/i18n/params.ts new file mode 100644 index 00000000..6036aff1 --- /dev/null +++ b/packages/core/src/core/i18n/params.ts @@ -0,0 +1,41 @@ +import type { Contains, UnionToIntersection } from '@videojs/utils/types'; +import type { LOCALES } from './locales'; +import type { TranslationParams } from './params.generated'; + +/** BCP 47 language tag; built-ins are narrowed for autocomplete. */ +export type Locale = (typeof LOCALES)[number] | (string & {}); + +/** Nested shape used by authored locale files. */ +export interface Translations { + readonly [key: string]: string | Translations | undefined; +} + +export type { TranslationParams } from './params.generated'; + +export type TranslationKey = keyof TranslationParams; + +type ParametricKey = { + [Key in TranslationKey]: TranslationParams[Key] extends never ? never : Key; +}[TranslationKey]; + +type ParametricTemplate = + Params extends Record + ? UnionToIntersection< + { + [Name in keyof Params & string]: Contains<`{${Name}}`>; + }[keyof Params & string] + > + : never; + +type ParametricTranslations = { + [Key in ParametricKey]: ParametricTemplate; +}; + +/** Player copy keyed by semantic key; all entries are optional overlays. */ +export type FlatTranslations = { + [Key in TranslationKey]?: TranslationParams[Key] extends never + ? string + : Key extends keyof ParametricTranslations + ? ParametricTranslations[Key] + : string; +} & Record; diff --git a/packages/core/src/core/i18n/registry.ts b/packages/core/src/core/i18n/registry.ts index af144130..b53dd4a0 100644 --- a/packages/core/src/core/i18n/registry.ts +++ b/packages/core/src/core/i18n/registry.ts @@ -1,7 +1,7 @@ -import en from './locales/en'; -import type { Locale, Translations } from './types'; +import type { FlatTranslations, Locale, Translations } from './params'; +import { flattenTranslations } from './utils'; -const registry = new Map>(); +const registry = new Map>(); const subscribers = new Set<() => void>(); function notify(): void { @@ -78,8 +78,8 @@ export function findLocaleKeys(locale: Locale): Locale[] { return out; } -function mergeI18nTranslations(chain: Locale[]): Translations { - const merged: Partial = {}; +function mergeI18nTranslations(chain: Locale[]): FlatTranslations { + const merged: Partial = {}; for (let i = chain.length - 1; i >= 0; i--) { const tag = chain[i]!; const layer = registry.get(tag); @@ -87,30 +87,30 @@ function mergeI18nTranslations(chain: Locale[]): Translations { Object.assign(merged, layer); } } - return merged as Translations; + return merged as FlatTranslations; } /** * Register or merge translation strings for a BCP 47 locale tag. * * @param locale - BCP 47 tag (normalized to lowercase; unicode extensions stripped for the registry key). - * @param translations - Partial map of opaque keys to translated strings; merges with any existing layer for the tag. + * @param translations - Partial nested locale values; merges with any existing layer for the tag. * @public */ export function registerI18n(locale: Locale, translations: Partial): void { const tag = getCanonicalLocaleKey(locale); const existing = registry.get(tag) ?? {}; - registry.set(tag, { ...existing, ...translations }); + registry.set(tag, { ...existing, ...flattenTranslations(translations) }); notify(); } /** - * Return the merged translation map for a locale, walking the BCP 47 lookup chain to English defaults. + * Return the merged registered translation map for a locale. Built-in English defaults are supplied by text descriptors. * * @param locale - BCP 47 tag to resolve (e.g. `es-MX`, `zh-Hant-HK`). * @public */ -export function getI18nTranslations(locale: Locale): Translations { +export function getI18nTranslations(locale: Locale): FlatTranslations { return mergeI18nTranslations(findLocaleKeys(locale)); } @@ -137,11 +137,8 @@ export function hasRegisteredLocale(locale: Locale): boolean { return registry.has(getCanonicalLocaleKey(locale)); } -/** Restores the registry to built-in English only (test isolation). */ +/** Clears registered locale overlays (test isolation). */ export function resetI18nRegistry(): void { registry.clear(); subscribers.clear(); - registry.set('en', { ...en }); } - -registry.set('en', { ...en }); diff --git a/packages/core/src/core/i18n/resolve-text.ts b/packages/core/src/core/i18n/resolve-text.ts new file mode 100644 index 00000000..943654cd --- /dev/null +++ b/packages/core/src/core/i18n/resolve-text.ts @@ -0,0 +1,5 @@ +import type { Text } from './text'; + +export function resolveText(text: Text | string): string { + return typeof text === 'string' ? text : text.text; +} diff --git a/packages/core/src/core/i18n/resolve-translation.ts b/packages/core/src/core/i18n/resolve-translation.ts index ad70c8f0..a0ec43fe 100644 --- a/packages/core/src/core/i18n/resolve-translation.ts +++ b/packages/core/src/core/i18n/resolve-translation.ts @@ -1,18 +1,19 @@ -import type { TranslationKey, TranslationParams, Translator } from './types'; +import type { TranslationKey, TranslationParams } from './params'; +import type { TranslationOptions, Translator } from './translator'; -type ResolveTranslationArgs = Phrase extends TranslationKey - ? TranslationParams[Phrase] extends never - ? [] - : [params: TranslationParams[Phrase]] - : [params?: Record]; +type ResolveTranslationArgs = Key extends TranslationKey + ? TranslationParams[Key] extends never + ? [params?: TranslationOptions] + : [params: TranslationParams[Key] & TranslationOptions] + : [params?: Record & TranslationOptions]; -/** Resolves a phrase with optional template params via a translator. */ -export function resolveTranslation( +/** Resolves a semantic key with optional template params via a translator. */ +export function resolveTranslation( translator: Translator, - phrase: Phrase, - ...args: ResolveTranslationArgs + key: Key, + ...args: ResolveTranslationArgs ): string { const [params] = args; const translate = translator as (key: string, params?: unknown) => string; - return params !== undefined ? translate(phrase, params) : translate(phrase); + return params !== undefined ? translate(key, params) : translate(key); } diff --git a/packages/core/src/core/i18n/tests/browser-translation.test.ts b/packages/core/src/core/i18n/tests/browser-translation.test.ts index 5fdf1a42..a8031f8a 100644 --- a/packages/core/src/core/i18n/tests/browser-translation.test.ts +++ b/packages/core/src/core/i18n/tests/browser-translation.test.ts @@ -8,6 +8,7 @@ import { } from '../browser-translation'; import en from '../locales/en'; import { registerI18n, resetI18nRegistry } from '../registry'; +import { flattenTranslations } from '../utils'; type MockAvailability = 'available' | 'downloadable' | 'unavailable'; @@ -62,11 +63,13 @@ describe('shouldAttemptBrowserTranslation', () => { }); it('attempts when a loaded built-in pack is missing English keys', () => { - expect(shouldAttemptBrowserTranslation('es', ['es'], { Play: 'Ir' })).toBe(true); + expect(shouldAttemptBrowserTranslation('es', ['es'], { 'buttons.play': 'Ir' })).toBe(true); }); it('skips when a loaded built-in pack covers English keys', () => { - expect(shouldAttemptBrowserTranslation('es', ['es'], { ...en, Play: 'Ir' })).toBe(false); + expect(shouldAttemptBrowserTranslation('es', ['es'], { ...flattenTranslations(en), 'buttons.play': 'Ir' })).toBe( + false + ); }); it('attempts when only English lazy tags loaded', () => { @@ -74,7 +77,7 @@ describe('shouldAttemptBrowserTranslation', () => { }); it('skips when a non-en tag in the chain is registered', () => { - registerI18n('es', { Play: 'Ir' }); + registerI18n('es', { 'buttons.play': 'Ir' }); expect(shouldAttemptBrowserTranslation('es-MX', [])).toBe(false); }); @@ -107,8 +110,8 @@ describe('getBrowserTranslations', () => { }); const result = await getBrowserTranslations('fr'); - expect(result.Play).toBe('Jouer'); - expect(result.Pause).toBe('translated:Pause'); + expect(result['buttons.play']).toBe('Jouer'); + expect(result['buttons.pause']).toBe('translated:Pause'); }); it('preserves {param} placeholders in translated strings', async () => { @@ -117,7 +120,7 @@ describe('getBrowserTranslations', () => { }); const result = await getBrowserTranslations('fr'); - expect(result['Seek forward {seconds} seconds']).toBe('FR:Seek forward {seconds} seconds'); + expect(result['seek.forward']).toBe('FR:Seek forward {seconds} seconds'); }); it('masks named placeholders as numeric slots for whole-string translation', async () => { @@ -138,8 +141,8 @@ describe('getBrowserTranslations', () => { const result = await getBrowserTranslations('fr'); expect(translatedInputs).toContain('Seek backward {0} seconds'); expect(translatedInputs.some((text) => text.includes('{seconds}'))).toBe(false); - expect(result['Seek backward {seconds} seconds']).toBe('Mencari mundur {seconds} detik'); - expect(result['Seek forward {seconds} seconds']).toBe('Mencari maju {seconds} detik'); + expect(result['seek.backward']).toBe('Mencari mundur {seconds} detik'); + expect(result['seek.forward']).toBe('Mencari maju {seconds} detik'); }); it('restores slots when the translator adds spaces inside braces', async () => { @@ -148,7 +151,7 @@ describe('getBrowserTranslations', () => { }); const result = await getBrowserTranslations('fr'); - expect(result['Playback rate {rate}']).toBe('Kecepatan pemutaran {rate}'); + expect(result['playback.rate']).toBe('Kecepatan pemutaran {rate}'); }); it('interpolates seek seconds after browser translation', async () => { @@ -158,9 +161,9 @@ describe('getBrowserTranslations', () => { const { createTranslator } = await import('../translator'); const result = await getBrowserTranslations('fr'); - const t = createTranslator({ ...result, Play: 'Play' } as import('../types').Translations, 'fr'); + const t = createTranslator(result, 'fr'); - expect(t('Seek forward {seconds} seconds', { seconds: 10 })).toBe('Mencari maju 10 detik'); + expect(t('seek.forward', { seconds: 10 })).toBe('Mencari maju 10 detik'); }); it('caches results per target language', async () => { @@ -184,7 +187,7 @@ describe('getBrowserTranslations', () => { onModelDownload: { start: onStart, finish: onFinish }, }); expect(translator.create).toHaveBeenCalledTimes(1); - expect(result.Play).toBe('Main'); + expect(result['buttons.play']).toBe('Main'); expect(onStart).toHaveBeenCalledWith('id'); expect(onFinish).toHaveBeenCalledWith('id'); }); diff --git a/packages/core/src/core/i18n/tests/load-locale.test.ts b/packages/core/src/core/i18n/tests/load-locale.test.ts index f1302f4c..1488f726 100644 --- a/packages/core/src/core/i18n/tests/load-locale.test.ts +++ b/packages/core/src/core/i18n/tests/load-locale.test.ts @@ -9,40 +9,40 @@ describe('loadLocale', () => { }); it('skips tags already registered via registerI18n', async () => { - registerI18n('es', { Play: 'Custom' }); + registerI18n('es', { 'buttons.play': 'Custom' }); await expect(loadLocale('es')).resolves.toBeUndefined(); resetI18nRegistry(); }); it('loads shipped locale packs by tag', async () => { const es = await loadLocale('es'); - expect(es?.Play).toBe('Reproducir'); + expect(es?.['buttons.play']).toBe('Reproducir'); }); it('loads alias tags', async () => { const pt = await loadLocale('pt'); - expect(pt?.Play).toBeTruthy(); + expect(pt?.['buttons.play']).toBeTruthy(); }); it('loads regional tags regardless of casing', async () => { const ptBr = await loadLocale('pt-br'); const zhTw = await loadLocale('zh-TW'); - expect(ptBr?.Play).toBeTruthy(); - expect(zhTw?.Play).toBeTruthy(); + expect(ptBr?.['buttons.play']).toBeTruthy(); + expect(zhTw?.['buttons.play']).toBeTruthy(); }); it('loads regional tags via the locale lookup chain', async () => { const esMx = await loadLocale('es-MX'); - expect(esMx?.Play).toBe('Reproducir'); + expect(esMx?.['buttons.play']).toBe('Reproducir'); }); it('loads Traditional Chinese for zh-Hant regional tags', async () => { const zhHantHk = await loadLocale('zh-Hant-HK'); - expect(zhHantHk?.Pause).toBe('暫停'); + expect(zhHantHk?.['buttons.pause']).toBe('暫停'); }); it('loads packs when unicode locale extensions are present', async () => { const zhCn = await loadLocale('zh-CN-u-nu-hans'); - expect(zhCn?.Play).toBeTruthy(); + expect(zhCn?.['buttons.play']).toBeTruthy(); }); }); diff --git a/packages/core/src/core/i18n/tests/registry.test.ts b/packages/core/src/core/i18n/tests/registry.test.ts index e0b3202f..1cab43c6 100644 --- a/packages/core/src/core/i18n/tests/registry.test.ts +++ b/packages/core/src/core/i18n/tests/registry.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import en from '../locales/en'; import { findLocaleKeys, getI18nTranslations, @@ -16,49 +15,49 @@ describe('i18n registry', () => { }); it('merges multiple registerI18n calls for one locale', () => { - registerI18n('es', { Play: 'Ir' }); - registerI18n('es', { Pause: 'Pausa' }); + registerI18n('es', { buttons: { play: 'Ir' } }); + registerI18n('es', { buttons: { pause: 'Pausa' } }); const es = getI18nTranslations('es'); - expect(es.Play).toBe('Ir'); - expect(es.Pause).toBe('Pausa'); + expect(es['buttons.play']).toBe('Ir'); + expect(es['buttons.pause']).toBe('Pausa'); }); - it('inherits from English for keys missing in the locale', () => { - registerI18n('es', { Play: 'Ir' }); + it('leaves missing keys for descriptor fallbacks', () => { + registerI18n('es', { 'buttons.play': 'Ir' }); const es = getI18nTranslations('es'); - expect(es.Mute).toBe(en.Mute); + expect(es['buttons.mute']).toBeUndefined(); }); it('resolves es-419-u-nu-latn to the es layer before English', () => { - registerI18n('es', { Play: 'ES-generic' }); + registerI18n('es', { 'buttons.play': 'ES-generic' }); const merged = getI18nTranslations('es-419-u-nu-latn'); - expect(merged.Play).toBe('ES-generic'); + expect(merged['buttons.play']).toBe('ES-generic'); }); it('registers unicode-extension locales under the same key as findLocaleKeys', () => { - registerI18n('es-u-nu-latn', { Play: 'Latin-numerals ES' }); + registerI18n('es-u-nu-latn', { 'buttons.play': 'Latin-numerals ES' }); expect(hasRegisteredLocale('es-u-nu-latn')).toBe(true); - expect(getI18nTranslations('es-u-nu-latn').Play).toBe('Latin-numerals ES'); + expect(getI18nTranslations('es-u-nu-latn')['buttons.play']).toBe('Latin-numerals ES'); }); it('does not strip -u- subtags inside a private-use extension', () => { - registerI18n('en-x-u-k0', { Play: 'Private U' }); - registerI18n('en-x-u-k1', { Play: 'Private U2' }); - expect(getI18nTranslations('en-x-u-k0').Play).toBe('Private U'); - expect(getI18nTranslations('en-x-u-k1').Play).toBe('Private U2'); + registerI18n('en-x-u-k0', { 'buttons.play': 'Private U' }); + registerI18n('en-x-u-k1', { 'buttons.play': 'Private U2' }); + expect(getI18nTranslations('en-x-u-k0')['buttons.play']).toBe('Private U'); + expect(getI18nTranslations('en-x-u-k1')['buttons.play']).toBe('Private U2'); }); it('walks zh-Hant-HK through zh-TW before zh', () => { expect(findLocaleKeys('zh-Hant-HK')).toEqual(['zh-hant-hk', 'zh-hant', 'zh-tw', 'zh', 'en']); - registerI18n('zh', { Play: 'ZH' }); - registerI18n('zh-TW', { Play: 'TW' }); - registerI18n('zh-Hant', { Pause: 'Hant' }); - registerI18n('zh-Hant-HK', { Replay: 'HK' }); + registerI18n('zh', { 'buttons.play': 'ZH' }); + registerI18n('zh-TW', { 'buttons.play': 'TW' }); + registerI18n('zh-Hant', { 'buttons.pause': 'Hant' }); + registerI18n('zh-Hant-HK', { 'buttons.replay': 'HK' }); const merged = getI18nTranslations('zh-Hant-HK'); - expect(merged.Replay).toBe('HK'); - expect(merged.Pause).toBe('Hant'); - expect(merged.Play).toBe('TW'); - expect(merged.Mute).toBe(en.Mute); + expect(merged['buttons.replay']).toBe('HK'); + expect(merged['buttons.pause']).toBe('Hant'); + expect(merged['buttons.play']).toBe('TW'); + expect(merged['buttons.mute']).toBeUndefined(); }); it('walks zh-Hans-HK through zh-CN before zh', () => { @@ -67,32 +66,32 @@ describe('i18n registry', () => { it('truncates en-GB-scotland toward en-GB then en', () => { expect(findLocaleKeys('en-GB-scotland')).toEqual(['en-gb-scotland', 'en-gb', 'en']); - registerI18n('en-GB', { Play: 'GB' }); + registerI18n('en-GB', { 'buttons.play': 'GB' }); const merged = getI18nTranslations('en-GB-scotland'); - expect(merged.Play).toBe('GB'); + expect(merged['buttons.play']).toBe('GB'); }); it('does not use a sibling locale tag when only the parent matches (es-AR vs es-MX)', () => { - registerI18n('es-MX', { Play: 'MX' }); - registerI18n('es', { Play: 'ES' }); + registerI18n('es-MX', { 'buttons.play': 'MX' }); + registerI18n('es', { 'buttons.play': 'ES' }); const merged = getI18nTranslations('es-AR'); - expect(merged.Play).toBe('ES'); + expect(merged['buttons.play']).toBe('ES'); }); it('reports hasRegisteredLocale with normalized tags', () => { - expect(hasRegisteredLocale('en')).toBe(true); + expect(hasRegisteredLocale('en')).toBe(false); expect(hasRegisteredLocale('fr')).toBe(false); - registerI18n('fr', { Play: 'Lire' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); expect(hasRegisteredLocale('FR')).toBe(true); }); it('onI18nRegistryChange unsubscribe stops notifications', () => { const spy = vi.fn(); const off = onI18nRegistryChange(spy); - registerI18n('de', { Play: 'Los' }); + registerI18n('de', { 'buttons.play': 'Los' }); expect(spy).toHaveBeenCalledOnce(); off(); - registerI18n('de', { Pause: 'Pause' }); + registerI18n('de', { 'buttons.pause': 'Pause' }); expect(spy).toHaveBeenCalledOnce(); }); }); diff --git a/packages/core/src/core/i18n/tests/resolve-translation.test.ts b/packages/core/src/core/i18n/tests/resolve-translation.test.ts index 3bea288d..100832ca 100644 --- a/packages/core/src/core/i18n/tests/resolve-translation.test.ts +++ b/packages/core/src/core/i18n/tests/resolve-translation.test.ts @@ -5,15 +5,15 @@ import { createTranslator } from '../translator'; const t = createTranslator( { - Play: 'Play', - '{duration} remaining': '{duration} remaining', + 'buttons.play': 'Play', + 'time.remainingSuffix': '{duration} remaining', }, 'en' ); describe('resolveTranslation', () => { it('interpolates a known phrase with params', () => { - expect(resolveTranslation(t, '{duration} remaining', { duration: '1 minute' })).toBe('1 minute remaining'); + expect(resolveTranslation(t, 'time.remainingSuffix', { duration: '1 minute' })).toBe('1 minute remaining'); }); it('supports custom fallback strings', () => { @@ -21,18 +21,18 @@ describe('resolveTranslation', () => { }); it('type checks known phrase params', () => { - resolveTranslation(t, '{duration} remaining', { duration: '1 minute' }); - resolveTranslation(t, 'Play'); + resolveTranslation(t, 'time.remainingSuffix', { duration: '1 minute' }); + resolveTranslation(t, 'buttons.play'); resolveTranslation(t, 'Custom label'); resolveTranslation(t, 'Custom {value}', { value: 10 }); - // @ts-expect-error Known param phrases require params. - resolveTranslation(t, '{duration} remaining'); + // @ts-expect-error Known param keys require params. + resolveTranslation(t, 'time.remainingSuffix'); - // @ts-expect-error Known no-param phrases reject params. - resolveTranslation(t, 'Play', { duration: '1 minute' }); + // @ts-expect-error Known no-param keys reject params. + resolveTranslation(t, 'buttons.play', { duration: '1 minute' }); - // @ts-expect-error Known param phrases require the matching param shape. - resolveTranslation(t, '{duration} remaining', { value: '1 minute' }); + // @ts-expect-error Known param keys require the matching param shape. + resolveTranslation(t, 'time.remainingSuffix', { value: '1 minute' }); }); }); diff --git a/packages/core/src/core/i18n/tests/translator.test.ts b/packages/core/src/core/i18n/tests/translator.test.ts index ec06225a..d7fd22cf 100644 --- a/packages/core/src/core/i18n/tests/translator.test.ts +++ b/packages/core/src/core/i18n/tests/translator.test.ts @@ -1,35 +1,80 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createTranslator } from '../translator'; describe('createTranslator', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('resolves a simple key', () => { - const t = createTranslator({ Play: 'Start' }, 'en'); - expect(t('Play')).toBe('Start'); + const t = createTranslator({ 'buttons.play': 'Start' }, 'en'); + expect(t('buttons.play')).toBe('Start'); }); it('interpolates {param} placeholders', () => { - const t = createTranslator({ 'Seek forward {seconds} seconds': 'Jump {seconds} s' }, 'en'); - expect(t('Seek forward {seconds} seconds', { seconds: 10 })).toBe('Jump 10 s'); + const t = createTranslator({ 'seek.forward': 'Jump {seconds} s' }, 'en'); + expect(t('seek.forward', { seconds: 10 })).toBe('Jump 10 s'); + }); + + it('accepts a Text descriptor and uses its English text as the fallback', () => { + const t = createTranslator({}, 'en'); + + expect(t({ key: 'buttons.play', text: 'Play' })).toBe('Play'); + }); + + it('translates a Text descriptor and interpolates its parameters', () => { + const t = createTranslator({ 'seek.forward': 'Avance {seconds} segundos' }, 'es'); + + expect(t({ key: 'seek.forward', text: 'Seek forward {seconds} seconds' }, { seconds: 10 })).toBe( + 'Avance 10 segundos' + ); }); it('falls back to the source phrase when no translation is defined', () => { const t = createTranslator({}, 'en'); - expect(t('Play')).toBe('Play'); + expect(t('buttons.play')).toBe('buttons.play'); + }); + + it('uses an inline default for a semantic key', () => { + const t = createTranslator({}, 'en'); + expect(t('buttons.play', { default: 'Play' })).toBe('Play'); + }); + + it('warns when a translation has no fallback', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const t = createTranslator({}, 'en'); + + expect(t('custom.missing')).toBe('custom.missing'); + expect(warn).toHaveBeenCalledWith('[videojs] Missing translation for "custom.missing".'); + }); + + it('does not warn when a custom key has an inline default', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const t = createTranslator({}, 'en'); + + expect(t('custom.label', { default: 'Label' })).toBe('Label'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not interpolate the inline default option', () => { + const t = createTranslator({ 'custom.label': '{default}' }, 'en'); + + expect(t('custom.label', { default: 'Fallback' })).toBe('{default}'); }); it('interpolates the source phrase fallback', () => { const t = createTranslator({}, 'en'); - expect(t('Seek forward {seconds} seconds', { seconds: 10 })).toBe('Seek forward 10 seconds'); + expect(t('seek.forward', { seconds: 10 })).toBe('seek.forward'); }); it('keeps tokens that are not supplied as params', () => { - const t = createTranslator({ Play: 'Hi {name}' }, 'en'); - expect(t('Play')).toBe('Hi {name}'); + const t = createTranslator({ 'buttons.play': 'Hi {name}' }, 'en'); + expect(t('buttons.play')).toBe('Hi {name}'); }); it('coerces numeric params to strings', () => { - const t = createTranslator({ 'Playback rate {rate}': 'Speed {rate}' }, 'en'); - expect(t('Playback rate {rate}', { rate: 1.25 })).toBe('Speed 1.25'); + const t = createTranslator({ 'playback.rate': 'Speed {rate}' }, 'en'); + expect(t('playback.rate', { rate: 1.25 })).toBe('Speed 1.25'); }); }); diff --git a/packages/core/src/core/i18n/text.ts b/packages/core/src/core/i18n/text.ts new file mode 100644 index 00000000..97f332ab --- /dev/null +++ b/packages/core/src/core/i18n/text.ts @@ -0,0 +1,12 @@ +import { isObject } from '@videojs/utils/predicate'; + +export interface Text { + readonly key: string; + readonly text: string; +} + +export function isText(value: unknown): value is Text { + return isObject(value) && 'key' in value && 'text' in value; +} + +export type TextParams = Record; diff --git a/packages/core/src/core/i18n/text/airplay.ts b/packages/core/src/core/i18n/text/airplay.ts new file mode 100644 index 00000000..6c9cfbcc --- /dev/null +++ b/packages/core/src/core/i18n/text/airplay.ts @@ -0,0 +1,14 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'airplay.'; + +export const startText = { + key: `${prefix}start`, + text: 'Start AirPlay', +} as const satisfies Text; + +export const stopText = { + key: `${prefix}stop`, + text: 'Stop AirPlay', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/buttons.ts b/packages/core/src/core/i18n/text/buttons.ts new file mode 100644 index 00000000..e5aa5864 --- /dev/null +++ b/packages/core/src/core/i18n/text/buttons.ts @@ -0,0 +1,29 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'buttons.'; + +export const playText = { + key: `${prefix}play`, + text: 'Play', +} as const satisfies Text; + +export const pauseText = { + key: `${prefix}pause`, + text: 'Pause', +} as const satisfies Text; + +export const replayText = { + key: `${prefix}replay`, + text: 'Replay', +} as const satisfies Text; + +export const muteText = { + key: `${prefix}mute`, + text: 'Mute', +} as const satisfies Text; + +export const unmuteText = { + key: `${prefix}unmute`, + text: 'Unmute', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/captions.ts b/packages/core/src/core/i18n/text/captions.ts new file mode 100644 index 00000000..f7f8bc35 --- /dev/null +++ b/packages/core/src/core/i18n/text/captions.ts @@ -0,0 +1,14 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'captions.'; + +export const enableText = { + key: `${prefix}enable`, + text: 'Enable captions', +} as const satisfies Text; + +export const disableText = { + key: `${prefix}disable`, + text: 'Disable captions', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/cast.ts b/packages/core/src/core/i18n/text/cast.ts new file mode 100644 index 00000000..d8045417 --- /dev/null +++ b/packages/core/src/core/i18n/text/cast.ts @@ -0,0 +1,19 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'cast.'; + +export const startText = { + key: `${prefix}start`, + text: 'Start casting', +} as const satisfies Text; + +export const stopText = { + key: `${prefix}stop`, + text: 'Stop casting', +} as const satisfies Text; + +export const connectingText = { + key: `${prefix}connecting`, + text: 'Connecting', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/common.ts b/packages/core/src/core/i18n/text/common.ts new file mode 100644 index 00000000..2c6a1f83 --- /dev/null +++ b/packages/core/src/core/i18n/text/common.ts @@ -0,0 +1,14 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'common.'; + +export const emptyText = { + key: `${prefix}empty`, + text: '', +} as const satisfies Text; + +export const okText = { + key: `${prefix}ok`, + text: 'OK', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/errors.ts b/packages/core/src/core/i18n/text/errors.ts new file mode 100644 index 00000000..cd3a90c2 --- /dev/null +++ b/packages/core/src/core/i18n/text/errors.ts @@ -0,0 +1,39 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'errors.'; + +export const abortedText = { + key: `${prefix}aborted`, + text: 'You stopped media playback before it finished.', +} as const satisfies Text; + +export const networkText = { + key: `${prefix}network`, + text: 'This media could not be loaded due to a network or server issue.', +} as const satisfies Text; + +export const decodeText = { + key: `${prefix}decode`, + text: 'This media could not be played. It may be corrupted, or your browser may not support its format.', +} as const satisfies Text; + +export const sourceText = { + key: `${prefix}source`, + text: 'This media could not be loaded. It may be unavailable, or your browser may not support its format.', +} as const satisfies Text; + +export const encryptedText = { + key: `${prefix}encrypted`, + text: 'This media could not be played because it could not be decrypted.', +} as const satisfies Text; + +export const titleText = { + key: `${prefix}title`, + text: 'Something went wrong.', +} as const satisfies Text; + +export const unexpectedText = { + key: `${prefix}unexpected`, + text: 'An unexpected error occurred.', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/fullscreen.ts b/packages/core/src/core/i18n/text/fullscreen.ts new file mode 100644 index 00000000..6219db2a --- /dev/null +++ b/packages/core/src/core/i18n/text/fullscreen.ts @@ -0,0 +1,14 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'fullscreen.'; + +export const enterText = { + key: `${prefix}enter`, + text: 'Enter fullscreen', +} as const satisfies Text; + +export const exitText = { + key: `${prefix}exit`, + text: 'Exit fullscreen', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/live.ts b/packages/core/src/core/i18n/text/live.ts new file mode 100644 index 00000000..2fe85ec3 --- /dev/null +++ b/packages/core/src/core/i18n/text/live.ts @@ -0,0 +1,19 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'live.'; + +export const playingText = { + key: `${prefix}playing`, + text: 'Playing live', +} as const satisfies Text; + +export const seekToEdgeText = { + key: `${prefix}seekToEdge`, + text: 'Seek to live edge', +} as const satisfies Text; + +export const badgeText = { + key: `${prefix}badge`, + text: 'Live', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/menu.ts b/packages/core/src/core/i18n/text/menu.ts new file mode 100644 index 00000000..e2e16392 --- /dev/null +++ b/packages/core/src/core/i18n/text/menu.ts @@ -0,0 +1,64 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'menu.'; + +export const settingsText = { + key: `${prefix}settings`, + text: 'Settings', +} as const satisfies Text; + +export const qualityText = { + key: `${prefix}quality`, + text: 'Quality', +} as const satisfies Text; + +export const audioText = { + key: `${prefix}audio`, + text: 'Audio', +} as const satisfies Text; + +export const defaultText = { + key: `${prefix}default`, + text: 'Default', +} as const satisfies Text; + +export const speedText = { + key: `${prefix}speed`, + text: 'Speed', +} as const satisfies Text; + +export const captionsText = { + key: `${prefix}captions`, + text: 'Captions', +} as const satisfies Text; + +export const playbackRateText = { + key: `${prefix}playbackRate`, + text: 'Playback rate', +} as const satisfies Text; + +export const backText = { + key: `${prefix}back`, + text: 'Back', +} as const satisfies Text; + +export const offText = { + key: `${prefix}off`, + text: 'Off', +} as const satisfies Text; + +export const autoText = { + key: `${prefix}auto`, + text: 'Auto', +} as const satisfies Text; + +export const autoWithLabelText = { + key: `${prefix}autoWithLabel`, + text: 'Auto ({label})', +} as const satisfies Text; + +export const subtitlesText = { + key: `${prefix}subtitles`, + text: 'Subtitles', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/pip.ts b/packages/core/src/core/i18n/text/pip.ts new file mode 100644 index 00000000..f1a1e20a --- /dev/null +++ b/packages/core/src/core/i18n/text/pip.ts @@ -0,0 +1,14 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'pip.'; + +export const enterText = { + key: `${prefix}enter`, + text: 'Enter picture-in-picture', +} as const satisfies Text; + +export const exitText = { + key: `${prefix}exit`, + text: 'Exit picture-in-picture', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/playback.ts b/packages/core/src/core/i18n/text/playback.ts new file mode 100644 index 00000000..cb2fce92 --- /dev/null +++ b/packages/core/src/core/i18n/text/playback.ts @@ -0,0 +1,9 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'playback.'; + +export const rateText = { + key: `${prefix}rate`, + text: 'Playback rate {rate}', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/seek.ts b/packages/core/src/core/i18n/text/seek.ts new file mode 100644 index 00000000..f6934865 --- /dev/null +++ b/packages/core/src/core/i18n/text/seek.ts @@ -0,0 +1,14 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'seek.'; + +export const forwardText = { + key: `${prefix}forward`, + text: 'Seek forward {seconds} seconds', +} as const satisfies Text; + +export const backwardText = { + key: `${prefix}backward`, + text: 'Seek backward {seconds} seconds', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/slider.ts b/packages/core/src/core/i18n/text/slider.ts new file mode 100644 index 00000000..a8ee6d14 --- /dev/null +++ b/packages/core/src/core/i18n/text/slider.ts @@ -0,0 +1,9 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'slider.'; + +export const seekText = { + key: `${prefix}seek`, + text: 'Seek', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/status.ts b/packages/core/src/core/i18n/text/status.ts new file mode 100644 index 00000000..2576dff7 --- /dev/null +++ b/packages/core/src/core/i18n/text/status.ts @@ -0,0 +1,39 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'status.'; + +export const captionsOnText = { + key: `${prefix}captionsOn`, + text: 'Captions on', +} as const satisfies Text; + +export const captionsOffText = { + key: `${prefix}captionsOff`, + text: 'Captions off', +} as const satisfies Text; + +export const pausedText = { + key: `${prefix}paused`, + text: 'Paused', +} as const satisfies Text; + +export const playingText = { + key: `${prefix}playing`, + text: 'Playing', +} as const satisfies Text; + +export const fullscreenText = { + key: `${prefix}fullscreen`, + text: 'Fullscreen', +} as const satisfies Text; + +export const pipText = { + key: `${prefix}pip`, + text: 'Picture in picture', +} as const satisfies Text; + +export const exitPipText = { + key: `${prefix}exitPip`, + text: 'Exit picture in picture', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/time.ts b/packages/core/src/core/i18n/text/time.ts new file mode 100644 index 00000000..88d3805d --- /dev/null +++ b/packages/core/src/core/i18n/text/time.ts @@ -0,0 +1,44 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'time.'; + +export const currentText = { + key: `${prefix}current`, + text: 'Current time', +} as const satisfies Text; + +export const durationText = { + key: `${prefix}duration`, + text: 'Duration', +} as const satisfies Text; + +export const remainingText = { + key: `${prefix}remaining`, + text: 'Remaining', +} as const satisfies Text; + +export const remainingSuffixText = { + key: `${prefix}remainingSuffix`, + text: '{duration} remaining', +} as const satisfies Text; + +export const showElapsedText = { + key: `${prefix}showElapsed`, + text: '{duration}. Show elapsed time.', +} as const satisfies Text; + +export const showDurationText = { + key: `${prefix}showDuration`, + text: '{duration}. Show duration.', +} as const satisfies Text; + +export const showRemainingText = { + key: `${prefix}showRemaining`, + text: '{duration}. Show remaining time.', +} as const satisfies Text; + +export const positionText = { + key: `${prefix}position`, + text: '{current} of {duration}', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/text/volume.ts b/packages/core/src/core/i18n/text/volume.ts new file mode 100644 index 00000000..9e8d3d86 --- /dev/null +++ b/packages/core/src/core/i18n/text/volume.ts @@ -0,0 +1,24 @@ +/** Generated by packages/core/scripts/generate-i18n-locales.ts — do not edit. */ +import type { Text } from '../text'; + +const prefix = 'volume.'; + +export const mutedValueText = { + key: `${prefix}mutedValue`, + text: '{percent}, muted', +} as const satisfies Text; + +export const mutedText = { + key: `${prefix}muted`, + text: 'Muted', +} as const satisfies Text; + +export const labelText = { + key: `${prefix}label`, + text: 'Volume', +} as const satisfies Text; + +export const valueText = { + key: `${prefix}value`, + text: 'Volume {value}', +} as const satisfies Text; diff --git a/packages/core/src/core/i18n/translate-text.ts b/packages/core/src/core/i18n/translate-text.ts new file mode 100644 index 00000000..14daad45 --- /dev/null +++ b/packages/core/src/core/i18n/translate-text.ts @@ -0,0 +1,9 @@ +import { resolveText } from './resolve-text'; +import type { Text, TextParams } from './text'; +import type { Translator } from './translator'; +import { interpolate } from './utils'; + +export function translateText(text: Text | string, translator?: Translator, params?: TextParams): string { + if (typeof text === 'string') return text; + return translator ? translator(text, params) : interpolate(resolveText(text), params); +} diff --git a/packages/core/src/core/i18n/translator.ts b/packages/core/src/core/i18n/translator.ts index 2b051b0d..1b9b90e2 100644 --- a/packages/core/src/core/i18n/translator.ts +++ b/packages/core/src/core/i18n/translator.ts @@ -1,17 +1,26 @@ -import type { Locale, TranslationParams, Translations, Translator } from './types'; +import type { FlatTranslations, Locale, TranslationKey, TranslationParams } from './params'; +import type { Text, TextParams } from './text'; -const PLACEHOLDER = /\{([^{}]+)\}/g; - -function interpolate(template: string, params?: Record): string { - if (!params) return template; - return template.replace(PLACEHOLDER, (match, name: string) => { - if (Object.hasOwn(params, name)) { - return String(params[name as keyof typeof params]); - } - return match; - }); +export interface TranslationOptions { + default?: string; } +export type Translator = { + ( + key: Key, + ...args: Key extends TranslationKey + ? TranslationParams[Key] extends never + ? [params?: TranslationOptions] + : [params: TranslationParams[Key] & TranslationOptions] + : [params?: TextParams & TranslationOptions] + ): string; + (text: Text, params?: TextParams): string; +}; + +import { interpolate } from './utils'; + +declare const __DEV__: boolean; + /** * Builds a typed translator from a resolved translation map (typically from `getI18nTranslations`). * @@ -19,12 +28,25 @@ function interpolate(template: string, params?: Record) * @param locale - BCP 47 tag associated with the map (reserved for future locale-aware behavior). * @public */ -export function createTranslator(translations: Translations, locale: Locale): Translator { +export function createTranslator(translations: FlatTranslations, locale: Locale): Translator { void locale; - const translate = (phrase: keyof TranslationParams, params?: unknown): string => { - const raw = translations[phrase] ?? String(phrase); - return interpolate(raw, params as Record | undefined); + const translate = (input: Text | string, params?: unknown): string => { + const options = params as (TextParams & { default?: string }) | undefined; + const isDescriptor = typeof input !== 'string'; + const key = isDescriptor ? input.key : input; + const translation = translations[key]; + + if (__DEV__ && translation === undefined && !isDescriptor && options?.default === undefined) { + console.warn(`[videojs] Missing translation for "${key}".`); + } + + const fallback = options?.default; + const values = options ? { ...options } : undefined; + if (values) delete values.default; + + const raw = translation ?? (isDescriptor ? input.text : fallback) ?? String(key); + return interpolate(raw, values); }; return translate as Translator; diff --git a/packages/core/src/core/i18n/types.ts b/packages/core/src/core/i18n/types.ts deleted file mode 100644 index f12f25ba..00000000 --- a/packages/core/src/core/i18n/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Contains, EnsureRecord } from '@videojs/utils/types'; -import type { LOCALES } from './locales'; - -/** BCP 47 language tag; built-ins are narrowed for autocomplete. */ -export type Locale = (typeof LOCALES)[number] | (string & {}); - -/** Per-phrase argument contract: `never` means the translator only accepts the phrase. */ -export type TranslationParams = { - Play: never; - Pause: never; - Replay: never; - Mute: never; - Unmute: never; - 'Seek forward {seconds} seconds': { seconds: number | string }; - 'Seek backward {seconds} seconds': { seconds: number | string }; - 'Enter fullscreen': never; - 'Exit fullscreen': never; - 'Enable captions': never; - 'Disable captions': never; - 'Enter picture-in-picture': never; - 'Exit picture-in-picture': never; - 'Playing live': never; - 'Seek to live edge': never; - Live: never; - 'Start casting': never; - 'Stop casting': never; - Connecting: never; - Seek: never; - Volume: never; - 'Current time': never; - Duration: never; - Remaining: never; - '{duration} remaining': { duration: string }; - '{duration}. Show elapsed time.': { duration: string }; - '{duration}. Show duration.': { duration: string }; - '{duration}. Show remaining time.': { duration: string }; - 'Playback rate {rate}': { rate: number | string }; - '{current} of {duration}': { current: string; duration: string }; - '{percent}, muted': { percent: number | string }; - Muted: never; - 'Volume {value}': { value: string }; - 'Captions on': never; - 'Captions off': never; - Paused: never; - Playing: never; - Fullscreen: never; - 'Picture in picture': never; - 'Exit picture in picture': never; - 'You stopped media playback before it finished.': never; - 'This media could not be loaded due to a network or server issue.': never; - 'This media could not be played. It may be corrupted, or your browser may not support its format.': never; - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.': never; - 'This media could not be played because it could not be decrypted.': never; - '': never; - 'Something went wrong.': never; - OK: never; - 'An unexpected error occurred.': never; - Settings: never; - Quality: never; - Audio: never; - Default: never; - Speed: never; - Captions: never; - 'Playback rate': never; - Back: never; - Off: never; - Auto: never; - 'Auto ({label})': { label: string }; - Subtitles: never; -}; - -export type TranslationKey = keyof TranslationParams; - -type ParametricKey = { - [K in keyof TranslationParams]: TranslationParams[K] extends never ? never : K; -}[keyof TranslationParams]; - -/** Placeholder shape for each phrase that accepts `t(phrase, params)`. */ -type ParametricTranslations = EnsureRecord< - ParametricKey, - string, - { - 'Seek forward {seconds} seconds': Contains<'{seconds}'>; - 'Seek backward {seconds} seconds': Contains<'{seconds}'>; - 'Playback rate {rate}': Contains<'{rate}'>; - '{current} of {duration}': Contains<'{current}'> & Contains<'{duration}'>; - '{duration} remaining': Contains<'{duration}'>; - '{duration}. Show elapsed time.': Contains<'{duration}'>; - '{duration}. Show duration.': Contains<'{duration}'>; - '{duration}. Show remaining time.': Contains<'{duration}'>; - '{percent}, muted': Contains<'{percent}'>; - 'Volume {value}': Contains<'{value}'>; - 'Auto ({label})': Contains<'{label}'>; - } ->; - -/** Player copy keyed by the default English UI string; all entries are optional overlays. */ -export type Translations = { - [K in keyof TranslationParams]?: TranslationParams[K] extends never - ? string - : K extends keyof ParametricTranslations - ? ParametricTranslations[K] - : never; -}; - -export type Translator = ( - phrase: K, - ...args: TranslationParams[K] extends never ? [] : [params: TranslationParams[K]] -) => string; diff --git a/packages/core/src/core/i18n/utils/flatten.ts b/packages/core/src/core/i18n/utils/flatten.ts new file mode 100644 index 00000000..bc12e1e4 --- /dev/null +++ b/packages/core/src/core/i18n/utils/flatten.ts @@ -0,0 +1,10 @@ +import { flatten } from '@videojs/utils/object'; +import type { FlatTranslations, Translations } from '../params'; + +export interface FlattenTranslationsOptions { + prefix?: string; +} + +export function flattenTranslations(locale: Translations, options: FlattenTranslationsOptions = {}): FlatTranslations { + return flatten(locale, options) as FlatTranslations; +} diff --git a/packages/core/src/core/i18n/utils/index.ts b/packages/core/src/core/i18n/utils/index.ts new file mode 100644 index 00000000..8925ba58 --- /dev/null +++ b/packages/core/src/core/i18n/utils/index.ts @@ -0,0 +1,2 @@ +export { type FlattenTranslationsOptions, flattenTranslations } from './flatten'; +export { interpolate } from './interpolate'; diff --git a/packages/core/src/core/i18n/utils/interpolate.ts b/packages/core/src/core/i18n/utils/interpolate.ts new file mode 100644 index 00000000..d3bd9113 --- /dev/null +++ b/packages/core/src/core/i18n/utils/interpolate.ts @@ -0,0 +1,10 @@ +import type { TextParams } from '../text'; + +const PLACEHOLDER = /\{([^{}]+)\}/g; + +export function interpolate(template: string, params?: TextParams): string { + if (!params) return template; + return template.replace(PLACEHOLDER, (match, name: string) => { + return Object.hasOwn(params, name) ? String(params[name]) : match; + }); +} diff --git a/packages/core/src/core/ui/airplay-button/airplay-button-core.ts b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts index a8d1309c..8dccaf68 100644 --- a/packages/core/src/core/ui/airplay-button/airplay-button-core.ts +++ b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts @@ -2,7 +2,9 @@ import { createState } from '@videojs/store'; import { supportsWebKitAirPlay } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { startText, stopText } from '../../i18n/text/airplay'; +import { connectingText } from '../../i18n/text/cast'; import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; import type { ButtonState } from '../types'; @@ -10,7 +12,7 @@ import { resolveLabel } from '../utils/resolve-label'; export interface AirPlayButtonProps { /** Custom label for the button. */ - label?: string | ((state: AirPlayButtonState) => string) | undefined; + label?: Text | string | ((state: AirPlayButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -44,13 +46,13 @@ export class AirPlayButtonCore { this.#props = defaults(props, AirPlayButtonCore.defaultProps); } - getLabel(state: AirPlayButtonState): string { + getLabel(state: AirPlayButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - if (state.state === 'connected') return 'Stop AirPlay'; - if (state.state === 'connecting') return 'Connecting'; - return 'Start AirPlay'; + if (state.state === 'connected') return stopText; + if (state.state === 'connecting') return connectingText; + return startText; } getAttrs(state: AirPlayButtonState) { @@ -75,7 +77,7 @@ export class AirPlayButtonCore { state: media.remotePlaybackState, availability: isAirPlaySupported ? media.remotePlaybackAvailability : 'unsupported', }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts b/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts index b788f2c3..99ff755d 100644 --- a/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts +++ b/packages/core/src/core/ui/airplay-button/tests/airplay-button-core.test.ts @@ -78,17 +78,26 @@ describe('AirPlayButtonCore', () => { describe('getLabel', () => { it('returns Start AirPlay when disconnected', () => { const core = new AirPlayButtonCore(); - expect(core.getLabel(createState({ state: 'disconnected' }))).toBe('Start AirPlay'); + expect(core.getLabel(createState({ state: 'disconnected' }))).toMatchObject({ + key: 'airplay.start', + text: 'Start AirPlay', + }); }); it('returns Stop AirPlay when connected', () => { const core = new AirPlayButtonCore(); - expect(core.getLabel(createState({ state: 'connected' }))).toBe('Stop AirPlay'); + expect(core.getLabel(createState({ state: 'connected' }))).toMatchObject({ + key: 'airplay.stop', + text: 'Stop AirPlay', + }); }); it('returns Connecting when connecting', () => { const core = new AirPlayButtonCore(); - expect(core.getLabel(createState({ state: 'connecting' }))).toBe('Connecting'); + expect(core.getLabel(createState({ state: 'connecting' }))).toMatchObject({ + key: 'cast.connecting', + text: 'Connecting', + }); }); it('returns custom string label', () => { @@ -108,7 +117,7 @@ describe('AirPlayButtonCore', () => { it('returns aria-label', () => { const core = new AirPlayButtonCore(); const attrs = core.getAttrs(createState()); - expect(attrs['aria-label']).toBe('Start AirPlay'); + expect(attrs['aria-label']).toMatchObject({ key: 'airplay.start', text: 'Start AirPlay' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts b/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts index 4e4d8a10..d0a6234f 100644 --- a/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts +++ b/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts @@ -1,23 +1,24 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { audioText } from '../../i18n/text/menu'; import type { MediaAudioTrack, MediaAudioTrackState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface AudioTrackRadioGroupProps { /** Custom label for the options group. */ - label?: string | ((state: AudioTrackRadioGroupState) => string) | undefined; + label?: Text | string | ((state: AudioTrackRadioGroupState) => Text | string) | undefined; /** Custom formatter for visible track labels. */ - formatTrack?: ((track: MediaAudioTrack) => string) | undefined; + formatTrack?: ((track: MediaAudioTrack) => Text | string) | undefined; /** Whether audio track selection is disabled. */ disabled?: boolean | undefined; } export interface AudioTrackRadioGroupTrack { value: string; - label: string; + label: Text | string; } export interface AudioTrackRadioGroupState extends ButtonState { @@ -27,11 +28,11 @@ export interface AudioTrackRadioGroupState extends ButtonState { availability: 'available' | 'unavailable'; } -function formatTrackLabel(track: MediaAudioTrack): string { +function formatTrackLabel(track: MediaAudioTrack): Text | string { if (track.label) return track.label; if (track.language) return track.language; if (track.kind) return track.kind; - return 'Audio'; + return audioText; } function getTrackValue(track: MediaAudioTrack, index: number): string { @@ -64,14 +65,14 @@ export class AudioTrackRadioGroupCore { this.#props = defaults(props, AudioTrackRadioGroupCore.defaultProps); } - getLabel(state: AudioTrackRadioGroupState): string { + getLabel(state: AudioTrackRadioGroupState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - return 'Audio'; + return audioText; } - getTrackLabel(track: MediaAudioTrack): string { + getTrackLabel(track: MediaAudioTrack): Text | string { return this.#props.formatTrack(track); } @@ -101,7 +102,7 @@ export class AudioTrackRadioGroupCore { disabled: this.#props.disabled || availability === 'unavailable', availability, }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts b/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts index 1387e882..d342224a 100644 --- a/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts +++ b/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts @@ -59,7 +59,7 @@ describe('AudioTrackRadioGroupCore', () => { expect(core.getState().tracks).toEqual([ { value: '0', label: 'en' }, { value: '1', label: 'commentary' }, - { value: '2', label: 'Audio' }, + { value: '2', label: { key: 'menu.audio', text: 'Audio' } }, ]); }); @@ -91,7 +91,7 @@ describe('AudioTrackRadioGroupCore', () => { describe('getLabel', () => { it('returns the default label', () => { const core = new AudioTrackRadioGroupCore(); - expect(core.getLabel(createState())).toBe('Audio'); + expect(core.getLabel(createState())).toMatchObject({ key: 'menu.audio', text: 'Audio' }); }); it('returns a custom string label', () => { diff --git a/packages/core/src/core/ui/captions-button/captions-button-core.ts b/packages/core/src/core/ui/captions-button/captions-button-core.ts index d3c5dfcd..ffaa3bcd 100644 --- a/packages/core/src/core/ui/captions-button/captions-button-core.ts +++ b/packages/core/src/core/ui/captions-button/captions-button-core.ts @@ -2,14 +2,15 @@ import { createState } from '@videojs/store'; import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { disableText, enableText } from '../../i18n/text/captions'; import type { MediaTextTrackState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface CaptionsButtonProps { /** Custom label for the button. */ - label?: string | ((state: CaptionsButtonState) => string) | undefined; + label?: Text | string | ((state: CaptionsButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; /** When true with multiple tracks, pointer activation opens a menu instead of toggling. React sets this automatically inside `Menu.Trigger`. */ @@ -44,11 +45,11 @@ export class CaptionsButtonCore { this.#props = defaults(props, CaptionsButtonCore.defaultProps); } - getLabel(state: CaptionsButtonState): string { + getLabel(state: CaptionsButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - return state.subtitlesShowing ? 'Disable captions' : 'Enable captions'; + return state.subtitlesShowing ? disableText : enableText; } getAttrs(state: CaptionsButtonState) { @@ -69,7 +70,7 @@ export class CaptionsButtonCore { : 'unavailable'; this.state.patch({ subtitlesShowing: media.subtitlesShowing, availability }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts index 14a45189..2b00b558 100644 --- a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts +++ b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts @@ -69,12 +69,18 @@ describe('CaptionsButtonCore', () => { describe('getLabel', () => { it('returns Enable captions when captions are disabled', () => { const core = new CaptionsButtonCore(); - expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('Enable captions'); + expect(core.getLabel(createState({ subtitlesShowing: false }))).toMatchObject({ + key: 'captions.enable', + text: 'Enable captions', + }); }); it('returns Disable captions when captions are enabled', () => { const core = new CaptionsButtonCore(); - expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Disable captions'); + expect(core.getLabel(createState({ subtitlesShowing: true }))).toMatchObject({ + key: 'captions.disable', + text: 'Disable captions', + }); }); it('returns custom string label', () => { @@ -94,7 +100,7 @@ describe('CaptionsButtonCore', () => { it('returns aria-label', () => { const core = new CaptionsButtonCore(); const attrs = core.getAttrs(createState({ subtitlesShowing: false })); - expect(attrs['aria-label']).toBe('Enable captions'); + expect(attrs['aria-label']).toMatchObject({ key: 'captions.enable', text: 'Enable captions' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts index 7338c823..2ad256ca 100644 --- a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts +++ b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts @@ -2,23 +2,25 @@ import { createState } from '@videojs/store'; import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { disableText, enableText } from '../../i18n/text/captions'; +import { captionsText, subtitlesText } from '../../i18n/text/menu'; import type { MediaTextTrack, MediaTextTrackState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface CaptionsRadioGroupProps { /** Custom label for the menu trigger. */ - label?: string | ((state: CaptionsRadioGroupState) => string) | undefined; + label?: Text | string | ((state: CaptionsRadioGroupState) => Text | string) | undefined; /** Custom formatter for visible track labels. */ - formatTrack?: ((track: MediaTextTrack) => string) | undefined; + formatTrack?: ((track: MediaTextTrack) => Text | string) | undefined; /** Whether track selection is disabled. */ disabled?: boolean | undefined; } export interface CaptionsRadioGroupTrack { value: string; - label: string; + label: Text | string; } export interface CaptionsRadioGroupState extends Pick, ButtonState { @@ -30,10 +32,10 @@ export interface CaptionsRadioGroupState extends Pick { describe('getLabel', () => { it('returns default labels based on showing state', () => { const core = new CaptionsRadioGroupCore(); - expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('Enable captions'); - expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Disable captions'); + expect(core.getLabel(createState({ subtitlesShowing: false }))).toMatchObject({ + key: 'captions.enable', + text: 'Enable captions', + }); + expect(core.getLabel(createState({ subtitlesShowing: true }))).toMatchObject({ + key: 'captions.disable', + text: 'Disable captions', + }); }); it('returns custom string label', () => { @@ -125,7 +131,10 @@ describe('CaptionsRadioGroupCore', () => { }) ).toBe('English'); expect(core.getTrackLabel({ kind: 'subtitles', label: '', language: 'es', mode: 'disabled' })).toBe('es'); - expect(core.getTrackLabel({ kind: 'captions', label: '', language: '', mode: 'disabled' })).toBe('Captions'); + expect(core.getTrackLabel({ kind: 'captions', label: '', language: '', mode: 'disabled' })).toMatchObject({ + key: 'menu.captions', + text: 'Captions', + }); }); it('adds default labels for unlabeled tracks', () => { @@ -140,8 +149,8 @@ describe('CaptionsRadioGroupCore', () => { core.setMedia(media); expect(core.getState().tracks).toEqual([ - { value: 'captions-en', label: 'Captions' }, - { value: 'subtitles-en', label: 'Subtitles' }, + { value: 'captions-en', label: { key: 'menu.captions', text: 'Captions' } }, + { value: 'subtitles-en', label: { key: 'menu.subtitles', text: 'Subtitles' } }, ]); }); diff --git a/packages/core/src/core/ui/cast-button/cast-button-core.ts b/packages/core/src/core/ui/cast-button/cast-button-core.ts index a9013e9e..d52ab63f 100644 --- a/packages/core/src/core/ui/cast-button/cast-button-core.ts +++ b/packages/core/src/core/ui/cast-button/cast-button-core.ts @@ -1,7 +1,8 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { connectingText, startText, stopText } from '../../i18n/text/cast'; import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; import type { ButtonState } from '../types'; @@ -9,7 +10,7 @@ import { resolveLabel } from '../utils/resolve-label'; export interface CastButtonProps { /** Custom label for the button. */ - label?: string | ((state: CastButtonState) => string) | undefined; + label?: Text | string | ((state: CastButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -42,13 +43,13 @@ export class CastButtonCore { this.#props = defaults(props, CastButtonCore.defaultProps); } - getLabel(state: CastButtonState): string { + getLabel(state: CastButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - if (state.castState === 'connected') return 'Stop casting'; - if (state.castState === 'connecting') return 'Connecting'; - return 'Start casting'; + if (state.castState === 'connected') return stopText; + if (state.castState === 'connecting') return connectingText; + return startText; } getAttrs(state: CastButtonState) { @@ -70,7 +71,7 @@ export class CastButtonCore { castState: media.remotePlaybackState, availability: castSupported ? media.remotePlaybackAvailability : 'unsupported', }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts b/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts index fc0b9781..24c5b0f1 100644 --- a/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts +++ b/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts @@ -78,17 +78,26 @@ describe('CastButtonCore', () => { describe('getLabel', () => { it('returns Start casting when disconnected', () => { const core = new CastButtonCore(); - expect(core.getLabel(createState({ castState: 'disconnected' }))).toBe('Start casting'); + expect(core.getLabel(createState({ castState: 'disconnected' }))).toMatchObject({ + key: 'cast.start', + text: 'Start casting', + }); }); it('returns Stop casting when connected', () => { const core = new CastButtonCore(); - expect(core.getLabel(createState({ castState: 'connected' }))).toBe('Stop casting'); + expect(core.getLabel(createState({ castState: 'connected' }))).toMatchObject({ + key: 'cast.stop', + text: 'Stop casting', + }); }); it('returns Connecting when connecting', () => { const core = new CastButtonCore(); - expect(core.getLabel(createState({ castState: 'connecting' }))).toBe('Connecting'); + expect(core.getLabel(createState({ castState: 'connecting' }))).toMatchObject({ + key: 'cast.connecting', + text: 'Connecting', + }); }); it('returns custom string label', () => { @@ -108,7 +117,7 @@ describe('CastButtonCore', () => { it('returns aria-label', () => { const core = new CastButtonCore(); const attrs = core.getAttrs(createState()); - expect(attrs['aria-label']).toBe('Start casting'); + expect(attrs['aria-label']).toMatchObject({ key: 'cast.start', text: 'Start casting' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts b/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts index 3181698c..d4d93888 100644 --- a/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts +++ b/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts @@ -1,25 +1,29 @@ -import type { TranslationParams } from '../../i18n/types'; +import type { TranslationParams } from '../../i18n/params'; +import type { Text } from '../../i18n/text'; +import { emptyText, okText } from '../../i18n/text/common'; +import { + abortedText, + decodeText, + encryptedText, + networkText, + sourceText, + titleText, + unexpectedText, +} from '../../i18n/text/errors'; import { MediaError } from '../../media/media-error'; export type MediaErrorTranslationKey = Extract< keyof TranslationParams, - | 'You stopped media playback before it finished.' - | 'This media could not be loaded due to a network or server issue.' - | 'This media could not be played. It may be corrupted, or your browser may not support its format.' - | 'This media could not be loaded. It may be unavailable, or your browser may not support its format.' - | 'This media could not be played because it could not be decrypted.' - | '' + 'errors.aborted' | 'errors.network' | 'errors.decode' | 'errors.source' | 'errors.encrypted' | 'common.empty' >; -const MEDIA_ERROR_CODE_TO_KEY: Record = { - [MediaError.MEDIA_ERR_ABORTED]: 'You stopped media playback before it finished.', - [MediaError.MEDIA_ERR_NETWORK]: 'This media could not be loaded due to a network or server issue.', - [MediaError.MEDIA_ERR_DECODE]: - 'This media could not be played. It may be corrupted, or your browser may not support its format.', - [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]: - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.', - [MediaError.MEDIA_ERR_ENCRYPTED]: 'This media could not be played because it could not be decrypted.', - [MediaError.MEDIA_ERR_CUSTOM]: '', +const MEDIA_ERROR_TRANSLATIONS: Record = { + [MediaError.MEDIA_ERR_ABORTED]: abortedText, + [MediaError.MEDIA_ERR_NETWORK]: networkText, + [MediaError.MEDIA_ERR_DECODE]: decodeText, + [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]: sourceText, + [MediaError.MEDIA_ERR_ENCRYPTED]: encryptedText, + [MediaError.MEDIA_ERR_CUSTOM]: emptyText, }; const STANDARD_CODE_UA_MESSAGES: Partial> = { @@ -31,15 +35,19 @@ function isStandardMediaErrorCode(code: number): boolean { } export function getMediaErrorTranslationKey(code: number): MediaErrorTranslationKey | undefined { - return MEDIA_ERROR_CODE_TO_KEY[code]; + return MEDIA_ERROR_TRANSLATIONS[code]?.key as MediaErrorTranslationKey | undefined; } -export function getErrorDialogTitleLabel(): string { - return 'Something went wrong.'; +export function getErrorDialogTitleText(): Text { + return titleText; } -export function getErrorDialogDismissLabel(): string { - return 'OK'; +export function getErrorDialogDismissText(): Text { + return okText; +} + +export function getErrorDialogUnexpectedText(): Text { + return unexpectedText; } /** @@ -49,26 +57,26 @@ export function getErrorDialogDismissLabel(): string { export function resolveErrorDialogDescription( error: (Pick & { context?: MediaError['context'] }) | null | undefined, cachedMessage?: string | null -): string { +): Text | string { if (error) { - const key = getMediaErrorTranslationKey(error.code); + const text = MEDIA_ERROR_TRANSLATIONS[error.code]; const message = error.message?.trim(); if (message) { const defaultForCode = MediaError.defaultMessages[error.code]; - if (key && defaultForCode && message === defaultForCode) { - return key; + if (text && defaultForCode && message === defaultForCode) { + return text; } const uaVariants = STANDARD_CODE_UA_MESSAGES[error.code]; - if (key && isStandardMediaErrorCode(error.code) && !error.context && uaVariants?.includes(message)) { - return key; + if (text && isStandardMediaErrorCode(error.code) && !error.context && uaVariants?.includes(message)) { + return text; } return message; } - if (key) return key; + if (text) return text; } const cached = cachedMessage?.trim(); if (cached) return cached; - return 'An unexpected error occurred.'; + return unexpectedText; } diff --git a/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts b/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts index dee6366f..7434a1b3 100644 --- a/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts +++ b/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts @@ -1,41 +1,44 @@ import { describe, expect, it } from 'vitest'; import { MediaError } from '../../../media/media-error'; import { - getErrorDialogDismissLabel, - getErrorDialogTitleLabel, + getErrorDialogDismissText, + getErrorDialogTitleText, getMediaErrorTranslationKey, resolveErrorDialogDescription, } from '../error-dialog-i18n'; describe('getMediaErrorTranslationKey', () => { it('maps standard MediaError codes to registry keys', () => { - expect(getMediaErrorTranslationKey(MediaError.MEDIA_ERR_NETWORK)).toBe( - 'This media could not be loaded due to a network or server issue.' - ); - expect(getMediaErrorTranslationKey(MediaError.MEDIA_ERR_ABORTED)).toBe( - 'You stopped media playback before it finished.' - ); + expect(getMediaErrorTranslationKey(MediaError.MEDIA_ERR_NETWORK)).toBe('errors.network'); + expect(getMediaErrorTranslationKey(MediaError.MEDIA_ERR_ABORTED)).toBe('errors.aborted'); }); }); -describe('getErrorDialogTitleLabel', () => { +describe('getErrorDialogTitleText', () => { it('returns the error dialog title key', () => { - expect(getErrorDialogTitleLabel()).toBe('Something went wrong.'); + expect(getErrorDialogTitleText()).toMatchObject({ + key: 'errors.title', + text: 'Something went wrong.', + }); }); }); -describe('getErrorDialogDismissLabel', () => { +describe('getErrorDialogDismissText', () => { it('returns the dismiss button key', () => { - expect(getErrorDialogDismissLabel()).toBe('OK'); + expect(getErrorDialogDismissText()).toMatchObject({ + key: 'common.ok', + text: 'OK', + }); }); }); describe('resolveErrorDialogDescription', () => { it('returns a registry key when the message matches the default for the code', () => { const error = new MediaError(undefined, MediaError.MEDIA_ERR_NETWORK); - expect(resolveErrorDialogDescription(error, null)).toBe( - 'This media could not be loaded due to a network or server issue.' - ); + expect(resolveErrorDialogDescription(error, null)).toMatchObject({ + key: 'errors.network', + text: 'This media could not be loaded due to a network or server issue.', + }); }); it('returns custom message text when context is provided', () => { @@ -50,13 +53,17 @@ describe('resolveErrorDialogDescription', () => { it('returns a registry key for browser-specific messages on standard codes', () => { const error = new MediaError('Failed to open media', MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED); - expect(resolveErrorDialogDescription(error, null)).toBe( - 'This media could not be loaded. It may be unavailable, or your browser may not support its format.' - ); + expect(resolveErrorDialogDescription(error, null)).toMatchObject({ + key: 'errors.source', + text: 'This media could not be loaded. It may be unavailable, or your browser may not support its format.', + }); }); it('falls back to cached message then generic key', () => { expect(resolveErrorDialogDescription(null, 'Cached')).toBe('Cached'); - expect(resolveErrorDialogDescription(null, null)).toBe('An unexpected error occurred.'); + expect(resolveErrorDialogDescription(null, null)).toMatchObject({ + key: 'errors.unexpected', + text: 'An unexpected error occurred.', + }); }); }); diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts index 74049a79..98061832 100644 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts @@ -1,14 +1,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { enterText, exitText } from '../../i18n/text/fullscreen'; import type { MediaFullscreenState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface FullscreenButtonProps { /** Custom label for the button. */ - label?: string | ((state: FullscreenButtonState) => string) | undefined; + label?: Text | string | ((state: FullscreenButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -41,11 +42,11 @@ export class FullscreenButtonCore { this.#props = defaults(props, FullscreenButtonCore.defaultProps); } - getLabel(state: FullscreenButtonState): string { + getLabel(state: FullscreenButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - return state.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'; + return state.fullscreen ? exitText : enterText; } getAttrs(state: FullscreenButtonState) { @@ -62,7 +63,7 @@ export class FullscreenButtonCore { getState(): FullscreenButtonState { const media = this.#media!; this.state.patch({ fullscreen: media.fullscreen, availability: media.fullscreenAvailability }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts index 8cc51c43..0fb4a0d4 100644 --- a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts +++ b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts @@ -48,12 +48,18 @@ describe('FullscreenButtonCore', () => { describe('getLabel', () => { it('returns Enter fullscreen when not fullscreen', () => { const core = new FullscreenButtonCore(); - expect(core.getLabel(createState({ fullscreen: false }))).toBe('Enter fullscreen'); + expect(core.getLabel(createState({ fullscreen: false }))).toMatchObject({ + key: 'fullscreen.enter', + text: 'Enter fullscreen', + }); }); it('returns Exit fullscreen when fullscreen', () => { const core = new FullscreenButtonCore(); - expect(core.getLabel(createState({ fullscreen: true }))).toBe('Exit fullscreen'); + expect(core.getLabel(createState({ fullscreen: true }))).toMatchObject({ + key: 'fullscreen.exit', + text: 'Exit fullscreen', + }); }); it('returns custom string label', () => { @@ -73,7 +79,7 @@ describe('FullscreenButtonCore', () => { it('returns aria-label', () => { const core = new FullscreenButtonCore(); const attrs = core.getAttrs(createState()); - expect(attrs['aria-label']).toBe('Enter fullscreen'); + expect(attrs['aria-label']).toMatchObject({ key: 'fullscreen.enter', text: 'Enter fullscreen' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/input-feedback/labels.ts b/packages/core/src/core/ui/input-feedback/labels.ts index 3f1a5f55..60a75562 100644 --- a/packages/core/src/core/ui/input-feedback/labels.ts +++ b/packages/core/src/core/ui/input-feedback/labels.ts @@ -1,19 +1,30 @@ -import type { Translator } from '../../i18n/types'; +import type { Translator } from '../../i18n'; +import { exitText } from '../../i18n/text/fullscreen'; +import { + captionsOffText, + captionsOnText, + exitPipText, + fullscreenText, + pausedText, + pipText, + playingText, +} from '../../i18n/text/status'; +import { labelText, mutedText, valueText } from '../../i18n/text/volume'; import type { InputIndicatorLabels } from './status'; /** Maps i18n indicator keys to {@link InputIndicatorLabels} for status / volume feedback. */ export function createInputIndicatorLabels(translator: Translator): InputIndicatorLabels { return { - muted: translator('Muted'), - volume: translator('Volume'), - volumeWithValue: (value) => translator('Volume {value}', { value }), - captionsOn: translator('Captions on'), - captionsOff: translator('Captions off'), - paused: translator('Paused'), - playing: translator('Playing'), - fullscreen: translator('Fullscreen'), - exitFullscreen: translator('Exit fullscreen'), - pictureInPicture: translator('Picture in picture'), - exitPictureInPicture: translator('Exit picture in picture'), + muted: translator(mutedText), + volume: translator(labelText), + volumeWithValue: (value) => translator(valueText, { value }), + captionsOn: translator(captionsOnText), + captionsOff: translator(captionsOffText), + paused: translator(pausedText), + playing: translator(playingText), + fullscreen: translator(fullscreenText), + exitFullscreen: translator(exitText), + pictureInPicture: translator(pipText), + exitPictureInPicture: translator(exitPipText), }; } diff --git a/packages/core/src/core/ui/input-feedback/tests/labels.test.ts b/packages/core/src/core/ui/input-feedback/tests/labels.test.ts index 8ff1130c..ea23f77d 100644 --- a/packages/core/src/core/ui/input-feedback/tests/labels.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/labels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createTranslator, type Translations } from '../../../i18n'; +import { createTranslator, type FlatTranslations } from '../../../i18n'; import { createInputIndicatorLabels } from '../labels'; @@ -8,18 +8,18 @@ describe('createInputIndicatorLabels', () => { const labels = createInputIndicatorLabels( createTranslator( { - Muted: 'Muet', - Volume: 'Volume', - 'Volume {value}': 'Volume {value}', - 'Captions on': 'Sous-titres activés', - 'Captions off': 'Sous-titres désactivés', - Paused: 'En pause', - Playing: 'Lecture en cours', - Fullscreen: 'Plein écran', - 'Exit fullscreen': 'Quitter le plein écran', - 'Picture in picture': 'Image dans l’image', - 'Exit picture in picture': 'Quitter l’image dans l’image', - } satisfies Translations, + 'volume.muted': 'Muet', + 'volume.label': 'Volume', + 'volume.value': 'Volume {value}', + 'status.captionsOn': 'Sous-titres activés', + 'status.captionsOff': 'Sous-titres désactivés', + 'status.paused': 'En pause', + 'status.playing': 'Lecture en cours', + 'status.fullscreen': 'Plein écran', + 'fullscreen.exit': 'Quitter le plein écran', + 'status.pip': 'Image dans l’image', + 'status.exitPip': 'Quitter l’image dans l’image', + } satisfies FlatTranslations, 'fr' ) ); diff --git a/packages/core/src/core/ui/live-button/live-button-core.ts b/packages/core/src/core/ui/live-button/live-button-core.ts index 10992c04..3f85533b 100644 --- a/packages/core/src/core/ui/live-button/live-button-core.ts +++ b/packages/core/src/core/ui/live-button/live-button-core.ts @@ -1,14 +1,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { badgeText, playingText, seekToEdgeText } from '../../i18n/text/live'; import type { MediaBufferState, MediaLiveState, MediaTimeState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface LiveButtonProps { /** Custom label for the button. */ - label?: string | ((state: LiveButtonState) => string) | undefined; + label?: Text | string | ((state: LiveButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -50,15 +51,8 @@ export interface LiveButtonState extends ButtonState { * @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0007-live-edge.md */ export class LiveButtonCore { - /** - * Default visible text. Auto-inserted by `media-live-button` and `` - * when no children are provided. Override globally for i18n: - * - * ```ts - * LiveButtonCore.defaultText = 'En Vivo'; - * ``` - */ - static defaultText = 'Live'; + /** Default visible text used when no children are provided. */ + static defaultText: Text | string = badgeText; static readonly defaultProps: NonNullableObject = { label: '', @@ -82,12 +76,12 @@ export class LiveButtonCore { this.#props = defaults(props, LiveButtonCore.defaultProps); } - getLabel(state: LiveButtonState): string { + getLabel(state: LiveButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - if (state.liveEdge) return 'Playing live'; - return 'Seek to live edge'; + if (state.liveEdge) return playingText; + return seekToEdgeText; } getAttrs(state: LiveButtonState) { @@ -108,7 +102,7 @@ export class LiveButtonCore { const liveEdge = live && this.#isAtLiveEdge(media); this.state.patch({ live, liveEdge }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts b/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts index d69ea2ba..ac70e7d6 100644 --- a/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts +++ b/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts @@ -148,12 +148,18 @@ describe('LiveButtonCore', () => { describe('getLabel', () => { it('returns "Seek to live edge" when behind live', () => { const core = new LiveButtonCore(); - expect(core.getLabel(createState({ live: true, liveEdge: false }))).toBe('Seek to live edge'); + expect(core.getLabel(createState({ live: true, liveEdge: false }))).toMatchObject({ + key: 'live.seekToEdge', + text: 'Seek to live edge', + }); }); it('returns "Playing live" when at live edge', () => { const core = new LiveButtonCore(); - expect(core.getLabel(createState({ live: true, liveEdge: true }))).toBe('Playing live'); + expect(core.getLabel(createState({ live: true, liveEdge: true }))).toMatchObject({ + key: 'live.playing', + text: 'Playing live', + }); }); it('returns custom string label', () => { diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.ts b/packages/core/src/core/ui/mute-button/mute-button-core.ts index 9508a203..9d4fbb41 100644 --- a/packages/core/src/core/ui/mute-button/mute-button-core.ts +++ b/packages/core/src/core/ui/mute-button/mute-button-core.ts @@ -1,7 +1,8 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { muteText, unmuteText } from '../../i18n/text/buttons'; import type { MediaVolumeState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; @@ -10,7 +11,7 @@ export type VolumeLevel = 'off' | 'low' | 'medium' | 'high'; export interface MuteButtonProps { /** Custom label for the button. */ - label?: string | ((state: MuteButtonState) => string) | undefined; + label?: Text | string | ((state: MuteButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -49,11 +50,11 @@ export class MuteButtonCore { this.#props = defaults(props, MuteButtonCore.defaultProps); } - getLabel(state: MuteButtonState): string { + getLabel(state: MuteButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - return state.muted ? 'Unmute' : 'Mute'; + return state.muted ? unmuteText : muteText; } getAttrs(state: MuteButtonState) { @@ -70,7 +71,7 @@ export class MuteButtonCore { getState(): MuteButtonState { const media = this.#media!; this.state.patch({ muted: media.muted || media.volume === 0, volumeLevel: getVolumeLevel(media) }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts index 4abee7ff..004d6905 100644 --- a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts +++ b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts @@ -82,12 +82,12 @@ describe('MuteButtonCore', () => { describe('getLabel', () => { it('returns mute when unmuted', () => { const core = new MuteButtonCore(); - expect(core.getLabel(createState({ muted: false }))).toBe('Mute'); + expect(core.getLabel(createState({ muted: false }))).toMatchObject({ key: 'buttons.mute', text: 'Mute' }); }); it('returns unmute when muted', () => { const core = new MuteButtonCore(); - expect(core.getLabel(createState({ muted: true }))).toBe('Unmute'); + expect(core.getLabel(createState({ muted: true }))).toMatchObject({ key: 'buttons.unmute', text: 'Unmute' }); }); it('returns custom string label', () => { @@ -107,7 +107,7 @@ describe('MuteButtonCore', () => { it('returns aria-label', () => { const core = new MuteButtonCore(); const attrs = core.getAttrs(createState({ muted: false })); - expect(attrs['aria-label']).toBe('Mute'); + expect(attrs['aria-label']).toMatchObject({ key: 'buttons.mute', text: 'Mute' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/pip-button/pip-button-core.ts b/packages/core/src/core/ui/pip-button/pip-button-core.ts index a5eb622f..77fcdb5d 100644 --- a/packages/core/src/core/ui/pip-button/pip-button-core.ts +++ b/packages/core/src/core/ui/pip-button/pip-button-core.ts @@ -1,14 +1,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { enterText, exitText } from '../../i18n/text/pip'; import type { MediaPictureInPictureState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface PiPButtonProps { /** Custom label for the button. */ - label?: string | ((state: PiPButtonState) => string) | undefined; + label?: Text | string | ((state: PiPButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -41,11 +42,11 @@ export class PiPButtonCore { this.#props = defaults(props, PiPButtonCore.defaultProps); } - getLabel(state: PiPButtonState): string { + getLabel(state: PiPButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - return state.pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture'; + return state.pip ? exitText : enterText; } getAttrs(state: PiPButtonState) { @@ -62,7 +63,7 @@ export class PiPButtonCore { getState(): PiPButtonState { const media = this.#media!; this.state.patch({ pip: media.pip, availability: media.pipAvailability }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts index 5abddf09..ba201b25 100644 --- a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts +++ b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts @@ -48,12 +48,18 @@ describe('PiPButtonCore', () => { describe('getLabel', () => { it('returns Enter picture-in-picture when not in PiP', () => { const core = new PiPButtonCore(); - expect(core.getLabel(createState({ pip: false }))).toBe('Enter picture-in-picture'); + expect(core.getLabel(createState({ pip: false }))).toMatchObject({ + key: 'pip.enter', + text: 'Enter picture-in-picture', + }); }); it('returns Exit picture-in-picture when in PiP', () => { const core = new PiPButtonCore(); - expect(core.getLabel(createState({ pip: true }))).toBe('Exit picture-in-picture'); + expect(core.getLabel(createState({ pip: true }))).toMatchObject({ + key: 'pip.exit', + text: 'Exit picture-in-picture', + }); }); it('returns custom string label', () => { @@ -73,7 +79,7 @@ describe('PiPButtonCore', () => { it('returns aria-label', () => { const core = new PiPButtonCore(); const attrs = core.getAttrs(createState()); - expect(attrs['aria-label']).toBe('Enter picture-in-picture'); + expect(attrs['aria-label']).toMatchObject({ key: 'pip.enter', text: 'Enter picture-in-picture' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts index a0032f69..d1db40de 100644 --- a/packages/core/src/core/ui/play-button/play-button-core.ts +++ b/packages/core/src/core/ui/play-button/play-button-core.ts @@ -1,14 +1,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { pauseText, playText, replayText } from '../../i18n/text/buttons'; import type { MediaPlaybackState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface PlayButtonProps { /** Custom label for the button. */ - label?: string | ((state: PlayButtonState) => string) | undefined; + label?: Text | string | ((state: PlayButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -39,12 +40,12 @@ export class PlayButtonCore { this.#props = defaults(props, PlayButtonCore.defaultProps); } - getLabel(state: PlayButtonState): string { + getLabel(state: PlayButtonState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - if (state.ended) return 'Replay'; - return state.paused ? 'Play' : 'Pause'; + if (state.ended) return replayText; + return state.paused ? playText : pauseText; } getAttrs(state: PlayButtonState) { @@ -62,7 +63,7 @@ export class PlayButtonCore { const media = this.#media!; this.state.patch({ paused: media.paused, ended: media.ended, started: media.started }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts index 602b544c..2fbaf314 100644 --- a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts +++ b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts @@ -67,17 +67,17 @@ describe('PlayButtonCore', () => { describe('getLabel', () => { it('returns play when paused', () => { const core = new PlayButtonCore(); - expect(core.getLabel(createState({ paused: true }))).toBe('Play'); + expect(core.getLabel(createState({ paused: true }))).toMatchObject({ key: 'buttons.play', text: 'Play' }); }); it('returns pause when playing', () => { const core = new PlayButtonCore(); - expect(core.getLabel(createState({ paused: false }))).toBe('Pause'); + expect(core.getLabel(createState({ paused: false }))).toMatchObject({ key: 'buttons.pause', text: 'Pause' }); }); it('returns replay when ended', () => { const core = new PlayButtonCore(); - expect(core.getLabel(createState({ ended: true }))).toBe('Replay'); + expect(core.getLabel(createState({ ended: true }))).toMatchObject({ key: 'buttons.replay', text: 'Replay' }); }); it('returns custom string label', () => { @@ -94,7 +94,7 @@ describe('PlayButtonCore', () => { it('falls back to default when function returns empty', () => { const core = new PlayButtonCore({ label: () => '' }); - expect(core.getLabel(createState({ paused: true }))).toBe('Play'); + expect(core.getLabel(createState({ paused: true }))).toMatchObject({ key: 'buttons.play', text: 'Play' }); }); }); @@ -102,7 +102,7 @@ describe('PlayButtonCore', () => { it('returns aria-label', () => { const core = new PlayButtonCore(); const attrs = core.getAttrs(createState({ paused: true })); - expect(attrs['aria-label']).toBe('Play'); + expect(attrs['aria-label']).toMatchObject({ key: 'buttons.play', text: 'Play' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts index fc5aaf69..d7fefa7b 100644 --- a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts @@ -1,14 +1,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { rateText } from '../../i18n/text/playback'; import type { MediaPlaybackRateState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface PlaybackRateButtonProps { /** Custom label for the button. */ - label?: string | ((state: PlaybackRateButtonState) => string) | undefined; + label?: Text | string | ((state: PlaybackRateButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; /** When true, pointer activation opens a menu instead of cycling. React sets this automatically inside `Menu.Trigger`. */ @@ -42,11 +43,11 @@ export class PlaybackRateButtonCore { this.#props = defaults(props, PlaybackRateButtonCore.defaultProps); } - getLabel(state: PlaybackRateButtonState): string { + getLabel(state: PlaybackRateButtonState): Text | string { const custom = resolveLabel(this.#props.label, state); if (custom !== undefined) return custom; - return 'Playback rate {rate}'; + return rateText; } getLabelParams(state: PlaybackRateButtonState): { rate: number } | undefined { @@ -68,7 +69,7 @@ export class PlaybackRateButtonCore { getState(): PlaybackRateButtonState { const media = this.#media!; this.state.patch({ rate: media.playbackRate }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts index 166badc0..b401908c 100644 --- a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts +++ b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts @@ -36,12 +36,18 @@ describe('PlaybackRateButtonCore', () => { describe('getLabel', () => { it('returns default label with rate', () => { const core = new PlaybackRateButtonCore(); - expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate {rate}'); + expect(core.getLabel(createState({ rate: 1.5 }))).toMatchObject({ + key: 'playback.rate', + text: 'Playback rate {rate}', + }); }); it('returns default label for rate 1', () => { const core = new PlaybackRateButtonCore(); - expect(core.getLabel(createState({ rate: 1 }))).toBe('Playback rate {rate}'); + expect(core.getLabel(createState({ rate: 1 }))).toMatchObject({ + key: 'playback.rate', + text: 'Playback rate {rate}', + }); }); it('returns custom string label', () => { @@ -60,7 +66,10 @@ describe('PlaybackRateButtonCore', () => { const core = new PlaybackRateButtonCore({ label: () => '', }); - expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate {rate}'); + expect(core.getLabel(createState({ rate: 1.5 }))).toMatchObject({ + key: 'playback.rate', + text: 'Playback rate {rate}', + }); }); }); @@ -80,7 +89,7 @@ describe('PlaybackRateButtonCore', () => { it('returns aria-label', () => { const core = new PlaybackRateButtonCore(); const attrs = core.getAttrs(createState({ rate: 1.5 })); - expect(attrs['aria-label']).toBe('Playback rate {rate}'); + expect(attrs['aria-label']).toMatchObject({ key: 'playback.rate', text: 'Playback rate {rate}' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts index 1026444f..6caeb97c 100644 --- a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts +++ b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts @@ -2,14 +2,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isUndefined } from '@videojs/utils/predicate'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { rateText } from '../../i18n/text/playback'; import type { MediaPlaybackRateState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface PlaybackRateRadioGroupProps { /** Custom label for the options group. */ - label?: string | ((state: PlaybackRateRadioGroupState) => string) | undefined; + label?: Text | string | ((state: PlaybackRateRadioGroupState) => Text | string) | undefined; /** Custom formatter for visible playback rate labels. */ formatRate?: ((rate: number) => string) | undefined; /** Whether playback rate selection is disabled. */ @@ -53,10 +54,10 @@ export class PlaybackRateRadioGroupCore { this.#props = defaults(props, PlaybackRateRadioGroupCore.defaultProps); } - getLabel(state: PlaybackRateRadioGroupState): string { + getLabel(state: PlaybackRateRadioGroupState): Text | string { const custom = resolveLabel(this.#props.label, state); if (custom !== undefined) return custom; - return 'Playback rate {rate}'; + return rateText; } getLabelParams(state: PlaybackRateRadioGroupState): { rate: number } | undefined { @@ -95,7 +96,7 @@ export class PlaybackRateRadioGroupCore { disabled: this.#props.disabled || media.playbackRates.length === 0, availability, }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts b/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts index 02243df1..84a8f50d 100644 --- a/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts +++ b/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts @@ -62,7 +62,10 @@ describe('PlaybackRateRadioGroupCore', () => { describe('getLabel', () => { it('returns default label with rate', () => { const core = new PlaybackRateRadioGroupCore(); - expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate {rate}'); + expect(core.getLabel(createState({ rate: 1.5 }))).toMatchObject({ + key: 'playback.rate', + text: 'Playback rate {rate}', + }); }); it('returns custom string label', () => { @@ -109,7 +112,7 @@ describe('PlaybackRateRadioGroupCore', () => { it('returns aria-label', () => { const core = new PlaybackRateRadioGroupCore(); const attrs = core.getAttrs(createState({ rate: 1.5 })); - expect(attrs['aria-label']).toBe('Playback rate {rate}'); + expect(attrs['aria-label']).toMatchObject({ key: 'playback.rate', text: 'Playback rate {rate}' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts index e0f31d29..c4aade80 100644 --- a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts +++ b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts @@ -1,30 +1,32 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text, type TextParams } from '../../i18n'; +import { autoText, autoWithLabelText, qualityText } from '../../i18n/text/menu'; import type { MediaQualityState, MediaVideoRendition } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; export interface QualityRadioGroupProps { /** Custom label for the options group. */ - label?: string | ((state: QualityRadioGroupState) => string) | undefined; + label?: Text | string | ((state: QualityRadioGroupState) => Text | string) | undefined; /** Custom formatter for visible rendition labels. */ - formatRendition?: ((rendition: MediaVideoRendition) => string) | undefined; + formatRendition?: ((rendition: MediaVideoRendition) => Text | string) | undefined; /** Whether quality selection is disabled. */ disabled?: boolean | undefined; } export interface QualityRadioGroupRendition { value: string; - label: string; + label: Text | string; tier?: string | undefined; badge?: string | undefined; } export interface QualityRadioGroupState extends ButtonState { renditions: readonly QualityRadioGroupRendition[]; - autoLabel: string; + autoLabel: Text | string; + autoLabelParams?: TextParams; value: string; disabled: boolean; availability: 'available' | 'unavailable'; @@ -64,11 +66,11 @@ function hasSameSize(rendition: MediaVideoRendition, renditions: readonly MediaV return Boolean(size && renditions.some((other) => other !== rendition && getRenditionSize(other) === size)); } -function formatRenditionLabel(rendition: MediaVideoRendition): string { +function formatRenditionLabel(rendition: MediaVideoRendition): Text | string { const size = getRenditionSize(rendition); if (size) return `${size}p`; if (rendition.bitrate) return formatBitrate(rendition.bitrate); - return 'Quality'; + return qualityText; } function formatRenditionBadge( @@ -115,7 +117,7 @@ export class QualityRadioGroupCore { readonly state = createState({ renditions: [], - autoLabel: 'Auto', + autoLabel: autoText, value: QUALITY_AUTO_VALUE, disabled: false, availability: 'unavailable', @@ -133,14 +135,14 @@ export class QualityRadioGroupCore { this.#props = defaults(props, QualityRadioGroupCore.defaultProps); } - getLabel(state: QualityRadioGroupState): string { + getLabel(state: QualityRadioGroupState): Text | string { const label = resolveLabel(this.#props.label, state); if (label) return label; - return 'Quality'; + return qualityText; } - getRenditionLabel(rendition: MediaVideoRendition): string { + getRenditionLabel(rendition: MediaVideoRendition): Text | string { if (this.#props.formatRendition !== QualityRadioGroupCore.defaultProps.formatRendition) { return this.#props.formatRendition(rendition); } @@ -205,7 +207,8 @@ export class QualityRadioGroupCore { this.state.patch({ renditions: media.videoRenditionList.map(toRendition), - autoLabel: selectedIndex === -1 && active ? `Auto (${active.label})` : 'Auto', + autoLabel: selectedIndex === -1 && active ? autoWithLabelText : autoText, + ...(selectedIndex === -1 && active && { autoLabelParams: { label: resolveText(active.label) } }), value: selectedIndex === -1 ? QUALITY_AUTO_VALUE @@ -213,7 +216,7 @@ export class QualityRadioGroupCore { disabled: this.#props.disabled || availability === 'unavailable', availability, }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts b/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts index 125964cd..70129b40 100644 --- a/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts +++ b/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts @@ -106,7 +106,8 @@ describe('QualityRadioGroupCore', () => { const state = core.getState(); expect(state.value).toBe(QUALITY_AUTO_VALUE); - expect(state.autoLabel).toBe('Auto (720p)'); + expect(state.autoLabel).toMatchObject({ key: 'menu.autoWithLabel', text: 'Auto ({label})' }); + expect(state.autoLabelParams).toEqual({ label: '720p' }); }); it('marks availability unavailable with one rendition', () => { @@ -121,7 +122,7 @@ describe('QualityRadioGroupCore', () => { describe('getLabel', () => { it('returns the default label', () => { const core = new QualityRadioGroupCore(); - expect(core.getLabel(createState())).toBe('Quality'); + expect(core.getLabel(createState())).toMatchObject({ key: 'menu.quality', text: 'Quality' }); }); it('returns a custom string label', () => { diff --git a/packages/core/src/core/ui/seek-button/seek-button-core.ts b/packages/core/src/core/ui/seek-button/seek-button-core.ts index f2d36697..de0b3f98 100644 --- a/packages/core/src/core/ui/seek-button/seek-button-core.ts +++ b/packages/core/src/core/ui/seek-button/seek-button-core.ts @@ -1,7 +1,8 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - +import { resolveText, type Text } from '../../i18n'; +import { backwardText, forwardText } from '../../i18n/text/seek'; import type { MediaTimeState } from '../../media/state'; import type { ButtonState } from '../types'; import { resolveLabel } from '../utils/resolve-label'; @@ -10,7 +11,7 @@ export interface SeekButtonProps { /** Seconds to seek. Positive = forward, negative = backward. Default `30`. */ seconds?: number | undefined; /** Custom label for the button. */ - label?: string | ((state: SeekButtonState) => string) | undefined; + label?: Text | string | ((state: SeekButtonState) => Text | string) | undefined; /** Whether the button is disabled. */ disabled?: boolean | undefined; } @@ -48,11 +49,11 @@ export class SeekButtonCore { this.#props = defaults(props, SeekButtonCore.defaultProps); } - getLabel(state: SeekButtonState): string { + getLabel(state: SeekButtonState): Text | string { const custom = resolveLabel(this.#props.label, state); if (custom !== undefined) return custom; - return state.direction === 'backward' ? 'Seek backward {seconds} seconds' : 'Seek forward {seconds} seconds'; + return state.direction === 'backward' ? backwardText : forwardText; } getLabelParams(state: SeekButtonState): { seconds: number } | undefined { @@ -76,7 +77,7 @@ export class SeekButtonCore { const direction: SeekButtonDirection = this.#props.seconds < 0 ? 'backward' : 'forward'; this.state.patch({ seeking: media.seeking, direction }); - this.state.patch({ label: this.getLabel(this.state.current) }); + this.state.patch({ label: resolveText(this.getLabel(this.state.current)) }); return this.state.current; } diff --git a/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts index ac8bf2b9..39c5e910 100644 --- a/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts +++ b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts @@ -75,19 +75,24 @@ describe('SeekButtonCore', () => { describe('getLabel', () => { it('returns forward label for forward direction', () => { const core = new SeekButtonCore({ seconds: 30 }); - expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward {seconds} seconds'); + expect(core.getLabel(createState({ direction: 'forward' }))).toMatchObject({ + key: 'seek.forward', + text: 'Seek forward {seconds} seconds', + }); }); it('returns backward label for backward direction', () => { const core = new SeekButtonCore({ seconds: -10 }); - expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Seek backward {seconds} seconds'); + expect(core.getLabel(createState({ direction: 'backward' }))).toMatchObject({ + key: 'seek.backward', + text: 'Seek backward {seconds} seconds', + }); }); it('uses absolute value in backward label', () => { const core = new SeekButtonCore({ seconds: -30 }); const label = core.getLabel(createState({ direction: 'backward' })); - expect(label).toBe('Seek backward {seconds} seconds'); - expect(label).not.toContain('-'); + expect(label).toMatchObject({ key: 'seek.backward', text: 'Seek backward {seconds} seconds' }); }); it('returns custom string label', () => { @@ -105,7 +110,10 @@ describe('SeekButtonCore', () => { it('falls back to default when function returns empty', () => { const core = new SeekButtonCore({ seconds: 10, label: () => '' }); - expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward {seconds} seconds'); + expect(core.getLabel(createState({ direction: 'forward' }))).toMatchObject({ + key: 'seek.forward', + text: 'Seek forward {seconds} seconds', + }); }); }); @@ -141,7 +149,7 @@ describe('SeekButtonCore', () => { it('returns aria-label', () => { const core = new SeekButtonCore({ seconds: 30 }); const attrs = core.getAttrs(createState({ direction: 'forward' })); - expect(attrs['aria-label']).toBe('Seek forward {seconds} seconds'); + expect(attrs['aria-label']).toMatchObject({ key: 'seek.forward', text: 'Seek forward {seconds} seconds' }); }); it('sets aria-disabled when disabled', () => { diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts index 7278db0b..2f59f077 100644 --- a/packages/core/src/core/ui/slider/slider-core.ts +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -1,13 +1,14 @@ import { clamp, roundToStep } from '@videojs/utils/number'; import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; +import type { Text } from '../../i18n'; import { resolveLabel } from '../utils/resolve-label'; /** Configuration shared by all slider variants. */ export interface SliderProps { /** Custom label for the slider. */ - label?: string | ((state: SliderState) => string) | undefined; + label?: Text | string | ((state: SliderState) => Text | string) | undefined; /** Step increment for value changes (arrow keys). */ step?: number | undefined; /** Large step increment (Page Up/Down keys). */ @@ -123,7 +124,7 @@ export class SliderCore { }; } - getLabel(state: SliderState): string { + getLabel(state: SliderState): Text | string { return resolveLabel(this.#props.label, state) || ''; } diff --git a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts index fe2fcc94..e5e61300 100644 --- a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts +++ b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts @@ -158,8 +158,8 @@ describe('TimeSliderCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-label']).toBe('Seek'); - expect(attrs['aria-valuetext']).toBe('{current} of {duration}'); + expect(attrs['aria-label']).toMatchObject({ key: 'slider.seek', text: 'Seek' }); + expect(attrs['aria-valuetext']).toMatchObject({ key: 'time.position', text: '{current} of {duration}' }); expect(core.getValueTextParams(state)).toEqual({ current: formatTimeAsPhrase(90), duration: formatTimeAsPhrase(300), @@ -184,7 +184,7 @@ describe('TimeSliderCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-valuetext']).toBe('{current} of {duration}'); + expect(attrs['aria-valuetext']).toMatchObject({ key: 'time.position', text: '{current} of {duration}' }); expect(core.getValueTextParams(state)).toEqual({ current: formatTimeAsPhrase(0), duration: formatTimeAsPhrase(0), @@ -199,7 +199,7 @@ describe('TimeSliderCore', () => { const attrs = core.getAttrs(state); expect(attrs['aria-valuenow']).toBe(150); - expect(attrs['aria-valuetext']).toBe('{current} of {duration}'); + expect(attrs['aria-valuetext']).toMatchObject({ key: 'time.position', text: '{current} of {duration}' }); expect(core.getValueTextParams(state)).toEqual({ current: formatTimeAsPhrase(150), duration: formatTimeAsPhrase(300), diff --git a/packages/core/src/core/ui/time-slider/time-slider-core.ts b/packages/core/src/core/ui/time-slider/time-slider-core.ts index 4ee55abc..822c5aa0 100644 --- a/packages/core/src/core/ui/time-slider/time-slider-core.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-core.ts @@ -1,7 +1,9 @@ import { defaults } from '@videojs/utils/object'; import { formatTimeAsPhrase } from '@videojs/utils/time'; import type { NonNullableObject } from '@videojs/utils/types'; - +import type { Text } from '../../i18n'; +import { seekText } from '../../i18n/text/slider'; +import { positionText } from '../../i18n/text/time'; import type { MediaBufferState, MediaPlaybackState, MediaTimeState } from '../../media/state'; import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; @@ -81,8 +83,8 @@ export class TimeSliderCore extends SliderCore { }; } - override getLabel(state: SliderState): string { - return super.getLabel(state) || 'Seek'; + override getLabel(state: SliderState): Text | string { + return super.getLabel(state) || seekText; } #announceValue(state: TimeSliderState): number { @@ -95,8 +97,8 @@ export class TimeSliderCore extends SliderCore { : formatTimeAsPhrase(seconds, { locale: this.#formatLocale }); } - getValueText(state: TimeSliderState): string { - return Number.isFinite(state.duration) ? '{current} of {duration}' : this.getValueTextParams(state).current; + getValueText(state: TimeSliderState): Text | string { + return Number.isFinite(state.duration) ? positionText : this.getValueTextParams(state).current; } getValueTextParams(state: TimeSliderState): { current: string; duration: string } | { current: string } { diff --git a/packages/core/src/core/ui/time/tests/time-core.test.ts b/packages/core/src/core/ui/time/tests/time-core.test.ts index 76094cd0..0c3fe9a1 100644 --- a/packages/core/src/core/ui/time/tests/time-core.test.ts +++ b/packages/core/src/core/ui/time/tests/time-core.test.ts @@ -1,11 +1,11 @@ import { formatTimeAsPhrase } from '@videojs/utils/time'; import { describe, expect, it } from 'vitest'; -import { createTranslator, resolveTranslation, translations } from '../../../i18n'; +import { createTranslator, flattenTranslations, translateText, translations } from '../../../i18n'; import type { MediaTimeState } from '../../../media/state'; import { TimeCore } from '../time-core'; -const t = createTranslator(translations, 'en'); +const t = createTranslator(flattenTranslations(translations), 'en'); function createMediaState(overrides: Partial = {}): MediaTimeState { return { @@ -108,21 +108,21 @@ describe('TimeCore', () => { const core = new TimeCore({ type: 'current' }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('Current time'); + expect(core.getLabel(state)).toMatchObject({ key: 'time.current', text: 'Current time' }); }); it('returns default label for duration', () => { const core = new TimeCore({ type: 'duration' }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('Duration'); + expect(core.getLabel(state)).toMatchObject({ key: 'time.duration', text: 'Duration' }); }); it('returns default label for remaining', () => { const core = new TimeCore({ type: 'remaining' }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('Remaining'); + expect(core.getLabel(state)).toMatchObject({ key: 'time.remaining', text: 'Remaining' }); }); it('returns custom string label', () => { @@ -146,9 +146,12 @@ describe('TimeCore', () => { const core = new TimeCore({ type: 'current', toggle: true }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('{duration}. Show remaining time.'); + expect(core.getLabel(state)).toMatchObject({ + key: 'time.showRemaining', + text: '{duration}. Show remaining time.', + }); expect(core.getLabelParams(state)).toEqual({ duration: '1 minute, 30 seconds' }); - expect(resolveTranslation(t, core.getLabel(state), core.getLabelParams(state))).toBe( + expect(translateText(core.getLabel(state), t, core.getLabelParams(state))).toBe( '1 minute, 30 seconds. Show remaining time.' ); }); @@ -157,9 +160,9 @@ describe('TimeCore', () => { const core = new TimeCore({ type: 'remaining', toggle: true }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('{duration}. Show duration.'); + expect(core.getLabel(state)).toMatchObject({ key: 'time.showDuration', text: '{duration}. Show duration.' }); expect(core.getLabelParams(state)).toEqual({ duration: '3 minutes, 30 seconds remaining' }); - expect(resolveTranslation(t, core.getLabel(state), core.getLabelParams(state))).toBe( + expect(translateText(core.getLabel(state), t, core.getLabelParams(state))).toBe( '3 minutes, 30 seconds remaining. Show duration.' ); }); @@ -168,8 +171,11 @@ describe('TimeCore', () => { const core = new TimeCore({ type: 'remaining', toggle: true }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state, 'current')).toBe('{duration}. Show elapsed time.'); - expect(resolveTranslation(t, core.getLabel(state, 'current'), core.getLabelParams(state))).toBe( + expect(core.getLabel(state, 'current')).toMatchObject({ + key: 'time.showElapsed', + text: '{duration}. Show elapsed time.', + }); + expect(translateText(core.getLabel(state, 'current'), t, core.getLabelParams(state))).toBe( '3 minutes, 30 seconds remaining. Show elapsed time.' ); }); @@ -182,7 +188,7 @@ describe('TimeCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-label']).toBe('Current time'); + expect(attrs['aria-label']).toMatchObject({ key: 'time.current', text: 'Current time' }); }); it('includes remaining suffix in label', () => { @@ -191,7 +197,7 @@ describe('TimeCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-label']).toBe('Remaining'); + expect(attrs['aria-label']).toMatchObject({ key: 'time.remaining', text: 'Remaining' }); }); it('returns button attributes when current time is toggleable', () => { @@ -202,7 +208,10 @@ describe('TimeCore', () => { expect(attrs.role).toBe('button'); expect(attrs.tabIndex).toBe(0); - expect(attrs['aria-label']).toBe('{duration}. Show remaining time.'); + expect(attrs['aria-label']).toMatchObject({ + key: 'time.showRemaining', + text: '{duration}. Show remaining time.', + }); expect(core.getLabelParams(state)).toEqual({ duration: '1 minute, 30 seconds' }); }); @@ -214,7 +223,10 @@ describe('TimeCore', () => { expect(attrs.role).toBe('button'); expect(attrs.tabIndex).toBe(0); - expect(attrs['aria-label']).toBe('{duration}. Show elapsed time.'); + expect(attrs['aria-label']).toMatchObject({ + key: 'time.showElapsed', + text: '{duration}. Show elapsed time.', + }); expect(core.getLabelParams(state)).toEqual({ duration: '3 minutes, 30 seconds remaining' }); }); @@ -226,7 +238,10 @@ describe('TimeCore', () => { expect(attrs.role).toBe('button'); expect(attrs.tabIndex).toBe(0); - expect(attrs['aria-label']).toBe('{duration}. Show remaining time.'); + expect(attrs['aria-label']).toMatchObject({ + key: 'time.showRemaining', + text: '{duration}. Show remaining time.', + }); }); it('does not return button attributes without toggle', () => { diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts index f9019359..ffc88514 100644 --- a/packages/core/src/core/ui/time/time-core.ts +++ b/packages/core/src/core/ui/time/time-core.ts @@ -1,7 +1,15 @@ import { defaults } from '@videojs/utils/object'; import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '@videojs/utils/time'; import type { NonNullableObject } from '@videojs/utils/types'; - +import type { Text } from '../../i18n'; +import { + currentText, + durationText, + remainingText, + showDurationText, + showElapsedText, + showRemainingText, +} from '../../i18n/text/time'; import type { MediaTimeState } from '../../media/state'; import { resolveLabel } from '../utils/resolve-label'; @@ -14,7 +22,7 @@ export interface TimeProps { /** Symbol prepended to remaining time. */ negativeSign?: string | undefined; /** Custom label for accessibility. */ - label?: string | ((state: TimeState) => string) | undefined; + label?: Text | string | ((state: TimeState) => Text | string) | undefined; /** Whether the time display can be toggled. */ toggle?: boolean | undefined; } @@ -34,19 +42,16 @@ export interface TimeState { datetime: string; } -const TOGGLE_LABEL_KEYS: Record< - TimeType, - '{duration}. Show elapsed time.' | '{duration}. Show duration.' | '{duration}. Show remaining time.' -> = { - current: '{duration}. Show elapsed time.', - duration: '{duration}. Show duration.', - remaining: '{duration}. Show remaining time.', +const TOGGLE_LABELS: Record = { + current: showElapsedText, + duration: showDurationText, + remaining: showRemainingText, }; -const DEFAULT_LABEL_KEYS: Record = { - current: 'Current time', - duration: 'Duration', - remaining: 'Remaining', +const DEFAULT_LABELS: Record = { + current: currentText, + duration: durationText, + remaining: remainingText, }; export class TimeCore { @@ -118,16 +123,16 @@ export class TimeCore { return currentType === 'duration' ? 'remaining' : 'duration'; } - getLabel(state: TimeState, type = this.#props.type): string { + getLabel(state: TimeState, type = this.#props.type): Text | string { const custom = resolveLabel(this.#props.label, state); if (custom !== undefined) return custom; if (!this.#props.toggle) { - return DEFAULT_LABEL_KEYS[this.#props.type]; + return DEFAULT_LABELS[this.#props.type]; } const toggleType = this.#getToggleType(type, state.type); - return TOGGLE_LABEL_KEYS[toggleType]; + return TOGGLE_LABELS[toggleType]; } getLabelParams(state: TimeState): { duration: string } | undefined { diff --git a/packages/core/src/core/ui/types.ts b/packages/core/src/core/ui/types.ts index 308bcefb..4e956e16 100644 --- a/packages/core/src/core/ui/types.ts +++ b/packages/core/src/core/ui/types.ts @@ -1,4 +1,5 @@ import type { State } from '@videojs/store'; +import type { Text } from '../i18n'; export type StateAttrMap = { [Key in keyof State]?: string; @@ -17,14 +18,14 @@ export interface MediaUIComponent } export interface ButtonState { - label: string; + label: Text | string; } /** Constraint for media button cores that provide a label derived from state. */ export interface MediaButtonComponent extends MediaUIComponent { readonly state: State; - getLabel(state: ComponentState): string; + getLabel(state: ComponentState): Text | string; } /** Extracts the media state parameter type from a core's `setMedia` method. */ diff --git a/packages/core/src/core/ui/utils/resolve-label.ts b/packages/core/src/core/ui/utils/resolve-label.ts index d8a83d68..82ce0ccd 100644 --- a/packages/core/src/core/ui/utils/resolve-label.ts +++ b/packages/core/src/core/ui/utils/resolve-label.ts @@ -1,9 +1,10 @@ import { isFunction } from '@videojs/utils/predicate'; +import type { Text } from '../../i18n'; export function resolveLabel( - label: string | ((state: State) => string) | undefined, + label: Text | string | ((state: State) => Text | string) | undefined, state: State -): string | undefined { +): Text | string | undefined { if (isFunction(label)) { return label(state) || undefined; } diff --git a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts index 1752b98a..489297a1 100644 --- a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts +++ b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts @@ -141,7 +141,7 @@ describe('VolumeSliderCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-label']).toBe('Volume'); + expect(attrs['aria-label']).toMatchObject({ key: 'volume.label', text: 'Volume' }); expect(attrs['aria-valuetext']).toBe(formatPercent(0.75)); expect(core.getValueTextParams(state)).toEqual({ percent: formatPercent(0.75) }); expect(attrs.role).toBe('slider'); @@ -154,7 +154,7 @@ describe('VolumeSliderCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-valuetext']).toBe('{percent}, muted'); + expect(attrs['aria-valuetext']).toMatchObject({ key: 'volume.mutedValue', text: '{percent}, muted' }); expect(core.getValueTextParams(state)).toEqual({ percent: formatPercent(0.5) }); }); @@ -186,7 +186,7 @@ describe('VolumeSliderCore', () => { const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-valuetext']).toBe('{percent}, muted'); + expect(attrs['aria-valuetext']).toMatchObject({ key: 'volume.mutedValue', text: '{percent}, muted' }); expect(core.getValueTextParams(state)).toEqual({ percent: formatPercent(0) }); }); }); diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts index e9239ca0..998ea22d 100644 --- a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -1,6 +1,8 @@ import { defaults } from '@videojs/utils/object'; import { formatPercent } from '@videojs/utils/percent'; import type { NonNullableObject } from '@videojs/utils/types'; +import type { Text } from '../../i18n'; +import { labelText, mutedValueText } from '../../i18n/text/volume'; import type { MediaVolumeState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; @@ -74,12 +76,12 @@ export class VolumeSliderCore extends SliderCore { return range > 0 ? (props.wheelStep / range) * 100 : 0; } - override getLabel(state: SliderState): string { - return super.getLabel(state) || 'Volume'; + override getLabel(state: SliderState): Text | string { + return super.getLabel(state) || labelText; } - getValueText(state: VolumeSliderState): string { - return state.muted ? '{percent}, muted' : this.getValueTextParams(state).percent; + getValueText(state: VolumeSliderState): Text | string { + return state.muted ? mutedValueText : this.getValueTextParams(state).percent; } getValueTextParams(state: VolumeSliderState): { percent: string } { diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index f2911711..256e01a7 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -2,14 +2,17 @@ import type { UserConfig } from 'tsdown'; import { defineConfig } from 'tsdown'; import { type PackageBuildMode, packageBuildConfig, packageBuildModes } from '../../build/tsdown.ts'; import packageJson from './package.json' with { type: 'json' }; +import en from './src/core/i18n/locales/en.ts'; import { LOCALES, localeAliases } from './src/core/i18n/locales.ts'; const localeTags = [...LOCALES, ...localeAliases(LOCALES)]; +const textNamespaces = [...new Set(Object.keys(en).map((key) => key.split('.')[0]))]; const localeEntries = Object.fromEntries([ ['i18n/locales/all', './src/core/i18n/locales/all.ts'], ['i18n/locales/en', './src/core/i18n/locales/en.ts'], ...localeTags.map((tag) => [`i18n/locales/${tag}`, `./src/core/i18n/locales/${tag}.ts`]), + ...textNamespaces.map((namespace) => [`i18n/text/${namespace}`, `./src/core/i18n/text/${namespace}.ts`]), ]); const createConfig = (mode: PackageBuildMode): UserConfig => ({ diff --git a/packages/html/src/cdn/i18n.ts b/packages/html/src/cdn/i18n.ts index 1e390517..7c5a88fa 100644 --- a/packages/html/src/cdn/i18n.ts +++ b/packages/html/src/cdn/i18n.ts @@ -1,7 +1,7 @@ export type { + FlatTranslations, Locale, TranslationParams, - Translations, Translator, } from '@videojs/core/i18n'; export { @@ -10,10 +10,13 @@ export { getBrowserTranslations, getI18nTranslations, hasRegisteredLocale, + isText, loadLocale, onI18nRegistryChange, registerI18n, resolveBrowserTranslationTarget, + resolveText, resolveTranslation, shouldAttemptBrowserTranslation, + translateText, } from '@videojs/core/i18n'; diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 7c595f93..384d9587 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -1,3 +1,4 @@ +import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu'; import { renderIcon } from '@videojs/icons/render/minimal'; import { badge, @@ -24,6 +25,7 @@ import { } from '@videojs/skins/minimal/tailwind/video.tailwind'; import { createTemplate } from '@videojs/utils/dom'; import { cn } from '@videojs/utils/style'; +import { renderText } from '../../i18n/render-text'; import { safeDefine } from '../safe-define'; import { SkinElement } from '../skin-element'; @@ -140,14 +142,14 @@ function getTemplateHTML() {
${renderIcon('switches', { class: cn(icon, menu.icon) })} - Quality + ${renderText(qualityText)} ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} @@ -155,7 +157,7 @@ function getTemplateHTML() { ${renderIcon('speech', { class: icon })} - Audio + ${renderText(audioText)} ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} @@ -163,7 +165,7 @@ function getTemplateHTML() { ${renderIcon('speed', { class: cn(icon, menu.icon) })} - Speed + ${renderText(speedText)} ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} @@ -171,7 +173,7 @@ function getTemplateHTML() { ${renderIcon('captions-off', { class: cn(icon, menu.icon) })} - Captions + ${renderText(captionsText)} ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} @@ -183,7 +185,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} - Quality + ${renderText(qualityText)}
@@ -205,7 +207,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} - Audio + ${renderText(audioText)}
@@ -223,7 +225,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} - Speed + ${renderText(speedText)}
@@ -241,7 +243,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} - Captions + ${renderText(captionsText)}
diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index cd01bbdf..20bc487b 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -1,5 +1,7 @@ +import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu'; import { renderIcon } from '@videojs/icons/render/minimal'; import { createShadowStyle, createTemplate } from '@videojs/utils/dom'; +import { renderText } from '../../i18n/render-text'; import { safeDefine } from '../safe-define'; import { SkinElement } from '../skin-element'; import styles from './minimal-skin.css?inline'; @@ -118,14 +120,14 @@ function getTemplateHTML() {
${renderIcon('switches', { class: 'media-icon' })} - Quality + ${renderText(qualityText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -133,7 +135,7 @@ function getTemplateHTML() { ${renderIcon('speech', { class: 'media-icon' })} - Audio + ${renderText(audioText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -141,7 +143,7 @@ function getTemplateHTML() { ${renderIcon('speed', { class: 'media-icon' })} - Speed + ${renderText(speedText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -149,7 +151,7 @@ function getTemplateHTML() { ${renderIcon('captions-off', { class: 'media-icon' })} - Captions + ${renderText(captionsText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -161,7 +163,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Quality + ${renderText(qualityText)}
@@ -183,7 +185,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Audio + ${renderText(audioText)}
@@ -201,7 +203,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Speed + ${renderText(speedText)}
@@ -219,7 +221,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Captions + ${renderText(captionsText)}
diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 392ff970..1447a9e0 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -1,3 +1,4 @@ +import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu'; import { renderIcon } from '@videojs/icons/render'; import { badge, @@ -24,6 +25,7 @@ import { } from '@videojs/skins/default/tailwind/video.tailwind'; import { createTemplate } from '@videojs/utils/dom'; import { cn } from '@videojs/utils/style'; +import { renderText } from '../../i18n/render-text'; import { safeDefine } from '../safe-define'; import { SkinElement } from '../skin-element'; @@ -134,14 +136,14 @@ function getTemplateHTML() {
${renderIcon('switches', { class: cn(icon, menu.icon) })} - Quality + ${renderText(qualityText)} ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} @@ -149,7 +151,7 @@ function getTemplateHTML() { ${renderIcon('speech', { class: icon })} - Audio + ${renderText(audioText)} ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} @@ -157,7 +159,7 @@ function getTemplateHTML() { ${renderIcon('speed', { class: cn(icon, menu.icon) })} - Speed + ${renderText(speedText)} ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} @@ -165,7 +167,7 @@ function getTemplateHTML() { ${renderIcon('captions-off', { class: cn(icon, menu.icon) })} - Captions + ${renderText(captionsText)} ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} @@ -177,7 +179,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} - Quality + ${renderText(qualityText)}
@@ -199,7 +201,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} - Audio + ${renderText(audioText)}
@@ -217,7 +219,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} - Speed + ${renderText(speedText)}
@@ -235,7 +237,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} - Captions + ${renderText(captionsText)}
diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 3cc43c4e..dbd9a881 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -1,5 +1,7 @@ +import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu'; import { renderIcon } from '@videojs/icons/render'; import { createShadowStyle, createTemplate } from '@videojs/utils/dom'; +import { renderText } from '../../i18n/render-text'; import { safeDefine } from '../safe-define'; import { SkinElement } from '../skin-element'; import styles from './skin.css?inline'; @@ -112,14 +114,14 @@ function getTemplateHTML() {
${renderIcon('switches', { class: 'media-icon' })} - Quality + ${renderText(qualityText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -127,7 +129,7 @@ function getTemplateHTML() { ${renderIcon('speech', { class: 'media-icon' })} - Audio + ${renderText(audioText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -135,7 +137,7 @@ function getTemplateHTML() { ${renderIcon('speed', { class: 'media-icon' })} - Speed + ${renderText(speedText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -143,7 +145,7 @@ function getTemplateHTML() { ${renderIcon('captions-off', { class: 'media-icon' })} - Captions + ${renderText(captionsText)} ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} @@ -155,7 +157,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Quality + ${renderText(qualityText)}
@@ -177,7 +179,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Audio + ${renderText(audioText)}
@@ -195,7 +197,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Speed + ${renderText(speedText)}
@@ -213,7 +215,7 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} - Captions + ${renderText(captionsText)}
diff --git a/packages/html/src/i18n/cache-key.ts b/packages/html/src/i18n/cache-key.ts new file mode 100644 index 00000000..1d3b6634 --- /dev/null +++ b/packages/html/src/i18n/cache-key.ts @@ -0,0 +1,6 @@ +import type { Text, TextParams } from '@videojs/core/i18n'; + +/** Serialize text content and parameters so dynamic labels invalidate their caches. */ +export function cacheKey(text: Text | string, params?: TextParams): string { + return JSON.stringify([text, params]); +} diff --git a/packages/html/src/i18n/index.ts b/packages/html/src/i18n/index.ts index d9acb929..43249971 100644 --- a/packages/html/src/i18n/index.ts +++ b/packages/html/src/i18n/index.ts @@ -1,6 +1,7 @@ import '../define/i18n'; export type { + FlatTranslations, Locale, TranslationParams, Translations, @@ -12,6 +13,7 @@ export { findLocaleKeys, getI18nTranslations, hasRegisteredLocale, + isText, onI18nRegistryChange, registerI18n, } from '@videojs/core/i18n'; @@ -34,3 +36,4 @@ export { I18nProviderMixin, MediaI18nProviderElement, } from './provider-element'; +export { renderText } from './render-text'; diff --git a/packages/html/src/i18n/provider-mixin.ts b/packages/html/src/i18n/provider-mixin.ts index ab35eb7c..e990b2f2 100644 --- a/packages/html/src/i18n/provider-mixin.ts +++ b/packages/html/src/i18n/provider-mixin.ts @@ -1,6 +1,7 @@ import { createTranslator, loadLocale as defaultLoader, + type FlatTranslations, findLocaleKeys, getBrowserTranslations, getI18nTranslations, @@ -8,7 +9,6 @@ import { onI18nRegistryChange, registerI18n, shouldAttemptBrowserTranslation, - type Translations, } from '@videojs/core/i18n'; import type { PropertyValues, ReactiveElement } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; @@ -53,7 +53,7 @@ export function createI18nProviderMixin({ context, loader = defaultLoader }: I18 #registryUnsubscribe: (() => void) | undefined; #ambientUnsubscribe: (() => void) | undefined; #registryEpoch = 0; - #lazyLayer: Partial = {}; + #lazyLayer: Partial = {}; #lazySeq = 0; /** Tracks locale used for `#lazyLayer`; ambient `lang` can change without the `lang` property. */ #resolvedLocaleForLazy: Locale | undefined; @@ -65,7 +65,7 @@ export function createI18nProviderMixin({ context, loader = defaultLoader }: I18 }; #publishedLocale: Locale | undefined; #publishedRegistryEpoch = -1; - #publishedLazyLayer: Partial | undefined; + #publishedLazyLayer: Partial | undefined; protected get i18nValue(): I18nContextValue { return this.#i18nValue; @@ -144,7 +144,7 @@ export function createI18nProviderMixin({ context, loader = defaultLoader }: I18 return; } const registryLayer = getI18nTranslations(locale); - const translations: Translations = { + const translations: FlatTranslations = { ...registryLayer, ...this.#lazyLayer, }; diff --git a/packages/html/src/i18n/render-text.ts b/packages/html/src/i18n/render-text.ts new file mode 100644 index 00000000..2e25b6b4 --- /dev/null +++ b/packages/html/src/i18n/render-text.ts @@ -0,0 +1,11 @@ +import type { Text } from '@videojs/core/i18n'; +import { escapeHtml } from '@videojs/utils/string'; + +/** Render a text descriptor as keyed media-text markup. */ +export function renderText(text: Text, attrs?: Record): string { + const attrText = Object.entries(attrs ?? {}) + .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`) + .join(''); + + return `${escapeHtml(text.text)}`; +} diff --git a/packages/html/src/i18n/tests/create-i18n.test.ts b/packages/html/src/i18n/tests/create-i18n.test.ts index d92b29c9..df650f12 100644 --- a/packages/html/src/i18n/tests/create-i18n.test.ts +++ b/packages/html/src/i18n/tests/create-i18n.test.ts @@ -18,7 +18,7 @@ describe('createI18n (HTML)', () => { }); it('media-i18n uses explicit lang for registry copy', async () => { - registerI18n('fr', { Play: 'Lire' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); const provider = new MediaI18nProviderElement(); provider.setAttribute('lang', 'fr'); document.body.appendChild(provider); @@ -27,10 +27,25 @@ describe('createI18n (HTML)', () => { }); it('media-text translates text content inside provider', async () => { - registerI18n('de', { Play: 'Los' }); + registerI18n('de', { 'buttons.play': 'Los' }); const provider = new MediaI18nProviderElement(); provider.setAttribute('lang', 'de'); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); + text.textContent = 'Play'; + provider.appendChild(text); + document.body.appendChild(provider); + await Promise.resolve(); + await Promise.resolve(); + expect(text.textContent).toBe('Los'); + }); + + it('media-text resolves a token with inline English fallback', async () => { + registerI18n('de', { 'buttons.play': 'Los' }); + const provider = new MediaI18nProviderElement(); + provider.setAttribute('lang', 'de'); + const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -48,10 +63,11 @@ describe('createI18n (HTML)', () => { }); it('media-text stores source text before translating', async () => { - registerI18n('de', { Play: 'Los' }); + registerI18n('de', { 'buttons.play': 'Los' }); const provider = new MediaI18nProviderElement(); provider.setAttribute('lang', 'de'); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -86,10 +102,11 @@ describe('createI18n (HTML)', () => { }); it('inherits ambient html lang when provider has no lang', async () => { - registerI18n('es', { Play: 'Ir' }); + registerI18n('es', { 'buttons.play': 'Ir' }); document.documentElement.lang = 'es'; const provider = new MediaI18nProviderElement(); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -99,11 +116,12 @@ describe('createI18n (HTML)', () => { }); it('updates media-text when html lang changes', async () => { - registerI18n('x-test-de', { Play: 'Los' }); - registerI18n('x-test-fr', { Play: 'Lire' }); + registerI18n('x-test-de', { 'buttons.play': 'Los' }); + registerI18n('x-test-fr', { 'buttons.play': 'Lire' }); document.documentElement.lang = 'x-test-de'; const provider = new MediaI18nProviderElement(); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -119,8 +137,8 @@ describe('createI18n (HTML)', () => { it('reloads builtin lazy overlays when ambient html lang changes', async () => { const { ProviderMixin, TextMixin } = createI18n({ loader: async (tag) => { - if (tag === 'x-test-lazy-de') return { Play: 'BuiltinDe' }; - if (tag === 'x-test-lazy-fr') return { Play: 'BuiltinFr' }; + if (tag === 'x-test-lazy-de') return { 'buttons.play': 'BuiltinDe' }; + if (tag === 'x-test-lazy-fr') return { 'buttons.play': 'BuiltinFr' }; return undefined; }, }); @@ -132,6 +150,7 @@ describe('createI18n (HTML)', () => { document.documentElement.lang = 'x-test-lazy-de'; const provider = new LazyAmbientProvider(); const text = new LazyAmbientText(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -146,11 +165,12 @@ describe('createI18n (HTML)', () => { }); it('updates media-text when provider lang changes', async () => { - registerI18n('de', { Play: 'Los' }); - registerI18n('fr', { Play: 'Lire' }); + registerI18n('de', { 'buttons.play': 'Los' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); const provider = new MediaI18nProviderElement(); provider.setAttribute('lang', 'de'); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -166,8 +186,8 @@ describe('createI18n (HTML)', () => { it('discards stale builtin load when provider lang is set right after insert', async () => { const { ProviderMixin, TextMixin } = createI18n({ loader: async (tag) => { - if (tag === 'en') return { Play: 'BuiltinEn' }; - if (tag === 'de') return { Play: 'BuiltinDe' }; + if (tag === 'en') return { 'buttons.play': 'BuiltinEn' }; + if (tag === 'de') return { 'buttons.play': 'BuiltinDe' }; return undefined; }, }); @@ -179,6 +199,7 @@ describe('createI18n (HTML)', () => { document.documentElement.lang = 'en'; const provider = new DriftProvider(); const text = new DriftText(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -189,7 +210,7 @@ describe('createI18n (HTML)', () => { }); it('shares Lit i18n context between createI18n() factories', async () => { - registerI18n('de', { Play: 'Los' }); + registerI18n('de', { 'buttons.play': 'Los' }); const { ProviderMixin: AProvider, TextMixin: AText } = createI18n(); const { TextMixin: BText } = createI18n(); class SharedProvider extends AProvider(ReactiveElement) {} @@ -203,6 +224,8 @@ describe('createI18n (HTML)', () => { provider.setAttribute('lang', 'de'); const textSame = new SharedTextA(); const textOther = new SharedTextB(); + textSame.setAttribute('token', 'buttons.play'); + textOther.setAttribute('token', 'buttons.play'); textSame.textContent = 'Play'; textOther.textContent = 'Play'; provider.appendChild(textSame); @@ -220,7 +243,7 @@ describe('createI18n (HTML)', () => { readonly #i18n = new Ctor(this, context); override connectedCallback(): void { super.connectedCallback(); - this.textContent = this.#i18n.value('Play'); + this.textContent = this.#i18n.value('buttons.play', { default: 'Play' }); } } customElements.define('i18n-probe-fallback', Probe); @@ -235,7 +258,7 @@ describe('createI18n (HTML)', () => { class Probe extends ReactiveElement { readonly #i18n = new Ctor(this, context); protected override updated(): void { - this.textContent = this.#i18n.value('Play'); + this.textContent = this.#i18n.value('buttons.play', { default: 'Play' }); } } customElements.define('i18n-probe-fallback-registry', Probe); @@ -244,7 +267,7 @@ describe('createI18n (HTML)', () => { await el.updateComplete; expect(el.textContent).toBe('Play'); - registerI18n('en', { Play: 'RegistryPlay' }); + registerI18n('en', { 'buttons.play': 'RegistryPlay' }); await vi.waitFor(() => { expect(el.textContent).toBe('RegistryPlay'); @@ -265,22 +288,22 @@ describe('createI18n (HTML)', () => { const second = el.i18n.value; expect(second).toBe(first); - registerI18n('en', { Play: 'RegistryPlay' }); + registerI18n('en', { 'buttons.play': 'RegistryPlay' }); await el.updateComplete; const third = el.i18n.value; expect(third).not.toBe(first); - expect(third('Play')).toBe('RegistryPlay'); + expect(third('buttons.play')).toBe('RegistryPlay'); }); it('provider keeps translator stable across unrelated updates', async () => { - registerI18n('x-stable', { Play: 'StablePlay' }); + registerI18n('x-stable', { 'buttons.play': 'StablePlay' }); const { context, I18nController: Ctor, ProviderMixin, } = createI18n({ - loader: async (tag) => (tag === 'x-stable' ? { Pause: 'LazyPause' } : undefined), + loader: async (tag) => (tag === 'x-stable' ? { 'buttons.pause': 'LazyPause' } : undefined), }); class StableProvider extends ProviderMixin(ReactiveElement) {} class Probe extends ReactiveElement { @@ -295,7 +318,7 @@ describe('createI18n (HTML)', () => { document.body.appendChild(provider); await vi.waitFor(() => { - expect(probe.i18n.value('Pause')).toBe('LazyPause'); + expect(probe.i18n.value('buttons.pause')).toBe('LazyPause'); }); const first = probe.i18n.value; @@ -305,34 +328,33 @@ describe('createI18n (HTML)', () => { expect(probe.i18n.value).toBe(first); - registerI18n('x-stable', { Replay: 'StableReplay' }); + registerI18n('x-stable', { 'buttons.replay': 'StableReplay' }); await vi.waitFor(() => { expect(probe.i18n.value).not.toBe(first); - expect(probe.i18n.value('Replay')).toBe('StableReplay'); + expect(probe.i18n.value('buttons.replay')).toBe('StableReplay'); }); }); - it('media-text refreshes fallback English without a provider when the registry changes', async () => { + it('keeps media-text fallback English without a provider when the registry changes', async () => { const text = new MediaTextElement(); text.textContent = 'Play'; document.body.appendChild(text); await text.updateComplete; expect(text.textContent).toBe('Play'); - registerI18n('en', { Play: 'RegistryPlay' }); + registerI18n('en', { 'buttons.play': 'RegistryPlay' }); - await vi.waitFor(() => { - expect(text.textContent).toBe('RegistryPlay'); - }); + await vi.waitFor(() => expect(text.textContent).toBe('Play')); }); it('registers browser translations when no locale pack exists', async () => { - vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ Play: 'BrowserPlay' }); + vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ 'buttons.play': 'BrowserPlay' }); const provider = new MediaI18nProviderElement(); provider.setAttribute('lang', 'xx'); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -343,12 +365,13 @@ describe('createI18n (HTML)', () => { }); it('skips browser translation when locale is already registered', async () => { - registerI18n('fr', { Play: 'Lire' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations'); const provider = new MediaI18nProviderElement(); provider.setAttribute('lang', 'fr'); const text = new MediaTextElement(); + text.setAttribute('token', 'buttons.play'); text.textContent = 'Play'; provider.appendChild(text); document.body.appendChild(provider); @@ -361,11 +384,11 @@ describe('createI18n (HTML)', () => { it('registers browser translations when a shipped locale pack is missing keys', async () => { const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ - Settings: 'Paramètres', - } satisfies Partial); + 'menu.settings': 'Paramètres', + } satisfies Partial); const { ProviderMixin, TextMixin } = createI18n({ - loader: async (tag) => (tag === 'fr' ? { Play: 'Lire' } : undefined), + loader: async (tag) => (tag === 'fr' ? { 'buttons.play': 'Lire' } : undefined), }); class PartialProvider extends ProviderMixin(ReactiveElement) {} class PartialText extends TextMixin(ReactiveElement) {} @@ -375,6 +398,7 @@ describe('createI18n (HTML)', () => { const provider = new PartialProvider(); provider.setAttribute('lang', 'fr'); const text = new PartialText(); + text.setAttribute('token', 'menu.settings'); text.textContent = 'Settings'; provider.appendChild(text); document.body.appendChild(provider); @@ -386,7 +410,7 @@ describe('createI18n (HTML)', () => { }); it('does not register browser translations after locale changes', async () => { - let resolveBrowser: ((value: Partial) => void) | undefined; + let resolveBrowser: ((value: Partial) => void) | undefined; const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockImplementation( () => new Promise((resolve) => { @@ -406,10 +430,10 @@ describe('createI18n (HTML)', () => { provider.setAttribute('lang', 'fr'); await Promise.resolve(); - resolveBrowser?.({ Play: 'StaleBrowserPlay' }); + resolveBrowser?.({ 'buttons.play': 'StaleBrowserPlay' }); await Promise.resolve(); await Promise.resolve(); - expect(registerI18nSpy).not.toHaveBeenCalledWith('xx', { Play: 'StaleBrowserPlay' }); + expect(registerI18nSpy).not.toHaveBeenCalledWith('xx', { 'buttons.play': 'StaleBrowserPlay' }); }); }); diff --git a/packages/html/src/i18n/tests/provider.test.ts b/packages/html/src/i18n/tests/provider.test.ts index 69bdd1ff..381ad0d6 100644 --- a/packages/html/src/i18n/tests/provider.test.ts +++ b/packages/html/src/i18n/tests/provider.test.ts @@ -1,4 +1,4 @@ -import { type Locale, registerI18n, resetI18nRegistry, type Translator } from '@videojs/core/i18n'; +import { type Locale, registerI18n, resetI18nRegistry, type Text, type Translator } from '@videojs/core/i18n'; import { type PropertyValues, ReactiveElement } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -8,7 +8,7 @@ import { i18nContext, MediaI18nProviderElement, MediaTextElement } from '../inde const skinTemplate = document.createElement('template'); skinTemplate.innerHTML = - ''; + ''; const missingKeyTemplate = document.createElement('template'); missingKeyTemplate.innerHTML = @@ -49,7 +49,10 @@ class TestI18nProviderElement extends ReactiveElement { readonly provider = new ContextProvider(this, { context: i18nContext, initialValue: { - translator: ((key: string) => (key === 'Settings' ? 'Ancestor settings' : key)) as Translator, + translator: ((value: string | Text) => { + const key = typeof value === 'string' ? value : value.key; + return key === 'menu.settings' ? 'Ancestor settings' : typeof value === 'string' ? value : value.text; + }) as Translator, locale: 'xx' as Locale, }, }); @@ -71,7 +74,7 @@ if (!customElements.get('test-skin-i18n-first-text')) { customElements.define('test-skin-i18n-first-text', TestFirstTextElement); } -firstUpdateTemplate.innerHTML = 'Settings'; +firstUpdateTemplate.innerHTML = 'Settings'; if (!customElements.get('test-skin-i18n-first-update')) { customElements.define('test-skin-i18n-first-update', TestFirstUpdateElement); @@ -96,7 +99,7 @@ describe('provider', () => { }); it('uses an ancestor translator for shadow labels', async () => { - registerI18n('xx', { Settings: 'Skin settings' }); + registerI18n('xx', { 'menu.settings': 'Skin settings' }); const root = document.createElement('div'); root.innerHTML = /*html*/ ` @@ -116,7 +119,7 @@ describe('provider', () => { }); it('uses English fallback for shadow labels without a provider', async () => { - registerI18n('xx', { Settings: 'Skin settings' }); + registerI18n('xx', { 'menu.settings': 'Skin settings' }); const skin = document.createElement('test-skin-i18n') as TestSkinElement; skin.lang = 'xx'; document.body.append(skin); @@ -128,7 +131,7 @@ describe('provider', () => { expect(text.textContent).toBe('Settings'); }); - it('falls back to the key when a shadow label is missing', async () => { + it('keeps the child text when a shadow label has no token', async () => { const skin = document.createElement('test-skin-i18n-missing-key') as TestMissingKeyElement; document.body.append(skin); @@ -140,8 +143,8 @@ describe('provider', () => { }); it('updates shadow labels when provider lang changes', async () => { - registerI18n('xx', { Settings: 'Skin settings' }); - registerI18n('yy', { Settings: 'Other settings' }); + registerI18n('xx', { 'menu.settings': 'Skin settings' }); + registerI18n('yy', { 'menu.settings': 'Other settings' }); const provider = new MediaI18nProviderElement(); const skin = document.createElement('test-skin-i18n') as TestSkinElement; provider.lang = 'xx'; @@ -160,7 +163,7 @@ describe('provider', () => { }); it('publishes provider lang before child text updates', async () => { - registerI18n('xx', { Settings: 'Skin settings' }); + registerI18n('xx', { 'menu.settings': 'Skin settings' }); const provider = new MediaI18nProviderElement(); const skin = document.createElement('test-skin-i18n-first-update') as TestFirstUpdateElement; provider.lang = 'xx'; diff --git a/packages/html/src/i18n/tests/render-text.test.ts b/packages/html/src/i18n/tests/render-text.test.ts new file mode 100644 index 00000000..26961832 --- /dev/null +++ b/packages/html/src/i18n/tests/render-text.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { renderText } from '../render-text'; + +describe('renderText', () => { + it('renders a keyed text element with its English fallback', () => { + expect(renderText({ key: 'menu.quality', text: 'Quality' })).toBe( + 'Quality' + ); + }); + + it('renders escaped attributes and text', () => { + expect(renderText({ key: 'menu.quality', text: '' }, { 'data-label': 'a&b' })).toBe( + '<Quality>' + ); + }); +}); diff --git a/packages/html/src/i18n/text-mixin.ts b/packages/html/src/i18n/text-mixin.ts index 162acc26..9727185f 100644 --- a/packages/html/src/i18n/text-mixin.ts +++ b/packages/html/src/i18n/text-mixin.ts @@ -1,4 +1,4 @@ -import type { Translator } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; import type { PropertyValues, ReactiveElement } from '@videojs/element'; import type { Constructor } from '@videojs/utils/types'; import type { I18nContext } from './context'; @@ -11,18 +11,16 @@ export interface TextMixinConfig { context: I18nContext; } -/** Authored phrase text is untyped; the runtime translator accepts any key. */ -function translateText(translator: Translator, key: string): string { - // Omit the required params - const translateLoose = translator as (k: string) => string; - return translateLoose(key); -} - export function createTextMixin({ context }: TextMixinConfig): I18nTextMixin { return (Base) => { class MediaText extends Base { + static override properties = { + token: { type: String }, + }; + readonly #i18n = new I18nController(this, context); #text: string | undefined; + token = ''; override connectedCallback(): void { this.#text ??= this.textContent?.trim() ?? ''; @@ -31,7 +29,13 @@ export function createTextMixin({ context }: TextMixinConfig): I18nTextMixin { protected override updated(changed: PropertyValues): void { super.updated(changed); - this.textContent = this.#text ? translateText(this.#i18n.value, this.#text) : ''; + if (!this.#text) { + this.textContent = ''; + return; + } + + const text: Text | string = this.token ? { key: this.token, text: this.#text } : this.#text; + this.textContent = typeof text === 'string' ? text : translateText(text, this.#i18n.value); } } diff --git a/packages/html/src/i18n/types.ts b/packages/html/src/i18n/types.ts index 4f6b2d27..424e5eca 100644 --- a/packages/html/src/i18n/types.ts +++ b/packages/html/src/i18n/types.ts @@ -1,4 +1,4 @@ -import type { Translations } from '@videojs/core/i18n'; +import type { FlatTranslations } from '@videojs/core/i18n'; import type { ReactiveElement } from '@videojs/element'; import type { Constructor } from '@videojs/utils/types'; @@ -11,4 +11,4 @@ export type ReactiveElementMixinBase = Constructor & Pick Promise | undefined>; +export type LocaleLoader = (tag: string) => Promise | undefined>; diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 015973cb..abe9aba1 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -18,6 +18,7 @@ export { createSelector, shallowEqual } from '@videojs/store'; export type { CreateI18nOptions, CreateI18nResult, + FlatTranslations, I18nContextValue, Locale, TranslationParams, diff --git a/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts b/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts index 62095392..741e428b 100644 --- a/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts +++ b/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts @@ -1,8 +1,8 @@ import { AudioTrackRadioGroupCore, AudioTrackRadioGroupDataAttrs } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectAudioTrack } from '@videojs/core/dom'; -import { resolveTranslation, type Translator } from '@videojs/core/i18n'; +import { type Text, type Translator, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; - +import { cacheKey } from '../../i18n/cache-key'; import { i18nContext } from '../../i18n/context'; import { I18nController } from '../../i18n/controller'; import { playerContext } from '../../player/context'; @@ -21,7 +21,7 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement { } satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>; disabled = false; - label = ''; + label: Text | string = ''; formatTrack = AudioTrackRadioGroupCore.defaultProps.formatTrack; readonly #core = new AudioTrackRadioGroupCore(); @@ -55,12 +55,12 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement { let state: AudioTrackRadioGroupCore.State | null = null; if (media) { - this.#core.setProps({ formatTrack: this.formatTrack, disabled: this.disabled }); + this.#core.setProps({ formatTrack: this.formatTrack, disabled: this.disabled, label: this.label }); this.#core.setMedia(media); state = this.#core.getState(); this.value = state.value; - this.applyAriaLabel(this.#i18n.value, this.label || 'Audio'); + this.applyAriaLabel(this.#i18n.value, this.#core.getLabel(state)); if (state.disabled) { this.setAttribute('aria-disabled', 'true'); } else { @@ -78,7 +78,7 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement { const template = this.getTemplate(); const templateKey = template?.innerHTML ?? ''; const translator = this.#i18n.value; - const tracksKey = `${state.tracks.map((track) => `${track.value}:${track.label}`).join('|')}::${this.#i18n.locale}::${templateKey}`; + const tracksKey = `${state.tracks.map((track) => `${track.value}:${cacheKey(track.label)}`).join('|')}::${this.#i18n.locale}::${templateKey}`; if (tracksKey !== this.#tracksKey || translator !== this.#tracksTranslator) { this.#tracksKey = tracksKey; @@ -90,9 +90,7 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement { } this.append( - ...state.tracks.map((track) => - this.#createItem(track.value, resolveTranslation(translator, track.label), template) - ) + ...state.tracks.map((track) => this.#createItem(track.value, translateText(track.label, translator), template)) ); } diff --git a/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts b/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts index c63e8de2..83e93779 100644 --- a/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts +++ b/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts @@ -194,7 +194,7 @@ describe('AudioTrackRadioGroupElement', () => { await waitForMenu(menu, options); - registerI18n('x-test-audio', { Audio: 'Sound' }); + registerI18n('x-test-audio', { 'menu.audio': 'Sound' }); await waitForAssertion(() => { const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; @@ -202,7 +202,7 @@ describe('AudioTrackRadioGroupElement', () => { }); }); - it('renders translated Default track labels', async () => { + it('keeps authored track labels literal', async () => { const { menu, options } = setup({ locale: 'x-test-audio', audioTrackList: [ @@ -213,11 +213,9 @@ describe('AudioTrackRadioGroupElement', () => { await waitForMenu(menu, options); - registerI18n('x-test-audio', { Default: 'Standard' }); - await waitForAssertion(() => { const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; - expect(items.map((item) => item.textContent)).toEqual(['Standard', 'English']); + expect(items.map((item) => item.textContent)).toEqual(['Default', 'English']); }); }); diff --git a/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts b/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts index c3323e93..715b9d7b 100644 --- a/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts +++ b/packages/html/src/ui/captions-radio-group/captions-radio-group-element.ts @@ -1,8 +1,9 @@ import { CAPTIONS_OFF_VALUE, CaptionsRadioGroupCore, CaptionsRadioGroupDataAttrs } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectTextTrack } from '@videojs/core/dom'; -import { resolveTranslation, type Translator } from '@videojs/core/i18n'; +import { type Text, type Translator, translateText } from '@videojs/core/i18n'; +import { offText } from '@videojs/core/i18n/text/menu'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; - +import { cacheKey } from '../../i18n/cache-key'; import { i18nContext } from '../../i18n/context'; import { I18nController } from '../../i18n/controller'; import { playerContext } from '../../player/context'; @@ -21,7 +22,7 @@ export class CaptionsRadioGroupElement extends MenuRadioGroupElement { } satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>; disabled = false; - label = ''; + label: Text | string = ''; readonly #core = new CaptionsRadioGroupCore(); readonly #i18n = new I18nController(this, i18nContext); @@ -72,7 +73,7 @@ export class CaptionsRadioGroupElement extends MenuRadioGroupElement { const template = this.getTemplate(); const templateKey = template?.innerHTML ?? ''; const translator = this.#i18n.value; - const tracksKey = `${state.tracks.map((track) => `${track.value}:${track.label}`).join('|')}::${this.#i18n.locale}::${templateKey}`; + const tracksKey = `${state.tracks.map((track) => `${track.value}:${cacheKey(track.label)}`).join('|')}::${this.#i18n.locale}::${templateKey}`; if (tracksKey !== this.#tracksKey || translator !== this.#tracksTranslator) { this.#tracksKey = tracksKey; @@ -83,11 +84,9 @@ export class CaptionsRadioGroupElement extends MenuRadioGroupElement { child.remove(); } - this.append(this.#createItem(CAPTIONS_OFF_VALUE, resolveTranslation(translator, 'Off'), template)); + this.append(this.#createItem(CAPTIONS_OFF_VALUE, translateText(offText, translator), template)); this.append( - ...state.tracks.map((track) => - this.#createItem(track.value, resolveTranslation(translator, track.label), template) - ) + ...state.tracks.map((track) => this.#createItem(track.value, translateText(track.label, translator), template)) ); } diff --git a/packages/html/src/ui/captions-radio-group/tests/captions-radio-group-element.test.ts b/packages/html/src/ui/captions-radio-group/tests/captions-radio-group-element.test.ts index ff0895a0..bef61261 100644 --- a/packages/html/src/ui/captions-radio-group/tests/captions-radio-group-element.test.ts +++ b/packages/html/src/ui/captions-radio-group/tests/captions-radio-group-element.test.ts @@ -114,7 +114,7 @@ describe('CaptionsRadioGroupElement', () => { await options.updateComplete; - registerI18n('x-test-captions', { Captions: 'Legendes', Off: 'Desactive' }); + registerI18n('x-test-captions', { 'menu.captions': 'Legendes', 'menu.off': 'Desactive' }); await waitForAssertion(() => { const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; diff --git a/packages/html/src/ui/error-dialog/error-dialog-element.ts b/packages/html/src/ui/error-dialog/error-dialog-element.ts index 1580f9f0..fa6cfc58 100644 --- a/packages/html/src/ui/error-dialog/error-dialog-element.ts +++ b/packages/html/src/ui/error-dialog/error-dialog-element.ts @@ -2,8 +2,9 @@ import { AlertDialogDataAttrs, type AlertDialogInput, ErrorDialogCore, - getErrorDialogDismissLabel, - getErrorDialogTitleLabel, + getErrorDialogDismissText, + getErrorDialogTitleText, + getErrorDialogUnexpectedText, type MediaError, resolveErrorDialogDescription, } from '@videojs/core'; @@ -15,7 +16,7 @@ import { createTransition, selectError, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; import type { PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { SnapshotController } from '@videojs/store/html'; @@ -45,7 +46,7 @@ export class ErrorDialogElement extends MediaElement { #dialog: AlertDialogApi | null = null; #snapshot: SnapshotController | null = null; #lastError: MediaError | null = null; - #lastDescription: string | null = null; + #lastDescription: ReturnType | null = null; #seenCopyParts = new WeakSet(); #authoredCopyParts = new WeakSet(); @@ -132,7 +133,7 @@ export class ErrorDialogElement extends MediaElement { const t = this.#i18n.value; const title = this.querySelector('media-alert-dialog-title'); if (title && !this.#hasAuthoredCopy(title)) { - title.textContent = resolveTranslation(t, getErrorDialogTitleLabel()); + title.textContent = translateText(getErrorDialogTitleText(), t); } const desc = this.querySelector('media-alert-dialog-description'); @@ -141,13 +142,13 @@ export class ErrorDialogElement extends MediaElement { if (description) { this.#lastDescription = description; } - const copy = description ?? this.#lastDescription ?? 'An unexpected error occurred.'; - desc.textContent = resolveTranslation(t, copy); + const copy = description ?? this.#lastDescription; + desc.textContent = copy ? translateText(copy, t) : translateText(getErrorDialogUnexpectedText(), t); } const close = this.querySelector('media-alert-dialog-close'); if (close && !this.#hasAuthoredCopy(close)) { - close.textContent = resolveTranslation(t, getErrorDialogDismissLabel()); + close.textContent = translateText(getErrorDialogDismissText(), t); } } diff --git a/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts b/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts index 0021a664..8aff2c8e 100644 --- a/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts +++ b/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts @@ -69,9 +69,9 @@ describe('ErrorDialogElement', () => { it('shows translated dialog copy when es locale is registered', async () => { registerI18n('es', { - 'Something went wrong.': 'Algo salió mal.', - OK: 'Aceptar', - 'An unexpected error occurred.': 'Ocurrió un error inesperado.', + 'errors.title': 'Algo salió mal.', + 'common.ok': 'Aceptar', + 'errors.unexpected': 'Ocurrió un error inesperado.', }); ensureDefined(MediaI18nProviderElement.tagName, MediaI18nProviderElement); ensureDefined(AlertDialogTitleElement.tagName, AlertDialogTitleElement); @@ -98,9 +98,9 @@ describe('ErrorDialogElement', () => { it('preserves authored title copy', async () => { registerI18n('es', { - 'Something went wrong.': 'Algo salió mal.', - OK: 'Aceptar', - 'An unexpected error occurred.': 'Ocurrió un error inesperado.', + 'errors.title': 'Algo salió mal.', + 'common.ok': 'Aceptar', + 'errors.unexpected': 'Ocurrió un error inesperado.', }); ensureDefined(MediaI18nProviderElement.tagName, MediaI18nProviderElement); ensureDefined(AlertDialogTitleElement.tagName, AlertDialogTitleElement); diff --git a/packages/html/src/ui/live-button/live-button-element.ts b/packages/html/src/ui/live-button/live-button-element.ts index bd80305b..b891f256 100644 --- a/packages/html/src/ui/live-button/live-button-element.ts +++ b/packages/html/src/ui/live-button/live-button-element.ts @@ -8,7 +8,7 @@ import { selectLive, selectTime, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { resolveText, type Text, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import type { State } from '@videojs/store'; @@ -35,7 +35,7 @@ export class LiveButtonElement extends MediaElement { }; disabled = false; - label = ''; + label: Text | string = ''; protected readonly core = new LiveButtonCore(); @@ -48,14 +48,16 @@ export class LiveButtonElement extends MediaElement { return this.core.state; } + #defaultContent = false; #disconnect: AbortController | null = null; override connectedCallback(): void { super.connectedCallback(); if (this.destroyed) return; - if (!this.textContent?.trim()) { - this.textContent = LiveButtonCore.defaultText; + this.#defaultContent ||= !this.textContent?.trim(); + if (this.#defaultContent) { + this.textContent = translateText(LiveButtonCore.defaultText, this.#i18n.value); } this.#disconnect = new AbortController(); @@ -83,7 +85,7 @@ export class LiveButtonElement extends MediaElement { /** Returns the button's current label derived from media state. */ getLabel(): string | undefined { - return this.core.state.current.label || undefined; + return this.core.state.current.label ? resolveText(this.core.state.current.label) : undefined; } /** Resolved label for tooltips and other display surfaces. */ @@ -91,7 +93,7 @@ export class LiveButtonElement extends MediaElement { const media = this.#getMedia(); if (!media) return undefined; const state = this.core.getState(); - return resolveTranslation(this.#i18n.value, this.core.getLabel(state)); + return translateText(this.core.getLabel(state), this.#i18n.value); } protected override willUpdate(changed: PropertyValues): void { @@ -102,6 +104,10 @@ export class LiveButtonElement extends MediaElement { protected override update(changed: PropertyValues): void { super.update(changed); + if (this.#defaultContent) { + this.textContent = translateText(LiveButtonCore.defaultText, this.#i18n.value); + } + const media = this.#getMedia(); if (!media) return; @@ -110,7 +116,7 @@ export class LiveButtonElement extends MediaElement { const attrs = this.core.getAttrs(state); applyElementProps(this, { ...attrs, - 'aria-label': resolveTranslation(this.#i18n.value, attrs['aria-label']), + 'aria-label': translateText(attrs['aria-label'], this.#i18n.value), }); applyStateDataAttrs(this, state, LiveButtonDataAttrs); } diff --git a/packages/html/src/ui/live-button/tests/live-button-element.test.ts b/packages/html/src/ui/live-button/tests/live-button-element.test.ts new file mode 100644 index 00000000..8f1e8b2b --- /dev/null +++ b/packages/html/src/ui/live-button/tests/live-button-element.test.ts @@ -0,0 +1,88 @@ +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MediaI18nProviderElement } from '../../../i18n'; +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { LiveButtonElement } from '../live-button-element'; + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function createLiveStore(): AnyPlayerStore { + return createStore()({ + name: 'liveButton', + state: () => ({ + liveEdgeStart: 90, + targetLiveWindow: 0, + currentTime: 80, + duration: 100, + seeking: false, + seek: vi.fn(), + buffered: [], + seekable: [{ start: 0, end: 100 }], + }), + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + readonly #provider = new ContextProvider(this, { context: playerContext }); + + setStore(store: AnyPlayerStore): void { + this.#provider.setValue(store); + } +} + +defineElement(LiveButtonElement.tagName, LiveButtonElement); +defineElement(MediaI18nProviderElement.tagName, MediaI18nProviderElement); +defineElement('test-live-button-player', TestPlayerProviderElement); + +function setup(locale: string, text?: string) { + const i18n = new MediaI18nProviderElement(); + const player = document.createElement('test-live-button-player') as TestPlayerProviderElement; + const button = document.createElement(LiveButtonElement.tagName) as LiveButtonElement; + + i18n.setAttribute('lang', locale); + player.setStore(createLiveStore()); + if (text) button.textContent = text; + player.append(button); + i18n.append(player); + document.body.append(i18n); + + return { i18n, button }; +} + +afterEach(() => { + resetI18nRegistry(); + document.body.innerHTML = ''; +}); + +describe('LiveButtonElement', () => { + it('translates the default badge and updates when locale changes', async () => { + registerI18n('es', { 'live.badge': 'En vivo' }); + registerI18n('fr', { 'live.badge': 'Direct' }); + const { i18n, button } = setup('es'); + + await button.updateComplete; + expect(button.textContent).toBe('En vivo'); + + i18n.setAttribute('lang', 'fr'); + await vi.waitFor(() => { + expect(button.textContent).toBe('Direct'); + }); + }); + + it('preserves authored badge copy', async () => { + registerI18n('es', { 'live.badge': 'En vivo' }); + const { button } = setup('es', 'On air'); + + await button.updateComplete; + expect(button.textContent).toBe('On air'); + }); +}); diff --git a/packages/html/src/ui/media-button-element.ts b/packages/html/src/ui/media-button-element.ts index 2c3eb15d..c6952f79 100644 --- a/packages/html/src/ui/media-button-element.ts +++ b/packages/html/src/ui/media-button-element.ts @@ -13,7 +13,7 @@ import { logMissingFeature, type UIEvent, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { isText, resolveText, type Text, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import type { State } from '@videojs/store'; @@ -43,7 +43,7 @@ export abstract class MediaButtonElement exte }; disabled = false; - label = ''; + label: Text | string = ''; protected abstract readonly core: Core; protected abstract readonly stateAttrMap: StateAttrMap>; @@ -108,7 +108,7 @@ export abstract class MediaButtonElement exte /** Returns the button's current label derived from media state. */ getLabel(): string | undefined { - return this.core.state.current.label || undefined; + return this.core.state.current.label ? resolveText(this.core.state.current.label) : undefined; } getShortcut(): string | undefined { @@ -120,7 +120,7 @@ export abstract class MediaButtonElement exte const media = this.mediaState.value; if (!media) return undefined; const state = this.core.getState() as InferComponentState; - return resolveTranslation(this.#i18n.value, this.core.getLabel(state), getLabelParams(this.core, state)); + return translateText(this.core.getLabel(state), this.#i18n.value, getLabelParams(this.core, state)); } protected override willUpdate(changed: PropertyValues): void { @@ -140,8 +140,8 @@ export abstract class MediaButtonElement exte this.core.setMedia(media); const state = this.core.getState() as InferComponentState; const attrs = (this.core.getAttrs?.(state) ?? {}) as Record; - if (typeof attrs['aria-label'] === 'string') { - attrs['aria-label'] = resolveTranslation(this.#i18n.value, attrs['aria-label'], getLabelParams(this.core, state)); + if (isText(attrs['aria-label'])) { + attrs['aria-label'] = translateText(attrs['aria-label'], this.#i18n.value, getLabelParams(this.core, state)); } applyElementProps(this, { ...attrs, diff --git a/packages/html/src/ui/media-ui-element.ts b/packages/html/src/ui/media-ui-element.ts index fcc4b3a0..738c4e29 100644 --- a/packages/html/src/ui/media-ui-element.ts +++ b/packages/html/src/ui/media-ui-element.ts @@ -1,6 +1,6 @@ import type { InferComponentState, InferMediaState, MediaUIComponent, StateAttrMap } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, logMissingFeature } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { isText, translateText } from '@videojs/core/i18n'; import type { PropertyValues } from '@videojs/element'; import { isFunction } from '@videojs/utils/predicate'; @@ -36,8 +36,8 @@ export abstract class MediaUIElement extends Medi const state = this.core.getState(); if (isFunction(this.core.getAttrs)) { const attrs = this.core.getAttrs(state) as Record; - if (typeof attrs['aria-label'] === 'string') { - attrs['aria-label'] = resolveTranslation(this.#i18n.value, attrs['aria-label']); + if (isText(attrs['aria-label'])) { + attrs['aria-label'] = translateText(attrs['aria-label'], this.#i18n.value); } applyElementProps(this, attrs); } diff --git a/packages/html/src/ui/menu/get-menu-item-setting-state.ts b/packages/html/src/ui/menu/get-menu-item-setting-state.ts index 99d01c43..d35cfc2b 100644 --- a/packages/html/src/ui/menu/get-menu-item-setting-state.ts +++ b/packages/html/src/ui/menu/get-menu-item-setting-state.ts @@ -12,21 +12,17 @@ import { QUALITY_AUTO_VALUE, type QualityRadioGroupCore, } from '@videojs/core'; +import type { Text, TextParams } from '@videojs/core/i18n'; +import { autoText, offText } from '@videojs/core/i18n/text/menu'; import type { MenuItemSettingType } from './menu-item-type'; export interface MenuItemSettingState { - label: string; - labelParams?: Record | undefined; + label: Text | string; + labelParams?: TextParams | undefined; availability: 'available' | 'unavailable'; } -function getAutoLabelState(label: string): Pick { - const match = /^Auto \((.+)\)$/.exec(label); - if (!match) return { label }; - return { label: 'Auto ({label})', labelParams: { label: match[1]! } }; -} - export function getMenuItemSettingState( type: MenuItemSettingType, cores: { @@ -53,7 +49,8 @@ export function getMenuItemSettingState( if (state.value === QUALITY_AUTO_VALUE) { return { - ...getAutoLabelState(state.autoLabel), + label: state.autoLabel, + labelParams: state.autoLabelParams, availability: state.availability, }; } @@ -61,7 +58,7 @@ export function getMenuItemSettingState( const rendition = state.renditions.find((candidate) => candidate.value === state.value); return { - label: rendition?.label ?? 'Auto', + label: rendition?.label ?? autoText, availability: state.availability, }; } @@ -81,13 +78,13 @@ export function getMenuItemSettingState( const state = cores.captions.getState(); if (state.value === CAPTIONS_OFF_VALUE) { - return { label: 'Off', availability: state.availability }; + return { label: offText, availability: state.availability }; } const track = state.tracks.find((candidate) => candidate.value === state.value); return { - label: track?.label ?? 'Off', + label: track?.label ?? offText, availability: state.availability, }; } diff --git a/packages/html/src/ui/menu/menu-back-element.ts b/packages/html/src/ui/menu/menu-back-element.ts index 5f02ff9e..43f413ed 100644 --- a/packages/html/src/ui/menu/menu-back-element.ts +++ b/packages/html/src/ui/menu/menu-back-element.ts @@ -1,5 +1,6 @@ import { applyElementProps } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; +import { backText } from '@videojs/core/i18n/text/menu'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; @@ -15,7 +16,7 @@ export class MenuBackElement extends MediaElement { label: { type: String }, } satisfies PropertyDeclarationMap<'label'>; - label = 'Back'; + label: Text | string = backText; readonly #i18n = new I18nController(this, i18nContext); readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); @@ -59,7 +60,7 @@ export class MenuBackElement extends MediaElement { applyElementProps(this, { role: 'button', - 'aria-label': resolveTranslation(this.#i18n.value, this.label), + 'aria-label': translateText(this.label, this.#i18n.value), }); } } diff --git a/packages/html/src/ui/menu/menu-item-element.ts b/packages/html/src/ui/menu/menu-item-element.ts index a4e12ea3..bf93ca50 100644 --- a/packages/html/src/ui/menu/menu-item-element.ts +++ b/packages/html/src/ui/menu/menu-item-element.ts @@ -13,7 +13,7 @@ import { selectQuality, selectTextTrack, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer, ContextProvider } from '@videojs/element/context'; import { i18nContext } from '../../i18n/context'; @@ -171,7 +171,7 @@ export class MenuItemElement extends MediaElement { this.#settingProvider.setValue({ type: this.type, ...setting, - label: resolveTranslation(this.#i18n.value, setting.label, setting.labelParams), + label: translateText(setting.label, this.#i18n.value, setting.labelParams), }); } diff --git a/packages/html/src/ui/menu/menu-radio-group-element.ts b/packages/html/src/ui/menu/menu-radio-group-element.ts index 3264632b..6a1839bf 100644 --- a/packages/html/src/ui/menu/menu-radio-group-element.ts +++ b/packages/html/src/ui/menu/menu-radio-group-element.ts @@ -1,4 +1,4 @@ -import { resolveTranslation, type Translator } from '@videojs/core/i18n'; +import { type Text, type Translator, translateText } from '@videojs/core/i18n'; import type { PropertyValues } from '@videojs/element'; import { RadioGroupElement } from '../radio-group/radio-group-element'; @@ -48,13 +48,17 @@ export class MenuRadioGroupElement extends RadioGroupElement { } } - protected applyAriaLabel(translator: Translator, label: string, params?: Record): void { + protected applyAriaLabel( + translator: Translator, + label: Text | string, + params?: Record + ): void { if (this.hasAttribute('aria-labelledby')) return; const current = this.getAttribute('aria-label'); if (current !== null && current !== this.#ariaLabel) return; - this.#ariaLabel = resolveTranslation(translator, label, params); + this.#ariaLabel = translateText(label, translator, params); this.setAttribute('aria-label', this.#ariaLabel); } } diff --git a/packages/html/src/ui/menu/tests/menu-element.test.ts b/packages/html/src/ui/menu/tests/menu-element.test.ts index 96958302..6e131af8 100644 --- a/packages/html/src/ui/menu/tests/menu-element.test.ts +++ b/packages/html/src/ui/menu/tests/menu-element.test.ts @@ -1,5 +1,6 @@ import type { MediaControlsState } from '@videojs/core'; import type { AnyPlayerStore } from '@videojs/core/dom'; +import type { Text } from '@videojs/core/i18n'; import { ContextProvider } from '@videojs/element/context'; import { createStore, flush } from '@videojs/store'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -226,6 +227,15 @@ describe('MenuElement', () => { expect(child.hasAttribute('data-align')).toBe(false); expectNoMenuStateAttrs(back); expectNoMenuStateAttrs(childItem); + expect(back.getAttribute('aria-label')).toBe('Back'); + + back.label = { key: 'custom.back', text: 'Go back' } as const satisfies Text; + await back.updateComplete; + expect(back.getAttribute('aria-label')).toBe('Go back'); + + back.label = 'menu.back'; + await back.updateComplete; + expect(back.getAttribute('aria-label')).toBe('menu.back'); }); it('marks root and nested menu views with generic view attributes', async () => { diff --git a/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts b/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts index 024c22b4..a2dd85d9 100644 --- a/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts +++ b/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts @@ -224,7 +224,7 @@ describe('MenuItemValueElement', () => { }); it('renders the active quality label when quality is automatic', async () => { - registerI18n('x-test-quality-hint', { 'Auto ({label})': 'Automatico ({label})' }); + registerI18n('x-test-quality-hint', { 'menu.autoWithLabel': 'Automatico ({label})' }); const { value } = setup( createQualityStore({ activeVideoRendition: { id: '1', height: 720, selected: false }, @@ -274,7 +274,7 @@ describe('MenuItemValueElement', () => { }); it('translates the fallback audio track label', async () => { - registerI18n('x-test-audio-hint', { Audio: 'Audio test' }); + registerI18n('x-test-audio-hint', { 'menu.audio': 'Audio test' }); const { value } = setup( createAudioTrackStore({ audioTrackList: [ @@ -293,7 +293,7 @@ describe('MenuItemValueElement', () => { }); it('renders the active caption track label', async () => { - registerI18n('x-test-captions-hint', { Captions: 'Legendes' }); + registerI18n('x-test-captions-hint', { 'menu.captions': 'Legendes' }); const { value } = setup( createTextTrackStore({ textTrackList: [ diff --git a/packages/html/src/ui/quality-radio-group/quality-radio-group-element.ts b/packages/html/src/ui/quality-radio-group/quality-radio-group-element.ts index d0ba5242..d8e97403 100644 --- a/packages/html/src/ui/quality-radio-group/quality-radio-group-element.ts +++ b/packages/html/src/ui/quality-radio-group/quality-radio-group-element.ts @@ -1,8 +1,8 @@ import { QUALITY_AUTO_VALUE, QualityRadioGroupCore, QualityRadioGroupDataAttrs } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectQuality } from '@videojs/core/dom'; -import { resolveTranslation, type Translator } from '@videojs/core/i18n'; +import { type Text, type Translator, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; - +import { cacheKey } from '../../i18n/cache-key'; import { i18nContext } from '../../i18n/context'; import { I18nController } from '../../i18n/controller'; import { playerContext } from '../../player/context'; @@ -11,14 +11,6 @@ import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element'; import { MenuRadioGroupElement } from '../menu/menu-radio-group-element'; import { MenuRadioItemElement } from '../menu/menu-radio-item-element'; -function resolveAutoLabel(translator: Translator, label: string): string { - const match = /^Auto \((.+)\)$/.exec(label); - if (match) { - return resolveTranslation(translator, 'Auto ({label})', { label: match[1]! }); - } - return resolveTranslation(translator, label); -} - export class QualityRadioGroupElement extends MenuRadioGroupElement { static override readonly tagName = 'media-quality-radio-group'; @@ -29,7 +21,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement { } satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>; disabled = false; - label = ''; + label: Text | string = ''; formatRendition = QualityRadioGroupCore.defaultProps.formatRendition; readonly #core = new QualityRadioGroupCore(); @@ -63,12 +55,12 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement { let state: QualityRadioGroupCore.State | null = null; if (media) { - this.#core.setProps({ formatRendition: this.formatRendition, disabled: this.disabled }); + this.#core.setProps({ formatRendition: this.formatRendition, disabled: this.disabled, label: this.label }); this.#core.setMedia(media); state = this.#core.getState(); this.value = state.value; - this.applyAriaLabel(this.#i18n.value, this.label || 'Quality'); + this.applyAriaLabel(this.#i18n.value, this.#core.getLabel(state)); this.#syncContent(state); } @@ -82,8 +74,11 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement { const templateKey = template?.innerHTML ?? ''; const translator = this.#i18n.value; const renditionsKey = `${state.renditions - .map((rendition) => `${rendition.value}:${rendition.label}:${rendition.tier ?? ''}:${rendition.badge ?? ''}`) - .join('|')}::${state.autoLabel}::${this.#i18n.locale}::${templateKey}`; + .map( + (rendition) => + `${rendition.value}:${cacheKey(rendition.label)}:${rendition.tier ?? ''}:${rendition.badge ?? ''}` + ) + .join('|')}::${cacheKey(state.autoLabel, state.autoLabelParams)}::${this.#i18n.locale}::${templateKey}`; if (renditionsKey !== this.#renditionsKey || translator !== this.#renditionsTranslator) { this.#renditionsKey = renditionsKey; @@ -97,7 +92,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement { this.append( this.#createItem( QUALITY_AUTO_VALUE, - resolveAutoLabel(translator, state.autoLabel), + translateText(state.autoLabel, translator, state.autoLabelParams), undefined, undefined, template @@ -107,7 +102,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement { ...state.renditions.map((rendition) => this.#createItem( rendition.value, - resolveTranslation(translator, rendition.label), + translateText(rendition.label, translator), rendition.tier, rendition.badge, template diff --git a/packages/html/src/ui/quality-radio-group/tests/quality-radio-group-element.test.ts b/packages/html/src/ui/quality-radio-group/tests/quality-radio-group-element.test.ts index 6f932724..0d970151 100644 --- a/packages/html/src/ui/quality-radio-group/tests/quality-radio-group-element.test.ts +++ b/packages/html/src/ui/quality-radio-group/tests/quality-radio-group-element.test.ts @@ -202,7 +202,7 @@ describe('QualityRadioGroupElement', () => { await waitForMenu(menu, options); - registerI18n('x-test-quality', { Auto: 'Automatique' }); + registerI18n('x-test-quality', { 'menu.auto': 'Automatique' }); await waitForAssertion(() => { const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; diff --git a/packages/html/src/ui/slider/slider-element.ts b/packages/html/src/ui/slider/slider-element.ts index 7b909173..e05d2c8a 100644 --- a/packages/html/src/ui/slider/slider-element.ts +++ b/packages/html/src/ui/slider/slider-element.ts @@ -6,10 +6,12 @@ import { getSliderCSSVars, type SliderApi, } from '@videojs/core/dom'; +import { type Text, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { applyStyles, isRTL } from '@videojs/utils/dom'; - +import { i18nContext } from '../../i18n/context'; +import { I18nController } from '../../i18n/controller'; import { MediaElement } from '../media-element'; import { sliderContext } from './context'; @@ -28,7 +30,7 @@ export class SliderElement extends MediaElement { thumbAlignment: { type: String, attribute: 'thumb-alignment' }, } satisfies PropertyDeclarationMap; - label = SliderCore.defaultProps.label; + label: Text | string = ''; value = SliderCore.defaultProps.value; min = SliderCore.defaultProps.min; max = SliderCore.defaultProps.max; @@ -39,6 +41,7 @@ export class SliderElement extends MediaElement { thumbAlignment = SliderCore.defaultProps.thumbAlignment; readonly #core = new SliderCore(); + readonly #i18n = new I18nController(this, i18nContext); readonly #provider = new ContextProvider(this, { context: sliderContext }); #slider: SliderApi | null = null; @@ -118,7 +121,10 @@ export class SliderElement extends MediaElement { state, stateAttrMap: SliderDataAttrs, pointerValue: this.#core.valueFromPercent(state.pointerPercent), - thumbAttrs: this.#core.getAttrs(state), + thumbAttrs: (() => { + const attrs = this.#core.getAttrs(state); + return { ...attrs, 'aria-label': translateText(attrs['aria-label'], this.#i18n.value) }; + })(), thumbProps: this.#slider.thumbProps, }); } diff --git a/packages/html/src/ui/tests/media-button-element.test.ts b/packages/html/src/ui/tests/media-button-element.test.ts index e05ddb4d..067bb86d 100644 --- a/packages/html/src/ui/tests/media-button-element.test.ts +++ b/packages/html/src/ui/tests/media-button-element.test.ts @@ -104,8 +104,8 @@ describe('MediaButtonElement', () => { }); it('applies translated aria-label and updates on locale change', async () => { - registerI18n('es', { Play: 'Reproducir' }); - registerI18n('fr', { Play: 'Lire' }); + registerI18n('es', { 'buttons.play': 'Reproducir' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); ensureDefined(PlayButtonElement); ensureDefined(MediaI18nProviderElement); @@ -131,8 +131,8 @@ describe('MediaButtonElement', () => { }); it('updates aria-label when html lang changes and provider has no explicit lang', async () => { - registerI18n('de', { Play: 'Los' }); - registerI18n('fr', { Play: 'Lire' }); + registerI18n('de', { 'buttons.play': 'Los' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); document.documentElement.lang = 'de'; ensureDefined(PlayButtonElement); diff --git a/packages/html/src/ui/time-slider/time-slider-element.ts b/packages/html/src/ui/time-slider/time-slider-element.ts index 56a052c9..6c3599f0 100644 --- a/packages/html/src/ui/time-slider/time-slider-element.ts +++ b/packages/html/src/ui/time-slider/time-slider-element.ts @@ -10,7 +10,7 @@ import { selectPlayback, selectTime, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { applyStyles, isRTL } from '@videojs/utils/dom'; @@ -37,7 +37,7 @@ export class TimeSliderElement extends MediaElement { pauseOnDrag: { type: Boolean, attribute: 'pause-on-drag' }, } satisfies PropertyDeclarationMap>; - label = TimeSliderCore.defaultProps.label; + label: Text | string = ''; changeThrottle = TimeSliderCore.defaultProps.changeThrottle; step = TimeSliderCore.defaultProps.step; largeStep = TimeSliderCore.defaultProps.largeStep; @@ -165,10 +165,10 @@ export class TimeSliderElement extends MediaElement { pointerValue: this.#core.valueFromPercent(state.pointerPercent), thumbAttrs: { ...thumbAttrs, - 'aria-label': resolveTranslation(this.#i18n.value, thumbAttrs['aria-label']), - 'aria-valuetext': resolveTranslation( - this.#i18n.value, + 'aria-label': translateText(thumbAttrs['aria-label'], this.#i18n.value), + 'aria-valuetext': translateText( thumbAttrs['aria-valuetext'], + this.#i18n.value, this.#core.getValueTextParams(state) ), }, diff --git a/packages/html/src/ui/time/tests/time-element.test.ts b/packages/html/src/ui/time/tests/time-element.test.ts index 240cfffa..437b22ab 100644 --- a/packages/html/src/ui/time/tests/time-element.test.ts +++ b/packages/html/src/ui/time/tests/time-element.test.ts @@ -1,9 +1,12 @@ import type { MediaTimeState } from '@videojs/core'; import type { AnyPlayerStore } from '@videojs/core/dom'; +import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n'; import { ContextProvider } from '@videojs/element/context'; import { createStore } from '@videojs/store'; +import { formatTimeAsPhrase } from '@videojs/utils/time'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MediaI18nProviderElement } from '../../../i18n'; import { playerContext } from '../../../player/context'; import { MediaElement } from '../../media-element'; import { TimeElement } from '../time-element'; @@ -79,13 +82,21 @@ class TestPlayerProviderElement extends MediaElement { } defineElement('test-time-player', TestPlayerProviderElement); +defineElement(MediaI18nProviderElement.tagName, MediaI18nProviderElement); -async function setup(props: Partial = {}) { +async function setup(props: Partial = {}, locale?: string) { const provider = document.createElement('test-time-player') as TestPlayerProviderElement; const time = createElement(TimeElement); Object.assign(time, props); - document.body.append(provider); + if (locale) { + const i18n = new MediaI18nProviderElement(); + i18n.setAttribute('lang', locale); + i18n.append(provider); + document.body.append(i18n); + } else { + document.body.append(provider); + } provider.append(time); await time.updateComplete; await waitForAssertion(() => expect(time.textContent).toBeTruthy()); @@ -94,6 +105,7 @@ async function setup(props: Partial = {}) { } afterEach(() => { + resetI18nRegistry(); document.body.innerHTML = ''; }); @@ -127,6 +139,19 @@ describe('TimeElement', () => { expect(time.getAttribute('aria-label')).toBe('1 minute, 30 seconds. Show remaining time.'); }); + it('formats toggle labels with the active locale', async () => { + registerI18n('fr', { 'time.showRemaining': '{duration}. Afficher restant.' }); + + const { time } = await setup({ toggle: true }, 'fr'); + + expect(time.getAttribute('aria-label')).toBe(`${formatTimeAsPhrase(90, { locale: 'fr' })}. Afficher restant.`); + + time.type = 'duration'; + await time.updateComplete; + + expect(time.getAttribute('aria-label')).toBe(`${formatTimeAsPhrase(300, { locale: 'fr' })}. Afficher restant.`); + }); + it('does not toggle before media state is available', async () => { const provider = document.createElement('test-time-player') as TestPlayerProviderElement; const time = createElement(TimeElement); diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts index c1b1c806..986c7d19 100644 --- a/packages/html/src/ui/time/time-element.ts +++ b/packages/html/src/ui/time/time-element.ts @@ -1,6 +1,7 @@ import { TimeCore, TimeDataAttrs, type TimeType } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectTime } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; +import { remainingSuffixText } from '@videojs/core/i18n/text/time'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { isInteractiveActivation } from '@videojs/utils/dom'; import { formatTimeAsPhrase } from '@videojs/utils/time'; @@ -23,7 +24,7 @@ export class TimeElement extends MediaElement { type: TimeType = TimeCore.defaultProps.type; negativeSign = TimeCore.defaultProps.negativeSign; - label = TimeCore.defaultProps.label; + label: Text | string = ''; toggle = TimeCore.defaultProps.toggle; readonly #core = new TimeCore(); @@ -98,21 +99,20 @@ export class TimeElement extends MediaElement { const attrs = this.#core.getAttrs(state, this.type); applyElementProps(this, { ...attrs, - 'aria-label': resolveTranslation(this.#i18n.value, attrs['aria-label'], this.#getLabelParams(state)), + 'aria-label': translateText(attrs['aria-label'], this.#i18n.value, this.#getLabelParams(state)), }); applyStateDataAttrs(this, state, TimeDataAttrs); } #getLabelParams(state: TimeCore.State): { duration: string } | undefined { const params = this.#core.getLabelParams(state); - if (!params || state.type !== 'remaining') { - return params; - } + if (!params) return undefined; + + const duration = formatTimeAsPhrase(Math.abs(state.seconds), { locale: this.#i18n.locale }); return { - duration: resolveTranslation(this.#i18n.value, '{duration} remaining', { - duration: formatTimeAsPhrase(Math.abs(state.seconds), { locale: this.#i18n.locale }), - }), + duration: + state.type === 'remaining' ? translateText(remainingSuffixText, this.#i18n.value, { duration }) : duration, }; } diff --git a/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts b/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts index 4884c55a..51d4fb51 100644 --- a/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts +++ b/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts @@ -1,7 +1,7 @@ import type { ButtonState } from '@videojs/core'; import type { AnyPlayerStore, PlayerTarget } from '@videojs/core/dom'; import { HOTKEY_SHORTCUT_CHANGE_EVENT, playbackFeature } from '@videojs/core/dom'; -import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n'; +import { registerI18n, resetI18nRegistry, resolveText, type Text } from '@videojs/core/i18n'; import { ContextProvider } from '@videojs/element/context'; import { createState, createStore } from '@videojs/store'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -54,7 +54,7 @@ class TestTriggerElement extends HTMLElement { shortcut: string | undefined = 'K'; getLabel(): string | undefined { - return this.$state.current.label; + return this.$state.current.label ? resolveText(this.$state.current.label) : undefined; } getShortcut(): string | undefined { @@ -196,7 +196,7 @@ describe('TooltipElement', () => { }); it('shows translated label from the trigger control', async () => { - registerI18n('es', { Play: 'Reproducir' }); + registerI18n('es', { 'buttons.play': 'Reproducir' }); ensureDefined(TestPlayerProviderElement); ensureDefined(PlayButtonElement); @@ -225,8 +225,8 @@ describe('TooltipElement', () => { }); it('updates tooltip text when provider locale changes', async () => { - registerI18n('es', { Play: 'Reproducir' }); - registerI18n('fr', { Play: 'Lire' }); + registerI18n('es', { 'buttons.play': 'Reproducir' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); ensureDefined(TestPlayerProviderElement); ensureDefined(PlayButtonElement); @@ -260,16 +260,16 @@ describe('TooltipElement', () => { expect(TooltipLabelElement.findIn(tooltip)?.textContent).toBe('Lire'); }); - it('falls back to translating getLabel when getResolvedLabel is undefined', async () => { - registerI18n('es', { Play: 'Reproducir' }); + it('falls back to translating Text from getLabel when getResolvedLabel is undefined', async () => { + registerI18n('es', { 'buttons.play': 'Reproducir' }); class StubTrigger extends HTMLElement { static readonly tagName = 'stub-tooltip-trigger'; readonly $state = { subscribe: () => () => {} }; - getLabel(): string { - return 'Play'; + getLabel(): Text { + return { key: 'buttons.play', text: 'Play' }; } getResolvedLabel(): undefined { diff --git a/packages/html/src/ui/tooltip/tooltip-element.ts b/packages/html/src/ui/tooltip/tooltip-element.ts index dccb377a..e129ff6c 100644 --- a/packages/html/src/ui/tooltip/tooltip-element.ts +++ b/packages/html/src/ui/tooltip/tooltip-element.ts @@ -24,7 +24,7 @@ import { type TooltipChangeDetails, type TooltipOpenChangeReason, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; import type { State } from '@videojs/store'; @@ -41,7 +41,7 @@ import { TooltipLabelElement } from './tooltip-label-element'; import { TooltipShortcutElement } from './tooltip-shortcut-element'; type TriggerElement = HTMLElement & { - getLabel(): string | undefined; + getLabel(): Text | string | undefined; getResolvedLabel?(): string | undefined; getShortcut?: (() => string | undefined) | undefined; $state: State; @@ -265,7 +265,7 @@ export class TooltipElement extends MediaElement { const label = triggerEl.getLabel(); let resolved = isFunction(triggerEl.getResolvedLabel) ? triggerEl.getResolvedLabel() : undefined; if (resolved === undefined && label) { - resolved = resolveTranslation(this.#i18n.value, label); + resolved = translateText(label, this.#i18n.value); } const shortcut = triggerEl.getShortcut?.(); diff --git a/packages/html/src/ui/volume-slider/volume-slider-element.ts b/packages/html/src/ui/volume-slider/volume-slider-element.ts index 58cd5b6e..d8d23361 100644 --- a/packages/html/src/ui/volume-slider/volume-slider-element.ts +++ b/packages/html/src/ui/volume-slider/volume-slider-element.ts @@ -9,7 +9,7 @@ import { type SliderApi, selectVolume, } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { applyStyles, isRTL } from '@videojs/utils/dom'; @@ -34,7 +34,7 @@ export class VolumeSliderElement extends MediaElement { thumbAlignment: { type: String, attribute: 'thumb-alignment' }, } satisfies PropertyDeclarationMap>; - label = VolumeSliderCore.defaultProps.label; + label: Text | string = ''; step = VolumeSliderCore.defaultProps.step; largeStep = VolumeSliderCore.defaultProps.largeStep; wheelStep = VolumeSliderCore.defaultProps.wheelStep; @@ -143,10 +143,10 @@ export class VolumeSliderElement extends MediaElement { pointerValue: this.#core.valueFromPercent(state.pointerPercent), thumbAttrs: { ...thumbAttrs, - 'aria-label': resolveTranslation(this.#i18n.value, thumbAttrs['aria-label']), - 'aria-valuetext': resolveTranslation( - this.#i18n.value, + 'aria-label': translateText(thumbAttrs['aria-label'], this.#i18n.value), + 'aria-valuetext': translateText( thumbAttrs['aria-valuetext'], + this.#i18n.value, this.#core.getValueTextParams(state) ), }, diff --git a/packages/react/src/i18n/create-i18n.tsx b/packages/react/src/i18n/create-i18n.tsx index 07186a94..db37e4c1 100644 --- a/packages/react/src/i18n/create-i18n.tsx +++ b/packages/react/src/i18n/create-i18n.tsx @@ -1,6 +1,12 @@ 'use client'; -import { createTranslator, loadLocale as defaultLoader, type Locale, type Translations } from '@videojs/core/i18n'; +import { + createTranslator, + loadLocale as defaultLoader, + type FlatTranslations, + type Locale, + type Translations, +} from '@videojs/core/i18n'; import { effectiveLocale } from '@videojs/utils/dom'; import { type Context, type ReactNode, type RefObject, useContext, useMemo } from 'react'; @@ -14,7 +20,7 @@ import { useMergedTranslations } from './use-merged-translations'; export interface CreateI18nOptions { /** Override lazy loading of shipped locale packs (tests or custom loaders). */ - loader?: (tag: string) => Promise | undefined>; + loader?: (tag: string) => Promise | undefined>; } export interface I18nProviderProps { @@ -32,13 +38,12 @@ export interface I18nProviderProps { langRootRef?: RefObject; /** * Per-locale string overrides merged on top of the global registry and any lazy built-in - * packs for {@link locale}. Use the current English phrases returned by core controls, such as - * `Play`, `Pause`, and `Replay`. Applies to translated + * packs for {@link locale}. Applies to translated * `aria-label` values and tooltip copy for skin controls wired through `useTranslator`. * * @example * ```tsx - * + * * * * ``` diff --git a/packages/react/src/i18n/get-provider-root-props.ts b/packages/react/src/i18n/get-provider-root-props.ts index 30d4914a..798dc019 100644 --- a/packages/react/src/i18n/get-provider-root-props.ts +++ b/packages/react/src/i18n/get-provider-root-props.ts @@ -1,6 +1,34 @@ import type { AddLocaleRoot, I18nContextValue } from './context'; import type { I18nProviderProps } from './create-i18n'; +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function mergeTranslations( + parent: I18nProviderProps['translations'], + child: I18nProviderProps['translations'] +): I18nProviderProps['translations'] { + return { + ...parent, + ...child, + ...(parent && child + ? Object.fromEntries( + Object.keys(parent) + .filter((key) => isRecord(parent[key]) && isRecord(child[key])) + .map((key) => { + const parentValue = parent[key]; + const childValue = child[key]; + return [ + key, + isRecord(parentValue) && isRecord(childValue) ? { ...parentValue, ...childValue } : childValue, + ]; + }) + ) + : {}), + } as I18nProviderProps['translations']; +} + export interface I18nProviderRootProps extends I18nProviderProps { parentLocale?: I18nContextValue['locale']; localeFromProp?: boolean; @@ -26,7 +54,7 @@ export function getProviderRootProps( const parentLocale = props.langRootRef !== undefined ? parent?.locale : undefined; const inheritedTranslations = props.translations !== undefined && parent?.translations !== undefined - ? { ...parent.translations, ...props.translations } + ? mergeTranslations(parent.translations, props.translations) : (props.translations ?? (langRootOnly ? parent?.translations : undefined)); const onActiveLocaleChange = props.onActiveLocaleChange ?? parent?.onActiveLocaleChange; diff --git a/packages/react/src/i18n/index.ts b/packages/react/src/i18n/index.ts index 3e1fcd4f..320678e0 100644 --- a/packages/react/src/i18n/index.ts +++ b/packages/react/src/i18n/index.ts @@ -1,6 +1,7 @@ 'use client'; export type { + FlatTranslations, Locale, TranslationParams, Translations, @@ -12,6 +13,7 @@ export { findLocaleKeys, getI18nTranslations, hasRegisteredLocale, + isText, onI18nRegistryChange, registerI18n, } from '@videojs/core/i18n'; diff --git a/packages/react/src/i18n/tests/create-i18n.test.tsx b/packages/react/src/i18n/tests/create-i18n.test.tsx index 58dabf12..1f7ed309 100644 --- a/packages/react/src/i18n/tests/create-i18n.test.tsx +++ b/packages/react/src/i18n/tests/create-i18n.test.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import * as coreI18n from '@videojs/core/i18n'; -import { registerI18n, resetI18nRegistry, type Translations } from '@videojs/core/i18n'; +import { type FlatTranslations, registerI18n, resetI18nRegistry } from '@videojs/core/i18n'; import { createRef, type ReactElement } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -479,8 +479,8 @@ describe('createI18n', () => { }); it('does not apply stale lazy locale overlays after locale switch', async () => { - let resolveDe: ((value: Partial) => void) | undefined; - const deLoad = new Promise>((resolve) => { + let resolveDe: ((value: Partial) => void) | undefined; + const deLoad = new Promise>((resolve) => { resolveDe = resolve; }); @@ -721,7 +721,7 @@ describe('createI18n', () => { it('registers browser translations when no locale pack exists', async () => { const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ Play: 'BrowserPlay', - } satisfies Partial); + } satisfies Partial); const { I18nProvider, useTranslator } = createI18n(); @@ -745,7 +745,7 @@ describe('createI18n', () => { it('registers browser translations when a shipped locale pack is missing keys', async () => { const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ Settings: 'Paramètres', - } satisfies Partial); + } satisfies Partial); const { I18nProvider, useTranslator } = createI18n({ loader: async (tag) => (tag === 'fr' ? { Play: 'Lire' } : undefined), @@ -792,7 +792,7 @@ describe('createI18n', () => { }); it('does not register browser translations after locale changes', async () => { - let resolveBrowser: ((value: Partial) => void) | undefined; + let resolveBrowser: ((value: Partial) => void) | undefined; const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockImplementation( () => new Promise((resolve) => { @@ -820,7 +820,7 @@ describe('createI18n', () => { }); it('does not register browser translations after unmount', async () => { - let resolveBrowser: ((value: Partial) => void) | undefined; + let resolveBrowser: ((value: Partial) => void) | undefined; const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockImplementation( () => new Promise((resolve) => { diff --git a/packages/react/src/i18n/use-lazy-translations.ts b/packages/react/src/i18n/use-lazy-translations.ts index 062c0783..3908db96 100644 --- a/packages/react/src/i18n/use-lazy-translations.ts +++ b/packages/react/src/i18n/use-lazy-translations.ts @@ -1,18 +1,18 @@ import { + type FlatTranslations, findLocaleKeys, getBrowserTranslations, type Locale, registerI18n, shouldAttemptBrowserTranslation, - type Translations, } from '@videojs/core/i18n'; import { mergeLocaleOverlays } from '@videojs/utils/dom'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -export type LocaleLoader = (tag: string) => Promise | undefined>; +export type LocaleLoader = (tag: string) => Promise | undefined>; -export function useLazyTranslations(resolvedLocale: Locale, loader: LocaleLoader): Partial { - const [lazyLayer, setLazyLayer] = useState>({}); +export function useLazyTranslations(resolvedLocale: Locale, loader: LocaleLoader): Partial { + const [lazyLayer, setLazyLayer] = useState>({}); const lazySeqRef = useRef(0); // biome-ignore lint/correctness/useExhaustiveDependencies: rerun when `resolvedLocale` changes even though the reset callback does not reference it. diff --git a/packages/react/src/i18n/use-merged-translations.ts b/packages/react/src/i18n/use-merged-translations.ts index d1280886..afb3b702 100644 --- a/packages/react/src/i18n/use-merged-translations.ts +++ b/packages/react/src/i18n/use-merged-translations.ts @@ -1,13 +1,19 @@ -import { getI18nTranslations, type Locale, type Translations } from '@videojs/core/i18n'; +import { + type FlatTranslations, + flattenTranslations, + getI18nTranslations, + type Locale, + type Translations, +} from '@videojs/core/i18n'; import { useMemo } from 'react'; import { useRegistryEpoch } from './use-registry-epoch'; export function useMergedTranslations( resolvedLocale: Locale, - lazyLayer: Partial, + lazyLayer: Partial, translationsProp?: Partial -): Translations { +): FlatTranslations { const registryEpoch = useRegistryEpoch(); return useMemo(() => { @@ -16,7 +22,7 @@ export function useMergedTranslations( return { ...registryLayer, ...lazyLayer, - ...translationsProp, - } as Translations; + ...flattenTranslations(translationsProp ?? {}), + } as FlatTranslations; }, [resolvedLocale, lazyLayer, translationsProp, registryEpoch]); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b33fa1df..25f52f55 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -15,6 +15,7 @@ export { type CreateI18nResult, createI18n, createTranslator, + type FlatTranslations, findLocaleKeys, getI18nTranslations, hasRegisteredLocale, diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index 3161a9aa..d2c2642a 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -1,3 +1,4 @@ +import { playbackRateText } from '@videojs/core/i18n/text/menu'; import { button, buttonGroup, @@ -134,7 +135,7 @@ function PlaybackRateRadioGroup(): ReactNode { const { options, setValue, value } = state; return ( - + {options.map((option) => ( {option.label} diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index 8a4d6e4f..bd38ec96 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -1,3 +1,4 @@ +import { playbackRateText } from '@videojs/core/i18n/text/menu'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { useTranslator } from '@/i18n/context'; @@ -86,7 +87,7 @@ function PlaybackRateRadioGroup(): ReactNode { className="media-menu__group" value={value} onValueChange={setValue} - aria-label={t('Playback rate')} + aria-label={t(playbackRateText)} > {options.map((option) => ( diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index f3095ed0..690d6b54 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -1,3 +1,4 @@ +import { playbackRateText } from '@videojs/core/i18n/text/menu'; import { button, buttonGroup, @@ -136,7 +137,7 @@ function PlaybackRateRadioGroup(): ReactNode { const { options, setValue, value } = state; return ( - + {options.map((option) => ( {option.label} diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index 519d6cc3..6ce3c30e 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -1,3 +1,4 @@ +import { playbackRateText } from '@videojs/core/i18n/text/menu'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { useTranslator } from '@/i18n/context'; @@ -86,7 +87,7 @@ function PlaybackRateRadioGroup(): ReactNode { className="media-menu__group" value={value} onValueChange={setValue} - aria-label={t('Playback rate')} + aria-label={t(playbackRateText)} > {options.map((option) => ( diff --git a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx index 31d6d018..49036a7b 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -1,3 +1,4 @@ +import { captionsText } from '@videojs/core/i18n/text/menu'; import { bufferingIndicator, button, @@ -182,7 +183,7 @@ function CaptionsTrigger(): ReactNode { className={menu.group} value={captions.value} onValueChange={captions.setValue} - aria-label={t('Captions')} + aria-label={t(captionsText)} > {captions.options.map((option) => ( diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index c05dbd63..f5d41a1f 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -1,3 +1,4 @@ +import { captionsText } from '@videojs/core/i18n/text/menu'; import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react'; @@ -142,7 +143,7 @@ function CaptionsTrigger(): ReactNode { className="media-menu__group" value={captions.value} onValueChange={captions.setValue} - aria-label={t('Captions')} + aria-label={t(captionsText)} > {captions.options.map((option) => ( {captions.options.map((option) => ( diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index 90d5e6f7..f9658d05 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -1,3 +1,4 @@ +import { captionsText } from '@videojs/core/i18n/text/menu'; import { isString } from '@videojs/utils/predicate'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react'; @@ -143,7 +144,7 @@ function CaptionsTrigger(): ReactNode { className="media-menu__group" value={value} onValueChange={setValue} - aria-label={t('Captions')} + aria-label={t(captionsText)} > {options.map((option) => ( } > @@ -202,7 +210,7 @@ function SettingsMenu(): ReactNode { render={(props) => (
- {t('Quality')} + {t(qualityText)} @@ -213,14 +221,14 @@ function SettingsMenu(): ReactNode { - {t('Quality')} + {t(qualityText)} {quality.options.map((option) => ( (
- {t('Audio')} + {t(audioText)} @@ -267,14 +275,14 @@ function SettingsMenu(): ReactNode { - {t('Audio')} + {t(audioText)} {audioTrack.options.map((option) => ( (
- {t('Speed')} + {t(speedText)} @@ -317,14 +325,14 @@ function SettingsMenu(): ReactNode { - {t('Speed')} + {t(speedText)} {playbackRate.options.map((option) => ( (
- {t('Captions')} + {t(captionsText)} @@ -367,14 +375,14 @@ function SettingsMenu(): ReactNode { - {t('Captions')} + {t(captionsText)} {captions.options.map((option) => ( - }> + }> @@ -139,7 +147,7 @@ function SettingsMenu(): ReactNode { render={(props) => (
- {t('Quality')} + {t(qualityText)} @@ -150,14 +158,14 @@ function SettingsMenu(): ReactNode { - {t('Quality')} + {t(qualityText)} {quality.options.map((option) => ( (
- {t('Audio')} + {t(audioText)} @@ -204,14 +212,14 @@ function SettingsMenu(): ReactNode { - {t('Audio')} + {t(audioText)} {audioTrack.options.map((option) => ( (
- {t('Speed')} + {t(speedText)} @@ -254,14 +262,14 @@ function SettingsMenu(): ReactNode { - {t('Speed')} + {t(speedText)} {playbackRate.options.map((option) => ( (
- {t('Captions')} + {t(captionsText)} @@ -304,14 +312,14 @@ function SettingsMenu(): ReactNode { - {t('Captions')} + {t(captionsText)} {captions.options.map((option) => ( } > @@ -202,7 +210,7 @@ function SettingsMenu(): ReactNode { render={(props) => (
- {t('Quality')} + {t(qualityText)} @@ -213,14 +221,14 @@ function SettingsMenu(): ReactNode { - {t('Quality')} + {t(qualityText)} {quality.options.map((option) => ( (
- {t('Audio')} + {t(audioText)} @@ -267,14 +275,14 @@ function SettingsMenu(): ReactNode { - {t('Audio')} + {t(audioText)} {audioTrack.options.map((option) => ( (
- {t('Speed')} + {t(speedText)} @@ -317,14 +325,14 @@ function SettingsMenu(): ReactNode { - {t('Speed')} + {t(speedText)} {playbackRate.options.map((option) => ( (
- {t('Captions')} + {t(captionsText)} @@ -367,14 +375,14 @@ function SettingsMenu(): ReactNode { - {t('Captions')} + {t(captionsText)} {captions.options.map((option) => ( - }> + }> @@ -139,7 +147,7 @@ function SettingsMenu(): ReactNode { render={(props) => (
- {t('Quality')} + {t(qualityText)} @@ -150,14 +158,14 @@ function SettingsMenu(): ReactNode { - {t('Quality')} + {t(qualityText)} {quality.options.map((option) => ( (
- {t('Audio')} + {t(audioText)} @@ -204,14 +212,14 @@ function SettingsMenu(): ReactNode { - {t('Audio')} + {t(audioText)} {audioTrack.options.map((option) => ( (
- {t('Speed')} + {t(speedText)} @@ -254,14 +262,14 @@ function SettingsMenu(): ReactNode { - {t('Speed')} + {t(speedText)} {playbackRate.options.map((option) => ( (
- {t('Captions')} + {t(captionsText)} @@ -304,14 +312,14 @@ function SettingsMenu(): ReactNode { - {t('Captions')} + {t(captionsText)} {captions.options.map((option) => ( ({ value: track.value, - label: resolveTranslation(t, track.label), + label: translateText(track.label, t), disabled: state.disabled, })), disabled: state.disabled, diff --git a/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx b/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx index 8c8eeb86..a4242825 100644 --- a/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx +++ b/packages/react/src/ui/captions-radio-group/tests/use-captions-options.test.tsx @@ -132,7 +132,7 @@ describe('useCaptionsOptions', () => { it('translates default track labels', () => { registerI18n('xx', { - Captions: 'Captions translated', + 'menu.captions': 'Captions translated', }); renderCaptionsMenu({ diff --git a/packages/react/src/ui/captions-radio-group/use-captions-options.ts b/packages/react/src/ui/captions-radio-group/use-captions-options.ts index 791b12e9..dd6bcd9d 100644 --- a/packages/react/src/ui/captions-radio-group/use-captions-options.ts +++ b/packages/react/src/ui/captions-radio-group/use-captions-options.ts @@ -2,7 +2,8 @@ import { CAPTIONS_OFF_VALUE, CaptionsRadioGroupCore } from '@videojs/core'; import { logMissingFeature, selectTextTrack } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; +import { offText } from '@videojs/core/i18n/text/menu'; import { useCallback, useState } from 'react'; import { useTranslator } from '../../i18n/context'; @@ -58,12 +59,12 @@ export function useCaptionsOptions(props?: CaptionsOptionsProps): CaptionsOption options: [ { value: CAPTIONS_OFF_VALUE, - label: resolveTranslation(t, 'Off'), + label: translateText(offText, t), disabled: state.disabled, }, ...state.tracks.map((track) => ({ value: track.value, - label: resolveTranslation(t, track.label), + label: translateText(track.label, t), disabled: state.disabled, })), ], diff --git a/packages/react/src/ui/create-media-button.tsx b/packages/react/src/ui/create-media-button.tsx index b6ddcf49..75860798 100644 --- a/packages/react/src/ui/create-media-button.tsx +++ b/packages/react/src/ui/create-media-button.tsx @@ -2,9 +2,9 @@ import type { InferComponentState, InferMediaState, MediaButtonComponent, StateAttrMap } from '@videojs/core'; import { logMissingFeature } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { isText, translateText } from '@videojs/core/i18n'; import type { Selector } from '@videojs/store'; -import { isString, isUndefined } from '@videojs/utils/predicate'; +import { isUndefined } from '@videojs/utils/predicate'; import type { ForwardedRef, ForwardRefExoticComponent, RefAttributes } from 'react'; import { forwardRef, useLayoutEffect, useState } from 'react'; @@ -101,7 +101,7 @@ export function createMediaButton, P type State = InferComponentState; if (feature) core.setMedia(feature); const state = feature ? (core.getState() as State) : null; - const label = state ? resolveTranslation(translator, core.getLabel(state), getLabelParams(core, state)) : undefined; + const label = state ? translateText(core.getLabel(state), translator, getLabelParams(core, state)) : undefined; const tooltipText = state ? (tooltipLabel?.(core, state) ?? label) : undefined; // Forward label to tooltip popup content when inside a Tooltip.Root. @@ -120,8 +120,8 @@ export function createMediaButton, P const ariaLabel = attrs['aria-label']; const resolvedAttrs = { ...attrs, - ...(isString(ariaLabel) - ? { 'aria-label': resolveTranslation(translator, ariaLabel, getLabelParams(core, state)) } + ...(isText(ariaLabel) + ? { 'aria-label': translateText(ariaLabel, translator, getLabelParams(core, state)) } : undefined), 'aria-keyshortcuts': shortcut.aria, }; diff --git a/packages/react/src/ui/error-dialog/error-dialog-close.tsx b/packages/react/src/ui/error-dialog/error-dialog-close.tsx index 37b9c6a7..b85779d9 100644 --- a/packages/react/src/ui/error-dialog/error-dialog-close.tsx +++ b/packages/react/src/ui/error-dialog/error-dialog-close.tsx @@ -1,8 +1,8 @@ 'use client'; import type { AlertDialogCore } from '@videojs/core'; -import { getErrorDialogDismissLabel } from '@videojs/core'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { getErrorDialogDismissText } from '@videojs/core'; +import { translateText } from '@videojs/core/i18n'; import { forwardRef, type ReactNode, useCallback } from 'react'; import { useTranslator } from '../../i18n/context'; import type { UIComponentProps } from '../../utils/types'; @@ -23,7 +23,7 @@ export const ErrorDialogClose = forwardRef { describe('ErrorDialog', () => { it('shows translated title, description, and dismiss label when locale is es', () => { registerI18n('es', { - 'Something went wrong.': 'Algo salió mal.', - OK: 'Aceptar', - 'This media could not be loaded due to a network or server issue.': 'Error de red.', - 'An unexpected error occurred.': 'Ocurrió un error inesperado.', + 'errors.title': 'Algo salió mal.', + 'common.ok': 'Aceptar', + 'errors.network': 'Error de red.', + 'errors.unexpected': 'Ocurrió un error inesperado.', }); const error = { diff --git a/packages/react/src/ui/live-button/live-button.tsx b/packages/react/src/ui/live-button/live-button.tsx index 24b3e6a4..d8116734 100644 --- a/packages/react/src/ui/live-button/live-button.tsx +++ b/packages/react/src/ui/live-button/live-button.tsx @@ -2,7 +2,7 @@ import { LiveButtonCore, LiveButtonDataAttrs, type LiveButtonMediaState } from '@videojs/core'; import { logMissingFeature, selectBuffer, selectLive, selectTime } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; import { forwardRef, type ReactNode, useLayoutEffect, useState } from 'react'; import { useTranslator } from '../../i18n/context'; @@ -26,8 +26,7 @@ export interface LiveButtonProps extends UIComponentProps<'button', LiveButtonCo * itself rather than going through `createMediaButton`, since the LiveButton * needs three feature slices to detect the live edge and seek. * - * Falls back to `LiveButtonCore.defaultText` (`'Live'` by default) when no - * children are provided. Override the static field globally for i18n. + * Displays the translated live badge when no children are provided. * * @example * ```tsx @@ -70,7 +69,7 @@ export const LiveButton = forwardRef( if (media) core.setMedia(media); const state = media ? core.getState() : null; - const labelText = state ? resolveTranslation(translator, core.getLabel(state)) : undefined; + const labelText = state ? translateText(core.getLabel(state), translator) : undefined; useLayoutEffect(() => { if (!tooltipCtx) return; @@ -85,7 +84,7 @@ export const LiveButton = forwardRef( const attrs = core.getAttrs(state); const labelAttr = attrs['aria-label']; - const content = children ?? LiveButtonCore.defaultText; + const content = children ?? translateText(LiveButtonCore.defaultText, translator); return renderElement( 'button', @@ -99,7 +98,7 @@ export const LiveButton = forwardRef( { children: content, ...elementProps, - 'aria-label': labelAttr ? resolveTranslation(translator, labelAttr) : labelAttr, + 'aria-label': labelAttr ? translateText(labelAttr, translator) : labelAttr, }, getButtonProps(), ], diff --git a/packages/react/src/ui/live-button/tests/live-button.test.tsx b/packages/react/src/ui/live-button/tests/live-button.test.tsx new file mode 100644 index 00000000..e941dad1 --- /dev/null +++ b/packages/react/src/ui/live-button/tests/live-button.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '../../../i18n'; +import { createPlayerWrapper } from '../../../testing/mocks'; +import { LiveButton } from '../live-button'; + +afterEach(() => { + resetI18nRegistry(); + cleanup(); +}); + +function createWrapper() { + return createPlayerWrapper({ + liveEdgeStart: 90, + targetLiveWindow: 0, + currentTime: 80, + duration: 100, + seeking: false, + seek: vi.fn(), + buffered: [], + seekable: [{ start: 0, end: 100 }], + }).Wrapper; +} + +describe('LiveButton', () => { + it('translates the default badge and updates when locale changes', () => { + registerI18n('es', { 'live.badge': 'En vivo' }); + registerI18n('fr', { 'live.badge': 'Direct' }); + const Wrapper = createWrapper(); + + const { rerender } = render( + + + + + + ); + + expect(screen.getByTestId('live').textContent).toBe('En vivo'); + + rerender( + + + + + + ); + + expect(screen.getByTestId('live').textContent).toBe('Direct'); + }); + + it('preserves authored badge copy', () => { + registerI18n('es', { 'live.badge': 'En vivo' }); + const Wrapper = createWrapper(); + + render( + + + On air + + + ); + + expect(screen.getByTestId('live').textContent).toBe('On air'); + }); +}); diff --git a/packages/react/src/ui/menu/menu-back.tsx b/packages/react/src/ui/menu/menu-back.tsx index 67f220a2..c60f977a 100644 --- a/packages/react/src/ui/menu/menu-back.tsx +++ b/packages/react/src/ui/menu/menu-back.tsx @@ -1,7 +1,8 @@ 'use client'; import type { MenuState } from '@videojs/core'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { type Text, translateText } from '@videojs/core/i18n'; +import { backText } from '@videojs/core/i18n/text/menu'; import { forwardRef, useCallback } from 'react'; import { useTranslator } from '../../i18n/context'; @@ -11,12 +12,12 @@ import { useSubMenuContext } from './context'; export interface MenuBackProps extends UIComponentProps<'button', MenuState> { /** Accessible label for the back button. */ - label?: string; + label?: Text | string; } /** Button that navigates back to the parent menu view. Place at the top of a submenu Content. */ export const MenuBack = forwardRef(function MenuBack( - { render, className, style, label = 'Back', onClick, ...elementProps }, + { render, className, style, label = backText, onClick, ...elementProps }, forwardedRef ) { const t = useTranslator(); @@ -40,7 +41,7 @@ export const MenuBack = forwardRef(function Me props: [ { type: 'button' as const, - 'aria-label': resolveTranslation(t, label), + 'aria-label': translateText(label, t), onClick: handleClick, }, elementProps, diff --git a/packages/react/src/ui/menu/menu-item-setting-provider.tsx b/packages/react/src/ui/menu/menu-item-setting-provider.tsx index b9572f75..59bf8a88 100644 --- a/packages/react/src/ui/menu/menu-item-setting-provider.tsx +++ b/packages/react/src/ui/menu/menu-item-setting-provider.tsx @@ -1,7 +1,8 @@ 'use client'; import { CAPTIONS_OFF_VALUE } from '@videojs/core'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; +import { autoText, offText } from '@videojs/core/i18n/text/menu'; import type { ReactNode } from 'react'; import { useTranslator } from '../../i18n/context'; @@ -39,8 +40,8 @@ function CaptionsMenuItemSettingProvider({ children }: { children: ReactNode }): const { state, options, value } = captions; const label = value === CAPTIONS_OFF_VALUE - ? resolveTranslation(t, 'Off') - : (options.find((option) => option.value === value)?.label ?? resolveTranslation(t, 'Off')); + ? translateText(offText, t) + : (options.find((option) => option.value === value)?.label ?? translateText(offText, t)); return ( @@ -55,7 +56,7 @@ function QualityMenuItemSettingProvider({ children }: { children: ReactNode }): if (!quality) return children; const { state, options, value } = quality; - const label = options.find((option) => option.value === value)?.label ?? resolveTranslation(t, 'Auto'); + const label = options.find((option) => option.value === value)?.label ?? translateText(autoText, t); return ( diff --git a/packages/react/src/ui/menu/tests/menu.test.tsx b/packages/react/src/ui/menu/tests/menu.test.tsx index 6859620e..6efa31dc 100644 --- a/packages/react/src/ui/menu/tests/menu.test.tsx +++ b/packages/react/src/ui/menu/tests/menu.test.tsx @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { Text } from '@videojs/core/i18n'; import type { KeyboardEventHandler, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -380,6 +381,21 @@ function DynamicMenuFixture({ showCaptions }: { showCaptions: boolean }) { ); } +describe('MenuBack', () => { + it('resolves menu back text and preserves literal labels', () => { + const customText = { key: 'custom.back', text: 'Go back' } as const satisfies Text; + const { rerender } = render(); + + expect(screen.getByRole('button', { name: 'Back' })).toBeTruthy(); + + rerender(); + expect(screen.getByRole('button', { name: 'Go back' })).toBeTruthy(); + + rerender(); + expect(screen.getByRole('button', { name: 'menu.back' })).toBeTruthy(); + }); +}); + describe('MenuContent', () => { it('exposes the positioned side on root content', async () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { diff --git a/packages/react/src/ui/play-button/tests/play-button.test.tsx b/packages/react/src/ui/play-button/tests/play-button.test.tsx index 4ce3c776..bebe7899 100644 --- a/packages/react/src/ui/play-button/tests/play-button.test.tsx +++ b/packages/react/src/ui/play-button/tests/play-button.test.tsx @@ -13,8 +13,8 @@ afterEach(() => { describe('PlayButton', () => { it('applies translated aria-label and updates when locale changes', () => { - registerI18n('es', { Play: 'Reproducir' }); - registerI18n('fr', { Play: 'Lire' }); + registerI18n('es', { 'buttons.play': 'Reproducir' }); + registerI18n('fr', { 'buttons.play': 'Lire' }); const { Wrapper } = createPlayerWrapper({ paused: true, @@ -62,7 +62,7 @@ describe('PlayButton', () => { render( - + diff --git a/packages/react/src/ui/quality/tests/use-quality-options.test.tsx b/packages/react/src/ui/quality/tests/use-quality-options.test.tsx index a62badfe..c4334ac2 100644 --- a/packages/react/src/ui/quality/tests/use-quality-options.test.tsx +++ b/packages/react/src/ui/quality/tests/use-quality-options.test.tsx @@ -97,7 +97,7 @@ describe('useQualityOptions', () => { it('translates default auto labels', () => { registerI18n('xx', { - 'Auto ({label})': 'Auto translated ({label})', + 'menu.autoWithLabel': 'Auto translated ({label})', }); renderQualityOptions({ diff --git a/packages/react/src/ui/quality/use-quality-options.ts b/packages/react/src/ui/quality/use-quality-options.ts index 52e7806f..a0e8d4dd 100644 --- a/packages/react/src/ui/quality/use-quality-options.ts +++ b/packages/react/src/ui/quality/use-quality-options.ts @@ -2,7 +2,7 @@ import { QUALITY_AUTO_VALUE, QualityRadioGroupCore } from '@videojs/core'; import { logMissingFeature, selectQuality } from '@videojs/core/dom'; -import { resolveTranslation, type Translator } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; import { useCallback, useState } from 'react'; import { useTranslator } from '../../i18n/context'; @@ -26,16 +26,6 @@ export interface QualityOptionsResult { setValue: (value: string) => void; } -const AUTO_LABEL_RE = /^Auto \((.+)\)$/; - -function resolveAutoLabel(t: Translator, label: string): string { - const match = AUTO_LABEL_RE.exec(label); - if (match) { - return resolveTranslation(t, 'Auto ({label})', { label: match[1]! }); - } - return resolveTranslation(t, label); -} - /** * Create quality menu options (including an `Auto` option) from the player * video rendition state. Returns `null` when the quality feature is not @@ -68,12 +58,12 @@ export function useQualityOptions(props?: QualityOptionsProps): QualityOptionsRe options: [ { value: QUALITY_AUTO_VALUE, - label: resolveAutoLabel(t, state.autoLabel), + label: translateText(state.autoLabel, t, state.autoLabelParams), disabled: state.disabled, }, ...state.renditions.map((rendition) => ({ value: rendition.value, - label: resolveTranslation(t, rendition.label), + label: translateText(rendition.label, t), ...(rendition.tier && { tier: rendition.tier }), ...(rendition.badge && { badge: rendition.badge }), disabled: state.disabled, diff --git a/packages/react/src/ui/slider/slider-root.tsx b/packages/react/src/ui/slider/slider-root.tsx index 60f7d1ce..be884e7d 100644 --- a/packages/react/src/ui/slider/slider-root.tsx +++ b/packages/react/src/ui/slider/slider-root.tsx @@ -2,9 +2,10 @@ import { SliderCore, SliderDataAttrs } from '@videojs/core'; import { getSliderCSSVars } from '@videojs/core/dom'; +import { translateText } from '@videojs/core/i18n'; import type { ForwardedRef } from 'react'; import { forwardRef, useState } from 'react'; - +import { useTranslator } from '../../i18n/context'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; import { useSlider } from '../hooks/use-slider'; @@ -42,6 +43,7 @@ export const SliderRoot = forwardRef(function SliderRoot( } = componentProps; const [core] = useState(() => new SliderCore()); + const translator = useTranslator(); core.setProps({ label, min, max, step, largeStep, orientation, disabled, thumbAlignment }); const { @@ -79,7 +81,10 @@ export const SliderRoot = forwardRef(function SliderRoot( thumbRef: sliderThumbRef, thumbProps, stateAttrMap: SliderDataAttrs, - getAttrs: (sliderState) => core.getAttrs(sliderState), + getAttrs: (sliderState) => { + const attrs = core.getAttrs(sliderState); + return { ...attrs, 'aria-label': translateText(attrs['aria-label'], translator) }; + }, formatValue: undefined, }} > diff --git a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx index 6f948c31..c32d440f 100644 --- a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx +++ b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx @@ -204,7 +204,7 @@ describe('TimeSlider compound', () => { const { Wrapper } = createPlayerWrapper(); const { container } = render( - + diff --git a/packages/react/src/ui/time-slider/time-slider-root.tsx b/packages/react/src/ui/time-slider/time-slider-root.tsx index 4c4db830..efde2a77 100644 --- a/packages/react/src/ui/time-slider/time-slider-root.tsx +++ b/packages/react/src/ui/time-slider/time-slider-root.tsx @@ -2,7 +2,7 @@ import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core'; import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectPlayback, selectTime } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; import { formatTime } from '@videojs/utils/time'; import { forwardRef, useEffect, useState } from 'react'; @@ -129,10 +129,10 @@ export const TimeSliderRoot = forwardRef( const attrs = core.getAttrs(sliderState as TimeSliderCore.State); return { ...attrs, - 'aria-label': resolveTranslation(translator, attrs['aria-label']), - 'aria-valuetext': resolveTranslation( - translator, + 'aria-label': translateText(attrs['aria-label'], translator), + 'aria-valuetext': translateText( attrs['aria-valuetext'], + translator, core.getValueTextParams(sliderState as TimeSliderCore.State) ), }; diff --git a/packages/react/src/ui/time/tests/time.test.tsx b/packages/react/src/ui/time/tests/time.test.tsx index 0ab27cce..ac3c8e3d 100644 --- a/packages/react/src/ui/time/tests/time.test.tsx +++ b/packages/react/src/ui/time/tests/time.test.tsx @@ -62,10 +62,7 @@ describe('Time.Value', () => { const { rerender } = render( - + @@ -76,10 +73,7 @@ describe('Time.Value', () => { rerender( - + diff --git a/packages/react/src/ui/time/time-value.tsx b/packages/react/src/ui/time/time-value.tsx index 4937ad15..9b3c7cb9 100644 --- a/packages/react/src/ui/time/time-value.tsx +++ b/packages/react/src/ui/time/time-value.tsx @@ -2,7 +2,8 @@ import { TimeCore, TimeDataAttrs } from '@videojs/core'; import { logMissingFeature, selectTime } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; +import { remainingSuffixText } from '@videojs/core/i18n/text/time'; import { isInteractiveActivation } from '@videojs/utils/dom'; import { formatTimeAsPhrase } from '@videojs/utils/time'; import type { ForwardedRef, KeyboardEvent, MouseEvent } from 'react'; @@ -64,7 +65,7 @@ export const Value = forwardRef(function Value( ? { duration: state.type === 'remaining' - ? resolveTranslation(translator, '{duration} remaining', { + ? translateText(remainingSuffixText, translator, { duration: formatTimeAsPhrase(Math.abs(state.seconds), { locale }), }) : formatTimeAsPhrase(Math.abs(state.seconds), { locale }), @@ -114,7 +115,7 @@ export const Value = forwardRef(function Value( dateTime: state.datetime, children: content, ...attrs, - 'aria-label': resolveTranslation(translator, attrs['aria-label'], resolvedLabelParams), + 'aria-label': translateText(attrs['aria-label'], translator, resolvedLabelParams), ...(toggle ? { onClick: handleClick, onKeyDown: handleKeyDown } : undefined), }, elementProps, diff --git a/packages/react/src/ui/volume-slider/volume-slider-root.tsx b/packages/react/src/ui/volume-slider/volume-slider-root.tsx index 21077aaf..6ed7c95c 100644 --- a/packages/react/src/ui/volume-slider/volume-slider-root.tsx +++ b/packages/react/src/ui/volume-slider/volume-slider-root.tsx @@ -2,7 +2,7 @@ import { VolumeSliderCore, VolumeSliderDataAttrs } from '@videojs/core'; import { createWheelStep, getSliderCSSVars, logMissingFeature, selectVolume } from '@videojs/core/dom'; -import { resolveTranslation } from '@videojs/core/i18n'; +import { translateText } from '@videojs/core/i18n'; import { listen } from '@videojs/utils/dom'; import { forwardRef, useCallback, useRef, useState } from 'react'; @@ -121,10 +121,10 @@ export const VolumeSliderRoot = forwardRef, options: FlattenOptions = {}): Record { + const { prefix = '' } = options; + const result: Record = {}; + + for (const [key, value] of Object.entries(object)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(result, flatten(value as Record, { prefix: fullKey })); + } else { + result[fullKey] = value; + } + } + + return result; +} diff --git a/packages/utils/src/object/index.ts b/packages/utils/src/object/index.ts index d47058cc..fec6104b 100644 --- a/packages/utils/src/object/index.ts +++ b/packages/utils/src/object/index.ts @@ -1,5 +1,6 @@ export { deepEqual } from './deep-equal'; export { defaults } from './defaults'; +export { type FlattenOptions, flatten } from './flatten'; export { omit } from './omit'; export { pick } from './pick'; export { shallowEqual } from './shallow-equal'; diff --git a/packages/utils/src/object/tests/flatten.test.ts b/packages/utils/src/object/tests/flatten.test.ts new file mode 100644 index 00000000..ac529568 --- /dev/null +++ b/packages/utils/src/object/tests/flatten.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { flatten } from '../flatten'; + +describe('flatten', () => { + it('flattens nested objects into dot-separated keys', () => { + expect( + flatten({ + buttons: { play: 'Play', pause: 'Pause' }, + common: { empty: '' }, + }) + ).toEqual({ + 'buttons.play': 'Play', + 'buttons.pause': 'Pause', + 'common.empty': '', + }); + }); + + it('preserves non-object values as leaves', () => { + expect(flatten({ value: 0, enabled: false, items: ['one'] })).toEqual({ + value: 0, + enabled: false, + items: ['one'], + }); + }); + + it('accepts a prefix option', () => { + expect(flatten({ play: 'Play' }, { prefix: 'buttons' })).toEqual({ 'buttons.play': 'Play' }); + }); +}); diff --git a/site/scripts/build-ejected-skins.ts b/site/scripts/build-ejected-skins.ts index 09bc4e2d..5704d77c 100644 --- a/site/scripts/build-ejected-skins.ts +++ b/site/scripts/build-ejected-skins.ts @@ -54,7 +54,6 @@ interface HtmlSkinDef { template: string; css?: string; iconSet: 'default' | 'minimal'; - tailwindModule?: string; } interface ReactSkinDef { @@ -567,7 +566,6 @@ const SKINS: SkinDef[] = [ style: 'tailwind', template: 'packages/html/src/define/video/skin.tailwind.ts', iconSet: 'default', - tailwindModule: '@videojs/skins/default/tailwind/video.tailwind', }, { id: 'default-audio-tailwind', @@ -576,7 +574,6 @@ const SKINS: SkinDef[] = [ style: 'tailwind', template: 'packages/html/src/define/audio/skin.tailwind.ts', iconSet: 'default', - tailwindModule: '@videojs/skins/default/tailwind/audio.tailwind', }, { id: 'minimal-video-tailwind', @@ -585,7 +582,6 @@ const SKINS: SkinDef[] = [ style: 'tailwind', template: 'packages/html/src/define/video/minimal-skin.tailwind.ts', iconSet: 'minimal', - tailwindModule: '@videojs/skins/minimal/tailwind/video.tailwind', }, { id: 'minimal-audio-tailwind', @@ -594,7 +590,6 @@ const SKINS: SkinDef[] = [ style: 'tailwind', template: 'packages/html/src/define/audio/minimal-skin.tailwind.ts', iconSet: 'minimal', - tailwindModule: '@videojs/skins/minimal/tailwind/audio.tailwind', }, // React CSS @@ -712,11 +707,7 @@ function extractTemplateLiteral(source: string): string { return match[1]; } -/** - * Collect all import names that the template uses from the tailwind module. - * Parses lines like: `import { foo, bar } from '@videojs/skins/...'` - * and also picks up re-imports from other modules used in the template. - */ +/** Collect named package imports used by an HTML skin template. */ function parseImportedNames(source: string): Map { const imports = new Map(); const importRegex = /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g; @@ -737,21 +728,41 @@ function parseImportedNames(source: string): Map { return imports; } -async function loadCn(): Promise<(...args: unknown[]) => string> { - const mod = await import(pkgDistUrl('@videojs/utils/style')); - return mod.cn; -} +async function loadImportedNames( + source: string, + template: string, + templatePath: string, + context: Record +): Promise { + const modules = new Map>(); -async function loadTailwindTokens(specifier: string): Promise> { - return await import(pkgDistUrl(specifier)); + for (const [name, specifier] of parseImportedNames(source)) { + if (!new RegExp(`\\b${name}\\b`).test(template)) { + continue; + } + + let imported = modules.get(specifier); + if (!imported) { + const url = specifier.startsWith('@videojs/') + ? pkgDistUrl(specifier) + : pathToFileURL(resolve(ROOT, dirname(templatePath), specifier)).href; + const loaded = (await import(url)) as Record; + modules.set(specifier, loaded); + imported = loaded; + } + + if (name in imported) { + context[name] = imported[name]; + } + } } /** * Evaluate the HTML template by replacing `${...}` expressions with * their computed values. * - * Uses `new Function()` to evaluate the template literal in a context - * that provides renderIcon, cn, SEEK_TIME, and all tailwind tokens. + * Uses `new Function()` to evaluate the template literal in a context that + * provides the template's named package imports and skin-specific values. */ function evaluateTemplate(templateBody: string, context: Record): string { const keys = Object.keys(context); @@ -821,38 +832,13 @@ async function processHtmlSkin(skin: HtmlSkinDef): Promise { validatePackageImports(source, skin.template); const templateBody = extractTemplateLiteral(source); - const cn = await loadCn(); - // Build context object with all the variables the template needs const context: Record = { - renderIcon: createRenderMediaIcon(skin.iconSet), - cn, SEEK_TIME: 10, }; - if (skin.style === 'tailwind') { - // Load the primary tailwind module - if (skin.tailwindModule) { - const tokens = await loadTailwindTokens(skin.tailwindModule); - Object.assign(context, tokens); - } - - // Check if the source imports from additional tailwind modules - const imports = parseImportedNames(source); - const loadedModules = new Set(); - if (skin.tailwindModule) loadedModules.add(skin.tailwindModule); - - for (const [name, mod] of imports) { - if (mod.includes('/tailwind/') && !loadedModules.has(mod)) { - loadedModules.add(mod); - const extraTokens = await loadTailwindTokens(mod); - // Only add names that aren't already in context - if (!(name in context) && name in extraTokens) { - context[name] = extraTokens[name]; - } - } - } - } + await loadImportedNames(source, templateBody, skin.template, context); + context.renderIcon = createRenderMediaIcon(skin.iconSet); let html = evaluateTemplate(templateBody, context); html = replaceSlots(html, getSkinMediaType(skin));