feat: subtitle search & download from OpenSubtitles

close #343
This commit is contained in:
edde746
2026-04-01 22:20:59 +02:00
parent 64a5874566
commit 17f1075e64
39 changed files with 951 additions and 47 deletions
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Kapitler",
"noChaptersAvailable": "Ingen kapitler tilgængelige",
"queue": "Kø",
"noQueueItems": "Ingen elementer i køen"
"noQueueItems": "Ingen elementer i køen",
"searchSubtitles": "Søg undertekster",
"language": "Sprog",
"noSubtitlesFound": "Ingen undertekster fundet",
"subtitleDownloaded": "Undertekst downloadet",
"subtitleDownloadFailed": "Kunne ikke downloade undertekst",
"searchLanguages": "Søg sprog..."
},
"userStatus": {
"admin": "Administrator",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Kapitel",
"noChaptersAvailable": "Keine Kapitel verfügbar",
"queue": "Warteschlange",
"noQueueItems": "Keine Elemente in der Warteschlange"
"noQueueItems": "Keine Elemente in der Warteschlange",
"searchSubtitles": "Untertitel suchen",
"language": "Sprache",
"noSubtitlesFound": "Keine Untertitel gefunden",
"subtitleDownloaded": "Untertitel heruntergeladen",
"subtitleDownloadFailed": "Untertitel konnte nicht heruntergeladen werden",
"searchLanguages": "Sprachen suchen..."
},
"userStatus": {
"admin": "Eigentümer",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Chapters",
"noChaptersAvailable": "No chapters available",
"queue": "Queue",
"noQueueItems": "No items in queue"
"noQueueItems": "No items in queue",
"searchSubtitles": "Search Subtitles",
"language": "Language",
"noSubtitlesFound": "No subtitles found",
"subtitleDownloaded": "Subtitle downloaded",
"subtitleDownloadFailed": "Failed to download subtitle",
"searchLanguages": "Search languages..."
},
"userStatus": {
"admin": "Admin",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Capítulos",
"noChaptersAvailable": "No hay capítulos disponibles",
"queue": "Cola",
"noQueueItems": "No hay elementos en la cola"
"noQueueItems": "No hay elementos en la cola",
"searchSubtitles": "Buscar subtítulos",
"language": "Idioma",
"noSubtitlesFound": "No se encontraron subtítulos",
"subtitleDownloaded": "Subtítulo descargado",
"subtitleDownloadFailed": "Error al descargar subtítulo",
"searchLanguages": "Buscar idiomas..."
},
"userStatus": {
"admin": "Administrador",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Chapitres",
"noChaptersAvailable": "Aucun chapitre disponible",
"queue": "File d'attente",
"noQueueItems": "Aucun élément dans la file d'attente"
"noQueueItems": "Aucun élément dans la file d'attente",
"searchSubtitles": "Rechercher des sous-titres",
"language": "Langue",
"noSubtitlesFound": "Aucun sous-titre trouvé",
"subtitleDownloaded": "Sous-titre téléchargé",
"subtitleDownloadFailed": "Échec du téléchargement du sous-titre",
"searchLanguages": "Rechercher des langues..."
},
"userStatus": {
"admin": "Admin",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Capitoli",
"noChaptersAvailable": "Nessun capitolo disponibile",
"queue": "Coda",
"noQueueItems": "Nessun elemento in coda"
"noQueueItems": "Nessun elemento in coda",
"searchSubtitles": "Cerca sottotitoli",
"language": "Lingua",
"noSubtitlesFound": "Nessun sottotitolo trovato",
"subtitleDownloaded": "Sottotitolo scaricato",
"subtitleDownloadFailed": "Impossibile scaricare il sottotitolo",
"searchLanguages": "Cerca lingue..."
},
"userStatus": {
"admin": "Admin",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "チャプター",
"noChaptersAvailable": "チャプターがありません",
"queue": "キュー",
"noQueueItems": "キューにアイテムがありません"
"noQueueItems": "キューにアイテムがありません",
"searchSubtitles": "字幕を検索",
"language": "言語",
"noSubtitlesFound": "字幕が見つかりません",
"subtitleDownloaded": "字幕をダウンロードしました",
"subtitleDownloadFailed": "字幕のダウンロードに失敗しました",
"searchLanguages": "言語を検索..."
},
"userStatus": {
"admin": "管理者",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "챕터",
"noChaptersAvailable": "사용 가능한 챕터가 없습니다",
"queue": "재생 대기열",
"noQueueItems": "대기열에 항목이 없습니다"
"noQueueItems": "대기열에 항목이 없습니다",
"searchSubtitles": "자막 검색",
"language": "언어",
"noSubtitlesFound": "자막을 찾을 수 없습니다",
"subtitleDownloaded": "자막이 다운로드되었습니다",
"subtitleDownloadFailed": "자막 다운로드에 실패했습니다",
"searchLanguages": "언어 검색..."
},
"userStatus": {
"admin": "관리자",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Kapitler",
"noChaptersAvailable": "Ingen kapitler tilgjengelig",
"queue": "Kø",
"noQueueItems": "Ingen elementer i kø"
"noQueueItems": "Ingen elementer i kø",
"searchSubtitles": "Søk etter undertekster",
"language": "Språk",
"noSubtitlesFound": "Ingen undertekster funnet",
"subtitleDownloaded": "Undertekst lastet ned",
"subtitleDownloadFailed": "Kunne ikke laste ned undertekst",
"searchLanguages": "Søk etter språk..."
},
"userStatus": {
"admin": "Administrator",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Hoofdstukken",
"noChaptersAvailable": "Geen hoofdstukken beschikbaar",
"queue": "Wachtrij",
"noQueueItems": "Geen items in de wachtrij"
"noQueueItems": "Geen items in de wachtrij",
"searchSubtitles": "Ondertitels zoeken",
"language": "Taal",
"noSubtitlesFound": "Geen ondertitels gevonden",
"subtitleDownloaded": "Ondertitel gedownload",
"subtitleDownloadFailed": "Ondertitel downloaden mislukt",
"searchLanguages": "Talen zoeken..."
},
"userStatus": {
"admin": "Beheerder",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Rozdziały",
"noChaptersAvailable": "Brak dostępnych rozdziałów",
"queue": "Kolejka",
"noQueueItems": "Brak elementów w kolejce"
"noQueueItems": "Brak elementów w kolejce",
"searchSubtitles": "Szukaj napisów",
"language": "Język",
"noSubtitlesFound": "Nie znaleziono napisów",
"subtitleDownloaded": "Napisy pobrane",
"subtitleDownloadFailed": "Nie udało się pobrać napisów",
"searchLanguages": "Szukaj języków..."
},
"userStatus": {
"admin": "Administrator",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Capítulos",
"noChaptersAvailable": "Nenhum capítulo disponível",
"queue": "Fila",
"noQueueItems": "Nenhum item na fila"
"noQueueItems": "Nenhum item na fila",
"searchSubtitles": "Pesquisar legendas",
"language": "Idioma",
"noSubtitlesFound": "Nenhuma legenda encontrada",
"subtitleDownloaded": "Legenda baixada",
"subtitleDownloadFailed": "Falha ao baixar legenda",
"searchLanguages": "Pesquisar idiomas..."
},
"userStatus": {
"admin": "Admin",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Главы",
"noChaptersAvailable": "Главы недоступны",
"queue": "Очередь",
"noQueueItems": "В очереди нет элементов"
"noQueueItems": "В очереди нет элементов",
"searchSubtitles": "Поиск субтитров",
"language": "Язык",
"noSubtitlesFound": "Субтитры не найдены",
"subtitleDownloaded": "Субтитры загружены",
"subtitleDownloadFailed": "Не удалось загрузить субтитры",
"searchLanguages": "Поиск языков..."
},
"userStatus": {
"admin": "Администратор",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 12375 (825 per locale)
/// Strings: 12465 (831 per locale)
///
/// Built on 2026-04-01 at 09:43 UTC
/// Built on 2026-04-01 at 15:18 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsDa implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Ingen kapitler tilgængelige';
@override String get queue => '';
@override String get noQueueItems => 'Ingen elementer i køen';
@override String get searchSubtitles => 'Søg undertekster';
@override String get language => 'Sprog';
@override String get noSubtitlesFound => 'Ingen undertekster fundet';
@override String get subtitleDownloaded => 'Undertekst downloadet';
@override String get subtitleDownloadFailed => 'Kunne ikke downloade undertekst';
@override String get searchLanguages => 'Søg sprog...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsDa {
'videoControls.noChaptersAvailable' => 'Ingen kapitler tilgængelige',
'videoControls.queue' => '',
'videoControls.noQueueItems' => 'Ingen elementer i køen',
'videoControls.searchSubtitles' => 'Søg undertekster',
'videoControls.language' => 'Sprog',
'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet',
'videoControls.subtitleDownloaded' => 'Undertekst downloadet',
'videoControls.subtitleDownloadFailed' => 'Kunne ikke downloade undertekst',
'videoControls.searchLanguages' => 'Søg sprog...',
'userStatus.admin' => 'Administrator',
'userStatus.restricted' => 'Begrænset',
'userStatus.protected' => 'Beskyttet',
@@ -1828,14 +1840,14 @@ extension on TranslationsDa {
'liveTv.guide' => 'Guide',
'liveTv.noChannels' => 'Ingen kanaler tilgængelige',
'liveTv.noDvr' => 'Ingen DVR konfigureret på nogen server',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Ingen programdata tilgængelig',
'liveTv.live' => 'LIVE',
'liveTv.reloadGuide' => 'Genindlæs guide',
'liveTv.now' => 'Nu',
'liveTv.today' => 'I dag',
'liveTv.midnight' => 'Midnat',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Nat',
'liveTv.morning' => 'Morgen',
'liveTv.daytime' => 'Dagtid',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Keine Kapitel verfügbar';
@override String get queue => 'Warteschlange';
@override String get noQueueItems => 'Keine Elemente in der Warteschlange';
@override String get searchSubtitles => 'Untertitel suchen';
@override String get language => 'Sprache';
@override String get noSubtitlesFound => 'Keine Untertitel gefunden';
@override String get subtitleDownloaded => 'Untertitel heruntergeladen';
@override String get subtitleDownloadFailed => 'Untertitel konnte nicht heruntergeladen werden';
@override String get searchLanguages => 'Sprachen suchen...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsDe {
'videoControls.noChaptersAvailable' => 'Keine Kapitel verfügbar',
'videoControls.queue' => 'Warteschlange',
'videoControls.noQueueItems' => 'Keine Elemente in der Warteschlange',
'videoControls.searchSubtitles' => 'Untertitel suchen',
'videoControls.language' => 'Sprache',
'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden',
'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen',
'videoControls.subtitleDownloadFailed' => 'Untertitel konnte nicht heruntergeladen werden',
'videoControls.searchLanguages' => 'Sprachen suchen...',
'userStatus.admin' => 'Eigentümer',
'userStatus.restricted' => 'Eingeschränkt',
'userStatus.protected' => 'Geschützt',
@@ -1828,14 +1840,14 @@ extension on TranslationsDe {
'liveTv.guide' => 'Programmführer',
'liveTv.noChannels' => 'Keine Kanäle verfügbar',
'liveTv.noDvr' => 'Kein DVR auf einem Server konfiguriert',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Keine Programmdaten verfügbar',
'liveTv.live' => 'LIVE',
'liveTv.reloadGuide' => 'Programmführer neu laden',
'liveTv.now' => 'Jetzt',
'liveTv.today' => 'Heute',
'liveTv.midnight' => 'Mitternacht',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Morgen',
'liveTv.daytime' => 'Tagsüber',
+26 -2
View File
@@ -1162,6 +1162,24 @@ class TranslationsVideoControlsEn {
/// en: 'No items in queue'
String get noQueueItems => 'No items in queue';
/// en: 'Search Subtitles'
String get searchSubtitles => 'Search Subtitles';
/// en: 'Language'
String get language => 'Language';
/// en: 'No subtitles found'
String get noSubtitlesFound => 'No subtitles found';
/// en: 'Subtitle downloaded'
String get subtitleDownloaded => 'Subtitle downloaded';
/// en: 'Failed to download subtitle'
String get subtitleDownloadFailed => 'Failed to download subtitle';
/// en: 'Search languages...'
String get searchLanguages => 'Search languages...';
}
// Path: userStatus
@@ -3326,6 +3344,12 @@ extension on Translations {
'videoControls.noChaptersAvailable' => 'No chapters available',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.searchSubtitles' => 'Search Subtitles',
'videoControls.language' => 'Language',
'videoControls.noSubtitlesFound' => 'No subtitles found',
'videoControls.subtitleDownloaded' => 'Subtitle downloaded',
'videoControls.subtitleDownloadFailed' => 'Failed to download subtitle',
'videoControls.searchLanguages' => 'Search languages...',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Restricted',
'userStatus.protected' => 'Protected',
@@ -3485,14 +3509,14 @@ extension on Translations {
'liveTv.guide' => 'Guide',
'liveTv.noChannels' => 'No channels available',
'liveTv.noDvr' => 'No DVR configured on any server',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'No program data available',
'liveTv.live' => 'LIVE',
'liveTv.reloadGuide' => 'Reload Guide',
'liveTv.now' => 'Now',
'liveTv.today' => 'Today',
'liveTv.midnight' => 'Midnight',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Overnight',
'liveTv.morning' => 'Morning',
'liveTv.daytime' => 'Daytime',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'No hay capítulos disponibles';
@override String get queue => 'Cola';
@override String get noQueueItems => 'No hay elementos en la cola';
@override String get searchSubtitles => 'Buscar subtítulos';
@override String get language => 'Idioma';
@override String get noSubtitlesFound => 'No se encontraron subtítulos';
@override String get subtitleDownloaded => 'Subtítulo descargado';
@override String get subtitleDownloadFailed => 'Error al descargar subtítulo';
@override String get searchLanguages => 'Buscar idiomas...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsEs {
'videoControls.noChaptersAvailable' => 'No hay capítulos disponibles',
'videoControls.queue' => 'Cola',
'videoControls.noQueueItems' => 'No hay elementos en la cola',
'videoControls.searchSubtitles' => 'Buscar subtítulos',
'videoControls.language' => 'Idioma',
'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos',
'videoControls.subtitleDownloaded' => 'Subtítulo descargado',
'videoControls.subtitleDownloadFailed' => 'Error al descargar subtítulo',
'videoControls.searchLanguages' => 'Buscar idiomas...',
'userStatus.admin' => 'Administrador',
'userStatus.restricted' => 'Restringido',
'userStatus.protected' => 'Protegido',
@@ -1828,14 +1840,14 @@ extension on TranslationsEs {
'liveTv.guide' => 'Guía',
'liveTv.noChannels' => 'No hay canales disponibles',
'liveTv.noDvr' => 'No hay DVR configurado en ningún servidor',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'No hay datos de programación disponibles',
'liveTv.live' => 'EN VIVO',
'liveTv.reloadGuide' => 'Recargar guía',
'liveTv.now' => 'Ahora',
'liveTv.today' => 'Hoy',
'liveTv.midnight' => 'Medianoche',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Madrugada',
'liveTv.morning' => 'Mañana',
'liveTv.daytime' => 'Día',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Aucun chapitre disponible';
@override String get queue => 'File d\'attente';
@override String get noQueueItems => 'Aucun élément dans la file d\'attente';
@override String get searchSubtitles => 'Rechercher des sous-titres';
@override String get language => 'Langue';
@override String get noSubtitlesFound => 'Aucun sous-titre trouvé';
@override String get subtitleDownloaded => 'Sous-titre téléchargé';
@override String get subtitleDownloadFailed => 'Échec du téléchargement du sous-titre';
@override String get searchLanguages => 'Rechercher des langues...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsFr {
'videoControls.noChaptersAvailable' => 'Aucun chapitre disponible',
'videoControls.queue' => 'File d\'attente',
'videoControls.noQueueItems' => 'Aucun élément dans la file d\'attente',
'videoControls.searchSubtitles' => 'Rechercher des sous-titres',
'videoControls.language' => 'Langue',
'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé',
'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé',
'videoControls.subtitleDownloadFailed' => 'Échec du téléchargement du sous-titre',
'videoControls.searchLanguages' => 'Rechercher des langues...',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Restreint',
'userStatus.protected' => 'Protégé',
@@ -1828,14 +1840,14 @@ extension on TranslationsFr {
'liveTv.guide' => 'Guide',
'liveTv.noChannels' => 'Aucune chaîne disponible',
'liveTv.noDvr' => 'Aucun DVR configuré sur les serveurs',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Aucune donnée de programme disponible',
'liveTv.live' => 'EN DIRECT',
'liveTv.reloadGuide' => 'Recharger le guide',
'liveTv.now' => 'Maintenant',
'liveTv.today' => 'Aujourd\'hui',
'liveTv.midnight' => 'Minuit',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Nuit',
'liveTv.morning' => 'Matin',
'liveTv.daytime' => 'Journée',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Nessun capitolo disponibile';
@override String get queue => 'Coda';
@override String get noQueueItems => 'Nessun elemento in coda';
@override String get searchSubtitles => 'Cerca sottotitoli';
@override String get language => 'Lingua';
@override String get noSubtitlesFound => 'Nessun sottotitolo trovato';
@override String get subtitleDownloaded => 'Sottotitolo scaricato';
@override String get subtitleDownloadFailed => 'Impossibile scaricare il sottotitolo';
@override String get searchLanguages => 'Cerca lingue...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsIt {
'videoControls.noChaptersAvailable' => 'Nessun capitolo disponibile',
'videoControls.queue' => 'Coda',
'videoControls.noQueueItems' => 'Nessun elemento in coda',
'videoControls.searchSubtitles' => 'Cerca sottotitoli',
'videoControls.language' => 'Lingua',
'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato',
'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato',
'videoControls.subtitleDownloadFailed' => 'Impossibile scaricare il sottotitolo',
'videoControls.searchLanguages' => 'Cerca lingue...',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Limitato',
'userStatus.protected' => 'Protetto',
@@ -1828,14 +1840,14 @@ extension on TranslationsIt {
'liveTv.guide' => 'Guida',
'liveTv.noChannels' => 'Nessun canale disponibile',
'liveTv.noDvr' => 'Nessun DVR configurato su nessun server',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Nessun dato di programma disponibile',
'liveTv.live' => 'IN DIRETTA',
'liveTv.reloadGuide' => 'Ricarica guida',
'liveTv.now' => 'Ora',
'liveTv.today' => 'Oggi',
'liveTv.midnight' => 'Mezzanotte',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Notte',
'liveTv.morning' => 'Mattina',
'liveTv.daytime' => 'Giorno',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsJa implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'チャプターがありません';
@override String get queue => 'キュー';
@override String get noQueueItems => 'キューにアイテムがありません';
@override String get searchSubtitles => '字幕を検索';
@override String get language => '言語';
@override String get noSubtitlesFound => '字幕が見つかりません';
@override String get subtitleDownloaded => '字幕をダウンロードしました';
@override String get subtitleDownloadFailed => '字幕のダウンロードに失敗しました';
@override String get searchLanguages => '言語を検索...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsJa {
'videoControls.noChaptersAvailable' => 'チャプターがありません',
'videoControls.queue' => 'キュー',
'videoControls.noQueueItems' => 'キューにアイテムがありません',
'videoControls.searchSubtitles' => '字幕を検索',
'videoControls.language' => '言語',
'videoControls.noSubtitlesFound' => '字幕が見つかりません',
'videoControls.subtitleDownloaded' => '字幕をダウンロードしました',
'videoControls.subtitleDownloadFailed' => '字幕のダウンロードに失敗しました',
'videoControls.searchLanguages' => '言語を検索...',
'userStatus.admin' => '管理者',
'userStatus.restricted' => '制限付き',
'userStatus.protected' => '保護済み',
@@ -1828,14 +1840,14 @@ extension on TranslationsJa {
'liveTv.guide' => '番組表',
'liveTv.noChannels' => 'チャンネルがありません',
'liveTv.noDvr' => 'どのサーバーにもDVRが設定されていません',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => '番組データがありません',
'liveTv.live' => 'ライブ',
'liveTv.reloadGuide' => '番組表を再読込',
'liveTv.now' => '現在',
'liveTv.today' => '今日',
'liveTv.midnight' => '深夜',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => '深夜',
'liveTv.morning' => '',
'liveTv.daytime' => '',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => '사용 가능한 챕터가 없습니다';
@override String get queue => '재생 대기열';
@override String get noQueueItems => '대기열에 항목이 없습니다';
@override String get searchSubtitles => '자막 검색';
@override String get language => '언어';
@override String get noSubtitlesFound => '자막을 찾을 수 없습니다';
@override String get subtitleDownloaded => '자막이 다운로드되었습니다';
@override String get subtitleDownloadFailed => '자막 다운로드에 실패했습니다';
@override String get searchLanguages => '언어 검색...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsKo {
'videoControls.noChaptersAvailable' => '사용 가능한 챕터가 없습니다',
'videoControls.queue' => '재생 대기열',
'videoControls.noQueueItems' => '대기열에 항목이 없습니다',
'videoControls.searchSubtitles' => '자막 검색',
'videoControls.language' => '언어',
'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다',
'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다',
'videoControls.subtitleDownloadFailed' => '자막 다운로드에 실패했습니다',
'videoControls.searchLanguages' => '언어 검색...',
'userStatus.admin' => '관리자',
'userStatus.restricted' => '제한됨',
'userStatus.protected' => '보호됨',
@@ -1828,14 +1840,14 @@ extension on TranslationsKo {
'liveTv.guide' => '편성표',
'liveTv.noChannels' => '사용 가능한 채널이 없습니다',
'liveTv.noDvr' => '서버에 DVR이 구성되어 있지 않습니다',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => '프로그램 데이터가 없습니다',
'liveTv.live' => '실시간',
'liveTv.reloadGuide' => '편성표 새로고침',
'liveTv.now' => '지금',
'liveTv.today' => '오늘',
'liveTv.midnight' => '자정',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => '심야',
'liveTv.morning' => '아침',
'liveTv.daytime' => '',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsNb implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Ingen kapitler tilgjengelig';
@override String get queue => '';
@override String get noQueueItems => 'Ingen elementer i kø';
@override String get searchSubtitles => 'Søk etter undertekster';
@override String get language => 'Språk';
@override String get noSubtitlesFound => 'Ingen undertekster funnet';
@override String get subtitleDownloaded => 'Undertekst lastet ned';
@override String get subtitleDownloadFailed => 'Kunne ikke laste ned undertekst';
@override String get searchLanguages => 'Søk etter språk...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsNb {
'videoControls.noChaptersAvailable' => 'Ingen kapitler tilgjengelig',
'videoControls.queue' => '',
'videoControls.noQueueItems' => 'Ingen elementer i kø',
'videoControls.searchSubtitles' => 'Søk etter undertekster',
'videoControls.language' => 'Språk',
'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet',
'videoControls.subtitleDownloaded' => 'Undertekst lastet ned',
'videoControls.subtitleDownloadFailed' => 'Kunne ikke laste ned undertekst',
'videoControls.searchLanguages' => 'Søk etter språk...',
'userStatus.admin' => 'Administrator',
'userStatus.restricted' => 'Begrenset',
'userStatus.protected' => 'Beskyttet',
@@ -1828,14 +1840,14 @@ extension on TranslationsNb {
'liveTv.guide' => 'Programguide',
'liveTv.noChannels' => 'Ingen kanaler tilgjengelig',
'liveTv.noDvr' => 'Ingen DVR konfigurert på noen server',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Ingen programdata tilgjengelig',
'liveTv.live' => 'DIREKTE',
'liveTv.reloadGuide' => 'Last inn programguide på nytt',
'liveTv.now' => '',
'liveTv.today' => 'I dag',
'liveTv.midnight' => 'Midnatt',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Natt',
'liveTv.morning' => 'Morgen',
'liveTv.daytime' => 'Dagtid',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Geen hoofdstukken beschikbaar';
@override String get queue => 'Wachtrij';
@override String get noQueueItems => 'Geen items in de wachtrij';
@override String get searchSubtitles => 'Ondertitels zoeken';
@override String get language => 'Taal';
@override String get noSubtitlesFound => 'Geen ondertitels gevonden';
@override String get subtitleDownloaded => 'Ondertitel gedownload';
@override String get subtitleDownloadFailed => 'Ondertitel downloaden mislukt';
@override String get searchLanguages => 'Talen zoeken...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsNl {
'videoControls.noChaptersAvailable' => 'Geen hoofdstukken beschikbaar',
'videoControls.queue' => 'Wachtrij',
'videoControls.noQueueItems' => 'Geen items in de wachtrij',
'videoControls.searchSubtitles' => 'Ondertitels zoeken',
'videoControls.language' => 'Taal',
'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden',
'videoControls.subtitleDownloaded' => 'Ondertitel gedownload',
'videoControls.subtitleDownloadFailed' => 'Ondertitel downloaden mislukt',
'videoControls.searchLanguages' => 'Talen zoeken...',
'userStatus.admin' => 'Beheerder',
'userStatus.restricted' => 'Beperkt',
'userStatus.protected' => 'Beschermd',
@@ -1828,14 +1840,14 @@ extension on TranslationsNl {
'liveTv.guide' => 'Gids',
'liveTv.noChannels' => 'Geen zenders beschikbaar',
'liveTv.noDvr' => 'Geen DVR geconfigureerd op een server',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar',
'liveTv.live' => 'LIVE',
'liveTv.reloadGuide' => 'Gids herladen',
'liveTv.now' => 'Nu',
'liveTv.today' => 'Vandaag',
'liveTv.midnight' => 'Middernacht',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Ochtend',
'liveTv.daytime' => 'Overdag',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsPl implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Brak dostępnych rozdziałów';
@override String get queue => 'Kolejka';
@override String get noQueueItems => 'Brak elementów w kolejce';
@override String get searchSubtitles => 'Szukaj napisów';
@override String get language => 'Język';
@override String get noSubtitlesFound => 'Nie znaleziono napisów';
@override String get subtitleDownloaded => 'Napisy pobrane';
@override String get subtitleDownloadFailed => 'Nie udało się pobrać napisów';
@override String get searchLanguages => 'Szukaj języków...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsPl {
'videoControls.noChaptersAvailable' => 'Brak dostępnych rozdziałów',
'videoControls.queue' => 'Kolejka',
'videoControls.noQueueItems' => 'Brak elementów w kolejce',
'videoControls.searchSubtitles' => 'Szukaj napisów',
'videoControls.language' => 'Język',
'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów',
'videoControls.subtitleDownloaded' => 'Napisy pobrane',
'videoControls.subtitleDownloadFailed' => 'Nie udało się pobrać napisów',
'videoControls.searchLanguages' => 'Szukaj języków...',
'userStatus.admin' => 'Administrator',
'userStatus.restricted' => 'Ograniczony',
'userStatus.protected' => 'Chroniony',
@@ -1828,14 +1840,14 @@ extension on TranslationsPl {
'liveTv.guide' => 'Przewodnik',
'liveTv.noChannels' => 'Brak dostępnych kanałów',
'liveTv.noDvr' => 'Brak skonfigurowanego DVR na żadnym serwerze',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Brak danych o programach',
'liveTv.live' => 'NA ŻYWO',
'liveTv.reloadGuide' => 'Odśwież przewodnik',
'liveTv.now' => 'Teraz',
'liveTv.today' => 'Dzisiaj',
'liveTv.midnight' => 'Północ',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Nocą',
'liveTv.morning' => 'Rano',
'liveTv.daytime' => 'W ciągu dnia',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsPt implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Nenhum capítulo disponível';
@override String get queue => 'Fila';
@override String get noQueueItems => 'Nenhum item na fila';
@override String get searchSubtitles => 'Pesquisar legendas';
@override String get language => 'Idioma';
@override String get noSubtitlesFound => 'Nenhuma legenda encontrada';
@override String get subtitleDownloaded => 'Legenda baixada';
@override String get subtitleDownloadFailed => 'Falha ao baixar legenda';
@override String get searchLanguages => 'Pesquisar idiomas...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsPt {
'videoControls.noChaptersAvailable' => 'Nenhum capítulo disponível',
'videoControls.queue' => 'Fila',
'videoControls.noQueueItems' => 'Nenhum item na fila',
'videoControls.searchSubtitles' => 'Pesquisar legendas',
'videoControls.language' => 'Idioma',
'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada',
'videoControls.subtitleDownloaded' => 'Legenda baixada',
'videoControls.subtitleDownloadFailed' => 'Falha ao baixar legenda',
'videoControls.searchLanguages' => 'Pesquisar idiomas...',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Restrito',
'userStatus.protected' => 'Protegido',
@@ -1828,14 +1840,14 @@ extension on TranslationsPt {
'liveTv.guide' => 'Guia',
'liveTv.noChannels' => 'Nenhum canal disponível',
'liveTv.noDvr' => 'Nenhum DVR configurado em nenhum servidor',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Nenhum dado de programação disponível',
'liveTv.live' => 'AO VIVO',
'liveTv.reloadGuide' => 'Recarregar Guia',
'liveTv.now' => 'Agora',
'liveTv.today' => 'Hoje',
'liveTv.midnight' => 'Meia-noite',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Madrugada',
'liveTv.morning' => 'Manhã',
'liveTv.daytime' => 'Dia',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsRu implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Главы недоступны';
@override String get queue => 'Очередь';
@override String get noQueueItems => 'В очереди нет элементов';
@override String get searchSubtitles => 'Поиск субтитров';
@override String get language => 'Язык';
@override String get noSubtitlesFound => 'Субтитры не найдены';
@override String get subtitleDownloaded => 'Субтитры загружены';
@override String get subtitleDownloadFailed => 'Не удалось загрузить субтитры';
@override String get searchLanguages => 'Поиск языков...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsRu {
'videoControls.noChaptersAvailable' => 'Главы недоступны',
'videoControls.queue' => 'Очередь',
'videoControls.noQueueItems' => 'В очереди нет элементов',
'videoControls.searchSubtitles' => 'Поиск субтитров',
'videoControls.language' => 'Язык',
'videoControls.noSubtitlesFound' => 'Субтитры не найдены',
'videoControls.subtitleDownloaded' => 'Субтитры загружены',
'videoControls.subtitleDownloadFailed' => 'Не удалось загрузить субтитры',
'videoControls.searchLanguages' => 'Поиск языков...',
'userStatus.admin' => 'Администратор',
'userStatus.restricted' => 'Ограниченный',
'userStatus.protected' => 'Защищённый',
@@ -1828,14 +1840,14 @@ extension on TranslationsRu {
'liveTv.guide' => 'Программа',
'liveTv.noChannels' => 'Нет доступных каналов',
'liveTv.noDvr' => 'DVR не настроен ни на одном сервере',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Нет данных о программах',
'liveTv.live' => 'ЭФИР',
'liveTv.reloadGuide' => 'Перезагрузить программу',
'liveTv.now' => 'Сейчас',
'liveTv.today' => 'Сегодня',
'liveTv.midnight' => 'Полночь',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Ночь',
'liveTv.morning' => 'Утро',
'liveTv.daytime' => 'День',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => 'Inga kapitel tillgängliga';
@override String get queue => '';
@override String get noQueueItems => 'Inga objekt i kön';
@override String get searchSubtitles => 'Sök undertexter';
@override String get language => 'Språk';
@override String get noSubtitlesFound => 'Inga undertexter hittades';
@override String get subtitleDownloaded => 'Undertext nedladdad';
@override String get subtitleDownloadFailed => 'Kunde inte ladda ner undertext';
@override String get searchLanguages => 'Sök språk...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsSv {
'videoControls.noChaptersAvailable' => 'Inga kapitel tillgängliga',
'videoControls.queue' => '',
'videoControls.noQueueItems' => 'Inga objekt i kön',
'videoControls.searchSubtitles' => 'Sök undertexter',
'videoControls.language' => 'Språk',
'videoControls.noSubtitlesFound' => 'Inga undertexter hittades',
'videoControls.subtitleDownloaded' => 'Undertext nedladdad',
'videoControls.subtitleDownloadFailed' => 'Kunde inte ladda ner undertext',
'videoControls.searchLanguages' => 'Sök språk...',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Begränsad',
'userStatus.protected' => 'Skyddad',
@@ -1828,14 +1840,14 @@ extension on TranslationsSv {
'liveTv.guide' => 'Programguide',
'liveTv.noChannels' => 'Inga kanaler tillgängliga',
'liveTv.noDvr' => 'Ingen DVR konfigurerad på någon server',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => 'Ingen programdata tillgänglig',
'liveTv.live' => 'LIVE',
'liveTv.reloadGuide' => 'Ladda om programguide',
'liveTv.now' => 'Nu',
'liveTv.today' => 'Idag',
'liveTv.midnight' => 'Midnatt',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => 'Natt',
'liveTv.morning' => 'Morgon',
'liveTv.daytime' => 'Dagtid',
+14 -2
View File
@@ -515,6 +515,12 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get noChaptersAvailable => '没有可用的章节';
@override String get queue => '播放队列';
@override String get noQueueItems => '队列中没有项目';
@override String get searchSubtitles => '搜索字幕';
@override String get language => '语言';
@override String get noSubtitlesFound => '未找到字幕';
@override String get subtitleDownloaded => '字幕已下载';
@override String get subtitleDownloadFailed => '字幕下载失败';
@override String get searchLanguages => '搜索语言...';
}
// Path: userStatus
@@ -1669,6 +1675,12 @@ extension on TranslationsZh {
'videoControls.noChaptersAvailable' => '没有可用的章节',
'videoControls.queue' => '播放队列',
'videoControls.noQueueItems' => '队列中没有项目',
'videoControls.searchSubtitles' => '搜索字幕',
'videoControls.language' => '语言',
'videoControls.noSubtitlesFound' => '未找到字幕',
'videoControls.subtitleDownloaded' => '字幕已下载',
'videoControls.subtitleDownloadFailed' => '字幕下载失败',
'videoControls.searchLanguages' => '搜索语言...',
'userStatus.admin' => '管理员',
'userStatus.restricted' => '受限',
'userStatus.protected' => '受保护',
@@ -1828,14 +1840,14 @@ extension on TranslationsZh {
'liveTv.guide' => '节目指南',
'liveTv.noChannels' => '没有可用的频道',
'liveTv.noDvr' => '没有服务器配置了DVR',
_ => null,
} ?? switch (path) {
'liveTv.noPrograms' => '没有可用的节目数据',
'liveTv.live' => '直播',
'liveTv.reloadGuide' => '重新加载节目指南',
'liveTv.now' => '现在',
'liveTv.today' => '今天',
'liveTv.midnight' => '午夜',
_ => null,
} ?? switch (path) {
'liveTv.overnight' => '凌晨',
'liveTv.morning' => '上午',
'liveTv.daytime' => '白天',
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "Kapitel",
"noChaptersAvailable": "Inga kapitel tillgängliga",
"queue": "Kö",
"noQueueItems": "Inga objekt i kön"
"noQueueItems": "Inga objekt i kön",
"searchSubtitles": "Sök undertexter",
"language": "Språk",
"noSubtitlesFound": "Inga undertexter hittades",
"subtitleDownloaded": "Undertext nedladdad",
"subtitleDownloadFailed": "Kunde inte ladda ner undertext",
"searchLanguages": "Sök språk..."
},
"userStatus": {
"admin": "Admin",
+7 -1
View File
@@ -374,7 +374,13 @@
"chapters": "章节",
"noChaptersAvailable": "没有可用的章节",
"queue": "播放队列",
"noQueueItems": "队列中没有项目"
"noQueueItems": "队列中没有项目",
"searchSubtitles": "搜索字幕",
"language": "语言",
"noSubtitlesFound": "未找到字幕",
"subtitleDownloaded": "字幕已下载",
"subtitleDownloadFailed": "字幕下载失败",
"searchLanguages": "搜索语言..."
},
"userStatus": {
"admin": "管理员",
@@ -0,0 +1,63 @@
class PlexSubtitleSearchResult {
final int id;
final String key;
final String? codec;
final String? language;
final String? languageCode;
final double? score;
final String? providerTitle;
final String? title;
final String? displayTitle;
final bool hearingImpaired;
final bool perfectMatch;
final bool downloaded;
final bool forced;
PlexSubtitleSearchResult({
required this.id,
required this.key,
this.codec,
this.language,
this.languageCode,
this.score,
this.providerTitle,
this.title,
this.displayTitle,
this.hearingImpaired = false,
this.perfectMatch = false,
this.downloaded = false,
this.forced = false,
});
factory PlexSubtitleSearchResult.fromJson(Map<String, dynamic> json) {
return PlexSubtitleSearchResult(
id: _parseInt(json['id']),
key: json['key']?.toString() ?? '',
codec: json['codec']?.toString(),
language: json['language']?.toString(),
languageCode: json['languageCode']?.toString(),
score: _parseDouble(json['score']),
providerTitle: json['providerTitle']?.toString(),
title: json['title']?.toString(),
displayTitle: json['displayTitle']?.toString(),
hearingImpaired: json['hearingImpaired'] == 1 || json['hearingImpaired'] == true,
perfectMatch: json['perfectMatch'] == 1 || json['perfectMatch'] == true,
downloaded: json['downloaded'] == 1 || json['downloaded'] == true,
forced: json['forced'] == 1 || json['forced'] == true,
);
}
static int _parseInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static double? _parseDouble(dynamic v) {
if (v is double) return v;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v);
return null;
}
}
+50
View File
@@ -20,6 +20,7 @@ import '../models/plex_first_character.dart';
import '../models/plex_hub.dart';
import '../models/plex_library.dart';
import '../models/plex_media_info.dart';
import '../models/plex_subtitle_search_result.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../utils/content_utils.dart';
@@ -931,6 +932,55 @@ class PlexClient {
return true;
}
/// Search for subtitles from external providers (e.g. OpenSubtitles) via the Plex server.
/// [language] is an ISO 639-1 two-letter code (e.g. "en", "es").
Future<List<PlexSubtitleSearchResult>> searchSubtitles(
String ratingKey, {
required String language,
String? title,
int hearingImpaired = 0,
int forced = 0,
}) async {
return _wrapListApiCall<PlexSubtitleSearchResult>(
() => _dio.get('/library/metadata/$ratingKey/subtitles', queryParameters: {
'language': language,
if (title != null && title.isNotEmpty) 'title': title,
'hearingImpaired': hearingImpaired,
'forced': forced,
}),
(response) {
final container = _getMediaContainer(response);
final streams = container?['Stream'] as List? ?? [];
return streams.map((s) => PlexSubtitleSearchResult.fromJson(s as Map<String, dynamic>)).toList();
},
'Failed to search subtitles',
);
}
/// Download a subtitle from an external provider and add it to the media item.
/// The server downloads the file asynchronously; the new stream appears after a short delay.
Future<bool> downloadSubtitle(
String ratingKey, {
required String key,
required String codec,
required String language,
required bool hearingImpaired,
required bool forced,
required String providerTitle,
}) async {
return _wrapBoolApiCall(
() => _dio.put('/library/metadata/$ratingKey/subtitles', queryParameters: {
'key': key,
'codec': codec,
'language': language,
'hearingImpaired': hearingImpaired ? 1 : 0,
'forced': forced ? 1 : 0,
'providerTitle': providerTitle,
}),
'Failed to download subtitle',
);
}
/// Search across all libraries including individually shared items.
/// Uses /library/search (same endpoint as Plex Web) which finds shared content.
/// Only returns movies and shows, filtering out other types.
+19
View File
@@ -102,6 +102,25 @@ class LanguageCodes {
'TW': 'Taiwan',
};
/// Get all available languages as (code, name) pairs, sorted by name.
/// Returns ISO 639-1 two-letter codes with their English names.
static List<({String code, String name})> getAllLanguages() {
if (_codes == null) return [];
final languages = <({String code, String name})>[];
for (final entry in _codes!.entries) {
final code = entry.key;
// Skip locale variants like "en-GB" — only include base 2-letter codes
if (code.contains('-')) continue;
final map = entry.value as Map<String, dynamic>;
final name = map['name'] as String?;
if (name != null) {
languages.add((code: code, name: name));
}
}
languages.sort((a, b) => a.name.compareTo(b.name));
return languages;
}
/// Get the English name of a language from its code
static String? getLanguageName(String languageCode) {
if (_codes == null) return null;
@@ -40,6 +40,9 @@ class TrackControlsState {
final bool subtitlesVisible;
final bool showQueueButton;
final Function(PlexMetadata)? onQueueItemSelected;
final String ratingKey;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
const TrackControlsState({
this.availableVersions = const [],
@@ -75,5 +78,8 @@ class TrackControlsState {
this.subtitlesVisible = true,
this.showQueueButton = false,
this.onQueueItemSelected,
this.ratingKey = '',
this.mediaTitle,
this.onSubtitleDownloaded,
});
}
@@ -0,0 +1,379 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../i18n/strings.g.dart';
import '../../../models/plex_subtitle_search_result.dart';
import '../../../utils/language_codes.dart';
import '../../../utils/provider_extensions.dart';
import '../../../utils/snackbar_helper.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
class SubtitleSearchSheet extends StatefulWidget {
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
const SubtitleSearchSheet({
super.key,
required this.ratingKey,
required this.serverId,
this.mediaTitle,
this.onSubtitleDownloaded,
});
@override
State<SubtitleSearchSheet> createState() => _SubtitleSearchSheetState();
}
class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> {
String _languageCode = 'en';
String _languageName = 'English';
final _titleController = TextEditingController();
Timer? _debounceTimer;
List<PlexSubtitleSearchResult>? _results;
bool _isSearching = false;
String? _error;
String? _downloadingKey;
// Internal view switching instead of push/pop
bool _showLanguagePicker = false;
@override
void initState() {
super.initState();
_initDefaultLanguage();
WidgetsBinding.instance.addPostFrameCallback((_) => _search());
}
void _initDefaultLanguage() {
final locale = WidgetsBinding.instance.platformDispatcher.locale;
final code = locale.languageCode;
final name = LanguageCodes.getLanguageName(code);
if (name != null) {
_languageCode = code;
_languageName = name;
}
}
@override
void dispose() {
_debounceTimer?.cancel();
_titleController.dispose();
super.dispose();
}
Future<void> _search() async {
if (!mounted) return;
setState(() {
_isSearching = true;
_error = null;
});
try {
final client = context.getClientForServer(widget.serverId);
final title = _titleController.text.trim();
final results = await client.searchSubtitles(
widget.ratingKey,
language: _languageCode,
title: title.isEmpty ? null : title,
);
if (!mounted) return;
setState(() {
_results = results;
_isSearching = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isSearching = false;
});
}
}
void _onTitleChanged(String _) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), _search);
}
void _onLanguageSelected(String code, String name) {
setState(() {
_languageCode = code;
_languageName = name;
_showLanguagePicker = false;
});
_search();
}
Future<void> _downloadSubtitle(PlexSubtitleSearchResult result) async {
if (_downloadingKey != null) return;
setState(() => _downloadingKey = result.key);
try {
final client = context.getClientForServer(widget.serverId);
final success = await client.downloadSubtitle(
widget.ratingKey,
key: result.key,
codec: result.codec ?? 'srt',
language: result.languageCode ?? _languageCode,
hearingImpaired: result.hearingImpaired,
forced: result.forced,
providerTitle: result.providerTitle ?? '',
);
if (!mounted) return;
if (success) {
await widget.onSubtitleDownloaded?.call();
if (!mounted) return;
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
OverlaySheetController.of(context).close();
} else {
showErrorSnackBar(context, t.videoControls.subtitleDownloadFailed);
setState(() => _downloadingKey = null);
}
} catch (e) {
if (!mounted) return;
showErrorSnackBar(context, t.videoControls.subtitleDownloadFailed);
setState(() => _downloadingKey = null);
}
}
@override
Widget build(BuildContext context) {
if (_showLanguagePicker) {
return _LanguagePickerView(
currentCode: _languageCode,
onSelected: _onLanguageSelected,
onBack: () => setState(() => _showLanguagePicker = false),
);
}
return BaseVideoControlSheet(
title: t.videoControls.searchSubtitles,
icon: Symbols.search_rounded,
onBack: () => OverlaySheetController.of(context).pop(),
child: Column(
children: [
FocusableListTile(
leading: const AppIcon(Symbols.language_rounded),
title: Text(t.videoControls.language),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_languageName,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(width: 4),
const AppIcon(Symbols.arrow_drop_down_rounded),
],
),
onTap: () => setState(() => _showLanguagePicker = true),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TextField(
controller: _titleController,
decoration: InputDecoration(
hintText: widget.mediaTitle ?? 'Title',
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
onChanged: _onTitleChanged,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _search(),
),
),
Divider(height: 1, color: Theme.of(context).dividerColor),
Expanded(child: _buildResults()),
],
),
);
}
Widget _buildResults() {
if (_isSearching) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
);
}
if (_results == null) {
return const SizedBox.shrink();
}
if (_results!.isEmpty) {
return Center(
child: Text(
t.videoControls.noSubtitlesFound,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
);
}
return ListView.builder(
itemCount: _results!.length,
itemBuilder: (context, index) {
final result = _results![index];
final isDownloading = _downloadingKey == result.key;
final colorScheme = Theme.of(context).colorScheme;
Widget? trailing;
if (isDownloading) {
trailing = const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
} else {
final trailingChildren = <Widget>[];
if (result.perfectMatch) {
trailingChildren.add(
const AppIcon(Symbols.star_rounded, fill: 1, color: Color(0xFFCC7B19), size: 16),
);
}
if (result.score != null) {
trailingChildren.add(Text(
result.score!.toInt().toString(),
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
));
}
if (trailingChildren.isNotEmpty) {
trailing = Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: trailingChildren,
);
}
}
return FocusableListTile(
title: Text(
result.title ?? result.displayTitle ?? 'Unknown',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
result.displayTitle ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: trailing,
onTap: isDownloading ? null : () => _downloadSubtitle(result),
);
},
);
}
}
/// Language picker rendered as an internal view, not a pushed page.
class _LanguagePickerView extends StatefulWidget {
final String currentCode;
final void Function(String code, String name) onSelected;
final VoidCallback onBack;
const _LanguagePickerView({
required this.currentCode,
required this.onSelected,
required this.onBack,
});
@override
State<_LanguagePickerView> createState() => _LanguagePickerViewState();
}
class _LanguagePickerViewState extends State<_LanguagePickerView> {
final _filterController = TextEditingController();
late List<({String code, String name})> _allLanguages;
List<({String code, String name})> _filteredLanguages = [];
@override
void initState() {
super.initState();
_allLanguages = LanguageCodes.getAllLanguages();
_filteredLanguages = _allLanguages;
}
@override
void dispose() {
_filterController.dispose();
super.dispose();
}
void _onFilterChanged(String query) {
final lower = query.toLowerCase();
setState(() {
if (lower.isEmpty) {
_filteredLanguages = _allLanguages;
} else {
_filteredLanguages = _allLanguages.where((l) {
return l.name.toLowerCase().contains(lower) || l.code.toLowerCase().contains(lower);
}).toList();
}
});
}
@override
Widget build(BuildContext context) {
return BaseVideoControlSheet(
title: t.videoControls.language,
icon: Symbols.language_rounded,
onBack: widget.onBack,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _filterController,
autofocus: true,
decoration: InputDecoration(
hintText: t.videoControls.searchLanguages,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
prefixIcon: const Icon(Symbols.search_rounded, size: 20),
),
onChanged: _onFilterChanged,
),
),
Expanded(
child: ListView.builder(
itemCount: _filteredLanguages.length,
itemBuilder: (context, index) {
final lang = _filteredLanguages[index];
final isSelected = lang.code == widget.currentCode;
return FocusableListTile(
title: Text(
lang.name,
style: TextStyle(color: isSelected ? Theme.of(context).colorScheme.primary : null),
),
trailing: isSelected
? AppIcon(Symbols.check_rounded, fill: 1, color: Theme.of(context).colorScheme.primary)
: null,
onTap: () => widget.onSelected(lang.code, lang.name),
);
},
),
),
],
),
);
}
}
@@ -5,14 +5,21 @@ import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/scroll_utils.dart';
import '../../../utils/track_label_builder.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
import 'subtitle_search_sheet.dart';
import '../helpers/track_filter_helper.dart';
import '../helpers/track_selection_helper.dart';
/// Combined bottom sheet for selecting audio and subtitle tracks side-by-side.
class TrackSheet extends StatelessWidget {
final Player player;
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
@@ -20,6 +27,10 @@ class TrackSheet extends StatelessWidget {
const TrackSheet({
super.key,
required this.player,
this.ratingKey = '',
this.serverId = '',
this.mediaTitle,
this.onSubtitleDownloaded,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
@@ -88,6 +99,10 @@ class TrackSheet extends StatelessWidget {
tracks: subtitleTracks,
selection: selection,
player: player,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
onSubtitleDownloaded: onSubtitleDownloaded,
onTrackChanged: onSubtitleTrackChanged,
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
supportsSecondary: supportsSecondary,
@@ -113,6 +128,10 @@ class TrackSheet extends StatelessWidget {
tracks: subtitleTracks,
selection: selection,
player: player,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
onSubtitleDownloaded: onSubtitleDownloaded,
onTrackChanged: onSubtitleTrackChanged,
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
supportsSecondary: supportsSecondary,
@@ -207,6 +226,10 @@ class _SubtitleColumn extends StatefulWidget {
final List<SubtitleTrack> tracks;
final TrackSelection selection;
final Player player;
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Function(SubtitleTrack)? onTrackChanged;
final Function(SubtitleTrack)? onSecondaryTrackChanged;
final bool supportsSecondary;
@@ -216,6 +239,10 @@ class _SubtitleColumn extends StatefulWidget {
required this.tracks,
required this.selection,
required this.player,
this.ratingKey = '',
this.serverId = '',
this.mediaTitle,
this.onSubtitleDownloaded,
this.onTrackChanged,
this.onSecondaryTrackChanged,
this.supportsSecondary = false,
@@ -358,6 +385,23 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
},
),
),
if (widget.ratingKey.isNotEmpty) ...[
Divider(height: 1, color: Theme.of(context).dividerColor),
FocusableListTile(
leading: const AppIcon(Symbols.search_rounded),
title: Text(t.videoControls.searchSubtitles),
onTap: () {
OverlaySheetController.of(context).push(
builder: (_) => SubtitleSearchSheet(
ratingKey: widget.ratingKey,
serverId: widget.serverId,
mediaTitle: widget.mediaTitle,
onSubtitleDownloaded: widget.onSubtitleDownloaded,
),
);
},
),
],
],
);
}
@@ -1170,6 +1170,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
subtitlesVisible: _subtitlesVisible,
showQueueButton: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
ratingKey: widget.metadata.ratingKey,
mediaTitle: widget.metadata.title,
onSubtitleDownloaded: _onSubtitleDownloaded,
);
}
@@ -1631,6 +1634,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
bool _handleGlobalKeyEvent(KeyEvent event) {
if (!mounted) return false;
// When an overlay sheet is open (e.g. subtitle search with text fields),
// don't consume key events — let text input work normally.
if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) {
return false;
}
// Back key fallback when _focusNode lost focus (TV, or desktop with nav on).
// Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here.
if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) {
@@ -2428,6 +2437,51 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
videoPlayerState?.navigateToQueueItem(item);
}
Future<void> _onSubtitleDownloaded() async {
if (!mounted) return;
// Wait for the server to finish downloading the subtitle file
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
try {
final client = _getClientForMetadata();
final data = await client.getVideoPlaybackData(widget.metadata.ratingKey);
if (!mounted || data.mediaInfo == null) return;
final token = client.config.token;
if (token == null) return;
// Find external subtitle tracks from the refreshed metadata
final existingUris = widget.player.state.tracks.subtitle
.where((t) => t.uri != null)
.map((t) => t.uri!)
.toSet();
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
if (!plexTrack.isExternal) continue;
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
if (url == null) continue;
// Skip tracks already loaded in the player
if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue;
await widget.player.addSubtitleTrack(
uri: url,
title: plexTrack.displayTitle ?? plexTrack.language ?? 'Downloaded',
language: plexTrack.languageCode,
select: true,
);
// Save the selection on the server so it persists across sessions
final partId = data.mediaInfo!.partId;
if (partId != null) {
await client.selectStreams(partId, subtitleStreamID: plexTrack.id);
}
}
} catch (e) {
appLogger.w('Failed to refresh subtitles after download', error: e);
}
}
Future<void> _switchMediaVersion(int newMediaIndex) async {
if (newMediaIndex == widget.selectedMediaIndex) {
return; // Already using this version
@@ -97,6 +97,9 @@ class TrackChapterControls extends StatelessWidget {
bool get subtitlesVisible => trackControlsState.subtitlesVisible;
bool get showQueueButton => trackControlsState.showQueueButton;
Function(PlexMetadata)? get onQueueItemSelected => trackControlsState.onQueueItemSelected;
String get ratingKey => trackControlsState.ratingKey;
String? get mediaTitle => trackControlsState.mediaTitle;
Future<void> Function()? get onSubtitleDownloaded => trackControlsState.onSubtitleDownloaded;
/// Handle key event for button navigation
KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) {
@@ -257,6 +260,10 @@ class TrackChapterControls extends StatelessWidget {
.show(
builder: (_) => TrackSheet(
player: player,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
onSubtitleDownloaded: onSubtitleDownloaded,
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,