diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 5c5eb63..e774d58 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -239,6 +239,14 @@ export default {
'content.expires_hint': 'After this date the item stops serving and is dropped from playlists. Leave blank for no expiry.',
'content.label_unstable_connection': 'Unstable connection (cap at 720p)',
'content.unstable_connection_hint': 'Forces YouTube to a 720p ceiling so weak or unstable WiFi buffers less. Leave off for full quality.',
+ 'content.label_captions_enabled': 'Enable captions',
+ 'content.label_captions_lang': 'Caption language',
+ 'content.captions_hint': 'Turns on YouTube captions in the chosen language on every display. Availability depends on the video having captions.',
+ 'content.label_subtitle_file': 'Subtitle file (.vtt)',
+ 'content.label_subtitle_lang': 'Subtitle language',
+ 'content.subtitle_current': 'A subtitle track is attached. Choose a new file to replace it.',
+ 'content.subtitle_remove': 'Remove the current subtitle',
+ 'content.subtitle_hint': 'Upload a WebVTT (.vtt) file to show subtitles on this video across all displays.',
'content.label_replace_file': 'Replace File',
'content.replace_file_hint': 'Leave empty to keep current file',
'content.folder_root_option': '— Root —',
diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js
index 0bd48d0..7fcf191 100644
--- a/frontend/js/i18n/es.js
+++ b/frontend/js/i18n/es.js
@@ -196,6 +196,14 @@ export default {
'content.label_folder': 'Carpeta',
'content.label_unstable_connection': 'Conexión inestable (limitar a 720p)',
'content.unstable_connection_hint': 'Fuerza un tope de 720p en YouTube para que el WiFi débil o inestable almacene menos en búfer. Déjalo desactivado para calidad completa.',
+ 'content.label_captions_enabled': 'Activar subtítulos',
+ 'content.label_captions_lang': 'Idioma de subtítulos',
+ 'content.captions_hint': 'Activa los subtítulos de YouTube en el idioma elegido en todas las pantallas. Depende de que el video tenga subtítulos.',
+ 'content.label_subtitle_file': 'Archivo de subtítulos (.vtt)',
+ 'content.label_subtitle_lang': 'Idioma del subtítulo',
+ 'content.subtitle_current': 'Hay una pista de subtítulos adjunta. Elige un archivo nuevo para reemplazarla.',
+ 'content.subtitle_remove': 'Quitar el subtítulo actual',
+ 'content.subtitle_hint': 'Sube un archivo WebVTT (.vtt) para mostrar subtítulos en este video en todas las pantallas.',
'content.label_replace_file': 'Reemplazar archivo',
'content.replace_file_hint': 'Déjalo vacío para mantener el archivo actual',
'content.folder_root_option': '— Raíz —',
diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js
index 3725377..9462118 100644
--- a/frontend/js/views/content-library.js
+++ b/frontend/js/views/content-library.js
@@ -3,6 +3,14 @@ import { showToast } from '../components/toast.js';
import { esc, hydrateAuthImages } from '../utils.js';
import { t } from '../i18n.js';
+// #216: languages offered in the caption/subtitle pickers. Codes are BCP-47 primary tags —
+// enough for signage; extend as needed.
+const SUBTITLE_LANGS = [
+ ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'],
+ ['pt', 'Português'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['ja', '日本語'],
+ ['ko', '한국어'], ['zh', '中文'],
+];
+
function formatFileSize(bytes) {
if (!bytes) return '--';
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
@@ -672,6 +680,11 @@ function showEditModal(contentItem, onSave) {
const isRemote = !!contentItem.remote_url;
const isYoutube = contentItem.mime_type === 'video/youtube';
+ const isUploadedVideo = !isRemote && contentItem.mime_type?.startsWith('video/');
+ // #216: language `)
+ .join('');
overlay.innerHTML = `
@@ -724,6 +737,32 @@ function showEditModal(contentItem, onSave) {
${t('content.unstable_connection_hint')}
` : ''}
+ ${isYoutube ? `
+
+
+
+
+
+
+
${t('content.captions_hint')}
+
+ ` : ''}
+ ${isUploadedVideo ? `
+
+
+ ${contentItem.subtitle_url ? `
${t('content.subtitle_current')}
` : ''}
+
+
+
+
+
+ ${contentItem.subtitle_url ? `` : ''}
+
${t('content.subtitle_hint')}
+
+ ` : ''}
${!isRemote ? `
@@ -772,6 +811,26 @@ function showEditModal(contentItem, onSave) {
const newUnstable = unstableEl.checked ? 1 : 0;
if (newUnstable !== (contentItem.unstable_connection ? 1 : 0)) updateData.unstable_connection = newUnstable;
}
+ // #216: YouTube captions (checkbox + language).
+ const captionsEl = overlay.querySelector('#editCaptionsEnabled');
+ if (captionsEl) {
+ const newCaptions = captionsEl.checked ? 1 : 0;
+ if (newCaptions !== (contentItem.captions_enabled ? 1 : 0)) updateData.captions_enabled = newCaptions;
+ const capLang = overlay.querySelector('#editCaptionsLang')?.value || null;
+ if (capLang !== (contentItem.captions_lang || 'en')) updateData.captions_lang = capLang;
+ }
+ // #216: uploaded-video subtitle language change / removal (the FILE is sent separately below).
+ const subtitleFile = overlay.querySelector('#editSubtitleFile')?.files[0];
+ const subLangEl = overlay.querySelector('#editSubtitleLang');
+ const subRemove = overlay.querySelector('#editSubtitleRemove')?.checked;
+ if (subRemove) {
+ updateData.subtitle_url = null;
+ updateData.subtitle_lang = null;
+ } else if (subLangEl && !subtitleFile) {
+ // Lang-only change (no new file) — the upload endpoint handles lang when a file IS sent.
+ const subLang = subLangEl.value || null;
+ if (contentItem.subtitle_url && subLang !== (contentItem.subtitle_lang || 'en')) updateData.subtitle_lang = subLang;
+ }
if (Object.keys(updateData).length > 0) {
await fetch('/api/content/' + contentItem.id, {
@@ -792,6 +851,18 @@ function showEditModal(contentItem, onSave) {
});
}
+ // #216: upload a new subtitle .vtt if one was chosen (skipped when "remove" is ticked).
+ if (subtitleFile && !subRemove) {
+ const subForm = new FormData();
+ subForm.append('subtitle', subtitleFile);
+ if (subLangEl?.value) subForm.append('subtitle_lang', subLangEl.value);
+ await fetch('/api/content/' + contentItem.id + '/subtitle', {
+ method: 'POST',
+ headers,
+ body: subForm
+ });
+ }
+
overlay.remove();
showToast(t('content.toast.updated'), 'success');
if (onSave) onSave();
diff --git a/server/db/database.js b/server/db/database.js
index 6d9edaf..a151477 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -380,6 +380,15 @@ const migrations = [
// embed at 720p (playerVars.vq='hd720') so weak/unstable WiFi on Android TV doesn't
// buffer/stall on an auto-selected 1080p+ stream. DEFAULT 0 = no cap (today's behaviour).
"ALTER TABLE content ADD COLUMN unstable_connection INTEGER NOT NULL DEFAULT 0",
+ // #216: subtitle/caption support as a content property (applied automatically by the
+ // player, no in-player controls). YouTube uses captions_enabled + captions_lang (via the
+ // IFrame API); uploaded videos use subtitle_url (a .vtt filename in the content dir,
+ // served at /uploads/content/) + subtitle_lang for the