Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*.env
*.token
*.cfg
!/babel.cfg
*.key
*.pem
*.crt
Expand Down
22 changes: 22 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<locale>/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.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
168 changes: 168 additions & 0 deletions WEBUI/i18n.py
Original file line number Diff line number Diff line change
@@ -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 <html lang>, 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)
6 changes: 4 additions & 2 deletions WEBUI/static/chat_emotes.js
Original file line number Diff line number Diff line change
@@ -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}$/;
Expand Down Expand Up @@ -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 = '<span class="chat-redeem-badge">Punkte</span> ';
html += `<span class="chat-redeem-title">„${escapeHtml(title)}“ eingelöst</span>`;
let html = '<span class="chat-redeem-badge">' + escapeHtml(svT('Points')) + '</span> ';
const redeemed = escapeHtml(svT('Redeemed “%(title)s”')).replace('%(title)s', () => escapeHtml(title));
html += `<span class="chat-redeem-title">${redeemed}</span>`;
if (Number.isFinite(cost) && cost > 0) {
html += ` <span class="chat-redeem-cost">${escapeHtml(String(cost))}</span>`;
}
Expand Down
29 changes: 29 additions & 0 deletions WEBUI/static/css/tube_design_system.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
20 changes: 14 additions & 6 deletions WEBUI/static/dashboard_live.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
(function () {
"use strict";
var svT = window.svT || function (s) { return s; };

var pollTimer = null;
var isFetching = false;
Expand Down Expand Up @@ -82,17 +83,24 @@
// Desktop inline header
var headerCount = document.querySelector(".ns-pagination-inline span");
if (headerCount) {
headerCount.innerHTML = 'Seite <strong>' + data.page + '</strong> / <strong>' + data.total_pages + '</strong> (' + data.total_count + ' VODs)';
headerCount.innerHTML = svT('Page %(page)s / %(pages)s (%(count)s VODs)')
.replace('%(page)s', '<strong>' + data.page + '</strong>')
.replace('%(pages)s', '<strong>' + data.total_pages + '</strong>')
.replace('%(count)s', data.total_count);
}
// Desktop bottom nav
var bottomNavInfo = document.querySelector(".ns-pagination-bottom .ns-pagination-info");
if (bottomNavInfo) {
bottomNavInfo.innerHTML = 'Seite <strong>' + data.page + '</strong> von <strong>' + data.total_pages + '</strong> &bull; ' + data.total_count + ' VODs im Schnellzugriff';
bottomNavInfo.innerHTML = svT('Page %(page)s of %(pages)s')
.replace('%(page)s', '<strong>' + data.page + '</strong>')
.replace('%(pages)s', '<strong>' + data.total_pages + '</strong>')
+ ' &bull; ' + 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);
}
}

Expand All @@ -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&uuml;gbar &bull; Jetzt ansehen';
pill.innerHTML = '✨ ' + (newCount > 1 ? svT('%(n)s new VODs available').replace('%(n)s', newCount) : svT('1 new VOD available')) + ' &bull; ' + svT('Watch now');

pill.addEventListener("click", function () {
window.location.href = "/?page=1";
Expand Down
Loading
Loading