From 7bd77a4641a425a6f28f6c318cbe4cc9817f6411 Mon Sep 17 00:00:00 2001 From: Ignacio Rivero Date: Fri, 14 Mar 2025 22:29:27 -0300 Subject: [PATCH] Added l10n, and cookie for that --- .gitignore | 1 + locales/en.json | 31 ++++++++++++++++ locales/es.json | 31 ++++++++++++++++ localization.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ server.py | 74 +++++++++++++++++++++++++++---------- templates/index.html | 86 ++++++++++++++++++++++++++++--------------- utils.py | 33 ++++++++++++++++- 7 files changed, 294 insertions(+), 50 deletions(-) create mode 100644 locales/en.json create mode 100644 locales/es.json create mode 100644 localization.py diff --git a/.gitignore b/.gitignore index 308b2db..764c4cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ ipmpv.log .venv +.secret_key diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..cfa4c03 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,31 @@ +{ + "welcome_to_ipmpv": "welcome", + "current_channel": "Current channel", + "stop": "Stop", + "start_retroarch": "Start RetroArch", + "stop_retroarch": "Stop RetroArch", + "deinterlacing": "Deinterlacing", + "resolution": "Resolution", + "volume": "Volume", + "mute": "mute", + "toggle_osd": "Toggle OSD", + "on": "on", + "off": "off", + "play_custom_url": "Play Custom URL", + "enter_stream_url": "Enter stream URL", + "play": "Play", + "all_channels": "All Channels", + "loading": "Loading...", + "now_playing": "Now playing", + "error": "Error", + "connection_error": "Connection error. Please try again.", + "loading_channel": "Loading channel...", + "error_loading_channel": "Error loading channel. Try again.", + "volume_level": "Volume: {0}%", + "muted_yes": "Muted: yes", + "muted_no": "Muted: no", + "latency_high": "Hi", + "latency_low": "Lo", + "other": "Other", + "invalid_url": "Invalid or unsupported URL" +} diff --git a/locales/es.json b/locales/es.json new file mode 100644 index 0000000..f96a71b --- /dev/null +++ b/locales/es.json @@ -0,0 +1,31 @@ +{ + "welcome_to_ipmpv": "bienvenido/a", + "current_channel": "Canal actual", + "stop": "Detener", + "start_retroarch": "Iniciar RetroArch", + "stop_retroarch": "Detener RetroArch", + "deinterlacing": "Desentrelazado", + "resolution": "Resolución", + "volume": "Volumen", + "mute": "mute", + "toggle_osd": "Alternar OSD", + "on": "sí", + "off": "no", + "play_custom_url": "Reproducir URL Personalizada", + "enter_stream_url": "Introduzca URL de la transmisión", + "play": "Reproducir", + "all_channels": "Todos los Canales", + "loading": "Cargando...", + "now_playing": "Reproduciendo ahora", + "error": "Error", + "connection_error": "Error de conexión. Por favor intente de nuevo.", + "loading_channel": "Cargando canal...", + "error_loading_channel": "Error al cargar el canal. Intente de nuevo.", + "volume_level": "Volumen: {0}%", + "muted_yes": "Silencio: sí", + "muted_no": "Silencio: no", + "latency_high": "Hi", + "latency_low": "Lo", + "other": "Otro", + "invalid_url": "URL inválida o no soportada" +} diff --git a/localization.py b/localization.py new file mode 100644 index 0000000..79a8967 --- /dev/null +++ b/localization.py @@ -0,0 +1,88 @@ +import os +import json +from flask import request, session + +class Localization: + """Handles localization for IPMPV""" + + def __init__(self, default_language='en'): + """Initialize the localization system""" + self.default_language = default_language + self.translations = {} + self.available_languages = [] + self._load_translations() + + def _load_translations(self): + """Load all translation files from the locales directory""" + locales_dir = os.path.join(os.path.dirname(__file__), 'locales') + + # Create locales directory if it doesn't exist + if not os.path.exists(locales_dir): + os.makedirs(locales_dir) + + # Load each translation file + for filename in os.listdir(locales_dir): + if filename.endswith('.json'): + language_code = filename.split('.')[0] + self.available_languages.append(language_code) + + with open(os.path.join(locales_dir, filename), 'r', encoding='utf-8') as f: + self.translations[language_code] = json.load(f) + + # If no translations are available, create an empty one for the default language + if not self.translations: + self.available_languages.append(self.default_language) + self.translations[self.default_language] = {} + + def get_language(self): + """Get the current language based on session or browser settings""" + # Check session first + if 'language' in session: + lang = session['language'] + if lang in self.available_languages: + return lang + + # Check browser Accept-Language header + if request.accept_languages: + for lang in request.accept_languages.values(): + if lang[:2] in self.available_languages: + session['language'] = lang[:2] + return lang[:2] + + # Fallback to default language + return self.default_language + + def set_language(self, language_code): + """Set the current language""" + if language_code in self.available_languages: + session['language'] = language_code + return True + return False + + def translate(self, key, language=None): + """Translate a key to the specified or current language""" + if language is None: + language = self.get_language() + + # If the language doesn't exist, use default + if language not in self.translations: + language = self.default_language + + # If the key exists in the language, return the translation + if key in self.translations[language]: + return self.translations[language][key] + + # If not found in the current language, try the default language + if language != self.default_language and key in self.translations[self.default_language]: + return self.translations[self.default_language][key] + + # If still not found, return the key itself + return key + +# Create a global localization instance +localization = Localization() + +# Helper function to use in templates +def _(key): + """Shorthand for translating a key""" + return localization.translate(key) \ No newline at end of file diff --git a/server.py b/server.py index 4081d46..67a9617 100755 --- a/server.py +++ b/server.py @@ -6,8 +6,9 @@ import re import subprocess import threading import flask -from flask import request, jsonify, send_from_directory -from utils import is_valid_url, change_resolution, get_current_resolution, is_wayland +from flask import request, jsonify, send_from_directory, session, redirect, url_for +from localization import localization, _ +from utils import is_valid_url, change_resolution, get_current_resolution, is_wayland, get_or_create_secret_key class IPMPVServer: """Flask server for IPMPV web interface.""" @@ -25,6 +26,7 @@ class IPMPVServer: self.ipmpv_retroarch_cmd = ipmpv_retroarch_cmd self.retroarch_p = None self.volume_control = volume_control + self.app.secret_key = get_or_create_secret_key() # Register routes self._register_routes() @@ -36,6 +38,11 @@ class IPMPVServer: def _register_routes(self): """Register Flask routes.""" + @self.app.route("/switch_language/") + def switch_language(language): + localization.set_language(language) + return redirect(request.referrer or url_for('index')) + @self.app.route("/") def index(): return self._handle_index() @@ -120,18 +127,19 @@ class IPMPVServer: def serve_favicon(): return send_from_directory("static", 'favicon.ico', mimetype='image/vnd.microsoft.icon') - def _handle_index(self): """Handle the index route.""" from channels import group_channels - + grouped_channels = group_channels(self.channels) flat_channel_list = [channel for channel in self.channels] - + # Create the channel groups HTML channel_groups_html = "" for group, ch_list in grouped_channels.items(): - channel_groups_html += f'
{group}' + # Translate group name if it's a common group + translated_group = _(group.lower()) if group.lower() in ["other"] else group + channel_groups_html += f'
{translated_group}' for channel in ch_list: index = flat_channel_list.index(channel) # Get correct global index channel_groups_html += f''' @@ -141,22 +149,50 @@ class IPMPVServer:
''' channel_groups_html += '
' - - # Replace placeholders with actual values + + # Get current language and available languages for language selector + current_language = localization.get_language() + languages = { + 'en': 'English', + 'es': 'Español' + # Add more languages here as you support them + } + + language_selector_html = "" + for code, name in languages.items(): + selected = ' selected' if code == current_language else '' + language_selector_html += f'' + + # Replace placeholders with actual values, using translation html = open("templates/index.html").read() - html = html.replace("%CURRENT_CHANNEL%", - self.channels[self.player.current_index]['name'] - if self.player.current_index is not None else "None") - html = html.replace("%RETROARCH_STATE%", - "ON" if self.retroarch_p and self.retroarch_p.poll() is None else "OFF") - html = html.replace("%RETROARCH_LABEL%", - "Stop RetroArch" if self.retroarch_p and self.retroarch_p.poll() is None else "Start RetroArch") - html = html.replace("%DEINTERLACE_STATE%", "ON" if self.player.deinterlace else "OFF") + html = html.replace("%WELCOME_TEXT%", _("welcome_to_ipmpv")) + html = html.replace("%CURRENT_CHANNEL_LABEL%", _("current_channel")) + html = html.replace("%CURRENT_CHANNEL%", + self.channels[self.player.current_index]['name'] + if self.player.current_index is not None else "None") + html = html.replace("%RETROARCH_STATE%", + "ON" if self.retroarch_p and self.retroarch_p.poll() is None else "OFF") + html = html.replace("%RETROARCH_LABEL%", + _("stop_retroarch") if self.retroarch_p and self.retroarch_p.poll() is None else _("start_retroarch")) + html = html.replace("%DEINTERLACE_LABEL%", _("deinterlacing")) + html = html.replace("%DEINTERLACE_STATE%", _("on") if self.player.deinterlace else _("off")) + html = html.replace("%RESOLUTION_LABEL%", _("resolution")) html = html.replace("%RESOLUTION%", self.resolution) html = html.replace("%LATENCY_STATE%", "ON" if self.player.low_latency else "OFF") - html = html.replace("%LATENCY_LABEL%", "Lo" if self.player.low_latency else "Hi") + html = html.replace("%LATENCY_LABEL%", _("latency_low") if self.player.low_latency else _("latency_high")) html = html.replace("%CHANNEL_GROUPS%", channel_groups_html) - + html = html.replace("%VOLUME_LABEL%", _("volume")) + html = html.replace("%MUTE_LABEL%", _("mute")) + html = html.replace("%TOGGLE_OSD_LABEL%", _("toggle_osd")) + html = html.replace("%ON_LABEL%", _("on")) + html = html.replace("%OFF_LABEL%", _("off")) + html = html.replace("%PLAY_CUSTOM_URL_LABEL%", _("play_custom_url")) + html = html.replace("%ENTER_URL_PLACEHOLDER%", _("enter_stream_url")) + html = html.replace("%PLAY_LABEL%", _("play")) + html = html.replace("%ALL_CHANNELS_LABEL%", _("all_channels")) + html = html.replace("%STOP_LABEL%", _("stop")) + html = html.replace("%LANGUAGE_SELECTOR%", language_selector_html) + return html def _handle_play_custom(self): @@ -164,7 +200,7 @@ class IPMPVServer: url = request.args.get("url") if not url or not is_valid_url(url): - return jsonify(success=False, error="Invalid or unsupported URL") + return jsonify(success=False, error=_("invalid_url")) self.player.player.loadfile(url) self.player.current_index = None diff --git a/templates/index.html b/templates/index.html index 5b406c1..4c7a288 100644 --- a/templates/index.html +++ b/templates/index.html @@ -304,7 +304,7 @@ } h1 { - font-size: 28px; + font-size: 48px; margin: 15px 0; } @@ -422,13 +422,34 @@ padding: 14px; } } + + .language-selector { + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + } + + .language-selector select { + background-color: var(--secondary-bg); + color: var(--text-color); + border: 1px solid #444; + padding: 5px; + border-radius: var(--border-radius); + } +
+ +
+
-

Welcome to IPMPV

-

Current Channel: %CURRENT_CHANNEL%

+

%WELCOME_TEXT%

+

%CURRENT_CHANNEL_LABEL%: %CURRENT_CHANNEL%

@@ -440,49 +461,49 @@
- +
-

Volume

+

%VOLUME_LABEL%

- +
-

Toggle OSD

+

%TOGGLE_OSD_LABEL%

- - + +
-

Play Custom URL

+

%PLAY_CUSTOM_URL_LABEL%

- + - +
-

All Channels

+

%ALL_CHANNELS_LABEL%

%CHANNEL_GROUPS% @@ -490,6 +511,11 @@