From c96b9d0101325fea25d74b216e1feb9dc8b3a65f Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Tue, 20 Oct 2026 14:25:03 +0300 Subject: [PATCH] feat: a language select on the player page and in the app Both played with H5P's own strings in English, the page's language. The player page now has a Language select beside the Trusted/Untrusted switch, English by default; the app has one in its menu, defaulting to the first of the browser's languages the runtime has and remembering the learner's pick on the device. A change plays the current package again, in the app where the learner was. The list is the runtime's own (packages/runtime/l10n/index.json), so a language its weekly refresh adds appears with no change here. Each is named in its own language where the browser has that language's data, and in English otherwise: Chromium has none for Albanian or Armenian and answered in the browser's language. Closes #53. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 ++++- apps/demo/app/app.css | 3 ++ apps/demo/app/app.js | 31 ++++++++++++++++ apps/demo/app/index.html | 4 ++ apps/demo/demo/languages.js | 67 ++++++++++++++++++++++++++++++++++ apps/demo/demo/player-page.css | 21 +++++++++++ apps/demo/demo/player-page.js | 20 +++++++++- apps/demo/index.html | 8 ++-- apps/demo/tests/app.test.ts | 46 +++++++++++++++++++++-- 9 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 apps/demo/demo/languages.js diff --git a/AGENTS.md b/AGENTS.md index 2cb13ce..d3cdb15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,8 @@ apps/demo/ the demo app: the dev server for the whole repository, demo/page-common.js what the player page, the cmi5 page and the app share: the message box, the log, `foreignHost` (whether a package is another site's) and the trust prompt, one copy since 2026-10-15; + demo/languages.js the language select's list for the player page + and the app, read from the runtime's l10n/index.json (#53); demo/theme.js the light/dark switch, a classic script every page loads in its head before the stylesheet, since the CSP allows no inline script and a pinned theme has to be on before the @@ -806,7 +808,13 @@ element, and the element acts. That relay is why `frame-document.ts` has a `mess page is not in English, Thai included, which finds nothing in it. A script rather than JSON because the frame's `script-src` names the runtime's origin and its `connect-src` does not. A `runtime` without `l10nJs`, or a runtime directory without the file (its 404 ignored: no - `data-h5p`), leaves the frame English. The embed page takes `&lang=`. + `data-h5p`), leaves the frame English. The embed page takes `&lang=`. The player page and the + app have a language select (2026-10-20, #53), filled from the runtime's `l10n/index.json`, each + language named in its own tongue only where the browser has that language's data — Chromium has + none for Albanian, Armenian and a dozen more, and answered in the browser's language — and set + as the element's `lang`, a change playing the current package again. The page starts on + English, its own; the app on the first of the browser's languages the runtime has, and keeps + the learner's pick in `localStorage`. The translations are Moodle's: H5P publishes none for the core, each platform translates it itself, and Moodle's translators (AMOS) are the most complete, under GPL-3.0-or-later; Lumi's set diff --git a/apps/demo/app/app.css b/apps/demo/app/app.css index c091d83..03d66b0 100644 --- a/apps/demo/app/app.css +++ b/apps/demo/app/app.css @@ -118,6 +118,9 @@ @supports not selector(:popover-open) { .app #menu-button { display: none; } } .app .menu button { justify-content: flex-start; min-height: 2.5rem; border: 0; background: transparent; font-weight: 400; } .app .menu button:hover { background: var(--surface-2); } +/* The select keeps the shared sheet's width, or the menu would grow to the longest language's name. */ +.app .menu-language { display: grid; gap: 0.35rem; margin-top: 0.25rem; padding: 0.6rem 0.75rem 0; border-top: 1px solid var(--line); } +.app .menu-note { max-width: 16rem; margin: 0; padding: 0.3rem 0.75rem 0.5rem; color: var(--muted); font-size: 0.8rem; line-height: 1.4; } /* ---------------------------------------------------------------- the screens */ diff --git a/apps/demo/app/app.js b/apps/demo/app/app.js index 7d66bdb..48d69f6 100644 --- a/apps/demo/app/app.js +++ b/apps/demo/app/app.js @@ -13,6 +13,7 @@ */ import { foreignHost, messageIn, trustPrompt } from '../demo/page-common.js' +import { fillLanguages, offered, preferredLanguage } from '../demo/languages.js' import { pack } from '@missing-elements/h5p-libraries' import librariesNotice from '@missing-elements/h5p-libraries/libraries.txt?url' import { copyOf, fileKey, forget, forgetAll, linkKey, recentFiles, remember } from './recent.js' @@ -338,6 +339,36 @@ let current = null */ let opens = 0 +/** + * The language of H5P's own strings, from the menu: by default the first of the browser's + * languages the runtime has, else English, and once the learner picks one, that, remembered on + * this device. The element reads its `lang` when a load starts, so a change opens the current + * file again, where the learner was: the content saves its state as its document goes. Set here, + * before anything opens, a launch's file and a link's `?src=` included. + */ +const LANGUAGE_KEY = 'h5p-app-language' +const languageSelect = $('#language') +{ + let kept = null + try { + kept = localStorage.getItem(LANGUAGE_KEY) + } catch { + // Storage refused, as in some private windows: the browser's language, every time. + } + const browserLanguages = navigator.languages?.length ? navigator.languages : [navigator.language] + fillLanguages(languageSelect, kept && offered(kept) ? kept : preferredLanguage(browserLanguages)) + player.setAttribute('lang', languageSelect.value) +} +languageSelect.addEventListener('change', () => { + try { + localStorage.setItem(LANGUAGE_KEY, languageSelect.value) + } catch { + // Not remembered: it holds for this visit. + } + player.setAttribute('lang', languageSelect.value) + void reopen?.(player.getAttribute('libraries')) +}) + /** A file's name as a title until the package says its own: without the extension. */ const fileTitle = (name) => name.replace(/\.h5p$/i, '') || name diff --git a/apps/demo/app/index.html b/apps/demo/app/index.html index d503968..8c41f8b 100644 --- a/apps/demo/app/index.html +++ b/apps/demo/app/index.html @@ -73,6 +73,9 @@ + + +
@@ -149,6 +152,7 @@

Open a file

  • The files you opened are listed under it, the latest first. Choose one to open it again where you left off.
  • Installed from Chrome or Edge, the app opens .h5p files from your file manager: double-click one, or use Open with. Safari and iOS do not hand files to installed apps; use Choose a file there.
  • A link to /app/?src= and a package's address opens that package in the app, for a teacher sending a course by link.
  • +
  • H5P's own buttons and dialogs — full screen, the licence details, the notice when a file has changed — are in your browser's language where the app has it, and in English otherwise. Language in the menu changes it. The content's own text, its questions and buttons, stays in the language its author wrote it in.
  • What works offline

    diff --git a/apps/demo/demo/languages.js b/apps/demo/demo/languages.js new file mode 100644 index 0000000..71d5deb --- /dev/null +++ b/apps/demo/demo/languages.js @@ -0,0 +1,67 @@ +/** + * The languages the runtime has H5P's own strings in — the fullscreen button, Rights of use, + * Reuse, Embed, the licence names, the "content has changed" dialog — for the language select on + * the player page and in the app. Read from the runtime's own index, so a language its weekly + * refresh adds appears here with no change. English is the core's own and not in the index. What + * a select sets is the element's `lang`; which strings a tag gets is the runtime's to decide. + */ + +import { aliases, languages } from '../../../packages/runtime/l10n/index.json' + +/** + * A tag's name in its own language, capitalised as a list of languages shows it; `null` where the + * browser has none. A browser without a language's data names it in its own language instead — + * Chromium, asked in German for Albanian's own name, answered "Albanisch" — so a name is taken + * only from data for the language itself. + */ +function ownName(tag) { + try { + const names = new Intl.DisplayNames([tag], { type: 'language', fallback: 'none' }) + if (names.resolvedOptions().locale.split('-')[0] !== tag.split('-')[0]) return null + const name = names.of(tag) + return name ? name[0].toLocaleUpperCase(tag) + name.slice(1) : null + } catch { + return null + } +} + +/** + * Each language as `{ tag, label }`, the label its own name and the English one ("Deutsch — + * German"), in the order of their own names, which is where a speaker looks for theirs. + */ +export const LANGUAGES = [['en', 'English'], ...Object.entries(languages).map(([tag, { name }]) => [tag, name.replace(/^(.+) - (.+)$/, '$1 ($2)')])] + .map(([tag, english]) => { + const own = ownName(tag) + const label = own && own.toLowerCase() !== english.toLowerCase() ? `${own} — ${english}` : english + return { tag, label, order: own ?? english } + }) + .sort((a, b) => a.order.localeCompare(b.order, 'en', { sensitivity: 'base' })) + .map(({ tag, label }) => ({ tag, label })) + +/** Whether the select offers `tag`, written as the runtime's index writes it. */ +export const offered = (tag) => LANGUAGES.some((language) => language.tag === tag) + +/** Fills `select` with the languages, `selected` chosen. */ +export function fillLanguages(select, selected) { + select.replaceChildren(...LANGUAGES.map(({ tag, label }) => new Option(label, tag, false, tag === selected))) + select.value = selected +} + +/** + * The first of `preferred`, the browser's languages with the most wanted first, that the runtime + * has: by its own aliases, and by dropping subtags from the end, as its lookup does; English when + * it has none of them. Only what the select starts on: the runtime looks the tag up itself. + */ +export function preferredLanguage(preferred) { + const tags = new Map(LANGUAGES.map(({ tag }) => [tag.toLowerCase(), tag])) + const aliased = new Map(Object.entries(aliases).map(([alias, tag]) => [alias.toLowerCase(), tag])) + for (const wanted of preferred) { + const subtags = String(wanted).replaceAll('_', '-').toLowerCase().split('-') + for (let length = subtags.length; length > 0; length--) { + const tag = subtags.slice(0, length).join('-') + const found = tags.get(tag) ?? aliased.get(tag) + if (found) return found + } + } + return 'en' +} diff --git a/apps/demo/demo/player-page.css b/apps/demo/demo/player-page.css index b4ad495..d62f6b8 100644 --- a/apps/demo/demo/player-page.css +++ b/apps/demo/demo/player-page.css @@ -502,6 +502,27 @@ label.file:has(input:focus-visible) { outline: 2px solid var(--accent); outline- .mode label:has(input:disabled) input { cursor: default; } .mode-note { flex: 1 1 20rem; margin: 0; color: var(--muted); font-size: 0.85rem; line-height: 1.45; } +/* The language of H5P's own strings, beside the modes and in the app's menu (demo/languages.js). */ +/* At most 16rem, and narrower where that does not fit. Sized by its longest name the select pushed + a phone's form past the screen's edge, and so did a fixed 16rem at 320 px, in all three engines: + a fixed width is also the narrowest a select will go. A percentage width is not. */ +.language { display: inline-flex; flex: 0 1 auto; align-items: center; gap: 0.5rem; min-width: 0; max-width: 100%; color: var(--muted); font-size: 0.875rem; font-weight: 500; } +.language select { + flex: 0 1 auto; + width: 100%; + min-width: 0; + max-width: 16rem; + min-height: 2.5rem; + padding: 0.25rem 0.6rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--fg); + font: inherit; + font-weight: 400; +} +.language select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + .samples { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.875rem 0 0; } .samples button { diff --git a/apps/demo/demo/player-page.js b/apps/demo/demo/player-page.js index d3f392e..9b67e3d 100644 --- a/apps/demo/demo/player-page.js +++ b/apps/demo/demo/player-page.js @@ -5,6 +5,7 @@ */ import { foreignHost, logInto, messageIn, trustPrompt } from './page-common.js' +import { fillLanguages } from './languages.js' import { pack } from '@missing-elements/h5p-libraries' const player = document.querySelector('h5p-player') @@ -18,6 +19,7 @@ const bar = document.querySelector('#progress') const message = document.querySelector('#message') const log = document.querySelector('#log') const refreshToggle = document.querySelector('#refresh') +const languageSelect = document.querySelector('#language') const confirmBox = document.querySelector('#confirm-src') const modeNote = document.querySelector('#mode-note') const modeTag = document.querySelector('#mode-tag') @@ -241,10 +243,12 @@ const REFRESH_NOTES = { on: ' Where the pack has a newer version of a library the package carries, that version plays.', off: ' The libraries the package carries play as exported.' } +/** What the language select changes, and what it cannot: the content's text is in the package. */ +const LANGUAGE_NOTE = " The language is that of H5P's own buttons and dialogs; the content's text stays as its author wrote it." const playsUntrusted = () => form.elements.mode.value === 'untrusted' const describeMode = () => { const mode = form.elements.mode.value - modeNote.textContent = NOTES[mode] + (mode === 'trusted' ? REFRESH_NOTES[refreshToggle.checked ? 'on' : 'off'] : '') + modeNote.textContent = NOTES[mode] + (mode === 'trusted' ? REFRESH_NOTES[refreshToggle.checked ? 'on' : 'off'] : '') + LANGUAGE_NOTE // Untrusted, none of the package's own libraries play, so there is nothing to refresh. refreshToggle.disabled = mode === 'untrusted' } @@ -254,12 +258,21 @@ const setMode = (mode) => { } setMode(form.elements.mode.value) +/** + * The language of H5P's own strings: English, the page's own, until the visitor picks another. + * The element reads its `lang` when a load starts, as it reads the modes. + */ +fillLanguages(languageSelect, 'en') + +/** Puts the form's choices on the element, for the load about to start; returns them for the log. */ const applyMode = () => { const untrusted = playsUntrusted() player.untrusted = untrusted if (refreshToggle.checked) player.removeAttribute('refresh-libraries') else player.setAttribute('refresh-libraries', 'off') - return untrusted ? ' (untrusted: none of its own code runs)' : refreshToggle.checked ? '' : ' (libraries as exported)' + player.setAttribute('lang', languageSelect.value) + const language = languageSelect.value === 'en' ? '' : ` (H5P's own strings: ${languageSelect.value})` + return (untrusted ? ' (untrusted: none of its own code runs)' : refreshToggle.checked ? '' : ' (libraries as exported)') + language } /** Plays a package at a URL, in the switch's mode. */ @@ -317,6 +330,9 @@ refreshToggle.addEventListener('change', () => { reload?.() }) +// And in another language: the fullscreen button, Rights of use and the licence names change. +languageSelect.addEventListener('change', () => reload?.()) + // A sample marked `data-untrusted` plays as data against this site's bundle: a content-only // export, as h5p.com and h5p.org make them, the way a site plays a package it did not make. The // others play in whichever mode the switch is in. diff --git a/apps/demo/index.html b/apps/demo/index.html index 1f8e327..7ddde04 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -114,9 +114,9 @@

    Self-hosted H5P in one web component

    Choose file - +
    @@ -125,6 +125,8 @@

    Self-hosted H5P in one web component

    + +

    The package's own scripts run on this site. What it lacks comes from this site's library pack.

    diff --git a/apps/demo/tests/app.test.ts b/apps/demo/tests/app.test.ts index ac0cae2..3b51568 100644 --- a/apps/demo/tests/app.test.ts +++ b/apps/demo/tests/app.test.ts @@ -73,10 +73,11 @@ const nextReady = (page: Page) => * unless a test brings its own: Playwright cannot answer it, and without it the app takes the path * Safari and Firefox take, the page's file input. `onDisk` opens it in a profile on disk, as a * browser window is (closed with `closePage`), and `installed` answers `display-mode` as an - * installed window would; see `profileOnDisk`. + * installed window would; see `profileOnDisk`. `locale` is the browser's language, the + * `navigator.languages` the app picks its default language from. */ -async function openApp(path = '/app/', { keepPicker = false, onDisk = false, installed = false } = {}) { - const page = onDisk ? await profileOnDisk({ installed }) : await browser.newPage({ viewport: VIEWPORT }) +async function openApp(path = '/app/', { keepPicker = false, onDisk = false, installed = false, locale = undefined as string | undefined } = {}) { + const page = onDisk ? await profileOnDisk({ installed }) : await browser.newPage({ viewport: VIEWPORT, locale }) const errors: string[] = [] page.on('pageerror', (error) => errors.push(error.message)) if (!keepPicker) { @@ -517,6 +518,45 @@ it('has a menu and an About dialog that Escape closes', async () => { await page.close() }) +it('shows H5P’s own strings in the browser’s language, or the one picked in the menu, and remembers the pick', async () => { + const { page, errors } = await openApp('/app/', { locale: 'fr-FR' }) + /** The language the element was given, the one the menu shows, and the frame's fullscreen label. */ + const language = () => + page.evaluate(() => { + const player = document.querySelector('h5p-player')! + const frame = player.shadowRoot!.querySelector('iframe')!.contentWindow as unknown as { H5P?: { t(key: string): string } } | null + return { + lang: player.getAttribute('lang'), + menu: document.querySelector('#language')!.value, + fullscreen: frame?.H5P?.t('fullscreen') ?? null + } + }) + + // French by default, from the browser's own language, which the runtime has. + const choosing = page.waitForEvent('filechooser') + await page.click('#open') + await (await choosing).setFiles(QUIZ) + await playerIs(page, 'ready') + expect(await language()).toEqual({ lang: 'fr', menu: 'fr', fullscreen: 'Plein écran' }) + const steps = await page.evaluate(() => history.length) + + // Picked in the menu: the open file plays again in German, on the same screen and history step. + await page.click('#menu-button') + const again = nextReady(page) + await page.selectOption('#language', 'de') + await again + expect(await language()).toEqual({ lang: 'de', menu: 'de', fullscreen: 'Vollbild' }) + expect(await seen(page)).toMatchObject({ screen: 'player', bar: 'Do you know what just happened?' }) + expect(await page.evaluate(() => history.length)).toBe(steps) + + // Remembered over the browser's own on the next visit. + await page.reload() + await workerChecked(page) + expect(await language()).toMatchObject({ lang: 'de', menu: 'de' }) + expect(errors).toEqual([]) + await page.close() +}) + it('plays a file dropped on it, and says what went wrong with a link that fails', async () => { const { page } = await openApp() await page.evaluate(async () => {