diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74f6f63..a54f4e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,12 @@ jobs: - name: Lint run: ruff check . + - name: Translations (placeholders, compiled catalogs up to date) + run: | + python scripts/check_translations.py + pybabel compile -d WEBUI/translations + git diff --exit-code -- WEBUI/translations + - name: Unit tests run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore index 53c5274..1416ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.env *.token *.cfg +!/babel.cfg *.key *.pem *.crt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 900f264..97d58e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,28 @@ python -m unittest discover -s tests -v - **Paths from clients** are validated against `config.BASE_DIR` (`_is_direct_child`, `safe_join`) before touching the file system. +## Translations + +The UI source language is English; catalogs live in `WEBUI/translations//LC_MESSAGES/messages.po`. + +- In templates use `{{ _('Text') }}`, in Python `gettext("Text")` (or `lazy_gettext` for + module-level constants; `_` is a throwaway variable in `webui.py`), in static JS + `svT('Text')` and register the string in `JS_MESSAGES` in `WEBUI/i18n.py`. +- Placeholders are named: `_('%(count)s VODs', count=n)`. A literal percent sign is `%%`. +- After changing strings, refresh and compile the catalogs: + +```bash +pybabel extract -F babel.cfg -k lazy_gettext -k N_ --no-location --sort-output -o WEBUI/translations/messages.pot . +pybabel update -i WEBUI/translations/messages.pot -d WEBUI/translations --no-fuzzy-matching +pybabel compile -d WEBUI/translations +python scripts/check_translations.py +``` + +- Improving a translation: edit the `msgstr` in the locale's `messages.po`, then compile. + Commit both the `.po` and the `.mo` file; CI checks that they match. +- Legal texts (`content/agb.md`, `content/datenschutz.md`) are German originals with English + translations (`*.en.md`); other languages show the English version. + ## Pull requests - One topic per pull request; small, focused commits. diff --git a/README.md b/README.md index 436a583..e0ac286 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,11 @@ a web interface where you can replay a stream with its chat, cut clips and manage who may watch what. It runs on a single Linux server; video delivery is handed to Nginx so the Python process stays small. -> The web interface is currently available in **German only**. +**Languages:** English, Deutsch, Français, Español, Italiano, Português (Brasil), +Nederlands, Polski, Türkçe, 日本語, 简体中文 and 한국어. The interface follows the +browser language and can be switched on the login page and in the sidebar. +Translations other than English and German were produced without native-speaker +review; corrections are very welcome (see [CONTRIBUTING.md](CONTRIBUTING.md#translations)). ## Screenshots diff --git a/WEBUI/i18n.py b/WEBUI/i18n.py new file mode 100644 index 0000000..71cabda --- /dev/null +++ b/WEBUI/i18n.py @@ -0,0 +1,168 @@ +"""UI language selection and translation setup (Flask-Babel / gettext). + +Source strings are English. A request's language is, in order: the language the +user picked (``sv_lang`` cookie), the best match for the browser's +``Accept-Language`` header, then English. +""" + +from __future__ import annotations + +import os + +from flask import request +from flask_babel import Babel, get_locale, gettext + +LANG_COOKIE = "sv_lang" +DEFAULT_LOCALE = "en" +TRANSLATIONS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "translations") + +# Babel locale id -> (BCP 47 tag for , native name for the switcher) +LOCALES = { + "en": ("en", "English"), + "de": ("de", "Deutsch"), + "fr": ("fr", "Français"), + "es": ("es", "Español"), + "it": ("it", "Italiano"), + "pt_BR": ("pt-BR", "Português (Brasil)"), + "nl": ("nl", "Nederlands"), + "pl": ("pl", "Polski"), + "tr": ("tr", "Türkçe"), + "ja": ("ja", "日本語"), + "zh_Hans": ("zh-Hans", "简体中文"), + "ko": ("ko", "한국어"), +} + +# Accept-Language tags (lower-case) -> Babel locale id. Plain "pt" and "zh" +# fall back to the only variant we ship. +_TAG_TO_LOCALE = { + "en": "en", "de": "de", "fr": "fr", "es": "es", "it": "it", + "pt-br": "pt_BR", "pt": "pt_BR", "nl": "nl", "pl": "pl", "tr": "tr", + "ja": "ja", "ko": "ko", + "zh-hans": "zh_Hans", "zh-cn": "zh_Hans", "zh-sg": "zh_Hans", "zh": "zh_Hans", +} + + +def normalize_locale(value): + """Map a cookie value or language tag to a supported Babel locale id, else None.""" + raw = str(value or "").strip() + if raw in LOCALES: + return raw + return _TAG_TO_LOCALE.get(raw.replace("_", "-").lower()) + + +def locale_from_accept_language(accept_languages): + """Best supported locale for a werkzeug ``LanguageAccept`` value, else None. + + Tags are tried in the client's quality order; a region the app does not ship + (e.g. ``de-AT``) falls back to its base language. + """ + for tag, _quality in accept_languages: + locale = normalize_locale(tag) + if locale is None and "-" in tag: + locale = normalize_locale(tag.split("-", 1)[0]) + if locale: + return locale + return None + + +def select_locale(): + chosen = normalize_locale(request.cookies.get(LANG_COOKIE)) + if chosen: + return chosen + return locale_from_accept_language(request.accept_languages) or DEFAULT_LOCALE + + +def current_locale_id(): + locale = get_locale() + if locale is None: + return DEFAULT_LOCALE + ident = str(locale) + if ident.startswith("zh"): + return "zh_Hans" + return ident if ident in LOCALES else DEFAULT_LOCALE + + +def N_(text): + """Mark a string for extraction only (pybabel -k N_); translated at runtime.""" + return text + + +# Strings used by static JavaScript. JS looks them up with svT("English text"), +# which returns the translation from window.SV_I18N or the English text itself. +# Add every string a static .js file shows to the user here. +JS_MESSAGES = ( + N_("Points"), + N_("Redeemed “%(title)s”"), + N_("Page %(page)s / %(pages)s (%(count)s VODs)"), + N_("Page %(page)s of %(pages)s"), + N_("%(count)s VODs in quick access"), + N_("Page %(page)s / %(total)s"), + N_("%(n)s new VODs available"), + N_("1 new VOD available"), + N_("Watch now"), + N_("Open clip"), + N_("Pause preview"), + N_("Play preview"), + N_("Buffer is still short — the clip window grows with it."), + N_("Discard clip? Unsaved changes will be lost."), + N_("✓ CLIP SAVED"), + N_("Clip saved."), + N_("✕ CLIP FAILED"), + N_("Clip failed."), + N_("Clip is taking too long."), + N_("Creating clip…"), + N_("Could not load status."), + N_("Clip URL is missing on this page."), + N_("Clip is already starting…"), + N_("Could not start clip."), + N_("Clip created."), + N_("Could not create clip."), + N_("Invalid server response (%(status)s)."), + N_("Saved."), + N_("Archive"), + N_("Exit fullscreen"), + N_("Fullscreen"), + N_("Collapse chat"), + N_("Expand chat"), + N_("Resume playback"), + N_("Pause playback"), + N_("Play"), + N_("Pause"), + N_("Unmute"), + N_("Mute"), + N_("Muted"), + N_("Sound"), + N_("Photo viewer"), + N_("Image controls"), + N_("Zoom out"), + N_("Zoom in"), + N_("Close"), + N_("Drive quota active — do not press Play again (it extends the block). Seek back into the already loaded part, or wait about %(minutes)s min."), + N_("Video player controls"), + N_("Play / pause"), + N_("Progress"), + N_("Volume"), + N_("Start playback"), +) + + +def js_catalog(): + """English source -> translation for the current request's language.""" + return {text: gettext(text) for text in JS_MESSAGES} + + +def template_context(): + locale_id = current_locale_id() + return { + "current_locale": locale_id, + "html_lang": LOCALES[locale_id][0], + "available_locales": [(key, name) for key, (_tag, name) in LOCALES.items()], + "js_i18n": js_catalog, + } + + +def init_app(app): + app.config.setdefault("BABEL_DEFAULT_LOCALE", DEFAULT_LOCALE) + app.config.setdefault("BABEL_TRANSLATION_DIRECTORIES", TRANSLATIONS_DIR) + Babel(app, locale_selector=select_locale) + app.context_processor(template_context) diff --git a/WEBUI/static/chat_emotes.js b/WEBUI/static/chat_emotes.js index 499d63f..ce92944 100644 --- a/WEBUI/static/chat_emotes.js +++ b/WEBUI/static/chat_emotes.js @@ -1,5 +1,6 @@ (function () { 'use strict'; + var svT = window.svT || function (s) { return s; }; const nativeIdPattern = /^[A-Za-z0-9_-]{1,64}$/; const badgePartPattern = /^[A-Za-z0-9_-]{1,64}$/; @@ -463,8 +464,9 @@ if (!reward || typeof reward !== 'object') return ''; const title = String(reward.title || 'Channel Points').trim() || 'Channel Points'; const cost = Number(reward.cost); - let html = 'Punkte '; - html += `„${escapeHtml(title)}“ eingelöst`; + let html = '' + escapeHtml(svT('Points')) + ' '; + const redeemed = escapeHtml(svT('Redeemed “%(title)s”')).replace('%(title)s', () => escapeHtml(title)); + html += `${redeemed}`; if (Number.isFinite(cost) && cost > 0) { html += ` ${escapeHtml(String(cost))}`; } diff --git a/WEBUI/static/css/tube_design_system.css b/WEBUI/static/css/tube_design_system.css index 208c487..a66f19d 100644 --- a/WEBUI/static/css/tube_design_system.css +++ b/WEBUI/static/css/tube_design_system.css @@ -4867,3 +4867,32 @@ body.page-mobile-streamer .ns-pagination .pagination-page.is-current { animation: vodCardPulse 1.2s ease-out; border-color: rgba(77, 99, 255, 0.7) !important; } + + +/* Language switcher (login page, sidebar footer) */ +.ns-lang-switch { + display: flex; + justify-content: center; + margin: 0; +} + +.ns-lang-switch select { + min-height: 44px; + max-width: 100%; + padding: 0 32px 0 12px; + border: 1px solid var(--nebula-border, #26344F); + border-radius: 8px; + background: rgba(11, 18, 32, 0.8); + color: var(--nebula-text-secondary, #A2AEC8); + font-size: 13px; + cursor: pointer; +} + +.ns-lang-switch select:focus-visible { + outline: 2px solid #4D63FF; + outline-offset: 2px; +} + +body.page-login .nebula-login-lang { + margin-top: 16px; +} \ No newline at end of file diff --git a/WEBUI/static/dashboard_live.js b/WEBUI/static/dashboard_live.js index 9a55768..868ce63 100644 --- a/WEBUI/static/dashboard_live.js +++ b/WEBUI/static/dashboard_live.js @@ -4,6 +4,7 @@ */ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; var pollTimer = null; var isFetching = false; @@ -82,17 +83,24 @@ // Desktop inline header var headerCount = document.querySelector(".ns-pagination-inline span"); if (headerCount) { - headerCount.innerHTML = 'Seite ' + data.page + ' / ' + data.total_pages + ' (' + data.total_count + ' VODs)'; + headerCount.innerHTML = svT('Page %(page)s / %(pages)s (%(count)s VODs)') + .replace('%(page)s', '' + data.page + '') + .replace('%(pages)s', '' + data.total_pages + '') + .replace('%(count)s', data.total_count); } // Desktop bottom nav var bottomNavInfo = document.querySelector(".ns-pagination-bottom .ns-pagination-info"); if (bottomNavInfo) { - bottomNavInfo.innerHTML = 'Seite ' + data.page + ' von ' + data.total_pages + ' • ' + data.total_count + ' VODs im Schnellzugriff'; + bottomNavInfo.innerHTML = svT('Page %(page)s of %(pages)s') + .replace('%(page)s', '' + data.page + '') + .replace('%(pages)s', '' + data.total_pages + '') + + ' • ' + svT('%(count)s VODs in quick access').replace('%(count)s', data.total_count); } // Mobile header counter - var mobileCounter = document.querySelector("section > div > span:nth-child(2)"); - if (mobileCounter && mobileCounter.textContent.indexOf("Seite") !== -1) { - mobileCounter.textContent = 'Seite ' + data.page + ' / ' + data.total_pages; + var mobileCounter = document.querySelector("[data-vod-page-counter]"); + if (mobileCounter) { + mobileCounter.textContent = svT('Page %(page)s / %(total)s') + .replace('%(page)s', data.page).replace('%(total)s', data.total_pages); } } @@ -106,7 +114,7 @@ var pill = document.createElement("div"); pill.id = "ns-vod-update-pill"; pill.style.cssText = "position: sticky; top: 12px; z-index: 100; margin: 0 auto 14px auto; max-width: 320px; background: linear-gradient(135deg, #4D63FF 0%, #754DFF 100%); color: #FFF; padding: 10px 18px; border-radius: 20px; font-size: 13px; font-weight: 600; text-align: center; box-shadow: 0 4px 16px rgba(77,99,255,0.4); cursor: pointer; transition: transform 0.2s ease, opacity 0.2s ease;"; - pill.innerHTML = '✨ ' + (newCount > 1 ? newCount + ' neue VODs' : '1 neues VOD') + ' verfügbar • Jetzt ansehen'; + pill.innerHTML = '✨ ' + (newCount > 1 ? svT('%(n)s new VODs available').replace('%(n)s', newCount) : svT('1 new VOD available')) + ' • ' + svT('Watch now'); pill.addEventListener("click", function () { window.location.href = "/?page=1"; diff --git a/WEBUI/static/live_clip_overlay.js b/WEBUI/static/live_clip_overlay.js index a84831a..8675521 100644 --- a/WEBUI/static/live_clip_overlay.js +++ b/WEBUI/static/live_clip_overlay.js @@ -1,5 +1,6 @@ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; var CLIP_DEFAULT_DURATION = 30; var CLIP_MAX_DURATION = 300; @@ -106,7 +107,7 @@ if (linkUrl) { link.hidden = false; link.href = linkUrl; - link.textContent = "Clip öffnen"; + link.textContent = svT("Open clip"); } else { link.hidden = true; link.removeAttribute("href"); @@ -183,7 +184,7 @@ clipPreviewToggle.innerHTML = isPlaying ? '' : ''; - clipPreviewToggle.title = isPlaying ? "Vorschau pausieren" : "Vorschau starten"; + clipPreviewToggle.title = isPlaying ? svT("Pause preview") : svT("Play preview"); clipPreviewToggle.setAttribute("aria-label", clipPreviewToggle.title); } @@ -437,7 +438,7 @@ clipResult.hidden = true; clipResult.replaceChildren(); } - setClipStatus(maxSeconds < 5 ? "Puffer noch kurz — das Clip-Fenster wächst mit." : "", ""); + setClipStatus(maxSeconds < 5 ? svT("Buffer is still short — the clip window grows with it.") : "", ""); setClipRange(nextStart, nextEnd, { dirty: false, seekPreview: false }); clipOverlay.hidden = false; clipOverlay.setAttribute("aria-hidden", "false"); @@ -465,7 +466,7 @@ function closeClipOverlay(force) { if (!clipOverlay || clipOverlay.hidden) return; if (!force && clipHasUnsavedChanges()) { - if (!window.confirm("Clip verwerfen? Nicht gespeicherte Änderungen gehen verloren.")) return; + if (!window.confirm(svT("Discard clip? Unsaved changes will be lost."))) return; } pauseClipPreview(); clearSeekRetry(); @@ -508,21 +509,21 @@ var data = result.data || {}; var status = data.status || ""; if (status === "done") { - showToast("success", "✓ CLIP GESPEICHERT", data.message || "Clip gespeichert.", data.clip_url || ""); + showToast("success", svT("✓ CLIP SAVED"), data.message || svT("Clip saved."), data.clip_url || ""); return; } if (status === "error" || !result.okHttp) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", data.message || data.error || "Clip fehlgeschlagen."); + showToast("error", svT("✕ CLIP FAILED"), data.message || data.error || svT("Clip failed.")); return; } if (Date.now() - started > POLL_TIMEOUT_MS) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Clip dauert zu lange."); + showToast("error", svT("✕ CLIP FAILED"), svT("Clip is taking too long.")); return; } - showToast("pending", "● CLIP", data.message || "Clip wird erstellt..."); + showToast("pending", "● CLIP", data.message || svT("Creating clip…")); pollTimer = window.setTimeout(tick, POLL_MS); }).catch(function () { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Status konnte nicht geladen werden."); + showToast("error", svT("✕ CLIP FAILED"), svT("Could not load status.")); }); }; tick(); @@ -530,16 +531,16 @@ function triggerInstantClip() { if (!instantUrl) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Clip-URL fehlt auf dieser Seite."); + showToast("error", svT("✕ CLIP FAILED"), svT("Clip URL is missing on this page.")); return; } var now = Date.now(); if (now - lastInstantAt < CLIENT_DEBOUNCE_MS) { - showToast("pending", "● CLIP", "Clip wird bereits gestartet…"); + showToast("pending", "● CLIP", svT("Clip is already starting…")); return; } lastInstantAt = now; - showToast("pending", "● CLIP", "Clip wird erstellt..."); + showToast("pending", "● CLIP", svT("Creating clip…")); var body = new FormData(); body.set("csrf_token", csrfToken()); body.set("source", source); @@ -561,24 +562,24 @@ }).catch(function () { return { okHttp: response.ok, - data: { ok: false, status: "error", message: "Ungültige Server-Antwort (" + response.status + ")." } + data: { ok: false, status: "error", message: svT("Invalid server response (%(status)s).").replace("%(status)s", response.status) } }; }); }).then(function (result) { var data = result.data || {}; if (data.status === "error" || data.ok === false) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", data.message || data.error || "Clip fehlgeschlagen."); + showToast("error", svT("✕ CLIP FAILED"), data.message || data.error || svT("Clip failed.")); return; } var jobId = data.job_id || data.id; if (!jobId) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", data.message || "Clip fehlgeschlagen."); + showToast("error", svT("✕ CLIP FAILED"), data.message || svT("Clip failed.")); return; } - showToast("pending", "● CLIP", data.message || "Clip wird erstellt..."); + showToast("pending", "● CLIP", data.message || svT("Creating clip…")); pollJob(jobId); }).catch(function () { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Clip konnte nicht gestartet werden."); + showToast("error", svT("✕ CLIP FAILED"), svT("Could not start clip.")); }); } @@ -660,7 +661,7 @@ event.preventDefault(); clipSubmitting = true; updateClipUi({}); - setClipStatus("Clip wird erstellt...", "pending"); + setClipStatus(svT("Creating clip…"), "pending"); if (clipResult) { clipResult.hidden = true; clipResult.replaceChildren(); @@ -674,12 +675,12 @@ clipInitialStart = clipStart; clipInitialEnd = clipEnd; clipInitialTitle = ((clipTitleInput && clipTitleInput.value) || "").trim(); - setClipStatus(data.message || "Clip erstellt.", "success"); + setClipStatus(data.message || svT("Clip created."), "success"); if (clipResult) { var link = document.createElement("a"); link.href = data.clip_url || "#"; - link.textContent = "Clip öffnen"; - clipResult.replaceChildren(document.createTextNode("Gespeichert. "), link); + link.textContent = svT("Open clip"); + clipResult.replaceChildren(document.createTextNode(svT("Saved.") + " "), link); clipResult.hidden = false; } } @@ -704,21 +705,21 @@ return; } if (status === "error" || !result.okHttp) { - setClipStatus(data.message || data.error || "Clip konnte nicht erstellt werden.", "error"); + setClipStatus(data.message || data.error || svT("Could not create clip."), "error"); clipSubmitting = false; updateClipUi({}); return; } if (Date.now() - started > POLL_TIMEOUT_MS) { - setClipStatus("Clip dauert zu lange.", "error"); + setClipStatus(svT("Clip is taking too long."), "error"); clipSubmitting = false; updateClipUi({}); return; } - setClipStatus(data.message || "Clip wird erstellt...", "pending"); + setClipStatus(data.message || svT("Creating clip…"), "pending"); pollTimer = window.setTimeout(tick, POLL_MS); }).catch(function () { - setClipStatus("Status konnte nicht geladen werden.", "error"); + setClipStatus(svT("Could not load status."), "error"); clipSubmitting = false; updateClipUi({}); }); @@ -736,14 +737,14 @@ }).then(function (response) { return response.json().then(function (data) { if (!response.ok || data.ok === false || data.status === "error") { - throw new Error(data.message || data.error || "Clip konnte nicht erstellt werden."); + throw new Error(data.message || data.error || svT("Could not create clip.")); } return data; }); }).then(function (data) { var jobId = data.job_id || data.id; if (jobId && data.status && data.status !== "done") { - setClipStatus(data.message || "Clip wird erstellt...", "pending"); + setClipStatus(data.message || svT("Creating clip…"), "pending"); pollOverlayJob(jobId); return; } @@ -751,7 +752,7 @@ clipSubmitting = false; updateClipUi({}); }).catch(function (error) { - setClipStatus((error && error.message) || "Clip konnte nicht erstellt werden.", "error"); + setClipStatus((error && error.message) || svT("Could not create clip."), "error"); clipSubmitting = false; updateClipUi({}); }); diff --git a/WEBUI/static/media_session.js b/WEBUI/static/media_session.js index af2fd61..02c2c19 100644 --- a/WEBUI/static/media_session.js +++ b/WEBUI/static/media_session.js @@ -1,4 +1,5 @@ (function () { + var svT = window.svT || function (s) { return s; }; function text(value, fallback) { const raw = value == null ? "" : String(value).trim(); return raw || fallback || ""; @@ -329,7 +330,7 @@ const artwork = text(data.mediaArtwork || (options && options.artwork), "/app-icon-512.png"); const title = text(data.mediaTitle || (options && options.title), document.title || "StreamVault"); const artist = text(data.mediaArtist || (options && options.artist), "StreamVault"); - const album = text(data.mediaAlbum || (options && options.album), isLive ? "Live" : "Archiv"); + const album = text(data.mediaAlbum || (options && options.album), isLive ? "Live" : svT("Archive")); try { navigator.mediaSession.metadata = new MediaMetadata({ diff --git a/WEBUI/static/mobile_landscape_chat.js b/WEBUI/static/mobile_landscape_chat.js index ceac0f6..cbf580c 100644 --- a/WEBUI/static/mobile_landscape_chat.js +++ b/WEBUI/static/mobile_landscape_chat.js @@ -1,5 +1,6 @@ (function (global) { "use strict"; + var svT = window.svT || function (s) { return s; }; const body = document.body; if (!body || !body.classList.contains("page-mobile-player")) return; @@ -57,8 +58,8 @@ if (fsBtn) { fsBtn.setAttribute("aria-pressed", forced ? "true" : "false"); - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); - fsBtn.title = forced ? "Vollbild beenden" : "Vollbild"; + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); + fsBtn.title = forced ? svT("Exit fullscreen") : svT("Fullscreen"); fsBtn.classList.toggle("is-active", forced); fsBtn.style.display = active ? "none" : ""; } @@ -76,9 +77,9 @@ chatBtn.setAttribute("aria-pressed", chatOpen ? "true" : "false"); chatBtn.setAttribute( "aria-label", - chatOpen ? "Chat einklappen" : "Chat ausklappen" + chatOpen ? svT("Collapse chat") : svT("Expand chat") ); - chatBtn.title = chatOpen ? "Chat einklappen" : "Chat ausklappen"; + chatBtn.title = chatOpen ? svT("Collapse chat") : svT("Expand chat"); } try { localStorage.setItem(KEY, chatOpen ? "1" : "0"); @@ -237,8 +238,8 @@ if (playPauseBtn) { const isPaused = vid.paused; playPauseBtn.classList.toggle("is-paused", isPaused); - playPauseBtn.setAttribute("aria-label", isPaused ? "Wiedergabe fortsetzen" : "Wiedergabe pausieren"); - playPauseBtn.title = isPaused ? "Wiedergabe fortsetzen" : "Wiedergabe pausieren"; + playPauseBtn.setAttribute("aria-label", isPaused ? svT("Resume playback") : svT("Pause playback")); + playPauseBtn.title = isPaused ? svT("Resume playback") : svT("Pause playback"); const playIcon = playPauseBtn.querySelector(".icon-play"); const pauseIcon = playPauseBtn.querySelector(".icon-pause"); const label = playPauseBtn.querySelector(".btn-label"); @@ -247,15 +248,15 @@ pauseIcon.style.display = isPaused ? "none" : "inline-block"; } if (label) { - label.textContent = isPaused ? "Play" : "Pause"; + label.textContent = isPaused ? svT("Play") : svT("Pause"); } } if (muteBtn) { const isMuted = vid.muted || vid.volume === 0; muteBtn.classList.toggle("is-muted", isMuted); - muteBtn.setAttribute("aria-label", isMuted ? "Ton einschalten" : "Stummschalten"); - muteBtn.title = isMuted ? "Ton einschalten" : "Stummschalten"; + muteBtn.setAttribute("aria-label", isMuted ? svT("Unmute") : svT("Mute")); + muteBtn.title = isMuted ? svT("Unmute") : svT("Mute"); const unmuteIcon = muteBtn.querySelector(".icon-unmute"); const muteIcon = muteBtn.querySelector(".icon-mute"); const label = muteBtn.querySelector(".btn-label"); @@ -264,7 +265,7 @@ muteIcon.style.display = isMuted ? "inline-block" : "none"; } if (label) { - label.textContent = isMuted ? "Stumm" : "Ton"; + label.textContent = isMuted ? svT("Muted") : svT("Sound"); } } } diff --git a/WEBUI/static/photo_viewer.js b/WEBUI/static/photo_viewer.js index ed6f31b..86b00ce 100644 --- a/WEBUI/static/photo_viewer.js +++ b/WEBUI/static/photo_viewer.js @@ -1,5 +1,6 @@ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; var MIN_ZOOM = 1; var MAX_ZOOM = 5; @@ -42,20 +43,20 @@ overlay.className = "ns-photo-viewer"; overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", "Fotoviewer"); + overlay.setAttribute("aria-label", svT("Photo viewer")); overlay.hidden = true; overlay.innerHTML = '
' + '
' + - '" + diff --git a/WEBUI/static/style.css b/WEBUI/static/style.css index 13b7b56..cce1314 100644 --- a/WEBUI/static/style.css +++ b/WEBUI/static/style.css @@ -376,7 +376,8 @@ body.page-admin .task-queue-panel { .task-queue-stats { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + /* Four across where they fit; long translated labels wrap to two rows on phones. */ + grid-template-columns: repeat(auto-fit, minmax(min(100%, 96px), 1fr)); gap: 8px; margin: 16px 0; } diff --git a/WEBUI/static/vod_debug.js b/WEBUI/static/vod_debug.js index 3053af7..abd6b87 100644 --- a/WEBUI/static/vod_debug.js +++ b/WEBUI/static/vod_debug.js @@ -1,4 +1,5 @@ (function () { + var svT = window.svT || function (s) { return s; }; var STORAGE_KEY = "streamvault_vod_debug"; var UI_KEY = "streamvault_vod_debug_ui"; var MAX_EVENTS = 500; @@ -228,18 +229,12 @@ var minutes = Math.max(15, Math.ceil((retryAfter || 3600) / 60)); var existing = document.getElementById("vodQuotaToast"); if (existing) { - existing.textContent = "Drive-Quota aktiv — nicht erneut Play drücken (verlängert die Sperre). " - + "Zurückspulen in den schon geladenen Teil, oder ca. " - + minutes - + " Min. warten."; + existing.textContent = svT("Drive quota active — do not press Play again (it extends the block). Seek back into the already loaded part, or wait about %(minutes)s min.").replace("%(minutes)s", minutes); return; } var el = document.createElement("div"); el.id = "vodQuotaToast"; - el.textContent = "Drive-Quota aktiv — nicht erneut Play drücken (verlängert die Sperre). " - + "Zurückspulen in den schon geladenen Teil, oder ca. " - + minutes - + " Min. warten."; + el.textContent = svT("Drive quota active — do not press Play again (it extends the block). Seek back into the already loaded part, or wait about %(minutes)s min.").replace("%(minutes)s", minutes); el.style.cssText = "position:fixed;left:50%;bottom:72px;transform:translateX(-50%);z-index:99998;max-width:min(520px,92vw);padding:10px 14px;border-radius:10px;background:rgba(120,20,20,.94);color:#fff;font:13px/1.4 system-ui,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.4)"; document.documentElement.appendChild(el); window.setTimeout(function () { diff --git a/WEBUI/static/vod_seek_hover.js b/WEBUI/static/vod_seek_hover.js index d888311..65911fc 100644 --- a/WEBUI/static/vod_seek_hover.js +++ b/WEBUI/static/vod_seek_hover.js @@ -6,6 +6,7 @@ */ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; function isFiniteNumber(v) { return typeof v === "number" && Number.isFinite(v); @@ -72,11 +73,11 @@ var bar = document.createElement("div"); bar.className = "vod-controlbar"; bar.setAttribute("role", "toolbar"); - bar.setAttribute("aria-label", "Videoplayer-Steuerung"); + bar.setAttribute("aria-label", svT("Video player controls")); bar.innerHTML = [ /* Play/Pause */ - '', @@ -84,7 +85,7 @@ '0:00 / --:--', /* Seekbar (flex-grows to fill available space) */ - '
', + '
', '
', '
', '
', @@ -94,13 +95,13 @@ '
', /* Volume */ - '', - '', + '', /* Fullscreen */ - '', ].join(""); @@ -205,7 +206,7 @@ function updatePlayBtn() { playBtn.innerHTML = video.paused ? ICON_PLAY : ICON_PAUSE; - playBtn.setAttribute("aria-label", video.paused ? "Wiedergabe" : "Pause"); + playBtn.setAttribute("aria-label", video.paused ? svT("Start playback") : svT("Pause")); if (video.paused) { showControls(); clearTimeout(hideTimer); @@ -240,7 +241,7 @@ window.StreamVaultMobileLandscape.toggle(); var forced = window.StreamVaultMobileLandscape.isForced(); fsBtn.innerHTML = forced ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); return; } if (!document.fullscreenElement) { @@ -258,17 +259,17 @@ && window.StreamVaultMobileLandscape) { var forced = window.StreamVaultMobileLandscape.isForced(); fsBtn.innerHTML = forced ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); return; } fsBtn.innerHTML = document.fullscreenElement ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", document.fullscreenElement ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", document.fullscreenElement ? svT("Exit fullscreen") : svT("Fullscreen")); }); function syncMobileFsIcon() { if (!window.StreamVaultMobileLandscape) return; var forced = window.StreamVaultMobileLandscape.isForced(); fsBtn.innerHTML = forced ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); } if (document.body) { document.body.addEventListener("streamvault:landscape-chat", syncMobileFsIcon); diff --git a/WEBUI/templates/admin.html b/WEBUI/templates/admin.html index fde3c5b..f3c8753 100644 --- a/WEBUI/templates/admin.html +++ b/WEBUI/templates/admin.html @@ -1,9 +1,9 @@ - + - Optionen & Systemsteuerung — StreamVault + {{ _('Options & system control') }} — StreamVault @@ -246,19 +246,19 @@
-

Optionen & Systemsteuerung

-
- Load {{ sys_load[0] }} - Frei {{ storage.free_str }} - Queue {{ task_queue.total_active }} +

{{ _('Options & system control') }}

+
+ {{ _('Load') }} {{ sys_load[0] }} + {{ _('Free') }} {{ storage.free_str }} + {{ _('Queue') }} {{ task_queue.total_active }}
-
@@ -266,58 +266,58 @@

Optionen & Systemsteuerung

{% if error %}
{{ error }}
{% endif %} {% if storage_alert and storage_alert.critical %}
- Speicher kritisch: {{ storage_alert.used_pct }}% belegt, nur noch {{ storage_alert.free_str }} frei. - {% if storage_alert.webhook_configured %}Webhook aktiv.{% else %}Webhook nicht konfiguriert.{% endif %} + {{ _('Storage critical: %(pct)s%% used, only %(free)s free.', pct=storage_alert.used_pct, free=storage_alert.free_str) }} + {% if storage_alert.webhook_configured %}{{ _('Webhook active.') }}{% else %}{{ _('Webhook not configured.') }}{% endif %}
{% endif %} -
- - - - +
+ + + +
-

System Status

-
Load Average (1m / 5m): {{ sys_load[0] }} / {{ sys_load[1] }}
-
Speicher (Total): {{ storage.total_str }}
-
Speicher (Frei): {{ storage.free_str }}
+

{{ _('System status') }}

+
{{ _('Load average (1m / 5m):') }} {{ sys_load[0] }} / {{ sys_load[1] }}
+
{{ _('Storage (total):') }} {{ storage.total_str }}
+
{{ _('Storage (free):') }} {{ storage.free_str }}
- +
-

Aufgaben-Queue

- {{ task_queue.total_active }} aktiv +

{{ _('Task queue') }}

+ {{ _('%(count)s active', count=task_queue.total_active) }}
{% if task_queue.error %}
{{ task_queue.error }}
{% else %}
- Wartend {{ task_queue.counts.pending or 0 }} - Läuft {{ task_queue.counts.running or 0 }} - Fertig {{ task_queue.counts.completed or 0 }} - Fehler {{ task_queue.counts.failed or 0 }} + {{ _('Pending') }} {{ task_queue.counts.pending or 0 }} + {{ _('Running') }} {{ task_queue.counts.running or 0 }} + {{ _('Done') }} {{ task_queue.counts.completed or 0 }} + {{ _('Failed') }} {{ task_queue.counts.failed or 0 }}
{% for task in task_queue.tasks[:8] %}
{{ task.status }} {{ task.type }} - {{ task.label or '-' }}{% if task.error %}Fehler: {{ task.error }}{% elif task.attempts > 1 %}{{ task.attempts }} Versuche{% endif %} + {{ task.label or '-' }}{% if task.error %}{{ _('Error: %(error)s', error=task.error) }}{% elif task.attempts > 1 %}{{ ngettext('%(num)d attempt', '%(num)d attempts', task.attempts) }}{% endif %} {{ task.age }}
{% else %} -
Keine Jobs in der Queue.
+
{{ _('No jobs in the queue.') }}
{% endfor %}
{% endif %} @@ -325,28 +325,28 @@

Aufgaben-Queue

-

Recording Health

- {{ recording_health|length }} Kanäle +

{{ _('Recording health') }}

+ {{ ngettext('%(num)d channel', '%(num)d channels', recording_health|length) }}
{% if recent_task_errors %}
-

Letzte Task-Fehler

+

{{ _('Recent task errors') }}

{{ recent_task_errors|length }}
@@ -367,23 +367,23 @@

Letzte Task-Fehler

-

Wartungsmodus & Bereichssteuerung

+

{{ _('Maintenance mode & area control') }}

- Steuere den globalen Wartungsmodus oder sperre gezielt einzelne Plattformbereiche. + {{ _('Control the global maintenance mode or lock individual platform areas.') }}

- Globaler Wartungsmodus: + {{ _('Global maintenance mode:') }} {% if maintenance.enabled %} - AKTIV (Gesperrt) + {{ _('ACTIVE (locked)') }} {% else %} - AUS (Normalbetrieb) + {{ _('OFF (normal operation)') }} {% endif %}
{% if maintenance.updated_label %}
- Zuletzt geändert: + {{ _('Last changed:') }} {{ maintenance.updated_label }}{% if maintenance.updated_by %} ({{ maintenance.updated_by }}){% endif %}
{% endif %} @@ -393,11 +393,11 @@

Wartungsmodus & Bereichssteuerung

{% else %} @@ -405,11 +405,11 @@

Wartungsmodus & Bereichssteuerung

{% endif %} @@ -420,56 +420,56 @@

Wartungsmodus & Bereichssteuerung

- - + +
-

Clip-Einstellungen

-

Instant-Clip Parameter und Hotkey im Web-Player.

+

{{ _('Clip settings') }}

+

{{ _('Instant clip parameters and hotkey in the web player.') }}

- +
- +
- +
- +
- +
@@ -479,22 +479,22 @@

Clip-Einstellungen

- Rollenmodell: Vollzugriff (admin), Archiv-Viewer (viewer), Nur-Live (live_only) & Discord-Member. + {{ _('Role model: full access (admin), archive viewer (viewer), live only (live_only) & Discord member.') }} - Benutzer & Gruppen - Traffic-Übersicht + {{ _('Users & groups') }} + {{ _('Traffic overview') }}