Added l10n, and cookie for that

This commit is contained in:
Ignacio Rivero 2025-03-14 22:29:27 -03:00
parent 24338557ee
commit 7bd77a4641
7 changed files with 294 additions and 50 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
__pycache__
ipmpv.log
.venv
.secret_key

31
locales/en.json Normal file
View File

@ -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"
}

31
locales/es.json Normal file
View File

@ -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"
}

88
localization.py Normal file
View File

@ -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)

View File

@ -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/<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'<div class="group">{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'<div class="group">{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:
</div>
'''
channel_groups_html += '</div>'
# 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'<option value="{code}"{selected}>{name}</option>'
# 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

View File

@ -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);
}
</style>
</head>
<body>
<div class="language-selector">
<select onchange="changeLanguage(this.value)">
%LANGUAGE_SELECTOR%
</select>
</div>
<div class="container">
<h1>Welcome to IPMPV</h1>
<p>Current Channel: <span id="current-channel">%CURRENT_CHANNEL%</span></p>
<h1><i>%WELCOME_TEXT%</i></h1>
<p>%CURRENT_CHANNEL_LABEL%: <span id="current-channel">%CURRENT_CHANNEL%</span></p>
<div class="section">
<div class="dpad-container">
@ -440,49 +461,49 @@
</div>
<div class="controls">
<button class="control-button" onclick="stopPlayer()">Stop</button>
<button class="control-button" onclick="stopPlayer()">%STOP_LABEL%</button>
<button id="retroarch-btn" class="%RETROARCH_STATE%" onclick="toggleRetroArch()">
<span id="retroarch-state">%RETROARCH_LABEL%</span>
</button>
<button id="deinterlace-btn" class="%DEINTERLACE_STATE%" onclick="toggleDeinterlace()">
Deinterlacing: <span id="deinterlace-state">%DEINTERLACE_STATE%</span>
%DEINTERLACE_LABEL%: <span id="deinterlace-state">%DEINTERLACE_STATE%</span>
</button>
<button id="resolution-btn" class="control-button" onclick="toggleResolution()">
Resolution: <span id="resolution-state">%RESOLUTION%</span>
%RESOLUTION_LABEL%: <span id="resolution-state">%RESOLUTION%</span>
</button>
</div>
<div class="section">
<h2>Volume</h2>
<h2>%VOLUME_LABEL%</h2>
<div class="osd-toggle">
<button class="leftbtn" id="vol-up-btn" onclick="volumeDown()">-</button>
<button class="midbtn %MUTE_STATE%" id="vol-mute-btn" onclick="toggleMute()">mute</button>
<button class="midbtn %MUTE_STATE%" id="vol-mute-btn" onclick="toggleMute()">%MUTE_LABEL%</button>
<button class="rightbtn" id="vol-dn-btn" onclick="volumeUp()">+</button>
</div>
</div>
<div class="section">
<h2>Toggle OSD</h2>
<h2>%TOGGLE_OSD_LABEL%</h2>
<div class="osd-toggle">
<button class="leftbtn" id="osd-on-btn" onclick="showOSD()">on</button>
<button class="rightbtn" id="osd-off-btn" onclick="hideOSD()">off</button>
<button class="leftbtn" id="osd-on-btn" onclick="showOSD()">%ON_LABEL%</button>
<button class="rightbtn" id="osd-off-btn" onclick="hideOSD()">%OFF_LABEL%</button>
</div>
</div>
<div class="section">
<h2>Play Custom URL</h2>
<h2>%PLAY_CUSTOM_URL_LABEL%</h2>
<div class="url-input-container">
<div class="url-input-group">
<input type="text" id="custom-url" class="custom-url-input" placeholder="Enter stream URL">
<input type="text" id="custom-url" class="custom-url-input" placeholder="%ENTER_URL_PLACEHOLDER%">
<button id="latency-btn" class="%LATENCY_STATE%" onclick="toggleLatency()">
<span id="latency-state">%LATENCY_LABEL%</span>
</button>
<button class="input-btn" onclick="playCustomURL()">Play</button>
<button class="input-btn" onclick="playCustomURL()">%PLAY_LABEL%</button>
</div>
</div>
</div>
<h2>All Channels</h2>
<h2>%ALL_CHANNELS_LABEL%</h2>
<div class="group-container">
<!-- Channel groups will be inserted here -->
%CHANNEL_GROUPS%
@ -490,6 +511,11 @@
</div>
<script>
// Function to change language
function changeLanguage(language) {
window.location.href = '/switch_language/' + language;
}
// Improve mobile touch experience
document.addEventListener('DOMContentLoaded', function () {
// Add active/touch state for all buttons
@ -514,7 +540,7 @@
// Show loading indicator
const playButton = document.querySelector('.input-btn');
const originalText = playButton.textContent;
playButton.textContent = "Loading...";
playButton.textContent = "%LOADING%";
playButton.disabled = true;
fetch(`/play_custom?url=${encodeURIComponent(url)}`)
@ -525,15 +551,15 @@
if (data.success) {
// Show toast instead of alert on mobile
showToast("Now playing: " + url);
showToast("%NOW_PLAYING%: " + url);
} else {
showToast("Error: " + data.error);
showToast("%ERROR%: " + data.error);
}
})
.catch(error => {
playButton.textContent = originalText;
playButton.disabled = false;
showToast("Connection error. Please try again.");
showToast("%CONNECTION_ERROR%");
});
}
@ -541,7 +567,7 @@
fetch(`/toggle_latency`)
.then(response => response.json())
.then(data => {
document.getElementById("latency-state").textContent = data.state ? "Lo" : "Hi";
document.getElementById("latency-state").textContent = data.state ? "%LATENCY_LOW%" : "%LATENCY_HIGH%";
document.getElementById("latency-btn").className = data.state ? "ON" : "OFF";
});
}
@ -550,7 +576,7 @@
fetch(`/toggle_retroarch`)
.then(response => response.json())
.then(data => {
document.getElementById("retroarch-state").textContent = data.state ? "Stop RetroArch" : "Start RetroArch";
document.getElementById("retroarch-state").textContent = data.state ? "%STOP_RETROARCH%" : "%START_RETROARCH%";
document.getElementById("retroarch-btn").className = data.state ? "ON" : "OFF";
});
}
@ -574,7 +600,7 @@
btn.disabled = true;
});
showToast("Loading channel...");
showToast("%LOADING_CHANNEL%");
fetch(`/channel?index=${index}`)
.then(() => window.location.reload())
@ -582,7 +608,7 @@
channelButtons.forEach(btn => {
btn.disabled = false;
});
showToast("Error loading channel. Try again.");
showToast("%ERROR_LOADING_CHANNEL%");
});
}
@ -590,7 +616,7 @@
fetch(`/toggle_deinterlace`)
.then(response => response.json())
.then(data => {
document.getElementById("deinterlace-state").textContent = data.state ? "ON" : "OFF";
document.getElementById("deinterlace-state").textContent = data.state ? "%ON_LABEL%" : "%OFF_LABEL%";
document.getElementById("deinterlace-btn").className = data.state ? "ON" : "OFF";
});
}
@ -607,7 +633,7 @@
fetch(`/volume_up`)
.then(response => response.json())
.then(data => {
showToast("Volume: " + data.volume + "%");
showToast("%VOLUME_LEVEL%".replace("{0}", data.volume));
});
}
@ -615,7 +641,7 @@
fetch(`/volume_down`)
.then(response => response.json())
.then(data => {
showToast("Volume: " + data.volume + "%");
showToast("%VOLUME_LEVEL%".replace("{0}", data.volume));
});
}
@ -623,19 +649,19 @@
fetch(`/toggle_mute`)
.then(response => response.json())
.then(data => {
muted = data.muted ? "yes" : "no";
showToast("Muted: " + muted);
muted = data.muted ? "%MUTED_YES%" : "%MUTED_NO%";
showToast(muted);
});
}
function channelUp() {
showToast("Loading channel...");
showToast("%LOADING_CHANNEL%");
fetch(`/channel_up`)
.then(() => window.location.reload())
}
function channelDown() {
showToast("Loading channel...");
showToast("%LOADING_CHANNEL%");
fetch(`/channel_down`)
.then(() => window.location.reload())
}

View File

@ -4,6 +4,7 @@
import os
import re
import subprocess
import secrets
# Environment variables
is_wayland = "WAYLAND_DISPLAY" in os.environ
@ -96,5 +97,35 @@ def change_resolution(current_resolution):
xrandr_env["DISPLAY"] = ":0"
subprocess.run(["xrandr", "--output", "Composite-1", "--mode", new_res], check=False, env=xrandr_env)
return get_current_resolution()
return current_resolution
def get_or_create_secret_key():
"""
Get the secret key from a file or create a new one if it doesn't exist.
Returns:
str: The secret key
"""
key_file = os.path.join(os.path.dirname(__file__), '.secret_key')
# If the file exists, read the key
if os.path.exists(key_file):
with open(key_file, 'r') as f:
return f.read().strip()
# Otherwise, generate a new key and save it
new_key = secrets.token_hex(16) # 32-character hex string
# Create the file with restricted permissions (readable only by the owner)
try:
# This works on Unix-like systems (Linux, macOS)
with open(key_file, 'w') as f:
f.write(new_key)
os.chmod(key_file, 0o600) # Owner can read/write, no one else can access
except:
# Simplified approach for Windows or if chmod fails
with open(key_file, 'w') as f:
f.write(new_key)
return new_key