Add internationalization (i18n) support with English and Russian locales

- Add translation JSON files (en.json, ru.json) with 110+ strings each
- Implement locale auto-detection from browser settings
- Add locale toggle button (EN/RU) with localStorage persistence
- Translate all user-facing text: auth, player, scripts, callbacks
- Fix dynamic content translation on locale switch (playback state, track title)
- Add comprehensive i18n documentation to CLAUDE.md
- Follow existing theme toggle pattern for consistency

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 04:27:50 +03:00
parent a0af855846
commit 9bbb8e1bd7
4 changed files with 464 additions and 46 deletions

View File

@@ -108,6 +108,24 @@
fill: var(--text-primary);
}
#locale-toggle {
background: none;
border: 2px solid var(--text-secondary);
color: var(--text-primary);
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
#locale-toggle:hover {
border-color: var(--accent);
color: var(--accent);
transform: scale(1.05);
}
.player-container {
background: var(--bg-secondary);
border-radius: 12px;
@@ -743,17 +761,17 @@
</head>
<body>
<!-- Clear Token Button -->
<button class="clear-token-btn" onclick="clearToken()" title="Clear saved token">Logout</button>
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button>
<!-- Auth Modal -->
<div id="auth-overlay">
<div class="auth-modal">
<h2>Media Server</h2>
<p>Enter your API token to connect to the media server.</p>
<input type="text" id="token-input" placeholder="Enter API Token" autocomplete="off">
<button class="btn-connect" onclick="authenticate()">Connect</button>
<h2 data-i18n="app.title">Media Server</h2>
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
<button class="btn-connect" onclick="authenticate()" data-i18n="auth.connect">Connect</button>
<div class="help-text">
<p>To get your token, run:</p>
<p data-i18n="auth.help">To get your token, run:</p>
<code>media-server --show-token</code>
</div>
<div class="error-message" id="auth-error"></div>
@@ -762,9 +780,9 @@
<div class="container">
<header>
<h1>Media Server</h1>
<h1 data-i18n="app.title">Media Server</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme" id="theme-toggle">
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle">
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg>
@@ -772,9 +790,10 @@
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
</svg>
</button>
<button id="locale-toggle" onclick="toggleLocale()" data-i18n-title="player.locale" title="Change language">EN</button>
<div class="status-indicator">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Disconnected</span>
<span id="status-text" data-i18n="player.status.disconnected">Disconnected</span>
</div>
</div>
</header>
@@ -785,14 +804,14 @@
</div>
<div class="track-info">
<div id="track-title">No media playing</div>
<div id="track-title" data-i18n="player.no_media">No media playing</div>
<div id="artist"></div>
<div id="album"></div>
<div class="playback-state">
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
<span id="playback-state">Idle</span>
<span id="playback-state" data-i18n="state.idle">Idle</span>
</div>
</div>
@@ -807,17 +826,17 @@
</div>
<div class="controls">
<button onclick="previousTrack()" title="Previous" id="btn-previous">
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
<button class="primary" onclick="togglePlayPause()" title="Play/Pause" id="btn-play-pause">
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
<svg viewBox="0 0 24 24" id="play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button onclick="nextTrack()" title="Next" id="btn-next">
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
@@ -825,7 +844,7 @@
</div>
<div class="volume-container">
<button class="mute-btn" onclick="toggleMute()" title="Mute" id="btn-mute">
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
<svg viewBox="0 0 24 24" id="mute-icon">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
@@ -835,37 +854,37 @@
</div>
<div class="source-info">
Source: <span id="source">Unknown</span>
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
</div>
</div>
<!-- Scripts Section -->
<div class="scripts-container" id="scripts-container" style="display: none;">
<h2>Quick Actions</h2>
<h2 data-i18n="scripts.quick_actions">Quick Actions</h2>
<div class="scripts-grid" id="scripts-grid">
<div class="scripts-empty">No scripts configured</div>
<div class="scripts-empty" data-i18n="scripts.no_scripts">No scripts configured</div>
</div>
</div>
<!-- Script Management Section -->
<div class="script-management">
<div class="script-management-header">
<h2>Script Management</h2>
<button class="add-script-btn" onclick="showAddScriptDialog()">+ Add</button>
<h2 data-i18n="scripts.management">Script Management</h2>
<button class="add-script-btn" onclick="showAddScriptDialog()" data-i18n="scripts.add">+ Add</button>
</div>
<table class="scripts-table">
<thead>
<tr>
<th>Name</th>
<th>Label</th>
<th>Command</th>
<th>Timeout</th>
<th>Actions</th>
<th data-i18n="scripts.table.name">Name</th>
<th data-i18n="scripts.table.label">Label</th>
<th data-i18n="scripts.table.command">Command</th>
<th data-i18n="scripts.table.timeout">Timeout</th>
<th data-i18n="scripts.table.actions">Actions</th>
</tr>
</thead>
<tbody id="scriptsTableBody">
<tr>
<td colspan="5" class="empty-state">No scripts configured. Click "Add Script" to create one.</td>
<td colspan="5" class="empty-state" data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</td>
</tr>
</tbody>
</table>
@@ -874,19 +893,19 @@
<!-- Callback Management Section -->
<div class="script-management">
<div class="script-management-header">
<h2>Callback Management</h2>
<button class="add-script-btn" onclick="showAddCallbackDialog()">+ Add</button>
<h2 data-i18n="callbacks.management">Callback Management</h2>
<button class="add-script-btn" onclick="showAddCallbackDialog()" data-i18n="callbacks.add">+ Add</button>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;">
Callbacks are scripts triggered automatically by media control events (play, pause, volume, etc.)
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" data-i18n="callbacks.description">
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
</p>
<table class="scripts-table">
<thead>
<tr>
<th>Event</th>
<th>Command</th>
<th>Timeout</th>
<th>Actions</th>
<th data-i18n="callbacks.table.event">Event</th>
<th data-i18n="callbacks.table.command">Command</th>
<th data-i18n="callbacks.table.timeout">Timeout</th>
<th data-i18n="callbacks.table.actions">Actions</th>
</tr>
</thead>
<tbody id="callbacksTableBody">
@@ -1027,6 +1046,143 @@
setTheme(newTheme);
}
// Locale management
let currentLocale = 'en';
let translations = {};
const supportedLocales = ['en', 'ru'];
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'Media Server',
'auth.connect': 'Connect',
'auth.placeholder': 'Enter API Token',
'player.status.connected': 'Connected',
'player.status.disconnected': 'Disconnected'
};
// Translation function
function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
// Replace parameters like {name}, {value}, etc.
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
// Load translation file
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
// Fallback to English if loading fails
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
// Detect browser locale
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
// Only return if we support it
return supportedLocales.includes(langCode) ? langCode : 'en';
}
// Initialize locale
async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
// Set locale
async function setLocale(locale) {
if (!supportedLocales.includes(locale)) {
locale = 'en';
}
// Load translations for the locale
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
// Update all text
updateAllText();
// Update locale toggle button (if visible)
updateLocaleToggle();
}
// Toggle between locales
async function toggleLocale() {
const newLocale = currentLocale === 'en' ? 'ru' : 'en';
await setLocale(newLocale);
}
// Update locale toggle button
function updateLocaleToggle() {
const localeButton = document.getElementById('locale-toggle');
if (localeButton) {
localeButton.textContent = currentLocale === 'en' ? 'RU' : 'EN';
localeButton.title = t('player.locale');
}
}
// Update all text on page
function updateAllText() {
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
// Update all elements with data-i18n-title attribute
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-apply dynamic content with new translations
// Update playback state
updatePlaybackState(currentState);
// Update connection status
const connected = ws && ws.readyState === WebSocket.OPEN;
updateConnectionStatus(connected);
// Re-apply last media status if available
if (lastStatus) {
document.getElementById('track-title').textContent = lastStatus.title || t('player.no_media');
document.getElementById('source').textContent = lastStatus.source || t('player.unknown_source');
}
// Reload tables to get translated content
const token = localStorage.getItem('media_server_token');
if (token) {
loadScriptsTable();
loadCallbacksTable();
}
}
let ws = null;
let reconnectTimeout = null;
let currentState = 'idle';
@@ -1034,6 +1190,7 @@
let currentPosition = 0;
let isUserAdjustingVolume = false;
let scripts = [];
let lastStatus = null; // Store last status for locale switching
// Position interpolation
let lastPositionUpdate = 0;
@@ -1041,10 +1198,13 @@
let interpolationInterval = null;
// Initialize on page load
window.addEventListener('DOMContentLoaded', () => {
window.addEventListener('DOMContentLoaded', async () => {
// Initialize theme
initTheme();
// Initialize locale (async - loads JSON file)
await initLocale();
const token = localStorage.getItem('media_server_token');
if (token) {
connectWebSocket(token);
@@ -1109,7 +1269,7 @@
function authenticate() {
const token = document.getElementById('token-input').value.trim();
if (!token) {
showAuthForm('Please enter a token');
showAuthForm(t('auth.required'));
return;
}
@@ -1122,7 +1282,7 @@
if (ws) {
ws.close();
}
showAuthForm('Token cleared. Please enter a new token.');
showAuthForm(t('auth.cleared'));
}
function connectWebSocket(token) {
@@ -1167,7 +1327,7 @@
if (event.code === 4001) {
// Invalid token
localStorage.removeItem('media_server_token');
showAuthForm('Invalid token. Please try again.');
showAuthForm(t('auth.invalid'));
} else if (event.code !== 1000) {
// Abnormal closure - attempt reconnect
reconnectTimeout = setTimeout(() => {
@@ -1194,16 +1354,19 @@
if (connected) {
dot.classList.add('connected');
text.textContent = 'Connected';
text.textContent = t('player.status.connected');
} else {
dot.classList.remove('connected');
text.textContent = 'Disconnected';
text.textContent = t('player.status.disconnected');
}
}
function updateUI(status) {
// Store status for locale switching
lastStatus = status;
// Update track info
document.getElementById('track-title').textContent = status.title || 'No media playing';
document.getElementById('track-title').textContent = status.title || t('player.no_media');
document.getElementById('artist').textContent = status.artist || '';
document.getElementById('album').textContent = status.album || '';
@@ -1243,7 +1406,7 @@
updateMuteIcon(status.muted);
// Update source
document.getElementById('source').textContent = status.source || 'Unknown';
document.getElementById('source').textContent = status.source || t('player.unknown_source');
// Enable/disable controls based on state
const hasMedia = status.state !== 'idle';
@@ -1266,22 +1429,22 @@
switch(state) {
case 'playing':
stateText.textContent = 'Playing';
stateText.textContent = t('state.playing');
stateIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
playPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
break;
case 'paused':
stateText.textContent = 'Paused';
stateText.textContent = t('state.paused');
stateIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
break;
case 'stopped':
stateText.textContent = 'Stopped';
stateText.textContent = t('state.stopped');
stateIcon.innerHTML = '<path d="M6 6h12v12H6z"/>';
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
break;
default:
stateText.textContent = 'Idle';
stateText.textContent = t('state.idle');
stateIcon.innerHTML = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
}