diff --git a/CLAUDE.md b/CLAUDE.md index 785507d..51be7e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,43 @@ The API token is generated on first run and displayed in the console output. Default port: `8765` +## Internationalization (i18n) + +The Web UI supports multiple languages with translations stored in separate JSON files. + +### Locale Files + +Translation files are located in: +- `media_server/static/locales/en.json` - English (default) +- `media_server/static/locales/ru.json` - Russian + +### Maintaining Translations + +**IMPORTANT:** When adding or modifying user-facing text in the Web UI: + +1. **Update all locale files** - Add or update the translation key in **both** `en.json` and `ru.json` +2. **Use consistent keys** - Follow the existing key naming pattern (e.g., `section.element`, `scripts.button.save`) +3. **Test both locales** - Verify translations appear correctly by switching between EN/RU + +### Adding New Text + +When adding new UI elements: + +1. Add the English text to `static/locales/en.json` +2. Add the Russian translation to `static/locales/ru.json` +3. In HTML: use `data-i18n="key.name"` for text content +4. In HTML: use `data-i18n-placeholder="key.name"` for input placeholders +5. In HTML: use `data-i18n-title="key.name"` for title attributes +6. In JavaScript: use `t('key.name')` or `t('key.name', {param: value})` for dynamic text + +### Adding New Locales + +To add support for a new language: + +1. Create `media_server/static/locales/{lang_code}.json` (copy from `en.json`) +2. Translate all strings to the new language +3. Add the language code to `supportedLocales` array in `index.html` + ## Versioning Version is tracked in two files that must be kept in sync: diff --git a/media_server/static/index.html b/media_server/static/index.html index 6103c3a..6cae0e1 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -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 @@ - +
-

Media Server

-

Enter your API token to connect to the media server.

- - +

Media Server

+

Enter your API token to connect to the media server.

+ +
-

To get your token, run:

+

To get your token, run:

media-server --show-token
@@ -762,9 +780,9 @@
-

Media Server

+

Media Server

- +
- Disconnected + Disconnected
@@ -785,14 +804,14 @@
-
No media playing
+
No media playing
- Idle + Idle
@@ -807,17 +826,17 @@
- - -
-
- Source: Unknown + Source: Unknown
-

Script Management

- +

Script Management

+
- - - - - + + + + + - +
NameLabelCommandTimeoutActionsNameLabelCommandTimeoutActions
No scripts configured. Click "Add Script" to create one.No scripts configured. Click "Add" to create one.
@@ -874,19 +893,19 @@
-

Callback Management

- +

Callback Management

+
-

- Callbacks are scripts triggered automatically by media control events (play, pause, volume, etc.) +

+ Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)

- - - - + + + + @@ -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 = ''; playPauseIcon.innerHTML = ''; break; case 'paused': - stateText.textContent = 'Paused'; + stateText.textContent = t('state.paused'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; break; case 'stopped': - stateText.textContent = 'Stopped'; + stateText.textContent = t('state.stopped'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; break; default: - stateText.textContent = 'Idle'; + stateText.textContent = t('state.idle'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; } diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json new file mode 100644 index 0000000..2f25018 --- /dev/null +++ b/media_server/static/locales/en.json @@ -0,0 +1,109 @@ +{ + "app.title": "Media Server", + "auth.message": "Enter your API token to connect to the media server.", + "auth.placeholder": "Enter API Token", + "auth.connect": "Connect", + "auth.help": "To get your token, run:", + "auth.logout": "Logout", + "auth.logout.title": "Clear saved token", + "auth.invalid": "Invalid token. Please try again.", + "auth.cleared": "Token cleared. Please enter a new token.", + "auth.required": "Please enter a token", + "player.theme": "Toggle theme", + "player.locale": "Change language", + "player.previous": "Previous", + "player.play": "Play/Pause", + "player.next": "Next", + "player.mute": "Mute", + "player.status.connected": "Connected", + "player.status.disconnected": "Disconnected", + "player.no_media": "No media playing", + "player.source": "Source:", + "player.unknown_source": "Unknown", + "state.playing": "Playing", + "state.paused": "Paused", + "state.stopped": "Stopped", + "state.idle": "Idle", + "scripts.quick_actions": "Quick Actions", + "scripts.no_scripts": "No scripts configured", + "scripts.management": "Script Management", + "scripts.add": "Add", + "scripts.table.name": "Name", + "scripts.table.label": "Label", + "scripts.table.command": "Command", + "scripts.table.timeout": "Timeout", + "scripts.table.actions": "Actions", + "scripts.empty": "No scripts configured. Click 'Add' to create one.", + "scripts.dialog.add": "Add Script", + "scripts.dialog.edit": "Edit Script", + "scripts.field.name": "Script Name *", + "scripts.field.label": "Label", + "scripts.field.command": "Command *", + "scripts.field.description": "Description", + "scripts.field.icon": "Icon (MDI)", + "scripts.field.timeout": "Timeout (seconds)", + "scripts.placeholder.name": "Only letters, numbers, and underscores allowed", + "scripts.placeholder.label": "Human-readable name", + "scripts.placeholder.command": "e.g., shutdown /s /t 0", + "scripts.placeholder.description": "What does this script do?", + "scripts.placeholder.icon": "e.g., mdi:power", + "scripts.button.cancel": "Cancel", + "scripts.button.save": "Save", + "scripts.button.edit": "Edit", + "scripts.button.delete": "Delete", + "scripts.msg.executed": "{name} executed successfully", + "scripts.msg.execute_failed": "Failed to execute {name}", + "scripts.msg.execute_error": "Error executing {name}", + "scripts.msg.created": "Script created successfully", + "scripts.msg.updated": "Script updated successfully", + "scripts.msg.create_failed": "Failed to create script", + "scripts.msg.update_failed": "Failed to update script", + "scripts.msg.deleted": "Script deleted successfully", + "scripts.msg.delete_failed": "Failed to delete script", + "scripts.msg.not_found": "Script not found", + "scripts.msg.load_failed": "Failed to load script details", + "scripts.msg.list_failed": "Failed to load scripts", + "scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?", + "callbacks.management": "Callback Management", + "callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)", + "callbacks.add": "Add", + "callbacks.table.event": "Event", + "callbacks.table.command": "Command", + "callbacks.table.timeout": "Timeout", + "callbacks.table.actions": "Actions", + "callbacks.empty": "No callbacks configured. Click 'Add' to create one.", + "callbacks.dialog.add": "Add Callback", + "callbacks.dialog.edit": "Edit Callback", + "callbacks.field.event": "Event *", + "callbacks.field.command": "Command *", + "callbacks.field.timeout": "Timeout (seconds)", + "callbacks.field.workdir": "Working Directory", + "callbacks.placeholder.event": "Select event...", + "callbacks.placeholder.command": "e.g., shutdown /s /t 0", + "callbacks.placeholder.workdir": "Optional", + "callbacks.button.cancel": "Cancel", + "callbacks.button.save": "Save", + "callbacks.button.edit": "Edit", + "callbacks.button.delete": "Delete", + "callbacks.event.on_play": "on_play - After play succeeds", + "callbacks.event.on_pause": "on_pause - After pause succeeds", + "callbacks.event.on_stop": "on_stop - After stop succeeds", + "callbacks.event.on_next": "on_next - After next track succeeds", + "callbacks.event.on_previous": "on_previous - After previous track succeeds", + "callbacks.event.on_volume": "on_volume - After volume change", + "callbacks.event.on_mute": "on_mute - After mute toggle", + "callbacks.event.on_seek": "on_seek - After seek succeeds", + "callbacks.event.on_turn_on": "on_turn_on - Callback-only action", + "callbacks.event.on_turn_off": "on_turn_off - Callback-only action", + "callbacks.event.on_toggle": "on_toggle - Callback-only action", + "callbacks.msg.created": "Callback created successfully", + "callbacks.msg.updated": "Callback updated successfully", + "callbacks.msg.create_failed": "Failed to create callback", + "callbacks.msg.update_failed": "Failed to update callback", + "callbacks.msg.deleted": "Callback deleted successfully", + "callbacks.msg.delete_failed": "Failed to delete callback", + "callbacks.msg.not_found": "Callback not found", + "callbacks.msg.load_failed": "Failed to load callback details", + "callbacks.msg.list_failed": "Failed to load callbacks", + "callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?" +} diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json new file mode 100644 index 0000000..061d395 --- /dev/null +++ b/media_server/static/locales/ru.json @@ -0,0 +1,109 @@ +{ + "app.title": "Медиа Сервер", + "auth.message": "Введите API токен для подключения к медиа серверу.", + "auth.placeholder": "Введите API токен", + "auth.connect": "Подключиться", + "auth.help": "Чтобы получить токен, выполните:", + "auth.logout": "Выйти", + "auth.logout.title": "Очистить сохраненный токен", + "auth.invalid": "Неверный токен. Пожалуйста, попробуйте снова.", + "auth.cleared": "Токен очищен. Пожалуйста, введите новый токен.", + "auth.required": "Пожалуйста, введите токен", + "player.theme": "Переключить тему", + "player.locale": "Изменить язык", + "player.previous": "Предыдущий", + "player.play": "Воспроизвести/Пауза", + "player.next": "Следующий", + "player.mute": "Без звука", + "player.status.connected": "Подключено", + "player.status.disconnected": "Отключено", + "player.no_media": "Медиа не воспроизводится", + "player.source": "Источник:", + "player.unknown_source": "Неизвестно", + "state.playing": "Воспроизведение", + "state.paused": "Пауза", + "state.stopped": "Остановлено", + "state.idle": "Ожидание", + "scripts.quick_actions": "Быстрые Действия", + "scripts.no_scripts": "Скрипты не настроены", + "scripts.management": "Управление Скриптами", + "scripts.add": "Добавить", + "scripts.table.name": "Имя", + "scripts.table.label": "Метка", + "scripts.table.command": "Команда", + "scripts.table.timeout": "Таймаут", + "scripts.table.actions": "Действия", + "scripts.empty": "Скрипты не настроены. Нажмите 'Добавить' для создания.", + "scripts.dialog.add": "Добавить Скрипт", + "scripts.dialog.edit": "Редактировать Скрипт", + "scripts.field.name": "Имя Скрипта *", + "scripts.field.label": "Метка", + "scripts.field.command": "Команда *", + "scripts.field.description": "Описание", + "scripts.field.icon": "Иконка (MDI)", + "scripts.field.timeout": "Таймаут (секунды)", + "scripts.placeholder.name": "Только буквы, цифры и подчеркивания", + "scripts.placeholder.label": "Человеко-читаемое имя", + "scripts.placeholder.command": "например, shutdown /s /t 0", + "scripts.placeholder.description": "Что делает этот скрипт?", + "scripts.placeholder.icon": "например, mdi:power", + "scripts.button.cancel": "Отмена", + "scripts.button.save": "Сохранить", + "scripts.button.edit": "Редактировать", + "scripts.button.delete": "Удалить", + "scripts.msg.executed": "{name} выполнен успешно", + "scripts.msg.execute_failed": "Не удалось выполнить {name}", + "scripts.msg.execute_error": "Ошибка выполнения {name}", + "scripts.msg.created": "Скрипт создан успешно", + "scripts.msg.updated": "Скрипт обновлен успешно", + "scripts.msg.create_failed": "Не удалось создать скрипт", + "scripts.msg.update_failed": "Не удалось обновить скрипт", + "scripts.msg.deleted": "Скрипт удален успешно", + "scripts.msg.delete_failed": "Не удалось удалить скрипт", + "scripts.msg.not_found": "Скрипт не найден", + "scripts.msg.load_failed": "Не удалось загрузить данные скрипта", + "scripts.msg.list_failed": "Не удалось загрузить скрипты", + "scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?", + "callbacks.management": "Управление Обратными Вызовами", + "callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)", + "callbacks.add": "Добавить", + "callbacks.table.event": "Событие", + "callbacks.table.command": "Команда", + "callbacks.table.timeout": "Таймаут", + "callbacks.table.actions": "Действия", + "callbacks.empty": "Обратные вызовы не настроены. Нажмите 'Добавить' для создания.", + "callbacks.dialog.add": "Добавить Обратный Вызов", + "callbacks.dialog.edit": "Редактировать Обратный Вызов", + "callbacks.field.event": "Событие *", + "callbacks.field.command": "Команда *", + "callbacks.field.timeout": "Таймаут (секунды)", + "callbacks.field.workdir": "Рабочая Директория", + "callbacks.placeholder.event": "Выберите событие...", + "callbacks.placeholder.command": "например, shutdown /s /t 0", + "callbacks.placeholder.workdir": "Опционально", + "callbacks.button.cancel": "Отмена", + "callbacks.button.save": "Сохранить", + "callbacks.button.edit": "Редактировать", + "callbacks.button.delete": "Удалить", + "callbacks.event.on_play": "on_play - После успешного воспроизведения", + "callbacks.event.on_pause": "on_pause - После успешной паузы", + "callbacks.event.on_stop": "on_stop - После успешной остановки", + "callbacks.event.on_next": "on_next - После успешного перехода к следующему", + "callbacks.event.on_previous": "on_previous - После успешного перехода к предыдущему", + "callbacks.event.on_volume": "on_volume - После изменения громкости", + "callbacks.event.on_mute": "on_mute - После переключения звука", + "callbacks.event.on_seek": "on_seek - После успешной перемотки", + "callbacks.event.on_turn_on": "on_turn_on - Действие только для обратных вызовов", + "callbacks.event.on_turn_off": "on_turn_off - Действие только для обратных вызовов", + "callbacks.event.on_toggle": "on_toggle - Действие только для обратных вызовов", + "callbacks.msg.created": "Обратный вызов создан успешно", + "callbacks.msg.updated": "Обратный вызов обновлен успешно", + "callbacks.msg.create_failed": "Не удалось создать обратный вызов", + "callbacks.msg.update_failed": "Не удалось обновить обратный вызов", + "callbacks.msg.deleted": "Обратный вызов удален успешно", + "callbacks.msg.delete_failed": "Не удалось удалить обратный вызов", + "callbacks.msg.not_found": "Обратный вызов не найден", + "callbacks.msg.load_failed": "Не удалось загрузить данные обратного вызова", + "callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы", + "callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?" +}
EventCommandTimeoutActionsEventCommandTimeoutActions