feat: live tv
This commit is contained in:
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Mediatheken",
|
||||
"downloads": "Downloads"
|
||||
"downloads": "Downloads",
|
||||
"liveTv": "Live-TV"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "Live-TV",
|
||||
"channels": "Kanäle",
|
||||
"guide": "Programmführer",
|
||||
"recordings": "Aufnahmen",
|
||||
"subscriptions": "Aufnahmeregeln",
|
||||
"scheduled": "Geplant",
|
||||
"noChannels": "Keine Kanäle verfügbar",
|
||||
"noDvr": "Kein DVR auf einem Server konfiguriert",
|
||||
"tuneFailed": "Kanal konnte nicht eingestellt werden",
|
||||
"loading": "Kanäle werden geladen...",
|
||||
"nowPlaying": "Läuft gerade",
|
||||
"whatsOnNow": "Jetzt im TV",
|
||||
"record": "Aufnehmen",
|
||||
"recordSeries": "Serie aufnehmen",
|
||||
"cancelRecording": "Aufnahme abbrechen",
|
||||
"deleteSubscription": "Aufnahmeregel löschen",
|
||||
"deleteSubscriptionConfirm": "Möchten Sie diese Aufnahmeregel wirklich löschen?",
|
||||
"subscriptionDeleted": "Aufnahmeregel gelöscht",
|
||||
"noPrograms": "Keine Programmdaten verfügbar",
|
||||
"noRecordings": "Keine Aufnahmen geplant",
|
||||
"noSubscriptions": "Keine Aufnahmeregeln",
|
||||
"channelNumber": "Kanal ${number}",
|
||||
"live": "LIVE",
|
||||
"hd": "HD",
|
||||
"premiere": "NEU",
|
||||
"reloadGuide": "Programmführer neu laden",
|
||||
"guideReloaded": "Programmdaten neu geladen",
|
||||
"allChannels": "Alle Kanäle"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Libraries",
|
||||
"downloads": "Downloads"
|
||||
"downloads": "Downloads",
|
||||
"liveTv": "Live TV"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "Live TV",
|
||||
"channels": "Channels",
|
||||
"guide": "Guide",
|
||||
"recordings": "Recordings",
|
||||
"subscriptions": "Subscriptions",
|
||||
"scheduled": "Scheduled",
|
||||
"noChannels": "No channels available",
|
||||
"noDvr": "No DVR configured on any server",
|
||||
"tuneFailed": "Failed to tune channel",
|
||||
"loading": "Loading channels...",
|
||||
"nowPlaying": "Now Playing",
|
||||
"whatsOnNow": "What's On Now",
|
||||
"record": "Record",
|
||||
"recordSeries": "Record Series",
|
||||
"cancelRecording": "Cancel Recording",
|
||||
"deleteSubscription": "Delete Recording Rule",
|
||||
"deleteSubscriptionConfirm": "Are you sure you want to delete this recording rule?",
|
||||
"subscriptionDeleted": "Recording rule deleted",
|
||||
"noPrograms": "No program data available",
|
||||
"noRecordings": "No recordings scheduled",
|
||||
"noSubscriptions": "No recording rules",
|
||||
"channelNumber": "Ch. ${number}",
|
||||
"live": "LIVE",
|
||||
"hd": "HD",
|
||||
"premiere": "NEW",
|
||||
"reloadGuide": "Reload Guide",
|
||||
"guideReloaded": "Guide data reloaded",
|
||||
"allChannels": "All Channels"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Bibliotecas",
|
||||
"downloads": "Descargas"
|
||||
"downloads": "Descargas",
|
||||
"liveTv": "TV en vivo"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "TV en vivo",
|
||||
"channels": "Canales",
|
||||
"guide": "Guía",
|
||||
"recordings": "Grabaciones",
|
||||
"subscriptions": "Reglas de grabación",
|
||||
"scheduled": "Programadas",
|
||||
"noChannels": "No hay canales disponibles",
|
||||
"noDvr": "No hay DVR configurado en ningún servidor",
|
||||
"tuneFailed": "Error al sintonizar el canal",
|
||||
"loading": "Cargando canales...",
|
||||
"nowPlaying": "Reproduciendo ahora",
|
||||
"whatsOnNow": "En emisión ahora",
|
||||
"record": "Grabar",
|
||||
"recordSeries": "Grabar serie",
|
||||
"cancelRecording": "Cancelar grabación",
|
||||
"deleteSubscription": "Eliminar regla de grabación",
|
||||
"deleteSubscriptionConfirm": "¿Estás seguro de que quieres eliminar esta regla de grabación?",
|
||||
"subscriptionDeleted": "Regla de grabación eliminada",
|
||||
"noPrograms": "No hay datos de programación disponibles",
|
||||
"noRecordings": "No hay grabaciones programadas",
|
||||
"noSubscriptions": "No hay reglas de grabación",
|
||||
"channelNumber": "Canal ${number}",
|
||||
"live": "EN VIVO",
|
||||
"hd": "HD",
|
||||
"premiere": "NUEVO",
|
||||
"reloadGuide": "Recargar guía",
|
||||
"guideReloaded": "Datos de la guía recargados",
|
||||
"allChannels": "Todos los canales"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Colecciones",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Bibliothèques",
|
||||
"downloads": "Téléchargements"
|
||||
"downloads": "Téléchargements",
|
||||
"liveTv": "TV en direct"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "TV en direct",
|
||||
"channels": "Chaînes",
|
||||
"guide": "Guide",
|
||||
"recordings": "Enregistrements",
|
||||
"subscriptions": "Règles d'enregistrement",
|
||||
"scheduled": "Programmés",
|
||||
"noChannels": "Aucune chaîne disponible",
|
||||
"noDvr": "Aucun DVR configuré sur les serveurs",
|
||||
"tuneFailed": "Impossible de syntoniser la chaîne",
|
||||
"loading": "Chargement des chaînes...",
|
||||
"nowPlaying": "En cours de lecture",
|
||||
"whatsOnNow": "En ce moment",
|
||||
"record": "Enregistrer",
|
||||
"recordSeries": "Enregistrer la série",
|
||||
"cancelRecording": "Annuler l'enregistrement",
|
||||
"deleteSubscription": "Supprimer la règle d'enregistrement",
|
||||
"deleteSubscriptionConfirm": "Voulez-vous vraiment supprimer cette règle d'enregistrement ?",
|
||||
"subscriptionDeleted": "Règle d'enregistrement supprimée",
|
||||
"noPrograms": "Aucune donnée de programme disponible",
|
||||
"noRecordings": "Aucun enregistrement programmé",
|
||||
"noSubscriptions": "Aucune règle d'enregistrement",
|
||||
"channelNumber": "Ch. ${number}",
|
||||
"live": "EN DIRECT",
|
||||
"hd": "HD",
|
||||
"premiere": "NOUVEAU",
|
||||
"reloadGuide": "Recharger le guide",
|
||||
"guideReloaded": "Données du guide rechargées",
|
||||
"allChannels": "Toutes les chaînes"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Librerie",
|
||||
"downloads": "Download"
|
||||
"downloads": "Download",
|
||||
"liveTv": "TV in diretta"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "TV in diretta",
|
||||
"channels": "Canali",
|
||||
"guide": "Guida",
|
||||
"recordings": "Registrazioni",
|
||||
"subscriptions": "Regole di registrazione",
|
||||
"scheduled": "Programmati",
|
||||
"noChannels": "Nessun canale disponibile",
|
||||
"noDvr": "Nessun DVR configurato su nessun server",
|
||||
"tuneFailed": "Impossibile sintonizzare il canale",
|
||||
"loading": "Caricamento canali...",
|
||||
"nowPlaying": "In riproduzione",
|
||||
"whatsOnNow": "In onda adesso",
|
||||
"record": "Registra",
|
||||
"recordSeries": "Registra serie",
|
||||
"cancelRecording": "Annulla registrazione",
|
||||
"deleteSubscription": "Elimina regola di registrazione",
|
||||
"deleteSubscriptionConfirm": "Sei sicuro di voler eliminare questa regola di registrazione?",
|
||||
"subscriptionDeleted": "Regola di registrazione eliminata",
|
||||
"noPrograms": "Nessun dato di programma disponibile",
|
||||
"noRecordings": "Nessuna registrazione programmata",
|
||||
"noSubscriptions": "Nessuna regola di registrazione",
|
||||
"channelNumber": "Canale ${number}",
|
||||
"live": "IN DIRETTA",
|
||||
"hd": "HD",
|
||||
"premiere": "NUOVO",
|
||||
"reloadGuide": "Ricarica guida",
|
||||
"guideReloaded": "Dati della guida ricaricati",
|
||||
"allChannels": "Tutti i canali"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Download",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "미디어 라이브러리",
|
||||
"downloads": "다운로드"
|
||||
"downloads": "다운로드",
|
||||
"liveTv": "실시간 TV"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "실시간 TV",
|
||||
"channels": "채널",
|
||||
"guide": "편성표",
|
||||
"recordings": "녹화",
|
||||
"subscriptions": "녹화 규칙",
|
||||
"scheduled": "예약됨",
|
||||
"noChannels": "사용 가능한 채널이 없습니다",
|
||||
"noDvr": "서버에 DVR이 구성되어 있지 않습니다",
|
||||
"tuneFailed": "채널 튜닝에 실패했습니다",
|
||||
"loading": "채널 로딩 중...",
|
||||
"nowPlaying": "현재 재생 중",
|
||||
"whatsOnNow": "지금 방송 중",
|
||||
"record": "녹화",
|
||||
"recordSeries": "시리즈 녹화",
|
||||
"cancelRecording": "녹화 취소",
|
||||
"deleteSubscription": "녹화 규칙 삭제",
|
||||
"deleteSubscriptionConfirm": "이 녹화 규칙을 삭제하시겠습니까?",
|
||||
"subscriptionDeleted": "녹화 규칙이 삭제되었습니다",
|
||||
"noPrograms": "프로그램 데이터가 없습니다",
|
||||
"noRecordings": "예약된 녹화가 없습니다",
|
||||
"noSubscriptions": "녹화 규칙이 없습니다",
|
||||
"channelNumber": "채널 ${number}",
|
||||
"live": "실시간",
|
||||
"hd": "HD",
|
||||
"premiere": "신규",
|
||||
"reloadGuide": "편성표 새로고침",
|
||||
"guideReloaded": "편성표 데이터가 새로고침되었습니다",
|
||||
"allChannels": "전체 채널"
|
||||
},
|
||||
"collections": {
|
||||
"title": "컬렉션",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Bibliotheken",
|
||||
"downloads": "Downloads"
|
||||
"downloads": "Downloads",
|
||||
"liveTv": "Live TV"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "Live TV",
|
||||
"channels": "Zenders",
|
||||
"guide": "Gids",
|
||||
"recordings": "Opnames",
|
||||
"subscriptions": "Opnameregels",
|
||||
"scheduled": "Gepland",
|
||||
"noChannels": "Geen zenders beschikbaar",
|
||||
"noDvr": "Geen DVR geconfigureerd op een server",
|
||||
"tuneFailed": "Kan zender niet afstemmen",
|
||||
"loading": "Zenders laden...",
|
||||
"nowPlaying": "Nu aan het afspelen",
|
||||
"whatsOnNow": "Nu op TV",
|
||||
"record": "Opnemen",
|
||||
"recordSeries": "Serie opnemen",
|
||||
"cancelRecording": "Opname annuleren",
|
||||
"deleteSubscription": "Opnameregel verwijderen",
|
||||
"deleteSubscriptionConfirm": "Weet je zeker dat je deze opnameregel wilt verwijderen?",
|
||||
"subscriptionDeleted": "Opnameregel verwijderd",
|
||||
"noPrograms": "Geen programmagegevens beschikbaar",
|
||||
"noRecordings": "Geen opnames gepland",
|
||||
"noSubscriptions": "Geen opnameregels",
|
||||
"channelNumber": "Kanaal ${number}",
|
||||
"live": "LIVE",
|
||||
"hd": "HD",
|
||||
"premiere": "NIEUW",
|
||||
"reloadGuide": "Gids herladen",
|
||||
"guideReloaded": "Gidsgegevens herladen",
|
||||
"allChannels": "Alle zenders"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 9
|
||||
/// Strings: 6219 (691 per locale)
|
||||
/// Strings: 6480 (720 per locale)
|
||||
///
|
||||
/// Built on 2026-02-11 at 13:27 UTC
|
||||
/// Built on 2026-02-11 at 15:19 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsDe with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsDe logs = _TranslationsLogsDe._(_root);
|
||||
@override late final _TranslationsLicensesDe licenses = _TranslationsLicensesDe._(_root);
|
||||
@override late final _TranslationsNavigationDe navigation = _TranslationsNavigationDe._(_root);
|
||||
@override late final _TranslationsLiveTvDe liveTv = _TranslationsLiveTvDe._(_root);
|
||||
@override late final _TranslationsDownloadsDe downloads = _TranslationsDownloadsDe._(_root);
|
||||
@override late final _TranslationsPlaylistsDe playlists = _TranslationsPlaylistsDe._(_root);
|
||||
@override late final _TranslationsCollectionsDe collections = _TranslationsCollectionsDe._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationDe implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => 'Mediatheken';
|
||||
@override String get downloads => 'Downloads';
|
||||
@override String get liveTv => 'Live-TV';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvDe implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvDe._(this._root);
|
||||
|
||||
final TranslationsDe _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Live-TV';
|
||||
@override String get channels => 'Kanäle';
|
||||
@override String get guide => 'Programmführer';
|
||||
@override String get recordings => 'Aufnahmen';
|
||||
@override String get subscriptions => 'Aufnahmeregeln';
|
||||
@override String get scheduled => 'Geplant';
|
||||
@override String get noChannels => 'Keine Kanäle verfügbar';
|
||||
@override String get noDvr => 'Kein DVR auf einem Server konfiguriert';
|
||||
@override String get tuneFailed => 'Kanal konnte nicht eingestellt werden';
|
||||
@override String get loading => 'Kanäle werden geladen...';
|
||||
@override String get nowPlaying => 'Läuft gerade';
|
||||
@override String get whatsOnNow => 'Jetzt im TV';
|
||||
@override String get record => 'Aufnehmen';
|
||||
@override String get recordSeries => 'Serie aufnehmen';
|
||||
@override String get cancelRecording => 'Aufnahme abbrechen';
|
||||
@override String get deleteSubscription => 'Aufnahmeregel löschen';
|
||||
@override String get deleteSubscriptionConfirm => 'Möchten Sie diese Aufnahmeregel wirklich löschen?';
|
||||
@override String get subscriptionDeleted => 'Aufnahmeregel gelöscht';
|
||||
@override String get noPrograms => 'Keine Programmdaten verfügbar';
|
||||
@override String get noRecordings => 'Keine Aufnahmen geplant';
|
||||
@override String get noSubscriptions => 'Keine Aufnahmeregeln';
|
||||
@override String channelNumber({required Object number}) => 'Kanal ${number}';
|
||||
@override String get live => 'LIVE';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => 'NEU';
|
||||
@override String get reloadGuide => 'Programmführer neu laden';
|
||||
@override String get guideReloaded => 'Programmdaten neu geladen';
|
||||
@override String get allChannels => 'Alle Kanäle';
|
||||
}
|
||||
|
||||
// Path: downloads
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsDe {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} Lizenzen',
|
||||
'navigation.libraries' => 'Mediatheken',
|
||||
'navigation.downloads' => 'Downloads',
|
||||
'navigation.liveTv' => 'Live-TV',
|
||||
'liveTv.title' => 'Live-TV',
|
||||
'liveTv.channels' => 'Kanäle',
|
||||
'liveTv.guide' => 'Programmführer',
|
||||
'liveTv.recordings' => 'Aufnahmen',
|
||||
'liveTv.subscriptions' => 'Aufnahmeregeln',
|
||||
'liveTv.scheduled' => 'Geplant',
|
||||
'liveTv.noChannels' => 'Keine Kanäle verfügbar',
|
||||
'liveTv.noDvr' => 'Kein DVR auf einem Server konfiguriert',
|
||||
'liveTv.tuneFailed' => 'Kanal konnte nicht eingestellt werden',
|
||||
'liveTv.loading' => 'Kanäle werden geladen...',
|
||||
'liveTv.nowPlaying' => 'Läuft gerade',
|
||||
'liveTv.whatsOnNow' => 'Jetzt im TV',
|
||||
'liveTv.record' => 'Aufnehmen',
|
||||
'liveTv.recordSeries' => 'Serie aufnehmen',
|
||||
'liveTv.cancelRecording' => 'Aufnahme abbrechen',
|
||||
'liveTv.deleteSubscription' => 'Aufnahmeregel löschen',
|
||||
'liveTv.deleteSubscriptionConfirm' => 'Möchten Sie diese Aufnahmeregel wirklich löschen?',
|
||||
'liveTv.subscriptionDeleted' => 'Aufnahmeregel gelöscht',
|
||||
'liveTv.noPrograms' => 'Keine Programmdaten verfügbar',
|
||||
'liveTv.noRecordings' => 'Keine Aufnahmen geplant',
|
||||
'liveTv.noSubscriptions' => 'Keine Aufnahmeregeln',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
|
||||
'liveTv.live' => 'LIVE',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NEU',
|
||||
'liveTv.reloadGuide' => 'Programmführer neu laden',
|
||||
'liveTv.guideReloaded' => 'Programmdaten neu geladen',
|
||||
'liveTv.allChannels' => 'Alle Kanäle',
|
||||
'downloads.title' => 'Downloads',
|
||||
'downloads.manage' => 'Verwalten',
|
||||
'downloads.tvShows' => 'Serien',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsDe {
|
||||
'downloads.noDownloadsTree' => 'Keine Downloads',
|
||||
'downloads.pauseAll' => 'Alle pausieren',
|
||||
'downloads.resumeAll' => 'Alle fortsetzen',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.deleteAll' => 'Alle löschen',
|
||||
'playlists.title' => 'Wiedergabelisten',
|
||||
'playlists.noPlaylists' => 'Keine Wiedergabelisten gefunden',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsDe {
|
||||
'playlists.playlist' => 'Wiedergabeliste',
|
||||
'collections.title' => 'Sammlungen',
|
||||
'collections.collection' => 'Sammlung',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.empty' => 'Sammlung ist leer',
|
||||
'collections.unknownLibrarySection' => 'Löschen nicht möglich: Unbekannte Bibliothekssektion',
|
||||
'collections.deleteCollection' => 'Sammlung löschen',
|
||||
|
||||
+128
-2
@@ -68,6 +68,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||
late final TranslationsLogsEn logs = TranslationsLogsEn._(_root);
|
||||
late final TranslationsLicensesEn licenses = TranslationsLicensesEn._(_root);
|
||||
late final TranslationsNavigationEn navigation = TranslationsNavigationEn._(_root);
|
||||
late final TranslationsLiveTvEn liveTv = TranslationsLiveTvEn._(_root);
|
||||
late final TranslationsCollectionsEn collections = TranslationsCollectionsEn._(_root);
|
||||
late final TranslationsPlaylistsEn playlists = TranslationsPlaylistsEn._(_root);
|
||||
late final TranslationsWatchTogetherEn watchTogether = TranslationsWatchTogetherEn._(_root);
|
||||
@@ -1631,6 +1632,102 @@ class TranslationsNavigationEn {
|
||||
|
||||
/// en: 'Downloads'
|
||||
String get downloads => 'Downloads';
|
||||
|
||||
/// en: 'Live TV'
|
||||
String get liveTv => 'Live TV';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class TranslationsLiveTvEn {
|
||||
TranslationsLiveTvEn._(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Live TV'
|
||||
String get title => 'Live TV';
|
||||
|
||||
/// en: 'Channels'
|
||||
String get channels => 'Channels';
|
||||
|
||||
/// en: 'Guide'
|
||||
String get guide => 'Guide';
|
||||
|
||||
/// en: 'Recordings'
|
||||
String get recordings => 'Recordings';
|
||||
|
||||
/// en: 'Subscriptions'
|
||||
String get subscriptions => 'Subscriptions';
|
||||
|
||||
/// en: 'Scheduled'
|
||||
String get scheduled => 'Scheduled';
|
||||
|
||||
/// en: 'No channels available'
|
||||
String get noChannels => 'No channels available';
|
||||
|
||||
/// en: 'No DVR configured on any server'
|
||||
String get noDvr => 'No DVR configured on any server';
|
||||
|
||||
/// en: 'Failed to tune channel'
|
||||
String get tuneFailed => 'Failed to tune channel';
|
||||
|
||||
/// en: 'Loading channels...'
|
||||
String get loading => 'Loading channels...';
|
||||
|
||||
/// en: 'Now Playing'
|
||||
String get nowPlaying => 'Now Playing';
|
||||
|
||||
/// en: 'What's On Now'
|
||||
String get whatsOnNow => 'What\'s On Now';
|
||||
|
||||
/// en: 'Record'
|
||||
String get record => 'Record';
|
||||
|
||||
/// en: 'Record Series'
|
||||
String get recordSeries => 'Record Series';
|
||||
|
||||
/// en: 'Cancel Recording'
|
||||
String get cancelRecording => 'Cancel Recording';
|
||||
|
||||
/// en: 'Delete Recording Rule'
|
||||
String get deleteSubscription => 'Delete Recording Rule';
|
||||
|
||||
/// en: 'Are you sure you want to delete this recording rule?'
|
||||
String get deleteSubscriptionConfirm => 'Are you sure you want to delete this recording rule?';
|
||||
|
||||
/// en: 'Recording rule deleted'
|
||||
String get subscriptionDeleted => 'Recording rule deleted';
|
||||
|
||||
/// en: 'No program data available'
|
||||
String get noPrograms => 'No program data available';
|
||||
|
||||
/// en: 'No recordings scheduled'
|
||||
String get noRecordings => 'No recordings scheduled';
|
||||
|
||||
/// en: 'No recording rules'
|
||||
String get noSubscriptions => 'No recording rules';
|
||||
|
||||
/// en: 'Ch. ${number}'
|
||||
String channelNumber({required Object number}) => 'Ch. ${number}';
|
||||
|
||||
/// en: 'LIVE'
|
||||
String get live => 'LIVE';
|
||||
|
||||
/// en: 'HD'
|
||||
String get hd => 'HD';
|
||||
|
||||
/// en: 'NEW'
|
||||
String get premiere => 'NEW';
|
||||
|
||||
/// en: 'Reload Guide'
|
||||
String get reloadGuide => 'Reload Guide';
|
||||
|
||||
/// en: 'Guide data reloaded'
|
||||
String get guideReloaded => 'Guide data reloaded';
|
||||
|
||||
/// en: 'All Channels'
|
||||
String get allChannels => 'All Channels';
|
||||
}
|
||||
|
||||
// Path: collections
|
||||
@@ -3023,6 +3120,35 @@ extension on Translations {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} licenses',
|
||||
'navigation.libraries' => 'Libraries',
|
||||
'navigation.downloads' => 'Downloads',
|
||||
'navigation.liveTv' => 'Live TV',
|
||||
'liveTv.title' => 'Live TV',
|
||||
'liveTv.channels' => 'Channels',
|
||||
'liveTv.guide' => 'Guide',
|
||||
'liveTv.recordings' => 'Recordings',
|
||||
'liveTv.subscriptions' => 'Subscriptions',
|
||||
'liveTv.scheduled' => 'Scheduled',
|
||||
'liveTv.noChannels' => 'No channels available',
|
||||
'liveTv.noDvr' => 'No DVR configured on any server',
|
||||
'liveTv.tuneFailed' => 'Failed to tune channel',
|
||||
'liveTv.loading' => 'Loading channels...',
|
||||
'liveTv.nowPlaying' => 'Now Playing',
|
||||
'liveTv.whatsOnNow' => 'What\'s On Now',
|
||||
'liveTv.record' => 'Record',
|
||||
'liveTv.recordSeries' => 'Record Series',
|
||||
'liveTv.cancelRecording' => 'Cancel Recording',
|
||||
'liveTv.deleteSubscription' => 'Delete Recording Rule',
|
||||
'liveTv.deleteSubscriptionConfirm' => 'Are you sure you want to delete this recording rule?',
|
||||
'liveTv.subscriptionDeleted' => 'Recording rule deleted',
|
||||
'liveTv.noPrograms' => 'No program data available',
|
||||
'liveTv.noRecordings' => 'No recordings scheduled',
|
||||
'liveTv.noSubscriptions' => 'No recording rules',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
|
||||
'liveTv.live' => 'LIVE',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NEW',
|
||||
'liveTv.reloadGuide' => 'Reload Guide',
|
||||
'liveTv.guideReloaded' => 'Guide data reloaded',
|
||||
'liveTv.allChannels' => 'All Channels',
|
||||
'collections.title' => 'Collections',
|
||||
'collections.collection' => 'Collection',
|
||||
'collections.empty' => 'Collection is empty',
|
||||
@@ -3040,6 +3166,8 @@ extension on Translations {
|
||||
'collections.addedToCollection' => 'Added to collection',
|
||||
'collections.errorAddingToCollection' => 'Failed to add to collection',
|
||||
'collections.created' => 'Collection created',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.removeFromCollection' => 'Remove from collection',
|
||||
'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Remove "${title}" from this collection?',
|
||||
'collections.removedFromCollection' => 'Removed from collection',
|
||||
@@ -3069,8 +3197,6 @@ extension on Translations {
|
||||
'playlists.errorDeleting' => 'Failed to delete playlist',
|
||||
'playlists.errorLoading' => 'Failed to load playlists',
|
||||
'playlists.errorAdding' => 'Failed to add to playlist',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'playlists.errorReordering' => 'Failed to reorder playlist item',
|
||||
'playlists.errorRemoving' => 'Failed to remove from playlist',
|
||||
'watchTogether.title' => 'Watch Together',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsEs with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsEs logs = _TranslationsLogsEs._(_root);
|
||||
@override late final _TranslationsLicensesEs licenses = _TranslationsLicensesEs._(_root);
|
||||
@override late final _TranslationsNavigationEs navigation = _TranslationsNavigationEs._(_root);
|
||||
@override late final _TranslationsLiveTvEs liveTv = _TranslationsLiveTvEs._(_root);
|
||||
@override late final _TranslationsCollectionsEs collections = _TranslationsCollectionsEs._(_root);
|
||||
@override late final _TranslationsPlaylistsEs playlists = _TranslationsPlaylistsEs._(_root);
|
||||
@override late final _TranslationsWatchTogetherEs watchTogether = _TranslationsWatchTogetherEs._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationEs implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => 'Bibliotecas';
|
||||
@override String get downloads => 'Descargas';
|
||||
@override String get liveTv => 'TV en vivo';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvEs implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvEs._(this._root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'TV en vivo';
|
||||
@override String get channels => 'Canales';
|
||||
@override String get guide => 'Guía';
|
||||
@override String get recordings => 'Grabaciones';
|
||||
@override String get subscriptions => 'Reglas de grabación';
|
||||
@override String get scheduled => 'Programadas';
|
||||
@override String get noChannels => 'No hay canales disponibles';
|
||||
@override String get noDvr => 'No hay DVR configurado en ningún servidor';
|
||||
@override String get tuneFailed => 'Error al sintonizar el canal';
|
||||
@override String get loading => 'Cargando canales...';
|
||||
@override String get nowPlaying => 'Reproduciendo ahora';
|
||||
@override String get whatsOnNow => 'En emisión ahora';
|
||||
@override String get record => 'Grabar';
|
||||
@override String get recordSeries => 'Grabar serie';
|
||||
@override String get cancelRecording => 'Cancelar grabación';
|
||||
@override String get deleteSubscription => 'Eliminar regla de grabación';
|
||||
@override String get deleteSubscriptionConfirm => '¿Estás seguro de que quieres eliminar esta regla de grabación?';
|
||||
@override String get subscriptionDeleted => 'Regla de grabación eliminada';
|
||||
@override String get noPrograms => 'No hay datos de programación disponibles';
|
||||
@override String get noRecordings => 'No hay grabaciones programadas';
|
||||
@override String get noSubscriptions => 'No hay reglas de grabación';
|
||||
@override String channelNumber({required Object number}) => 'Canal ${number}';
|
||||
@override String get live => 'EN VIVO';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => 'NUEVO';
|
||||
@override String get reloadGuide => 'Recargar guía';
|
||||
@override String get guideReloaded => 'Datos de la guía recargados';
|
||||
@override String get allChannels => 'Todos los canales';
|
||||
}
|
||||
|
||||
// Path: collections
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsEs {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} licencias',
|
||||
'navigation.libraries' => 'Bibliotecas',
|
||||
'navigation.downloads' => 'Descargas',
|
||||
'navigation.liveTv' => 'TV en vivo',
|
||||
'liveTv.title' => 'TV en vivo',
|
||||
'liveTv.channels' => 'Canales',
|
||||
'liveTv.guide' => 'Guía',
|
||||
'liveTv.recordings' => 'Grabaciones',
|
||||
'liveTv.subscriptions' => 'Reglas de grabación',
|
||||
'liveTv.scheduled' => 'Programadas',
|
||||
'liveTv.noChannels' => 'No hay canales disponibles',
|
||||
'liveTv.noDvr' => 'No hay DVR configurado en ningún servidor',
|
||||
'liveTv.tuneFailed' => 'Error al sintonizar el canal',
|
||||
'liveTv.loading' => 'Cargando canales...',
|
||||
'liveTv.nowPlaying' => 'Reproduciendo ahora',
|
||||
'liveTv.whatsOnNow' => 'En emisión ahora',
|
||||
'liveTv.record' => 'Grabar',
|
||||
'liveTv.recordSeries' => 'Grabar serie',
|
||||
'liveTv.cancelRecording' => 'Cancelar grabación',
|
||||
'liveTv.deleteSubscription' => 'Eliminar regla de grabación',
|
||||
'liveTv.deleteSubscriptionConfirm' => '¿Estás seguro de que quieres eliminar esta regla de grabación?',
|
||||
'liveTv.subscriptionDeleted' => 'Regla de grabación eliminada',
|
||||
'liveTv.noPrograms' => 'No hay datos de programación disponibles',
|
||||
'liveTv.noRecordings' => 'No hay grabaciones programadas',
|
||||
'liveTv.noSubscriptions' => 'No hay reglas de grabación',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Canal ${number}',
|
||||
'liveTv.live' => 'EN VIVO',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NUEVO',
|
||||
'liveTv.reloadGuide' => 'Recargar guía',
|
||||
'liveTv.guideReloaded' => 'Datos de la guía recargados',
|
||||
'liveTv.allChannels' => 'Todos los canales',
|
||||
'collections.title' => 'Colecciones',
|
||||
'collections.collection' => 'Colección',
|
||||
'collections.empty' => 'La colección está vacía',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsEs {
|
||||
'collections.addedToCollection' => 'Añadido a la colección',
|
||||
'collections.errorAddingToCollection' => 'Error al añadir a la colección',
|
||||
'collections.created' => 'Colección creada',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.removeFromCollection' => 'Eliminar de la colección',
|
||||
'collections.removeFromCollectionConfirm' => ({required Object title}) => '¿Eliminar "${title}" de esta colección?',
|
||||
'collections.removedFromCollection' => 'Eliminado de la colección',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsEs {
|
||||
'playlists.errorDeleting' => 'Error al eliminar la lista',
|
||||
'playlists.errorLoading' => 'Error al cargar las listas',
|
||||
'playlists.errorAdding' => 'Error al añadir a la lista',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'playlists.errorReordering' => 'Error al reordenar los elementos de la lista',
|
||||
'playlists.errorRemoving' => 'Error al eliminar de la lista',
|
||||
'watchTogether.title' => 'Ver Juntos',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsFr with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsFr logs = _TranslationsLogsFr._(_root);
|
||||
@override late final _TranslationsLicensesFr licenses = _TranslationsLicensesFr._(_root);
|
||||
@override late final _TranslationsNavigationFr navigation = _TranslationsNavigationFr._(_root);
|
||||
@override late final _TranslationsLiveTvFr liveTv = _TranslationsLiveTvFr._(_root);
|
||||
@override late final _TranslationsCollectionsFr collections = _TranslationsCollectionsFr._(_root);
|
||||
@override late final _TranslationsPlaylistsFr playlists = _TranslationsPlaylistsFr._(_root);
|
||||
@override late final _TranslationsWatchTogetherFr watchTogether = _TranslationsWatchTogetherFr._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationFr implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => 'Bibliothèques';
|
||||
@override String get downloads => 'Téléchargements';
|
||||
@override String get liveTv => 'TV en direct';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvFr implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvFr._(this._root);
|
||||
|
||||
final TranslationsFr _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'TV en direct';
|
||||
@override String get channels => 'Chaînes';
|
||||
@override String get guide => 'Guide';
|
||||
@override String get recordings => 'Enregistrements';
|
||||
@override String get subscriptions => 'Règles d\'enregistrement';
|
||||
@override String get scheduled => 'Programmés';
|
||||
@override String get noChannels => 'Aucune chaîne disponible';
|
||||
@override String get noDvr => 'Aucun DVR configuré sur les serveurs';
|
||||
@override String get tuneFailed => 'Impossible de syntoniser la chaîne';
|
||||
@override String get loading => 'Chargement des chaînes...';
|
||||
@override String get nowPlaying => 'En cours de lecture';
|
||||
@override String get whatsOnNow => 'En ce moment';
|
||||
@override String get record => 'Enregistrer';
|
||||
@override String get recordSeries => 'Enregistrer la série';
|
||||
@override String get cancelRecording => 'Annuler l\'enregistrement';
|
||||
@override String get deleteSubscription => 'Supprimer la règle d\'enregistrement';
|
||||
@override String get deleteSubscriptionConfirm => 'Voulez-vous vraiment supprimer cette règle d\'enregistrement ?';
|
||||
@override String get subscriptionDeleted => 'Règle d\'enregistrement supprimée';
|
||||
@override String get noPrograms => 'Aucune donnée de programme disponible';
|
||||
@override String get noRecordings => 'Aucun enregistrement programmé';
|
||||
@override String get noSubscriptions => 'Aucune règle d\'enregistrement';
|
||||
@override String channelNumber({required Object number}) => 'Ch. ${number}';
|
||||
@override String get live => 'EN DIRECT';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => 'NOUVEAU';
|
||||
@override String get reloadGuide => 'Recharger le guide';
|
||||
@override String get guideReloaded => 'Données du guide rechargées';
|
||||
@override String get allChannels => 'Toutes les chaînes';
|
||||
}
|
||||
|
||||
// Path: collections
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsFr {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} licences',
|
||||
'navigation.libraries' => 'Bibliothèques',
|
||||
'navigation.downloads' => 'Téléchargements',
|
||||
'navigation.liveTv' => 'TV en direct',
|
||||
'liveTv.title' => 'TV en direct',
|
||||
'liveTv.channels' => 'Chaînes',
|
||||
'liveTv.guide' => 'Guide',
|
||||
'liveTv.recordings' => 'Enregistrements',
|
||||
'liveTv.subscriptions' => 'Règles d\'enregistrement',
|
||||
'liveTv.scheduled' => 'Programmés',
|
||||
'liveTv.noChannels' => 'Aucune chaîne disponible',
|
||||
'liveTv.noDvr' => 'Aucun DVR configuré sur les serveurs',
|
||||
'liveTv.tuneFailed' => 'Impossible de syntoniser la chaîne',
|
||||
'liveTv.loading' => 'Chargement des chaînes...',
|
||||
'liveTv.nowPlaying' => 'En cours de lecture',
|
||||
'liveTv.whatsOnNow' => 'En ce moment',
|
||||
'liveTv.record' => 'Enregistrer',
|
||||
'liveTv.recordSeries' => 'Enregistrer la série',
|
||||
'liveTv.cancelRecording' => 'Annuler l\'enregistrement',
|
||||
'liveTv.deleteSubscription' => 'Supprimer la règle d\'enregistrement',
|
||||
'liveTv.deleteSubscriptionConfirm' => 'Voulez-vous vraiment supprimer cette règle d\'enregistrement ?',
|
||||
'liveTv.subscriptionDeleted' => 'Règle d\'enregistrement supprimée',
|
||||
'liveTv.noPrograms' => 'Aucune donnée de programme disponible',
|
||||
'liveTv.noRecordings' => 'Aucun enregistrement programmé',
|
||||
'liveTv.noSubscriptions' => 'Aucune règle d\'enregistrement',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
|
||||
'liveTv.live' => 'EN DIRECT',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NOUVEAU',
|
||||
'liveTv.reloadGuide' => 'Recharger le guide',
|
||||
'liveTv.guideReloaded' => 'Données du guide rechargées',
|
||||
'liveTv.allChannels' => 'Toutes les chaînes',
|
||||
'collections.title' => 'Collections',
|
||||
'collections.collection' => 'Collection',
|
||||
'collections.empty' => 'La collection est vide',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsFr {
|
||||
'collections.addedToCollection' => 'Ajouté à la collection',
|
||||
'collections.errorAddingToCollection' => 'Échec de l\'ajout à la collection',
|
||||
'collections.created' => 'Collection créée',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.removeFromCollection' => 'Supprimer de la collection',
|
||||
'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Retirer "${title}" de cette collection ?',
|
||||
'collections.removedFromCollection' => 'Retiré de la collection',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsFr {
|
||||
'playlists.errorDeleting' => 'Échec de suppression de playlist',
|
||||
'playlists.errorLoading' => 'Échec de chargement de playlists',
|
||||
'playlists.errorAdding' => 'Échec d\'ajout dans la playlist',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'playlists.errorReordering' => 'Échec de réordonnacement d\'élément de playlist',
|
||||
'playlists.errorRemoving' => 'Échec de suppression depuis la playlist',
|
||||
'watchTogether.title' => 'Regarder ensemble',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsIt with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsIt logs = _TranslationsLogsIt._(_root);
|
||||
@override late final _TranslationsLicensesIt licenses = _TranslationsLicensesIt._(_root);
|
||||
@override late final _TranslationsNavigationIt navigation = _TranslationsNavigationIt._(_root);
|
||||
@override late final _TranslationsLiveTvIt liveTv = _TranslationsLiveTvIt._(_root);
|
||||
@override late final _TranslationsDownloadsIt downloads = _TranslationsDownloadsIt._(_root);
|
||||
@override late final _TranslationsPlaylistsIt playlists = _TranslationsPlaylistsIt._(_root);
|
||||
@override late final _TranslationsCollectionsIt collections = _TranslationsCollectionsIt._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationIt implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => 'Librerie';
|
||||
@override String get downloads => 'Download';
|
||||
@override String get liveTv => 'TV in diretta';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvIt implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvIt._(this._root);
|
||||
|
||||
final TranslationsIt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'TV in diretta';
|
||||
@override String get channels => 'Canali';
|
||||
@override String get guide => 'Guida';
|
||||
@override String get recordings => 'Registrazioni';
|
||||
@override String get subscriptions => 'Regole di registrazione';
|
||||
@override String get scheduled => 'Programmati';
|
||||
@override String get noChannels => 'Nessun canale disponibile';
|
||||
@override String get noDvr => 'Nessun DVR configurato su nessun server';
|
||||
@override String get tuneFailed => 'Impossibile sintonizzare il canale';
|
||||
@override String get loading => 'Caricamento canali...';
|
||||
@override String get nowPlaying => 'In riproduzione';
|
||||
@override String get whatsOnNow => 'In onda adesso';
|
||||
@override String get record => 'Registra';
|
||||
@override String get recordSeries => 'Registra serie';
|
||||
@override String get cancelRecording => 'Annulla registrazione';
|
||||
@override String get deleteSubscription => 'Elimina regola di registrazione';
|
||||
@override String get deleteSubscriptionConfirm => 'Sei sicuro di voler eliminare questa regola di registrazione?';
|
||||
@override String get subscriptionDeleted => 'Regola di registrazione eliminata';
|
||||
@override String get noPrograms => 'Nessun dato di programma disponibile';
|
||||
@override String get noRecordings => 'Nessuna registrazione programmata';
|
||||
@override String get noSubscriptions => 'Nessuna regola di registrazione';
|
||||
@override String channelNumber({required Object number}) => 'Canale ${number}';
|
||||
@override String get live => 'IN DIRETTA';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => 'NUOVO';
|
||||
@override String get reloadGuide => 'Ricarica guida';
|
||||
@override String get guideReloaded => 'Dati della guida ricaricati';
|
||||
@override String get allChannels => 'Tutti i canali';
|
||||
}
|
||||
|
||||
// Path: downloads
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsIt {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} licenze',
|
||||
'navigation.libraries' => 'Librerie',
|
||||
'navigation.downloads' => 'Download',
|
||||
'navigation.liveTv' => 'TV in diretta',
|
||||
'liveTv.title' => 'TV in diretta',
|
||||
'liveTv.channels' => 'Canali',
|
||||
'liveTv.guide' => 'Guida',
|
||||
'liveTv.recordings' => 'Registrazioni',
|
||||
'liveTv.subscriptions' => 'Regole di registrazione',
|
||||
'liveTv.scheduled' => 'Programmati',
|
||||
'liveTv.noChannels' => 'Nessun canale disponibile',
|
||||
'liveTv.noDvr' => 'Nessun DVR configurato su nessun server',
|
||||
'liveTv.tuneFailed' => 'Impossibile sintonizzare il canale',
|
||||
'liveTv.loading' => 'Caricamento canali...',
|
||||
'liveTv.nowPlaying' => 'In riproduzione',
|
||||
'liveTv.whatsOnNow' => 'In onda adesso',
|
||||
'liveTv.record' => 'Registra',
|
||||
'liveTv.recordSeries' => 'Registra serie',
|
||||
'liveTv.cancelRecording' => 'Annulla registrazione',
|
||||
'liveTv.deleteSubscription' => 'Elimina regola di registrazione',
|
||||
'liveTv.deleteSubscriptionConfirm' => 'Sei sicuro di voler eliminare questa regola di registrazione?',
|
||||
'liveTv.subscriptionDeleted' => 'Regola di registrazione eliminata',
|
||||
'liveTv.noPrograms' => 'Nessun dato di programma disponibile',
|
||||
'liveTv.noRecordings' => 'Nessuna registrazione programmata',
|
||||
'liveTv.noSubscriptions' => 'Nessuna regola di registrazione',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Canale ${number}',
|
||||
'liveTv.live' => 'IN DIRETTA',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NUOVO',
|
||||
'liveTv.reloadGuide' => 'Ricarica guida',
|
||||
'liveTv.guideReloaded' => 'Dati della guida ricaricati',
|
||||
'liveTv.allChannels' => 'Tutti i canali',
|
||||
'downloads.title' => 'Download',
|
||||
'downloads.manage' => 'Gestisci',
|
||||
'downloads.tvShows' => 'Serie TV',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsIt {
|
||||
'downloads.noDownloadsTree' => 'Nessun download',
|
||||
'downloads.pauseAll' => 'Metti tutto in pausa',
|
||||
'downloads.resumeAll' => 'Riprendi tutto',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.deleteAll' => 'Elimina tutto',
|
||||
'playlists.title' => 'Playlist',
|
||||
'playlists.noPlaylists' => 'Nessuna playlist trovata',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsIt {
|
||||
'playlists.playlist' => 'Playlist',
|
||||
'collections.title' => 'Raccolte',
|
||||
'collections.collection' => 'Raccolta',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.empty' => 'La raccolta è vuota',
|
||||
'collections.unknownLibrarySection' => 'Impossibile eliminare: sezione libreria sconosciuta',
|
||||
'collections.deleteCollection' => 'Elimina raccolta',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsKo with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsKo logs = _TranslationsLogsKo._(_root);
|
||||
@override late final _TranslationsLicensesKo licenses = _TranslationsLicensesKo._(_root);
|
||||
@override late final _TranslationsNavigationKo navigation = _TranslationsNavigationKo._(_root);
|
||||
@override late final _TranslationsLiveTvKo liveTv = _TranslationsLiveTvKo._(_root);
|
||||
@override late final _TranslationsCollectionsKo collections = _TranslationsCollectionsKo._(_root);
|
||||
@override late final _TranslationsPlaylistsKo playlists = _TranslationsPlaylistsKo._(_root);
|
||||
@override late final _TranslationsWatchTogetherKo watchTogether = _TranslationsWatchTogetherKo._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationKo implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => '미디어 라이브러리';
|
||||
@override String get downloads => '다운로드';
|
||||
@override String get liveTv => '실시간 TV';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvKo implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvKo._(this._root);
|
||||
|
||||
final TranslationsKo _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => '실시간 TV';
|
||||
@override String get channels => '채널';
|
||||
@override String get guide => '편성표';
|
||||
@override String get recordings => '녹화';
|
||||
@override String get subscriptions => '녹화 규칙';
|
||||
@override String get scheduled => '예약됨';
|
||||
@override String get noChannels => '사용 가능한 채널이 없습니다';
|
||||
@override String get noDvr => '서버에 DVR이 구성되어 있지 않습니다';
|
||||
@override String get tuneFailed => '채널 튜닝에 실패했습니다';
|
||||
@override String get loading => '채널 로딩 중...';
|
||||
@override String get nowPlaying => '현재 재생 중';
|
||||
@override String get whatsOnNow => '지금 방송 중';
|
||||
@override String get record => '녹화';
|
||||
@override String get recordSeries => '시리즈 녹화';
|
||||
@override String get cancelRecording => '녹화 취소';
|
||||
@override String get deleteSubscription => '녹화 규칙 삭제';
|
||||
@override String get deleteSubscriptionConfirm => '이 녹화 규칙을 삭제하시겠습니까?';
|
||||
@override String get subscriptionDeleted => '녹화 규칙이 삭제되었습니다';
|
||||
@override String get noPrograms => '프로그램 데이터가 없습니다';
|
||||
@override String get noRecordings => '예약된 녹화가 없습니다';
|
||||
@override String get noSubscriptions => '녹화 규칙이 없습니다';
|
||||
@override String channelNumber({required Object number}) => '채널 ${number}';
|
||||
@override String get live => '실시간';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => '신규';
|
||||
@override String get reloadGuide => '편성표 새로고침';
|
||||
@override String get guideReloaded => '편성표 데이터가 새로고침되었습니다';
|
||||
@override String get allChannels => '전체 채널';
|
||||
}
|
||||
|
||||
// Path: collections
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsKo {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} 개의 라이선스',
|
||||
'navigation.libraries' => '미디어 라이브러리',
|
||||
'navigation.downloads' => '다운로드',
|
||||
'navigation.liveTv' => '실시간 TV',
|
||||
'liveTv.title' => '실시간 TV',
|
||||
'liveTv.channels' => '채널',
|
||||
'liveTv.guide' => '편성표',
|
||||
'liveTv.recordings' => '녹화',
|
||||
'liveTv.subscriptions' => '녹화 규칙',
|
||||
'liveTv.scheduled' => '예약됨',
|
||||
'liveTv.noChannels' => '사용 가능한 채널이 없습니다',
|
||||
'liveTv.noDvr' => '서버에 DVR이 구성되어 있지 않습니다',
|
||||
'liveTv.tuneFailed' => '채널 튜닝에 실패했습니다',
|
||||
'liveTv.loading' => '채널 로딩 중...',
|
||||
'liveTv.nowPlaying' => '현재 재생 중',
|
||||
'liveTv.whatsOnNow' => '지금 방송 중',
|
||||
'liveTv.record' => '녹화',
|
||||
'liveTv.recordSeries' => '시리즈 녹화',
|
||||
'liveTv.cancelRecording' => '녹화 취소',
|
||||
'liveTv.deleteSubscription' => '녹화 규칙 삭제',
|
||||
'liveTv.deleteSubscriptionConfirm' => '이 녹화 규칙을 삭제하시겠습니까?',
|
||||
'liveTv.subscriptionDeleted' => '녹화 규칙이 삭제되었습니다',
|
||||
'liveTv.noPrograms' => '프로그램 데이터가 없습니다',
|
||||
'liveTv.noRecordings' => '예약된 녹화가 없습니다',
|
||||
'liveTv.noSubscriptions' => '녹화 규칙이 없습니다',
|
||||
'liveTv.channelNumber' => ({required Object number}) => '채널 ${number}',
|
||||
'liveTv.live' => '실시간',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => '신규',
|
||||
'liveTv.reloadGuide' => '편성표 새로고침',
|
||||
'liveTv.guideReloaded' => '편성표 데이터가 새로고침되었습니다',
|
||||
'liveTv.allChannels' => '전체 채널',
|
||||
'collections.title' => '컬렉션',
|
||||
'collections.collection' => '컬렉션',
|
||||
'collections.empty' => '컬렉션이 비어 있습니다',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsKo {
|
||||
'collections.addedToCollection' => '컬렉션에 추가됨',
|
||||
'collections.errorAddingToCollection' => '컬렉션에 추가 실패',
|
||||
'collections.created' => '컬렉션 생성됨',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.removeFromCollection' => '컬렉션에서 제거',
|
||||
'collections.removeFromCollectionConfirm' => ({required Object title}) => '${title}을/를 이 컬렉션에서 제거 하시겠습니까?',
|
||||
'collections.removedFromCollection' => '컬렉션에서 제거됨',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsKo {
|
||||
'playlists.errorDeleting' => '재생 목록 삭제 실패',
|
||||
'playlists.errorLoading' => '재생 목록 로드 실패',
|
||||
'playlists.errorAdding' => '재생 목록에 추가 실패',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'playlists.errorReordering' => '재생 목록 항목 재정렬 실패',
|
||||
'playlists.errorRemoving' => '재생 목록에서 제거 실패',
|
||||
'watchTogether.title' => '함께 보기',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsNl with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsNl logs = _TranslationsLogsNl._(_root);
|
||||
@override late final _TranslationsLicensesNl licenses = _TranslationsLicensesNl._(_root);
|
||||
@override late final _TranslationsNavigationNl navigation = _TranslationsNavigationNl._(_root);
|
||||
@override late final _TranslationsLiveTvNl liveTv = _TranslationsLiveTvNl._(_root);
|
||||
@override late final _TranslationsDownloadsNl downloads = _TranslationsDownloadsNl._(_root);
|
||||
@override late final _TranslationsPlaylistsNl playlists = _TranslationsPlaylistsNl._(_root);
|
||||
@override late final _TranslationsCollectionsNl collections = _TranslationsCollectionsNl._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationNl implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => 'Bibliotheken';
|
||||
@override String get downloads => 'Downloads';
|
||||
@override String get liveTv => 'Live TV';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvNl implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvNl._(this._root);
|
||||
|
||||
final TranslationsNl _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Live TV';
|
||||
@override String get channels => 'Zenders';
|
||||
@override String get guide => 'Gids';
|
||||
@override String get recordings => 'Opnames';
|
||||
@override String get subscriptions => 'Opnameregels';
|
||||
@override String get scheduled => 'Gepland';
|
||||
@override String get noChannels => 'Geen zenders beschikbaar';
|
||||
@override String get noDvr => 'Geen DVR geconfigureerd op een server';
|
||||
@override String get tuneFailed => 'Kan zender niet afstemmen';
|
||||
@override String get loading => 'Zenders laden...';
|
||||
@override String get nowPlaying => 'Nu aan het afspelen';
|
||||
@override String get whatsOnNow => 'Nu op TV';
|
||||
@override String get record => 'Opnemen';
|
||||
@override String get recordSeries => 'Serie opnemen';
|
||||
@override String get cancelRecording => 'Opname annuleren';
|
||||
@override String get deleteSubscription => 'Opnameregel verwijderen';
|
||||
@override String get deleteSubscriptionConfirm => 'Weet je zeker dat je deze opnameregel wilt verwijderen?';
|
||||
@override String get subscriptionDeleted => 'Opnameregel verwijderd';
|
||||
@override String get noPrograms => 'Geen programmagegevens beschikbaar';
|
||||
@override String get noRecordings => 'Geen opnames gepland';
|
||||
@override String get noSubscriptions => 'Geen opnameregels';
|
||||
@override String channelNumber({required Object number}) => 'Kanaal ${number}';
|
||||
@override String get live => 'LIVE';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => 'NIEUW';
|
||||
@override String get reloadGuide => 'Gids herladen';
|
||||
@override String get guideReloaded => 'Gidsgegevens herladen';
|
||||
@override String get allChannels => 'Alle zenders';
|
||||
}
|
||||
|
||||
// Path: downloads
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsNl {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} licenties',
|
||||
'navigation.libraries' => 'Bibliotheken',
|
||||
'navigation.downloads' => 'Downloads',
|
||||
'navigation.liveTv' => 'Live TV',
|
||||
'liveTv.title' => 'Live TV',
|
||||
'liveTv.channels' => 'Zenders',
|
||||
'liveTv.guide' => 'Gids',
|
||||
'liveTv.recordings' => 'Opnames',
|
||||
'liveTv.subscriptions' => 'Opnameregels',
|
||||
'liveTv.scheduled' => 'Gepland',
|
||||
'liveTv.noChannels' => 'Geen zenders beschikbaar',
|
||||
'liveTv.noDvr' => 'Geen DVR geconfigureerd op een server',
|
||||
'liveTv.tuneFailed' => 'Kan zender niet afstemmen',
|
||||
'liveTv.loading' => 'Zenders laden...',
|
||||
'liveTv.nowPlaying' => 'Nu aan het afspelen',
|
||||
'liveTv.whatsOnNow' => 'Nu op TV',
|
||||
'liveTv.record' => 'Opnemen',
|
||||
'liveTv.recordSeries' => 'Serie opnemen',
|
||||
'liveTv.cancelRecording' => 'Opname annuleren',
|
||||
'liveTv.deleteSubscription' => 'Opnameregel verwijderen',
|
||||
'liveTv.deleteSubscriptionConfirm' => 'Weet je zeker dat je deze opnameregel wilt verwijderen?',
|
||||
'liveTv.subscriptionDeleted' => 'Opnameregel verwijderd',
|
||||
'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar',
|
||||
'liveTv.noRecordings' => 'Geen opnames gepland',
|
||||
'liveTv.noSubscriptions' => 'Geen opnameregels',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Kanaal ${number}',
|
||||
'liveTv.live' => 'LIVE',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NIEUW',
|
||||
'liveTv.reloadGuide' => 'Gids herladen',
|
||||
'liveTv.guideReloaded' => 'Gidsgegevens herladen',
|
||||
'liveTv.allChannels' => 'Alle zenders',
|
||||
'downloads.title' => 'Downloads',
|
||||
'downloads.manage' => 'Beheren',
|
||||
'downloads.tvShows' => 'Series',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsNl {
|
||||
'downloads.noDownloadsTree' => 'Geen downloads',
|
||||
'downloads.pauseAll' => 'Alles pauzeren',
|
||||
'downloads.resumeAll' => 'Alles hervatten',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.deleteAll' => 'Alles verwijderen',
|
||||
'playlists.title' => 'Afspeellijsten',
|
||||
'playlists.noPlaylists' => 'Geen afspeellijsten gevonden',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsNl {
|
||||
'playlists.playlist' => 'Afspeellijst',
|
||||
'collections.title' => 'Collecties',
|
||||
'collections.collection' => 'Collectie',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.empty' => 'Collectie is leeg',
|
||||
'collections.unknownLibrarySection' => 'Kan niet verwijderen: onbekende bibliotheeksectie',
|
||||
'collections.deleteCollection' => 'Collectie verwijderen',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsSv with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsSv logs = _TranslationsLogsSv._(_root);
|
||||
@override late final _TranslationsLicensesSv licenses = _TranslationsLicensesSv._(_root);
|
||||
@override late final _TranslationsNavigationSv navigation = _TranslationsNavigationSv._(_root);
|
||||
@override late final _TranslationsLiveTvSv liveTv = _TranslationsLiveTvSv._(_root);
|
||||
@override late final _TranslationsDownloadsSv downloads = _TranslationsDownloadsSv._(_root);
|
||||
@override late final _TranslationsPlaylistsSv playlists = _TranslationsPlaylistsSv._(_root);
|
||||
@override late final _TranslationsCollectionsSv collections = _TranslationsCollectionsSv._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationSv implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => 'Bibliotek';
|
||||
@override String get downloads => 'Nedladdningar';
|
||||
@override String get liveTv => 'Live-TV';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvSv implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvSv._(this._root);
|
||||
|
||||
final TranslationsSv _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Live-TV';
|
||||
@override String get channels => 'Kanaler';
|
||||
@override String get guide => 'Programguide';
|
||||
@override String get recordings => 'Inspelningar';
|
||||
@override String get subscriptions => 'Inspelningsregler';
|
||||
@override String get scheduled => 'Schemalagda';
|
||||
@override String get noChannels => 'Inga kanaler tillgängliga';
|
||||
@override String get noDvr => 'Ingen DVR konfigurerad på någon server';
|
||||
@override String get tuneFailed => 'Kunde inte ställa in kanalen';
|
||||
@override String get loading => 'Laddar kanaler...';
|
||||
@override String get nowPlaying => 'Spelas nu';
|
||||
@override String get whatsOnNow => 'På TV just nu';
|
||||
@override String get record => 'Spela in';
|
||||
@override String get recordSeries => 'Spela in serie';
|
||||
@override String get cancelRecording => 'Avbryt inspelning';
|
||||
@override String get deleteSubscription => 'Ta bort inspelningsregel';
|
||||
@override String get deleteSubscriptionConfirm => 'Är du säker på att du vill ta bort denna inspelningsregel?';
|
||||
@override String get subscriptionDeleted => 'Inspelningsregel borttagen';
|
||||
@override String get noPrograms => 'Ingen programdata tillgänglig';
|
||||
@override String get noRecordings => 'Inga inspelningar schemalagda';
|
||||
@override String get noSubscriptions => 'Inga inspelningsregler';
|
||||
@override String channelNumber({required Object number}) => 'Kanal ${number}';
|
||||
@override String get live => 'LIVE';
|
||||
@override String get hd => 'HD';
|
||||
@override String get premiere => 'NY';
|
||||
@override String get reloadGuide => 'Ladda om programguide';
|
||||
@override String get guideReloaded => 'Programdata omladdad';
|
||||
@override String get allChannels => 'Alla kanaler';
|
||||
}
|
||||
|
||||
// Path: downloads
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsSv {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} licenser',
|
||||
'navigation.libraries' => 'Bibliotek',
|
||||
'navigation.downloads' => 'Nedladdningar',
|
||||
'navigation.liveTv' => 'Live-TV',
|
||||
'liveTv.title' => 'Live-TV',
|
||||
'liveTv.channels' => 'Kanaler',
|
||||
'liveTv.guide' => 'Programguide',
|
||||
'liveTv.recordings' => 'Inspelningar',
|
||||
'liveTv.subscriptions' => 'Inspelningsregler',
|
||||
'liveTv.scheduled' => 'Schemalagda',
|
||||
'liveTv.noChannels' => 'Inga kanaler tillgängliga',
|
||||
'liveTv.noDvr' => 'Ingen DVR konfigurerad på någon server',
|
||||
'liveTv.tuneFailed' => 'Kunde inte ställa in kanalen',
|
||||
'liveTv.loading' => 'Laddar kanaler...',
|
||||
'liveTv.nowPlaying' => 'Spelas nu',
|
||||
'liveTv.whatsOnNow' => 'På TV just nu',
|
||||
'liveTv.record' => 'Spela in',
|
||||
'liveTv.recordSeries' => 'Spela in serie',
|
||||
'liveTv.cancelRecording' => 'Avbryt inspelning',
|
||||
'liveTv.deleteSubscription' => 'Ta bort inspelningsregel',
|
||||
'liveTv.deleteSubscriptionConfirm' => 'Är du säker på att du vill ta bort denna inspelningsregel?',
|
||||
'liveTv.subscriptionDeleted' => 'Inspelningsregel borttagen',
|
||||
'liveTv.noPrograms' => 'Ingen programdata tillgänglig',
|
||||
'liveTv.noRecordings' => 'Inga inspelningar schemalagda',
|
||||
'liveTv.noSubscriptions' => 'Inga inspelningsregler',
|
||||
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
|
||||
'liveTv.live' => 'LIVE',
|
||||
'liveTv.hd' => 'HD',
|
||||
'liveTv.premiere' => 'NY',
|
||||
'liveTv.reloadGuide' => 'Ladda om programguide',
|
||||
'liveTv.guideReloaded' => 'Programdata omladdad',
|
||||
'liveTv.allChannels' => 'Alla kanaler',
|
||||
'downloads.title' => 'Nedladdningar',
|
||||
'downloads.manage' => 'Hantera',
|
||||
'downloads.tvShows' => 'TV-serier',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsSv {
|
||||
'downloads.noDownloadsTree' => 'Inga nedladdningar',
|
||||
'downloads.pauseAll' => 'Pausa alla',
|
||||
'downloads.resumeAll' => 'Återuppta alla',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.deleteAll' => 'Ta bort alla',
|
||||
'playlists.title' => 'Spellistor',
|
||||
'playlists.noPlaylists' => 'Inga spellistor hittades',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsSv {
|
||||
'playlists.playlist' => 'Spellista',
|
||||
'collections.title' => 'Samlingar',
|
||||
'collections.collection' => 'Samling',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.empty' => 'Samlingen är tom',
|
||||
'collections.unknownLibrarySection' => 'Kan inte ta bort: okänd bibliotekssektion',
|
||||
'collections.deleteCollection' => 'Ta bort samling',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsZh with BaseTranslations<AppLocale, Translations> implements T
|
||||
@override late final _TranslationsLogsZh logs = _TranslationsLogsZh._(_root);
|
||||
@override late final _TranslationsLicensesZh licenses = _TranslationsLicensesZh._(_root);
|
||||
@override late final _TranslationsNavigationZh navigation = _TranslationsNavigationZh._(_root);
|
||||
@override late final _TranslationsLiveTvZh liveTv = _TranslationsLiveTvZh._(_root);
|
||||
@override late final _TranslationsDownloadsZh downloads = _TranslationsDownloadsZh._(_root);
|
||||
@override late final _TranslationsPlaylistsZh playlists = _TranslationsPlaylistsZh._(_root);
|
||||
@override late final _TranslationsCollectionsZh collections = _TranslationsCollectionsZh._(_root);
|
||||
@@ -761,6 +762,44 @@ class _TranslationsNavigationZh implements TranslationsNavigationEn {
|
||||
// Translations
|
||||
@override String get libraries => '媒体库';
|
||||
@override String get downloads => '下载';
|
||||
@override String get liveTv => '电视直播';
|
||||
}
|
||||
|
||||
// Path: liveTv
|
||||
class _TranslationsLiveTvZh implements TranslationsLiveTvEn {
|
||||
_TranslationsLiveTvZh._(this._root);
|
||||
|
||||
final TranslationsZh _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => '电视直播';
|
||||
@override String get channels => '频道';
|
||||
@override String get guide => '节目指南';
|
||||
@override String get recordings => '录制';
|
||||
@override String get subscriptions => '录制规则';
|
||||
@override String get scheduled => '已计划';
|
||||
@override String get noChannels => '没有可用的频道';
|
||||
@override String get noDvr => '没有服务器配置了DVR';
|
||||
@override String get tuneFailed => '无法调谐频道';
|
||||
@override String get loading => '正在加载频道...';
|
||||
@override String get nowPlaying => '正在播放';
|
||||
@override String get whatsOnNow => '正在播出';
|
||||
@override String get record => '录制';
|
||||
@override String get recordSeries => '录制系列';
|
||||
@override String get cancelRecording => '取消录制';
|
||||
@override String get deleteSubscription => '删除录制规则';
|
||||
@override String get deleteSubscriptionConfirm => '确定要删除此录制规则吗?';
|
||||
@override String get subscriptionDeleted => '录制规则已删除';
|
||||
@override String get noPrograms => '没有可用的节目数据';
|
||||
@override String get noRecordings => '没有计划的录制';
|
||||
@override String get noSubscriptions => '没有录制规则';
|
||||
@override String channelNumber({required Object number}) => '频道 ${number}';
|
||||
@override String get live => '直播';
|
||||
@override String get hd => '高清';
|
||||
@override String get premiere => '新';
|
||||
@override String get reloadGuide => '重新加载节目指南';
|
||||
@override String get guideReloaded => '节目指南已重新加载';
|
||||
@override String get allChannels => '所有频道';
|
||||
}
|
||||
|
||||
// Path: downloads
|
||||
@@ -1634,6 +1673,35 @@ extension on TranslationsZh {
|
||||
'licenses.licensesCount' => ({required Object count}) => '${count} 个许可证',
|
||||
'navigation.libraries' => '媒体库',
|
||||
'navigation.downloads' => '下载',
|
||||
'navigation.liveTv' => '电视直播',
|
||||
'liveTv.title' => '电视直播',
|
||||
'liveTv.channels' => '频道',
|
||||
'liveTv.guide' => '节目指南',
|
||||
'liveTv.recordings' => '录制',
|
||||
'liveTv.subscriptions' => '录制规则',
|
||||
'liveTv.scheduled' => '已计划',
|
||||
'liveTv.noChannels' => '没有可用的频道',
|
||||
'liveTv.noDvr' => '没有服务器配置了DVR',
|
||||
'liveTv.tuneFailed' => '无法调谐频道',
|
||||
'liveTv.loading' => '正在加载频道...',
|
||||
'liveTv.nowPlaying' => '正在播放',
|
||||
'liveTv.whatsOnNow' => '正在播出',
|
||||
'liveTv.record' => '录制',
|
||||
'liveTv.recordSeries' => '录制系列',
|
||||
'liveTv.cancelRecording' => '取消录制',
|
||||
'liveTv.deleteSubscription' => '删除录制规则',
|
||||
'liveTv.deleteSubscriptionConfirm' => '确定要删除此录制规则吗?',
|
||||
'liveTv.subscriptionDeleted' => '录制规则已删除',
|
||||
'liveTv.noPrograms' => '没有可用的节目数据',
|
||||
'liveTv.noRecordings' => '没有计划的录制',
|
||||
'liveTv.noSubscriptions' => '没有录制规则',
|
||||
'liveTv.channelNumber' => ({required Object number}) => '频道 ${number}',
|
||||
'liveTv.live' => '直播',
|
||||
'liveTv.hd' => '高清',
|
||||
'liveTv.premiere' => '新',
|
||||
'liveTv.reloadGuide' => '重新加载节目指南',
|
||||
'liveTv.guideReloaded' => '节目指南已重新加载',
|
||||
'liveTv.allChannels' => '所有频道',
|
||||
'downloads.title' => '下载',
|
||||
'downloads.manage' => '管理',
|
||||
'downloads.tvShows' => '电视剧',
|
||||
@@ -1651,6 +1719,8 @@ extension on TranslationsZh {
|
||||
'downloads.noDownloadsTree' => '暂无下载',
|
||||
'downloads.pauseAll' => '全部暂停',
|
||||
'downloads.resumeAll' => '全部继续',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'downloads.deleteAll' => '全部删除',
|
||||
'playlists.title' => '播放列表',
|
||||
'playlists.noPlaylists' => '未找到播放列表',
|
||||
@@ -1680,8 +1750,6 @@ extension on TranslationsZh {
|
||||
'playlists.playlist' => '播放列表',
|
||||
'collections.title' => '合集',
|
||||
'collections.collection' => '合集',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'collections.empty' => '合集为空',
|
||||
'collections.unknownLibrarySection' => '无法删除:未知的媒体库分区',
|
||||
'collections.deleteCollection' => '删除合集',
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "Bibliotek",
|
||||
"downloads": "Nedladdningar"
|
||||
"downloads": "Nedladdningar",
|
||||
"liveTv": "Live-TV"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "Live-TV",
|
||||
"channels": "Kanaler",
|
||||
"guide": "Programguide",
|
||||
"recordings": "Inspelningar",
|
||||
"subscriptions": "Inspelningsregler",
|
||||
"scheduled": "Schemalagda",
|
||||
"noChannels": "Inga kanaler tillgängliga",
|
||||
"noDvr": "Ingen DVR konfigurerad på någon server",
|
||||
"tuneFailed": "Kunde inte ställa in kanalen",
|
||||
"loading": "Laddar kanaler...",
|
||||
"nowPlaying": "Spelas nu",
|
||||
"whatsOnNow": "På TV just nu",
|
||||
"record": "Spela in",
|
||||
"recordSeries": "Spela in serie",
|
||||
"cancelRecording": "Avbryt inspelning",
|
||||
"deleteSubscription": "Ta bort inspelningsregel",
|
||||
"deleteSubscriptionConfirm": "Är du säker på att du vill ta bort denna inspelningsregel?",
|
||||
"subscriptionDeleted": "Inspelningsregel borttagen",
|
||||
"noPrograms": "Ingen programdata tillgänglig",
|
||||
"noRecordings": "Inga inspelningar schemalagda",
|
||||
"noSubscriptions": "Inga inspelningsregler",
|
||||
"channelNumber": "Kanal ${number}",
|
||||
"live": "LIVE",
|
||||
"hd": "HD",
|
||||
"premiere": "NY",
|
||||
"reloadGuide": "Ladda om programguide",
|
||||
"guideReloaded": "Programdata omladdad",
|
||||
"allChannels": "Alla kanaler"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Nedladdningar",
|
||||
|
||||
+32
-1
@@ -527,7 +527,38 @@
|
||||
},
|
||||
"navigation": {
|
||||
"libraries": "媒体库",
|
||||
"downloads": "下载"
|
||||
"downloads": "下载",
|
||||
"liveTv": "电视直播"
|
||||
},
|
||||
"liveTv": {
|
||||
"title": "电视直播",
|
||||
"channels": "频道",
|
||||
"guide": "节目指南",
|
||||
"recordings": "录制",
|
||||
"subscriptions": "录制规则",
|
||||
"scheduled": "已计划",
|
||||
"noChannels": "没有可用的频道",
|
||||
"noDvr": "没有服务器配置了DVR",
|
||||
"tuneFailed": "无法调谐频道",
|
||||
"loading": "正在加载频道...",
|
||||
"nowPlaying": "正在播放",
|
||||
"whatsOnNow": "正在播出",
|
||||
"record": "录制",
|
||||
"recordSeries": "录制系列",
|
||||
"cancelRecording": "取消录制",
|
||||
"deleteSubscription": "删除录制规则",
|
||||
"deleteSubscriptionConfirm": "确定要删除此录制规则吗?",
|
||||
"subscriptionDeleted": "录制规则已删除",
|
||||
"noPrograms": "没有可用的节目数据",
|
||||
"noRecordings": "没有计划的录制",
|
||||
"noSubscriptions": "没有录制规则",
|
||||
"channelNumber": "频道 ${number}",
|
||||
"live": "直播",
|
||||
"hd": "高清",
|
||||
"premiere": "新",
|
||||
"reloadGuide": "重新加载节目指南",
|
||||
"guideReloaded": "节目指南已重新加载",
|
||||
"allChannels": "所有频道"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "下载",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/// Represents a Live TV channel from the EPG
|
||||
class LiveTvChannel {
|
||||
final String key;
|
||||
final String? identifier;
|
||||
final String? callSign;
|
||||
final String? title;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
final String? number;
|
||||
final bool hd;
|
||||
final String? lineup;
|
||||
final String? slug;
|
||||
final bool? drm;
|
||||
|
||||
// Multi-server support
|
||||
final String? serverId;
|
||||
final String? serverName;
|
||||
|
||||
LiveTvChannel({
|
||||
required this.key,
|
||||
this.identifier,
|
||||
this.callSign,
|
||||
this.title,
|
||||
this.thumb,
|
||||
this.art,
|
||||
this.number,
|
||||
this.hd = false,
|
||||
this.lineup,
|
||||
this.slug,
|
||||
this.drm,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
factory LiveTvChannel.fromJson(Map<String, dynamic> json) {
|
||||
return LiveTvChannel(
|
||||
key: json['key'] as String? ?? json['ratingKey'] as String? ?? '',
|
||||
identifier: json['identifier'] as String? ?? json['channelIdentifier'] as String?,
|
||||
callSign: json['callSign'] as String?,
|
||||
title: json['title'] as String? ?? json['callSign'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
number: json['number'] as String? ?? json['channelNumber'] as String?,
|
||||
hd: json['hd'] == true || json['hd'] == 1,
|
||||
lineup: json['lineup'] as String?,
|
||||
slug: json['slug'] as String?,
|
||||
drm: json['drm'] == true || json['drm'] == 1,
|
||||
);
|
||||
}
|
||||
|
||||
LiveTvChannel copyWith({String? serverId, String? serverName}) {
|
||||
return LiveTvChannel(
|
||||
key: key,
|
||||
identifier: identifier,
|
||||
callSign: callSign,
|
||||
title: title,
|
||||
thumb: thumb,
|
||||
art: art,
|
||||
number: number,
|
||||
hd: hd,
|
||||
lineup: lineup,
|
||||
slug: slug,
|
||||
drm: drm,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
}
|
||||
|
||||
/// Display name: prefer callSign, fallback to title
|
||||
String get displayName => callSign ?? title ?? 'Channel $number';
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
|
||||
class LiveTvDvr {
|
||||
final String key;
|
||||
final String uuid;
|
||||
final String? make;
|
||||
final String? model;
|
||||
final String? modelNumber;
|
||||
final String? firmware;
|
||||
final int? tuners;
|
||||
final String? lineup;
|
||||
final String? lineupTitle;
|
||||
final String? lineupURL;
|
||||
final String? country;
|
||||
final String? language;
|
||||
final String? status;
|
||||
final List<ChannelMapping> channelMappings;
|
||||
|
||||
LiveTvDvr({
|
||||
required this.key,
|
||||
required this.uuid,
|
||||
this.make,
|
||||
this.model,
|
||||
this.modelNumber,
|
||||
this.firmware,
|
||||
this.tuners,
|
||||
this.lineup,
|
||||
this.lineupTitle,
|
||||
this.lineupURL,
|
||||
this.country,
|
||||
this.language,
|
||||
this.status,
|
||||
this.channelMappings = const [],
|
||||
});
|
||||
|
||||
factory LiveTvDvr.fromJson(Map<String, dynamic> json) {
|
||||
final mappings = <ChannelMapping>[];
|
||||
if (json['ChannelMapping'] != null) {
|
||||
for (final item in json['ChannelMapping'] as List) {
|
||||
try {
|
||||
mappings.add(ChannelMapping.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
return LiveTvDvr(
|
||||
key: json['key'] as String? ?? '',
|
||||
uuid: json['uuid'] as String? ?? '',
|
||||
make: json['make'] as String?,
|
||||
model: json['model'] as String?,
|
||||
modelNumber: json['modelNumber'] as String?,
|
||||
firmware: json['firmware'] as String?,
|
||||
tuners: (json['tuners'] as num?)?.toInt(),
|
||||
lineup: json['lineup'] as String?,
|
||||
lineupTitle: json['lineupTitle'] as String?,
|
||||
lineupURL: json['lineupURL'] as String?,
|
||||
country: json['country'] as String?,
|
||||
language: json['language'] as String?,
|
||||
status: json['status'] as String?,
|
||||
channelMappings: mappings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a channel mapping within a DVR device
|
||||
class ChannelMapping {
|
||||
final String? channelKey;
|
||||
final String? deviceIdentifier;
|
||||
final bool? enabled;
|
||||
final String? lineupIdentifier;
|
||||
|
||||
ChannelMapping({
|
||||
this.channelKey,
|
||||
this.deviceIdentifier,
|
||||
this.enabled,
|
||||
this.lineupIdentifier,
|
||||
});
|
||||
|
||||
factory ChannelMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ChannelMapping(
|
||||
channelKey: json['channelKey'] as String?,
|
||||
deviceIdentifier: json['deviceIdentifier'] as String?,
|
||||
enabled: json['enabled'] == true || json['enabled'] == 1,
|
||||
lineupIdentifier: json['lineupIdentifier'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/// Represents an EPG program entry (what's on a channel at a given time)
|
||||
class LiveTvProgram {
|
||||
final String? key;
|
||||
final String? ratingKey;
|
||||
final String? guid;
|
||||
final String title;
|
||||
final String? summary;
|
||||
final String? type;
|
||||
final int? year;
|
||||
final int? beginsAt; // epoch seconds
|
||||
final int? endsAt; // epoch seconds
|
||||
final String? grandparentTitle; // series name for episodes
|
||||
final String? parentTitle; // season name
|
||||
final int? index; // episode number
|
||||
final int? parentIndex; // season number
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
final String? channelIdentifier;
|
||||
final String? channelCallSign;
|
||||
final bool? live;
|
||||
final bool? premiere;
|
||||
|
||||
LiveTvProgram({
|
||||
this.key,
|
||||
this.ratingKey,
|
||||
this.guid,
|
||||
required this.title,
|
||||
this.summary,
|
||||
this.type,
|
||||
this.year,
|
||||
this.beginsAt,
|
||||
this.endsAt,
|
||||
this.grandparentTitle,
|
||||
this.parentTitle,
|
||||
this.index,
|
||||
this.parentIndex,
|
||||
this.thumb,
|
||||
this.art,
|
||||
this.channelIdentifier,
|
||||
this.channelCallSign,
|
||||
this.live,
|
||||
this.premiere,
|
||||
});
|
||||
|
||||
factory LiveTvProgram.fromJson(Map<String, dynamic> json) {
|
||||
return LiveTvProgram(
|
||||
key: json['key'] as String?,
|
||||
ratingKey: json['ratingKey'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
title: json['title'] as String? ?? 'Unknown Program',
|
||||
summary: json['summary'] as String?,
|
||||
type: json['type'] as String?,
|
||||
year: (json['year'] as num?)?.toInt(),
|
||||
beginsAt: (json['beginsAt'] as num?)?.toInt(),
|
||||
endsAt: (json['endsAt'] as num?)?.toInt(),
|
||||
grandparentTitle: json['grandparentTitle'] as String?,
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
index: (json['index'] as num?)?.toInt(),
|
||||
parentIndex: (json['parentIndex'] as num?)?.toInt(),
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
channelIdentifier: json['channelIdentifier'] as String?,
|
||||
channelCallSign: json['channelCallSign'] as String?,
|
||||
live: json['live'] == true || json['live'] == 1 || json['live'] == '1',
|
||||
premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1',
|
||||
);
|
||||
}
|
||||
|
||||
/// Start time as DateTime
|
||||
DateTime? get startTime => beginsAt != null ? DateTime.fromMillisecondsSinceEpoch(beginsAt! * 1000) : null;
|
||||
|
||||
/// End time as DateTime
|
||||
DateTime? get endTime => endsAt != null ? DateTime.fromMillisecondsSinceEpoch(endsAt! * 1000) : null;
|
||||
|
||||
/// Duration in minutes
|
||||
int get durationMinutes {
|
||||
if (beginsAt == null || endsAt == null) return 0;
|
||||
return ((endsAt! - beginsAt!) / 60).round();
|
||||
}
|
||||
|
||||
/// Whether this program is currently airing
|
||||
bool get isCurrentlyAiring {
|
||||
if (beginsAt == null || endsAt == null) return false;
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return now >= beginsAt! && now < endsAt!;
|
||||
}
|
||||
|
||||
/// Progress through the program (0.0 to 1.0)
|
||||
double get progress {
|
||||
if (beginsAt == null || endsAt == null) return 0.0;
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
if (now < beginsAt!) return 0.0;
|
||||
if (now >= endsAt!) return 1.0;
|
||||
return (now - beginsAt!) / (endsAt! - beginsAt!);
|
||||
}
|
||||
|
||||
/// Display title including series info for episodes
|
||||
String get displayTitle {
|
||||
if (grandparentTitle != null && index != null) {
|
||||
final seasonEpisode = parentIndex != null ? 'S${parentIndex}E$index' : 'E$index';
|
||||
return '$grandparentTitle - $seasonEpisode - $title';
|
||||
}
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/// Represents an upcoming scheduled recording from the DVR
|
||||
class ScheduledRecording {
|
||||
final String? key;
|
||||
final String? ratingKey;
|
||||
final String? guid;
|
||||
final String title;
|
||||
final String? summary;
|
||||
final String? type;
|
||||
final int? beginsAt;
|
||||
final int? endsAt;
|
||||
final String? grandparentTitle;
|
||||
final String? parentTitle;
|
||||
final int? index;
|
||||
final int? parentIndex;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
final String? channelIdentifier;
|
||||
final String? channelCallSign;
|
||||
final String? subscriptionID;
|
||||
final String? status;
|
||||
|
||||
ScheduledRecording({
|
||||
this.key,
|
||||
this.ratingKey,
|
||||
this.guid,
|
||||
required this.title,
|
||||
this.summary,
|
||||
this.type,
|
||||
this.beginsAt,
|
||||
this.endsAt,
|
||||
this.grandparentTitle,
|
||||
this.parentTitle,
|
||||
this.index,
|
||||
this.parentIndex,
|
||||
this.thumb,
|
||||
this.art,
|
||||
this.channelIdentifier,
|
||||
this.channelCallSign,
|
||||
this.subscriptionID,
|
||||
this.status,
|
||||
});
|
||||
|
||||
factory ScheduledRecording.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduledRecording(
|
||||
key: json['key'] as String?,
|
||||
ratingKey: json['ratingKey'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
title: json['title'] as String? ?? 'Unknown',
|
||||
summary: json['summary'] as String?,
|
||||
type: json['type'] as String?,
|
||||
beginsAt: (json['beginsAt'] as num?)?.toInt(),
|
||||
endsAt: (json['endsAt'] as num?)?.toInt(),
|
||||
grandparentTitle: json['grandparentTitle'] as String?,
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
index: (json['index'] as num?)?.toInt(),
|
||||
parentIndex: (json['parentIndex'] as num?)?.toInt(),
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
channelIdentifier: json['channelIdentifier'] as String?,
|
||||
channelCallSign: json['channelCallSign'] as String?,
|
||||
subscriptionID: json['subscriptionID'] as String?,
|
||||
status: json['status'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Start time as DateTime
|
||||
DateTime? get startTime => beginsAt != null ? DateTime.fromMillisecondsSinceEpoch(beginsAt! * 1000) : null;
|
||||
|
||||
/// End time as DateTime
|
||||
DateTime? get endTime => endsAt != null ? DateTime.fromMillisecondsSinceEpoch(endsAt! * 1000) : null;
|
||||
|
||||
/// Duration in minutes
|
||||
int get durationMinutes {
|
||||
if (beginsAt == null || endsAt == null) return 0;
|
||||
return ((endsAt! - beginsAt!) / 60).round();
|
||||
}
|
||||
|
||||
/// Display title including series info for episodes
|
||||
String get displayTitle {
|
||||
if (grandparentTitle != null && index != null) {
|
||||
final seasonEpisode = parentIndex != null ? 'S${parentIndex}E$index' : 'E$index';
|
||||
return '$grandparentTitle - $seasonEpisode - $title';
|
||||
}
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/// Represents a DVR recording subscription (recording rule)
|
||||
class LiveTvSubscription {
|
||||
final String key;
|
||||
final String? ratingKey;
|
||||
final String? guid;
|
||||
final String title;
|
||||
final String? summary;
|
||||
final String? type;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
final int? targetLibrarySectionID;
|
||||
final int? targetSectionID;
|
||||
final int? createdAt;
|
||||
final List<SubscriptionSetting> settings;
|
||||
|
||||
// Multi-server support
|
||||
final String? serverId;
|
||||
|
||||
LiveTvSubscription({
|
||||
required this.key,
|
||||
this.ratingKey,
|
||||
this.guid,
|
||||
required this.title,
|
||||
this.summary,
|
||||
this.type,
|
||||
this.thumb,
|
||||
this.art,
|
||||
this.targetLibrarySectionID,
|
||||
this.targetSectionID,
|
||||
this.createdAt,
|
||||
this.settings = const [],
|
||||
this.serverId,
|
||||
});
|
||||
|
||||
factory LiveTvSubscription.fromJson(Map<String, dynamic> json) {
|
||||
final settingsList = <SubscriptionSetting>[];
|
||||
if (json['Setting'] != null) {
|
||||
for (final item in json['Setting'] as List) {
|
||||
try {
|
||||
settingsList.add(SubscriptionSetting.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
return LiveTvSubscription(
|
||||
key: json['key'] as String? ?? '',
|
||||
ratingKey: json['ratingKey'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
title: json['title'] as String? ?? 'Unknown',
|
||||
summary: json['summary'] as String?,
|
||||
type: json['type'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
targetLibrarySectionID: (json['targetLibrarySectionID'] as num?)?.toInt(),
|
||||
targetSectionID: (json['targetSectionID'] as num?)?.toInt(),
|
||||
createdAt: (json['createdAt'] as num?)?.toInt(),
|
||||
settings: settingsList,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creation time as DateTime
|
||||
DateTime? get createdAtTime =>
|
||||
createdAt != null ? DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000) : null;
|
||||
}
|
||||
|
||||
/// Represents a setting within a DVR subscription
|
||||
class SubscriptionSetting {
|
||||
final String id;
|
||||
final String? label;
|
||||
final String? summary;
|
||||
final String type; // e.g., "bool", "enum", "int"
|
||||
final String? value;
|
||||
final String? defaultValue;
|
||||
final bool? hidden;
|
||||
final bool? advanced;
|
||||
final List<SubscriptionSettingOption>? enumValues;
|
||||
|
||||
SubscriptionSetting({
|
||||
required this.id,
|
||||
this.label,
|
||||
this.summary,
|
||||
required this.type,
|
||||
this.value,
|
||||
this.defaultValue,
|
||||
this.hidden,
|
||||
this.advanced,
|
||||
this.enumValues,
|
||||
});
|
||||
|
||||
factory SubscriptionSetting.fromJson(Map<String, dynamic> json) {
|
||||
List<SubscriptionSettingOption>? options;
|
||||
if (json['enumValues'] != null) {
|
||||
final parts = (json['enumValues'] as String).split('|');
|
||||
options = parts.map((part) {
|
||||
final kv = part.split(':');
|
||||
return SubscriptionSettingOption(
|
||||
value: kv[0],
|
||||
label: kv.length > 1 ? kv[1] : kv[0],
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return SubscriptionSetting(
|
||||
id: json['id'] as String? ?? '',
|
||||
label: json['label'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
type: json['type'] as String? ?? 'text',
|
||||
value: json['value']?.toString(),
|
||||
defaultValue: json['default']?.toString(),
|
||||
hidden: json['hidden'] == true || json['hidden'] == 1,
|
||||
advanced: json['advanced'] == true || json['advanced'] == 1,
|
||||
enumValues: options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an option in an enum-type subscription setting
|
||||
class SubscriptionSettingOption {
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
SubscriptionSettingOption({required this.value, required this.label});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Navigation tab identifiers
|
||||
enum NavigationTabId { discover, libraries, search, downloads, settings }
|
||||
enum NavigationTabId { discover, libraries, liveTv, search, downloads, settings }
|
||||
|
||||
/// Represents a navigation tab with its configuration
|
||||
class NavigationTab {
|
||||
@@ -21,25 +21,30 @@ class NavigationTab {
|
||||
}
|
||||
|
||||
/// Get the index for a tab ID in the visible tabs list
|
||||
static int indexFor(NavigationTabId id, {required bool isOffline}) {
|
||||
final tabs = getVisibleTabs(isOffline: isOffline);
|
||||
static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false}) {
|
||||
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv);
|
||||
return tabs.indexWhere((tab) => tab.id == id);
|
||||
}
|
||||
|
||||
/// Get tabs filtered by offline mode
|
||||
static List<NavigationTab> getVisibleTabs({required bool isOffline}) {
|
||||
return allNavigationTabs.where((tab) => !isOffline || !tab.onlineOnly).toList();
|
||||
/// Get tabs filtered by offline mode and feature availability
|
||||
static List<NavigationTab> getVisibleTabs({required bool isOffline, bool hasLiveTv = false}) {
|
||||
return allNavigationTabs.where((tab) {
|
||||
if (isOffline && tab.onlineOnly) return false;
|
||||
if (tab.id == NavigationTabId.liveTv && !hasLiveTv) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Check if a visual index corresponds to a specific tab ID
|
||||
static bool isTabAtIndex(NavigationTabId id, int index, {required bool isOffline}) {
|
||||
return indexFor(id, isOffline: isOffline) == index;
|
||||
static bool isTabAtIndex(NavigationTabId id, int index, {required bool isOffline, bool hasLiveTv = false}) {
|
||||
return indexFor(id, isOffline: isOffline, hasLiveTv: hasLiveTv) == index;
|
||||
}
|
||||
}
|
||||
|
||||
// Label getters (must be top-level for const constructor)
|
||||
String _getHomeLabel() => t.common.home;
|
||||
String _getLibrariesLabel() => t.navigation.libraries;
|
||||
String _getLiveTvLabel() => t.navigation.liveTv;
|
||||
String _getSearchLabel() => t.common.search;
|
||||
String _getDownloadsLabel() => t.navigation.downloads;
|
||||
String _getSettingsLabel() => t.common.settings;
|
||||
@@ -53,6 +58,7 @@ const allNavigationTabs = [
|
||||
icon: Symbols.video_library_rounded,
|
||||
getLabel: _getLibrariesLabel,
|
||||
),
|
||||
NavigationTab(id: NavigationTabId.liveTv, onlineOnly: true, icon: Symbols.live_tv_rounded, getLabel: _getLiveTvLabel),
|
||||
NavigationTab(id: NavigationTabId.search, onlineOnly: true, icon: Symbols.search_rounded, getLabel: _getSearchLabel),
|
||||
NavigationTab(
|
||||
id: NavigationTabId.downloads,
|
||||
|
||||
@@ -6,16 +6,35 @@ import '../services/multi_server_manager.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Cached info about a DVR-enabled server
|
||||
class LiveTvServerInfo {
|
||||
final String serverId;
|
||||
final String dvrKey;
|
||||
final String? lineup;
|
||||
|
||||
LiveTvServerInfo({required this.serverId, required this.dvrKey, this.lineup});
|
||||
}
|
||||
|
||||
/// Provider for multi-server Plex connections
|
||||
/// Manages multiple PlexClient instances and provides data aggregation
|
||||
class MultiServerProvider extends ChangeNotifier {
|
||||
final MultiServerManager _serverManager;
|
||||
final DataAggregationService _aggregationService;
|
||||
|
||||
/// Whether any connected server has Live TV / DVR
|
||||
bool _hasLiveTv = false;
|
||||
bool get hasLiveTv => _hasLiveTv;
|
||||
|
||||
/// Info about servers with DVR capability
|
||||
final List<LiveTvServerInfo> _liveTvServers = [];
|
||||
List<LiveTvServerInfo> get liveTvServers => List.unmodifiable(_liveTvServers);
|
||||
|
||||
MultiServerProvider(this._serverManager, this._aggregationService) {
|
||||
// Listen to server status changes
|
||||
_serverManager.statusStream.listen((_) {
|
||||
notifyListeners();
|
||||
// Re-check live TV availability when servers come online
|
||||
checkLiveTvAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +111,42 @@ class MultiServerProvider extends ChangeNotifier {
|
||||
// notifyListeners() will be called automatically via status stream
|
||||
}
|
||||
|
||||
/// Check all online servers for DVR/Live TV availability
|
||||
Future<void> checkLiveTvAvailability() async {
|
||||
final newLiveTvServers = <LiveTvServerInfo>[];
|
||||
|
||||
for (final serverId in onlineServerIds) {
|
||||
final client = getClientForServer(serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
try {
|
||||
final dvrs = await client.getDvrs();
|
||||
for (final dvr in dvrs) {
|
||||
newLiveTvServers.add(LiveTvServerInfo(
|
||||
serverId: serverId,
|
||||
dvrKey: dvr.key,
|
||||
lineup: dvr.lineup,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('LiveTV check failed for server $serverId', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
final hadLiveTv = _hasLiveTv;
|
||||
final oldServerIds = _liveTvServers.map((s) => s.serverId).toSet();
|
||||
final newServerIds = newLiveTvServers.map((s) => s.serverId).toSet();
|
||||
_liveTvServers
|
||||
..clear()
|
||||
..addAll(newLiveTvServers);
|
||||
_hasLiveTv = newLiveTvServers.isNotEmpty;
|
||||
|
||||
// Notify when availability changes OR when the server set changes
|
||||
if (hadLiveTv != _hasLiveTv || !oldServerIds.containsAll(newServerIds) || !newServerIds.containsAll(oldServerIds)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverManager.dispose();
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/livetv_scheduled_recording.dart';
|
||||
import '../../models/livetv_subscription.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
|
||||
/// Screen for managing DVR recording subscriptions and scheduled recordings
|
||||
class DvrRecordingsScreen extends StatefulWidget {
|
||||
const DvrRecordingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DvrRecordingsScreen> createState() => _DvrRecordingsScreenState();
|
||||
}
|
||||
|
||||
class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
List<LiveTvSubscription> _subscriptions = [];
|
||||
List<ScheduledRecording> _scheduled = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final liveTvServers = multiServer.liveTvServers;
|
||||
|
||||
if (liveTvServers.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = t.liveTv.noDvr;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final allSubscriptions = <LiveTvSubscription>[];
|
||||
final allScheduled = <ScheduledRecording>[];
|
||||
|
||||
for (final serverInfo in liveTvServers) {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
final subs = await client.getSubscriptions();
|
||||
allSubscriptions.addAll(subs);
|
||||
|
||||
final scheduled = await client.getScheduledRecordings();
|
||||
allScheduled.addAll(scheduled);
|
||||
}
|
||||
|
||||
// Sort scheduled by start time
|
||||
allScheduled.sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_subscriptions = allSubscriptions;
|
||||
_scheduled = allScheduled;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load DVR data', error: e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSubscription(LiveTvSubscription subscription) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.liveTv.deleteSubscription),
|
||||
content: Text(t.liveTv.deleteSubscriptionConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(t.common.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = subscription.serverId != null
|
||||
? multiServer.getClientForServer(subscription.serverId!)
|
||||
: null;
|
||||
|
||||
if (client != null) {
|
||||
final success = await client.deleteSubscription(subscription.key);
|
||||
if (success && mounted) {
|
||||
showSnackBar(context, t.liveTv.subscriptionDeleted);
|
||||
await _loadData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editSubscription(LiveTvSubscription subscription) async {
|
||||
// Filter to visible settings only
|
||||
final editableSettings = subscription.settings
|
||||
.where((s) => s.hidden != true)
|
||||
.toList();
|
||||
|
||||
if (editableSettings.isEmpty) return;
|
||||
|
||||
final prefs = <String, String>{};
|
||||
for (final setting in editableSettings) {
|
||||
prefs[setting.id] = setting.value ?? setting.defaultValue ?? '';
|
||||
}
|
||||
|
||||
final result = await showDialog<Map<String, String>?>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _SubscriptionEditDialog(
|
||||
subscription: subscription,
|
||||
settings: editableSettings,
|
||||
initialPrefs: prefs,
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = subscription.serverId != null
|
||||
? multiServer.getClientForServer(subscription.serverId!)
|
||||
: null;
|
||||
|
||||
if (client != null) {
|
||||
final success = await client.editSubscription(subscription.key, result);
|
||||
if (success) {
|
||||
await _loadData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.liveTv.recordings),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(text: t.liveTv.subscriptions),
|
||||
Tab(text: t.liveTv.scheduled),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadData,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSubscriptionsTab(theme),
|
||||
_buildScheduledTab(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubscriptionsTab(ThemeData theme) {
|
||||
if (_subscriptions.isEmpty) {
|
||||
return Center(child: Text(t.liveTv.noSubscriptions));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _subscriptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sub = _subscriptions[index];
|
||||
return _buildSubscriptionCard(sub, theme);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubscriptionCard(LiveTvSubscription subscription, ThemeData theme) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32),
|
||||
title: Text(
|
||||
subscription.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: subscription.type != null
|
||||
? Text(
|
||||
subscription.type!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (subscription.settings.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.settings_rounded),
|
||||
onPressed: () => _editSubscription(subscription),
|
||||
),
|
||||
IconButton(
|
||||
icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error),
|
||||
onPressed: () => _deleteSubscription(subscription),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScheduledTab(ThemeData theme) {
|
||||
if (_scheduled.isEmpty) {
|
||||
return Center(child: Text(t.liveTv.noRecordings));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _scheduled.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recording = _scheduled[index];
|
||||
return _buildScheduledCard(recording, theme);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScheduledCard(ScheduledRecording recording, ThemeData theme) {
|
||||
final startTime = recording.startTime;
|
||||
final timeStr = startTime != null
|
||||
? '${startTime.month}/${startTime.day} ${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}'
|
||||
: '';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red),
|
||||
title: Text(
|
||||
recording.displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (recording.channelCallSign != null) recording.channelCallSign!,
|
||||
timeStr,
|
||||
if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000),
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog for editing subscription settings
|
||||
class _SubscriptionEditDialog extends StatefulWidget {
|
||||
final LiveTvSubscription subscription;
|
||||
final List<SubscriptionSetting> settings;
|
||||
final Map<String, String> initialPrefs;
|
||||
|
||||
const _SubscriptionEditDialog({
|
||||
required this.subscription,
|
||||
required this.settings,
|
||||
required this.initialPrefs,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState();
|
||||
}
|
||||
|
||||
class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> {
|
||||
late Map<String, String> _prefs;
|
||||
final Map<String, TextEditingController> _textControllers = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prefs = Map.from(widget.initialPrefs);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _textControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.subscription.title),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.settings.map((setting) {
|
||||
return _buildSettingRow(setting);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_prefs),
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingRow(SubscriptionSetting setting) {
|
||||
final value = _prefs[setting.id] ?? setting.defaultValue ?? '';
|
||||
|
||||
if (setting.type == 'bool') {
|
||||
return SwitchListTile(
|
||||
title: Text(setting.label ?? setting.id),
|
||||
subtitle: setting.summary != null ? Text(setting.summary!) : null,
|
||||
value: value == '1' || value == 'true',
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
_prefs[setting.id] = newValue ? '1' : '0';
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (setting.type == 'enum' && setting.enumValues != null) {
|
||||
return ListTile(
|
||||
title: Text(setting.label ?? setting.id),
|
||||
subtitle: setting.summary != null ? Text(setting.summary!) : null,
|
||||
trailing: DropdownButton<String>(
|
||||
value: setting.enumValues!.any((e) => e.value == value) ? value : null,
|
||||
items: setting.enumValues!.map((option) {
|
||||
return DropdownMenuItem(value: option.value, child: Text(option.label));
|
||||
}).toList(),
|
||||
onChanged: (newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_prefs[setting.id] = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Default: text field
|
||||
final controller = _textControllers.putIfAbsent(
|
||||
setting.id,
|
||||
() => TextEditingController(text: value),
|
||||
);
|
||||
return ListTile(
|
||||
title: Text(setting.label ?? setting.id),
|
||||
subtitle: TextField(
|
||||
controller: controller,
|
||||
onChanged: (newValue) {
|
||||
_prefs[setting.id] = newValue;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/livetv_channel.dart';
|
||||
import '../../models/livetv_program.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/plex_url_helper.dart';
|
||||
import '../../utils/live_tv_player_navigation.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
|
||||
/// EPG (Electronic Program Guide) screen with a time-based grid
|
||||
class EpgGuideScreen extends StatefulWidget {
|
||||
const EpgGuideScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EpgGuideScreen> createState() => _EpgGuideScreenState();
|
||||
}
|
||||
|
||||
class _EpgGuideScreenState extends State<EpgGuideScreen> {
|
||||
static const _slotWidth = 180.0;
|
||||
static const _channelColumnWidth = 140.0;
|
||||
static const _rowHeight = 64.0;
|
||||
static const _timeHeaderHeight = 40.0;
|
||||
static const _minutesPerSlot = 30;
|
||||
|
||||
List<LiveTvChannel> _channels = [];
|
||||
List<LiveTvProgram> _programs = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
// Time range: 6 hours centered on current time
|
||||
late DateTime _gridStart;
|
||||
late DateTime _gridEnd;
|
||||
|
||||
final ScrollController _headerHorizontalController = ScrollController();
|
||||
final ScrollController _gridHorizontalController = ScrollController();
|
||||
final ScrollController _channelVerticalController = ScrollController();
|
||||
bool _syncingScroll = false;
|
||||
|
||||
Timer? _timeIndicatorTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initTimeRange();
|
||||
_loadData();
|
||||
|
||||
// Sync horizontal scroll: grid → header
|
||||
_gridHorizontalController.addListener(_syncGridToHeader);
|
||||
// Sync horizontal scroll: header → grid
|
||||
_headerHorizontalController.addListener(_syncHeaderToGrid);
|
||||
|
||||
// Update time indicator every minute
|
||||
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _syncGridToHeader() {
|
||||
if (_syncingScroll) return;
|
||||
_syncingScroll = true;
|
||||
if (_headerHorizontalController.hasClients) {
|
||||
_headerHorizontalController.jumpTo(_gridHorizontalController.offset);
|
||||
}
|
||||
_syncingScroll = false;
|
||||
}
|
||||
|
||||
void _syncHeaderToGrid() {
|
||||
if (_syncingScroll) return;
|
||||
_syncingScroll = true;
|
||||
if (_gridHorizontalController.hasClients) {
|
||||
_gridHorizontalController.jumpTo(_headerHorizontalController.offset);
|
||||
}
|
||||
_syncingScroll = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_gridHorizontalController.removeListener(_syncGridToHeader);
|
||||
_headerHorizontalController.removeListener(_syncHeaderToGrid);
|
||||
_headerHorizontalController.dispose();
|
||||
_gridHorizontalController.dispose();
|
||||
_channelVerticalController.dispose();
|
||||
_timeIndicatorTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initTimeRange() {
|
||||
final now = DateTime.now();
|
||||
// Start 1 hour before, rounded to nearest 30 min
|
||||
_gridStart = DateTime(now.year, now.month, now.day, now.hour);
|
||||
if (now.minute >= 30) {
|
||||
_gridStart = _gridStart.add(const Duration(minutes: 30));
|
||||
}
|
||||
_gridStart = _gridStart.subtract(const Duration(hours: 1));
|
||||
_gridEnd = _gridStart.add(const Duration(hours: 6));
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final liveTvServers = multiServer.liveTvServers;
|
||||
|
||||
if (liveTvServers.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = t.liveTv.noDvr;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final allChannels = <LiveTvChannel>[];
|
||||
final allPrograms = <LiveTvProgram>[];
|
||||
|
||||
for (final serverInfo in liveTvServers) {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
final channels = await client.getEpgChannels(lineup: serverInfo.lineup);
|
||||
allChannels.addAll(channels);
|
||||
|
||||
final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
|
||||
final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final programs = await client.getEpgGrid(
|
||||
lineup: serverInfo.lineup,
|
||||
beginsAt: startEpoch,
|
||||
endsAt: endEpoch,
|
||||
);
|
||||
allPrograms.addAll(programs);
|
||||
}
|
||||
|
||||
// Sort channels by number
|
||||
allChannels.sort((a, b) {
|
||||
final aNum = double.tryParse(a.number ?? '') ?? 999999;
|
||||
final bNum = double.tryParse(b.number ?? '') ?? 999999;
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_channels = allChannels;
|
||||
_programs = allPrograms;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Scroll to current time
|
||||
_scrollToNow();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load EPG data', error: e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToNow() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final now = DateTime.now();
|
||||
final minutesSinceStart = now.difference(_gridStart).inMinutes;
|
||||
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
|
||||
if (_gridHorizontalController.hasClients) {
|
||||
_gridHorizontalController.jumpTo(
|
||||
(offset - MediaQuery.of(context).size.width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get programs for a specific channel
|
||||
List<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
|
||||
final channelId = channel.identifier ?? channel.key;
|
||||
return _programs.where((p) => p.channelIdentifier == channelId).toList()
|
||||
..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
|
||||
}
|
||||
|
||||
double _totalGridWidth() {
|
||||
final totalMinutes = _gridEnd.difference(_gridStart).inMinutes;
|
||||
return (totalMinutes / _minutesPerSlot) * _slotWidth;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.liveTv.guide),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
tooltip: t.liveTv.reloadGuide,
|
||||
onPressed: _loadData,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadData,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _channels.isEmpty
|
||||
? Center(child: Text(t.liveTv.noChannels))
|
||||
: _buildGuideGrid(theme),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuideGrid(ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
// Time header
|
||||
Row(
|
||||
children: [
|
||||
// Empty corner cell
|
||||
SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
|
||||
// Scrollable time slots
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _headerHorizontalController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: _totalGridWidth(),
|
||||
height: _timeHeaderHeight,
|
||||
child: _buildTimeHeader(theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Channel rows + program grid
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// Fixed channel column
|
||||
SizedBox(
|
||||
width: _channelColumnWidth,
|
||||
child: ListView.builder(
|
||||
controller: _channelVerticalController,
|
||||
itemCount: _channels.length,
|
||||
itemExtent: _rowHeight,
|
||||
itemBuilder: (context, index) => _buildChannelCell(_channels[index], theme),
|
||||
),
|
||||
),
|
||||
// Scrollable program grid
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
// Sync vertical scroll from grid to channel column
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
notification.metrics.axis == Axis.vertical) {
|
||||
if (_channelVerticalController.hasClients) {
|
||||
_channelVerticalController.jumpTo(notification.metrics.pixels);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: _gridHorizontalController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: _totalGridWidth(),
|
||||
child: ListView.builder(
|
||||
itemCount: _channels.length,
|
||||
itemExtent: _rowHeight,
|
||||
itemBuilder: (context, index) {
|
||||
final channel = _channels[index];
|
||||
final programs = _getProgramsForChannel(channel);
|
||||
return _buildProgramRow(channel, programs, theme);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimeHeader(ThemeData theme) {
|
||||
final slots = <Widget>[];
|
||||
var current = _gridStart;
|
||||
|
||||
while (current.isBefore(_gridEnd)) {
|
||||
final timeStr = '${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}';
|
||||
slots.add(
|
||||
SizedBox(
|
||||
width: _slotWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
timeStr,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
current = current.add(const Duration(minutes: _minutesPerSlot));
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Row(children: slots),
|
||||
// Current time indicator
|
||||
_buildNowIndicator(theme),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNowIndicator(ThemeData theme) {
|
||||
final now = DateTime.now();
|
||||
if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble();
|
||||
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
|
||||
|
||||
return Positioned(
|
||||
left: offset,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 2,
|
||||
color: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(channel.serverId ?? '');
|
||||
|
||||
return Container(
|
||||
height: _rowHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (channel.thumb != null && client != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Image.network(
|
||||
'${client.config.baseUrl}${channel.thumb}'.withPlexToken(client.config.token),
|
||||
width: 28,
|
||||
height: 28,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox(width: 28),
|
||||
),
|
||||
)
|
||||
else
|
||||
const AppIcon(Symbols.live_tv_rounded, size: 28),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channel.number != null)
|
||||
Text(
|
||||
channel.number!,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
Text(
|
||||
channel.displayName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgramRow(LiveTvChannel channel, List<LiveTvProgram> programs, ThemeData theme) {
|
||||
if (programs.isEmpty) {
|
||||
return Container(
|
||||
height: _rowHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
t.liveTv.noPrograms,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final blocks = <Widget>[];
|
||||
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
|
||||
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
for (final program in programs) {
|
||||
final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
|
||||
final progEnd = (program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch);
|
||||
|
||||
if (progEnd <= progStart) continue;
|
||||
|
||||
final startOffset = progStart - gridStartEpoch;
|
||||
final duration = progEnd - progStart;
|
||||
final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth;
|
||||
final width = (duration / (_minutesPerSlot * 60)) * _slotWidth;
|
||||
|
||||
blocks.add(
|
||||
Positioned(
|
||||
left: left,
|
||||
width: width.clamp(2.0, double.infinity),
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
child: _buildProgramBlock(channel, program, theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: _rowHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
...blocks,
|
||||
_buildNowIndicator(theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgramBlock(LiveTvChannel channel, LiveTvProgram program, ThemeData theme) {
|
||||
final isCurrentlyAiring = program.isCurrentlyAiring;
|
||||
|
||||
return Material(
|
||||
color: isCurrentlyAiring
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: () => _showProgramDetails(channel, program),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
program.title,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isCurrentlyAiring
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (program.startTime != null)
|
||||
Text(
|
||||
'${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isCurrentlyAiring
|
||||
? theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
program.displayTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (program.isCurrentlyAiring)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
t.liveTv.live,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${channel.displayName} · ${program.startTime?.hour.toString().padLeft(2, '0')}:${program.startTime?.minute.toString().padLeft(2, '0')} - ${program.endTime?.hour.toString().padLeft(2, '0')}:${program.endTime?.minute.toString().padLeft(2, '0')} · ${formatDurationTextual(program.durationMinutes * 60000)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (program.summary != null && program.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
program.summary!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
if (program.isCurrentlyAiring)
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_tuneToChannel(channel);
|
||||
},
|
||||
icon: const AppIcon(Symbols.play_arrow_rounded),
|
||||
label: Text(t.common.play),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
// TODO: Record action
|
||||
},
|
||||
icon: const AppIcon(Symbols.fiber_manual_record_rounded),
|
||||
label: Text(t.liveTv.record),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _tuneToChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
|
||||
// Find the DVR server info matching this channel's serverId
|
||||
final serverInfo = multiServer.liveTvServers.where(
|
||||
(s) => s.serverId == channel.serverId,
|
||||
).firstOrNull ?? multiServer.liveTvServers.firstOrNull;
|
||||
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: _channels,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/livetv_channel.dart';
|
||||
import '../../models/livetv_program.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/plex_url_helper.dart';
|
||||
import '../../utils/live_tv_player_navigation.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import 'epg_guide_screen.dart';
|
||||
import 'dvr_recordings_screen.dart';
|
||||
|
||||
class LiveTvScreen extends StatefulWidget {
|
||||
const LiveTvScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LiveTvScreen> createState() => _LiveTvScreenState();
|
||||
}
|
||||
|
||||
class _LiveTvScreenState extends State<LiveTvScreen> {
|
||||
List<LiveTvChannel> _channels = [];
|
||||
Map<String, LiveTvProgram?> _nowPlaying = {};
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChannels();
|
||||
}
|
||||
|
||||
Future<void> _loadChannels() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final liveTvServers = multiServer.liveTvServers;
|
||||
|
||||
if (liveTvServers.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = t.liveTv.noDvr;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final allChannels = <LiveTvChannel>[];
|
||||
|
||||
for (final serverInfo in liveTvServers) {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
final channels = await client.getEpgChannels(lineup: serverInfo.lineup);
|
||||
allChannels.addAll(channels);
|
||||
}
|
||||
|
||||
// Sort channels by number
|
||||
allChannels.sort((a, b) {
|
||||
final aNum = double.tryParse(a.number ?? '') ?? 999999;
|
||||
final bNum = double.tryParse(b.number ?? '') ?? 999999;
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Load "now playing" data
|
||||
await _loadNowPlaying(allChannels);
|
||||
|
||||
setState(() {
|
||||
_channels = allChannels;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load Live TV channels', error: e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNowPlaying(List<LiveTvChannel> channels) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final nowPlaying = <String, LiveTvProgram?>{};
|
||||
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) continue;
|
||||
|
||||
try {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final programs = await client.getEpgGrid(
|
||||
lineup: serverInfo.lineup,
|
||||
beginsAt: now - 7200, // 2 hours before
|
||||
endsAt: now + 7200, // 2 hours after
|
||||
);
|
||||
|
||||
for (final program in programs) {
|
||||
if (program.isCurrentlyAiring && program.channelIdentifier != null) {
|
||||
nowPlaying[program.channelIdentifier!] = program;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to load now playing data', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _nowPlaying = nowPlaying);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
|
||||
// Find the DVR server info matching this channel's serverId
|
||||
final serverInfo = multiServer.liveTvServers.where(
|
||||
(s) => s.serverId == channel.serverId,
|
||||
).firstOrNull ?? multiServer.liveTvServers.firstOrNull;
|
||||
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: _channels,
|
||||
);
|
||||
}
|
||||
|
||||
void _openGuide() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const EpgGuideScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
void _openRecordings() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.liveTv.title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.menu_book_rounded),
|
||||
tooltip: t.liveTv.guide,
|
||||
onPressed: _openGuide,
|
||||
),
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.fiber_dvr_rounded),
|
||||
tooltip: t.liveTv.recordings,
|
||||
onPressed: _openRecordings,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadChannels,
|
||||
icon: const AppIcon(Symbols.refresh_rounded),
|
||||
label: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _channels.isEmpty
|
||||
? Center(child: Text(t.liveTv.noChannels))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadChannels,
|
||||
child: _buildChannelList(theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelList(ThemeData theme) {
|
||||
// Build "What's On Now" section + channel list
|
||||
final currentlyAiring = _channels.where((ch) {
|
||||
final id = ch.identifier ?? ch.key;
|
||||
return _nowPlaying.containsKey(id) && _nowPlaying[id] != null;
|
||||
}).toList();
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// "What's On Now" section
|
||||
if (currentlyAiring.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(t.liveTv.whatsOnNow, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 140,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: currentlyAiring.length,
|
||||
itemBuilder: (context, index) {
|
||||
final channel = currentlyAiring[index];
|
||||
final id = channel.identifier ?? channel.key;
|
||||
final program = _nowPlaying[id]!;
|
||||
return _buildNowPlayingCard(channel, program, theme);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// All channels header
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(t.liveTv.allChannels, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
|
||||
// Channel grid
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildChannelTile(
|
||||
_channels[index],
|
||||
theme,
|
||||
),
|
||||
childCount: _channels.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 80)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNowPlayingCard(LiveTvChannel channel, LiveTvProgram program, ThemeData theme) {
|
||||
final client = context.read<MultiServerProvider>().getClientForServer(channel.serverId ?? '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => _tuneChannel(channel),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (channel.thumb != null && client != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.network(
|
||||
'${client.config.baseUrl}${channel.thumb}'.withPlexToken(client.config.token),
|
||||
width: 32,
|
||||
height: 32,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox(width: 32, height: 32),
|
||||
),
|
||||
)
|
||||
else
|
||||
const AppIcon(Symbols.live_tv_rounded, size: 32),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
channel.displayName,
|
||||
style: theme.textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (channel.number != null)
|
||||
Text(
|
||||
t.liveTv.channelNumber(number: channel.number!),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_LiveBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
program.title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (program.grandparentTitle != null)
|
||||
Text(
|
||||
program.grandparentTitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Spacer(),
|
||||
// Progress bar
|
||||
LinearProgressIndicator(
|
||||
value: program.progress,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelTile(LiveTvChannel channel, ThemeData theme) {
|
||||
final id = channel.identifier ?? channel.key;
|
||||
final program = _nowPlaying[id];
|
||||
final client = context.read<MultiServerProvider>().getClientForServer(channel.serverId ?? '');
|
||||
|
||||
return ListTile(
|
||||
leading: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: channel.thumb != null && client != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.network(
|
||||
'${client.config.baseUrl}${channel.thumb}'.withPlexToken(client.config.token),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const Center(child: AppIcon(Symbols.live_tv_rounded)),
|
||||
),
|
||||
)
|
||||
: const Center(child: AppIcon(Symbols.live_tv_rounded)),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
if (channel.number != null) ...[
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
channel.number!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
channel.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (channel.hd)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
t.liveTv.hd,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: program != null
|
||||
? Row(
|
||||
children: [
|
||||
if (program.isCurrentlyAiring) ...[
|
||||
_LiveBadge(small: true),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
program.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
onTap: () => _tuneChannel(channel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveBadge extends StatelessWidget {
|
||||
final bool small;
|
||||
const _LiveBadge({this.small = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: small ? 4 : 6,
|
||||
vertical: small ? 1 : 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
t.liveTv.live,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: small ? 8 : 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import '../focus/dpad_navigator.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import 'discover_screen.dart';
|
||||
import 'libraries/libraries_screen.dart';
|
||||
import 'livetv/live_tv_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'downloads/downloads_screen.dart';
|
||||
import 'settings/settings_screen.dart';
|
||||
@@ -92,6 +93,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
bool _autoSwitchedToDownloads = false;
|
||||
|
||||
OfflineModeProvider? _offlineModeProvider;
|
||||
MultiServerProvider? _multiServerProvider;
|
||||
bool _lastHasLiveTv = false;
|
||||
|
||||
/// Prevents double-pushing the profile selection screen
|
||||
bool _isShowingProfileSelection = false;
|
||||
@@ -99,6 +102,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
late List<Widget> _screens;
|
||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
||||
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
||||
final GlobalKey<State<LiveTvScreen>> _liveTvKey = GlobalKey();
|
||||
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
||||
final GlobalKey<State<DownloadsScreen>> _downloadsKey = GlobalKey();
|
||||
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
|
||||
@@ -384,6 +388,14 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
_offlineModeProvider!.addListener(_handleOfflineStatusChanged);
|
||||
}
|
||||
|
||||
// Listen for Live TV / DVR availability changes
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
if (multiServer != _multiServerProvider) {
|
||||
_multiServerProvider?.removeListener(_handleLiveTvChanged);
|
||||
_multiServerProvider = multiServer;
|
||||
_multiServerProvider!.addListener(_handleLiveTvChanged);
|
||||
}
|
||||
|
||||
// Wire up Companion Remote command routing (desktop only, once)
|
||||
if (!_companionRemoteSetup && PlatformDetector.isDesktop(context)) {
|
||||
_companionRemoteSetup = true;
|
||||
@@ -402,40 +414,41 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
};
|
||||
|
||||
final receiver = CompanionRemoteReceiver.instance;
|
||||
final tabCount = _getVisibleTabs(_isOffline).length;
|
||||
|
||||
receiver.onTabNext = () {
|
||||
final tabCount = _getVisibleTabs(_isOffline).length;
|
||||
_selectTab((_currentIndex + 1) % tabCount);
|
||||
};
|
||||
receiver.onTabPrevious = () {
|
||||
final tabCount = _getVisibleTabs(_isOffline).length;
|
||||
_selectTab((_currentIndex - 1 + tabCount) % tabCount);
|
||||
};
|
||||
receiver.onTabDiscover = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabLibraries = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabSearch = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabDownloads = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabSettings = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onHome = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onSearchAction = (query) {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline);
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (idx >= 0) {
|
||||
_selectTab(idx);
|
||||
if (query != null && query.isNotEmpty) {
|
||||
@@ -458,6 +471,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
windowManager.setPreventClose(false);
|
||||
}
|
||||
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
||||
_multiServerProvider?.removeListener(_handleLiveTvChanged);
|
||||
_sidebarFocusScope.dispose();
|
||||
_contentFocusScope.dispose();
|
||||
|
||||
@@ -507,14 +521,16 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
|
||||
List<Widget> _buildScreens(bool offline) {
|
||||
// In offline mode, only show Downloads and Settings
|
||||
// In online mode, show all 5 screens
|
||||
if (offline) {
|
||||
return [DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey)];
|
||||
}
|
||||
|
||||
final hasLiveTv = context.read<MultiServerProvider>().hasLiveTv;
|
||||
|
||||
return [
|
||||
DiscoverScreen(key: _discoverKey, onBecameVisible: _onDiscoverBecameVisible),
|
||||
LibrariesScreen(key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged),
|
||||
if (hasLiveTv) LiveTvScreen(key: _liveTvKey),
|
||||
SearchScreen(key: _searchKey),
|
||||
DownloadsScreen(key: _downloadsKey),
|
||||
SettingsScreen(key: _settingsKey),
|
||||
@@ -539,6 +555,20 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
return newIndex >= 0 ? newIndex : 0;
|
||||
}
|
||||
|
||||
void _handleLiveTvChanged() {
|
||||
final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false;
|
||||
if (hasLiveTv == _lastHasLiveTv) return;
|
||||
_lastHasLiveTv = hasLiveTv;
|
||||
|
||||
setState(() {
|
||||
final currentTabId = _tabIdForIndex(_isOffline, _currentIndex);
|
||||
_screens = _buildScreens(_isOffline);
|
||||
// Restore the correct tab index after rebuilding
|
||||
final newIndex = NavigationTab.indexFor(currentTabId, isOffline: _isOffline, hasLiveTv: hasLiveTv);
|
||||
_currentIndex = newIndex >= 0 ? newIndex : 0;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleOfflineStatusChanged() {
|
||||
final newOffline = _offlineModeProvider?.isOffline ?? widget.isOfflineMode;
|
||||
|
||||
@@ -567,7 +597,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
// Coming back online: restore the last online tab if we forced a switch to Downloads.
|
||||
if (_autoSwitchedToDownloads) {
|
||||
final restoredTab = _lastOnlineTabId ?? NavigationTabId.discover;
|
||||
final restoredIndex = NavigationTab.indexFor(restoredTab, isOffline: _isOffline);
|
||||
final restoredIndex = NavigationTab.indexFor(restoredTab, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
_currentIndex = restoredIndex >= 0 ? restoredIndex : 0;
|
||||
} else {
|
||||
_currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline);
|
||||
@@ -622,7 +652,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
});
|
||||
}
|
||||
// When content regains focus while on Settings, restore focus to last focused setting
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline);
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
if (_currentIndex == settingsIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_settingsKey.currentState case final FocusableTab focusable) {
|
||||
@@ -776,7 +806,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
});
|
||||
|
||||
// Handle screen-specific logic
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline);
|
||||
final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv);
|
||||
|
||||
// Skip online-only screen logic in offline mode
|
||||
if (!_isOffline) {
|
||||
@@ -802,7 +832,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
}
|
||||
// Focus search input when selecting Search tab
|
||||
if (index == 2) {
|
||||
if (NavigationTab.isTabAtIndex(NavigationTabId.search, index, isOffline: _isOffline, hasLiveTv: _hasLiveTv)) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.focusSearchInput();
|
||||
}
|
||||
@@ -835,9 +865,18 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Live TV tab is currently visible
|
||||
bool get _hasLiveTv {
|
||||
try {
|
||||
return context.read<MultiServerProvider>().hasLiveTv;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get navigation tabs filtered by offline mode
|
||||
List<NavigationTab> _getVisibleTabs(bool isOffline) {
|
||||
return NavigationTab.getVisibleTabs(isOffline: isOffline);
|
||||
return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv);
|
||||
}
|
||||
|
||||
/// Get the tab ID for a given index, clamping to the available range.
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../mpv/mpv.dart';
|
||||
import '../mpv/player/player_android.dart';
|
||||
|
||||
import '../../services/plex_client.dart';
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../services/plex_api_cache.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
@@ -67,6 +68,15 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
final int selectedMediaIndex;
|
||||
final bool isOffline;
|
||||
|
||||
// Live TV fields
|
||||
final bool isLive;
|
||||
final String? liveChannelName;
|
||||
final String? liveStreamUrl;
|
||||
final List<LiveTvChannel>? liveChannels;
|
||||
final int? liveCurrentChannelIndex;
|
||||
final String? liveDvrKey;
|
||||
final PlexClient? liveClient;
|
||||
|
||||
const VideoPlayerScreen({
|
||||
super.key,
|
||||
required this.metadata,
|
||||
@@ -74,6 +84,13 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
this.preferredSubtitleTrack,
|
||||
this.selectedMediaIndex = 0,
|
||||
this.isOffline = false,
|
||||
this.isLive = false,
|
||||
this.liveChannelName,
|
||||
this.liveStreamUrl,
|
||||
this.liveChannels,
|
||||
this.liveCurrentChannelIndex,
|
||||
this.liveDvrKey,
|
||||
this.liveClient,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -112,6 +129,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _isHandlingBack = false;
|
||||
bool _hasThumbnails = false;
|
||||
|
||||
// Live TV channel navigation
|
||||
int _liveChannelIndex = -1;
|
||||
String? _liveChannelName;
|
||||
|
||||
// Auto-play next episode
|
||||
Timer? _autoPlayTimer;
|
||||
int _autoPlayCountdown = 5;
|
||||
@@ -171,6 +192,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_activeRatingKey = widget.metadata.ratingKey;
|
||||
_activeMediaIndex = widget.selectedMediaIndex;
|
||||
|
||||
// Initialize live TV channel tracking
|
||||
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
|
||||
_liveChannelName = widget.liveChannelName;
|
||||
|
||||
// Initialize Play Next dialog focus nodes
|
||||
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
||||
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
|
||||
@@ -567,6 +592,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _initializeServices() async {
|
||||
if (!mounted || player == null) return;
|
||||
|
||||
// Skip progress tracking for live TV
|
||||
if (widget.isLive) return;
|
||||
|
||||
// Get client (null in offline mode)
|
||||
final client = widget.isOffline ? null : _getClientForMetadata(context);
|
||||
|
||||
@@ -735,7 +763,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
Future<void> _loadAdjacentEpisodes() async {
|
||||
if (!mounted) return;
|
||||
if (!mounted || widget.isLive) return;
|
||||
|
||||
if (widget.isOffline) {
|
||||
// Offline mode: find next/previous from downloaded episodes
|
||||
@@ -806,6 +834,37 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _startPlayback() async {
|
||||
if (!mounted) return;
|
||||
|
||||
// Live TV mode: bypass standard playback initialization
|
||||
if (widget.isLive && widget.liveStreamUrl != null) {
|
||||
try {
|
||||
_hasFirstFrame.value = false;
|
||||
await player!.requestAudioFocus();
|
||||
|
||||
final client = widget.liveClient ?? _getClientForMetadata(context);
|
||||
final plexHeaders = client.config.headers;
|
||||
|
||||
await player!.open(
|
||||
Media(widget.liveStreamUrl!, headers: plexHeaders),
|
||||
play: true,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableVersions = [];
|
||||
_currentMediaInfo = null;
|
||||
_isPlayerInitialized = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to start live TV playback', error: e);
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, e.toString());
|
||||
_handleBackButton();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture providers before async gaps
|
||||
final offlineWatchService = widget.isOffline ? context.read<OfflineWatchSyncService>() : null;
|
||||
|
||||
@@ -1556,6 +1615,69 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await _navigateToEpisode(_previousEpisode!);
|
||||
}
|
||||
|
||||
bool _isSwitchingChannel = false;
|
||||
|
||||
/// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous)
|
||||
Future<void> _switchLiveChannel(int delta) async {
|
||||
final channels = widget.liveChannels;
|
||||
if (channels == null || channels.isEmpty) return;
|
||||
if (_isSwitchingChannel) return; // debounce concurrent switches
|
||||
|
||||
final newIndex = _liveChannelIndex + delta;
|
||||
if (newIndex < 0 || newIndex >= channels.length) return;
|
||||
|
||||
_isSwitchingChannel = true;
|
||||
|
||||
final channel = channels[newIndex];
|
||||
final channelId = channel.identifier ?? channel.key;
|
||||
appLogger.d('Switching to channel: ${channel.displayName} ($channelId)');
|
||||
|
||||
setState(() => _hasFirstFrame.value = false);
|
||||
|
||||
try {
|
||||
// Look up the correct client/DVR for this channel's server
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo = multiServer.liveTvServers.where(
|
||||
(s) => s.serverId == channel.serverId,
|
||||
).firstOrNull ?? multiServer.liveTvServers.firstOrNull;
|
||||
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
final result = await client.tuneChannel(serverInfo.dvrKey, channelId);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
|
||||
|
||||
await player!.open(
|
||||
Media(streamUrl, headers: client.config.headers),
|
||||
play: true,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_liveChannelIndex = newIndex;
|
||||
_liveChannelName = channel.displayName;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to switch channel', error: e);
|
||||
} finally {
|
||||
_isSwitchingChannel = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasNextChannel =>
|
||||
widget.isLive &&
|
||||
widget.liveChannels != null &&
|
||||
_liveChannelIndex >= 0 &&
|
||||
_liveChannelIndex < (widget.liveChannels!.length - 1);
|
||||
|
||||
bool get _hasPreviousChannel =>
|
||||
widget.isLive &&
|
||||
widget.liveChannels != null &&
|
||||
_liveChannelIndex > 0;
|
||||
|
||||
void _startAutoPlayTimer() {
|
||||
_autoPlayTimer?.cancel();
|
||||
_autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
@@ -1993,8 +2115,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
controls: (context) => plexVideoControlsBuilder(
|
||||
player!,
|
||||
widget.metadata,
|
||||
onNext: (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null,
|
||||
onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null,
|
||||
onNext: widget.isLive
|
||||
? (_hasNextChannel ? () => _switchLiveChannel(1) : null)
|
||||
: ((_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null),
|
||||
onPrevious: widget.isLive
|
||||
? (_hasPreviousChannel ? () => _switchLiveChannel(-1) : null)
|
||||
: ((_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null),
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
onTogglePIPMode: _togglePIPMode,
|
||||
@@ -2025,6 +2151,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null
|
||||
? (Duration time) => _buildThumbnailUrl(context, time)!
|
||||
: null,
|
||||
isLive: widget.isLive,
|
||||
liveChannelName: _liveChannelName,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,11 @@ import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../models/livetv_program.dart';
|
||||
import '../models/livetv_scheduled_recording.dart';
|
||||
import '../models/livetv_subscription.dart';
|
||||
import '../models/plex_config.dart';
|
||||
import '../models/play_queue_response.dart';
|
||||
import '../models/plex_file_info.dart';
|
||||
@@ -1936,6 +1941,265 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live TV / DVR Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get all DVR devices configured on this server
|
||||
Future<List<LiveTvDvr>> getDvrs() async {
|
||||
return _wrapListApiCall<LiveTvDvr>(
|
||||
() => _dio.get('/livetv/dvrs'),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Dvr'] != null) {
|
||||
return (container['Dvr'] as List)
|
||||
.map((json) => LiveTvDvr.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get DVRs',
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this server has at least one DVR configured
|
||||
Future<bool> hasDvr() async {
|
||||
final dvrs = await getDvrs();
|
||||
return dvrs.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get EPG channels for a specific lineup
|
||||
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (lineup != null) queryParams['lineup'] = lineup;
|
||||
|
||||
return _wrapListApiCall<LiveTvChannel>(
|
||||
() => _dio.get('/livetv/epg/channels', queryParameters: queryParams),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Channel'] != null) {
|
||||
return (container['Channel'] as List)
|
||||
.map((json) => LiveTvChannel.fromJson(json as Map<String, dynamic>)
|
||||
.copyWith(serverId: serverId, serverName: serverName))
|
||||
.toList();
|
||||
}
|
||||
// Also check for Metadata key (some endpoints return channels there)
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List)
|
||||
.map((json) => LiveTvChannel.fromJson(json as Map<String, dynamic>)
|
||||
.copyWith(serverId: serverId, serverName: serverName))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get EPG channels',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get guide/program data for channels (EPG grid data)
|
||||
/// Returns programs grouped in the MediaContainer
|
||||
Future<List<LiveTvProgram>> getEpgGrid({
|
||||
String? lineup,
|
||||
int? beginsAt,
|
||||
int? endsAt,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (lineup != null) queryParams['lineup'] = lineup;
|
||||
if (beginsAt != null) queryParams['beginsAt>'] = beginsAt;
|
||||
if (endsAt != null) queryParams['endsAt<'] = endsAt;
|
||||
|
||||
return _wrapListApiCall<LiveTvProgram>(
|
||||
() => _dio.get('/livetv/epg', queryParameters: queryParams),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
final programs = <LiveTvProgram>[];
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
for (final item in container['Metadata'] as List) {
|
||||
try {
|
||||
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
// Some responses nest programs inside Hub entries
|
||||
if (container != null && container['Hub'] != null) {
|
||||
for (final hub in container['Hub'] as List) {
|
||||
if (hub is Map && hub['Metadata'] != null) {
|
||||
for (final item in hub['Metadata'] as List) {
|
||||
try {
|
||||
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return programs;
|
||||
},
|
||||
'Failed to get EPG grid',
|
||||
);
|
||||
}
|
||||
|
||||
/// Tune to a live TV channel. Returns metadata and the stream URL path.
|
||||
Future<({PlexMetadata metadata, String streamPath})?> tuneChannel(String dvrKey, String channelIdentifier) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune',
|
||||
);
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
if (metadataJson == null) return null;
|
||||
|
||||
final metadata = _createTaggedMetadata(metadataJson);
|
||||
|
||||
// Extract stream path from Media[0].Part[0].key
|
||||
String? streamPath;
|
||||
final mediaList = metadataJson['Media'] as List?;
|
||||
if (mediaList != null && mediaList.isNotEmpty) {
|
||||
final parts = (mediaList[0] as Map<String, dynamic>)['Part'] as List?;
|
||||
if (parts != null && parts.isNotEmpty) {
|
||||
streamPath = (parts[0] as Map<String, dynamic>)['key'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
if (streamPath == null) return null;
|
||||
return (metadata: metadata, streamPath: streamPath);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to tune channel', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the DVR guide data
|
||||
Future<bool> reloadGuide(String dvrKey) async {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.post('/livetv/dvrs/$dvrKey/reloadGuide'),
|
||||
'Failed to reload guide',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get active live TV sessions
|
||||
Future<List<PlexMetadata>> getLiveTvSessions() async {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/livetv/sessions'),
|
||||
_extractMetadataList,
|
||||
'Failed to get live TV sessions',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all DVR recording subscriptions
|
||||
Future<List<LiveTvSubscription>> getSubscriptions() async {
|
||||
return _wrapListApiCall<LiveTvSubscription>(
|
||||
() => _dio.get('/media/subscriptions'),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['MediaSubscription'] != null) {
|
||||
return (container['MediaSubscription'] as List)
|
||||
.map((json) {
|
||||
final sub = LiveTvSubscription.fromJson(json as Map<String, dynamic>);
|
||||
return LiveTvSubscription(
|
||||
key: sub.key, ratingKey: sub.ratingKey, guid: sub.guid,
|
||||
title: sub.title, summary: sub.summary, type: sub.type,
|
||||
thumb: sub.thumb, art: sub.art,
|
||||
targetLibrarySectionID: sub.targetLibrarySectionID,
|
||||
targetSectionID: sub.targetSectionID, createdAt: sub.createdAt,
|
||||
settings: sub.settings, serverId: serverId,
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get subscriptions',
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a DVR recording subscription
|
||||
Future<LiveTvSubscription?> createSubscription({
|
||||
required String type,
|
||||
required int targetSectionID,
|
||||
required int targetLibrarySectionID,
|
||||
Map<String, String>? prefs,
|
||||
String? hint,
|
||||
String? uri,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': type,
|
||||
'targetSectionID': targetSectionID,
|
||||
'targetLibrarySectionID': targetLibrarySectionID,
|
||||
};
|
||||
if (hint != null) queryParams['hint'] = hint;
|
||||
if (uri != null) queryParams['uri'] = uri;
|
||||
if (prefs != null) {
|
||||
for (final entry in prefs.entries) {
|
||||
queryParams['prefs[${entry.key}]'] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
final response = await _dio.post('/media/subscriptions', queryParameters: queryParams);
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['MediaSubscription'] != null) {
|
||||
final subs = container['MediaSubscription'] as List;
|
||||
if (subs.isNotEmpty) {
|
||||
return LiveTvSubscription.fromJson(subs.first as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create subscription', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a DVR recording subscription
|
||||
Future<bool> deleteSubscription(String subscriptionId) async {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/media/subscriptions/$subscriptionId'),
|
||||
'Failed to delete subscription',
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit a DVR recording subscription's preferences
|
||||
Future<bool> editSubscription(String subscriptionId, Map<String, String> prefs) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
for (final entry in prefs.entries) {
|
||||
queryParams['prefs[${entry.key}]'] = entry.value;
|
||||
}
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.put('/media/subscriptions/$subscriptionId', queryParameters: queryParams),
|
||||
'Failed to edit subscription',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get scheduled DVR recordings
|
||||
Future<List<ScheduledRecording>> getScheduledRecordings() async {
|
||||
return _wrapListApiCall<ScheduledRecording>(
|
||||
() => _dio.get('/media/subscriptions/scheduled'),
|
||||
(response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List)
|
||||
.map((json) => ScheduledRecording.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get scheduled recordings',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get subscription template for a program (used for recording setup)
|
||||
Future<Map<String, dynamic>?> getSubscriptionTemplate(String guid) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/media/subscriptions/template',
|
||||
queryParameters: {'guid': guid},
|
||||
);
|
||||
return _getMediaContainer(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get subscription template', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleEndpointSwitch(String newBaseUrl) async {
|
||||
if (config.baseUrl == newBaseUrl) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../screens/video_player_screen.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/plex_url_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
|
||||
/// Tune to a live TV channel and launch the video player.
|
||||
///
|
||||
/// 1. Calls `tuneChannel()` to get metadata + stream path
|
||||
/// 2. Navigates to the VideoPlayerScreen with `isLive: true`
|
||||
///
|
||||
/// [channels] is the full channel list for channel up/down navigation.
|
||||
Future<void> navigateToLiveTv(
|
||||
BuildContext context, {
|
||||
required PlexClient client,
|
||||
required String dvrKey,
|
||||
required LiveTvChannel channel,
|
||||
List<LiveTvChannel>? channels,
|
||||
}) async {
|
||||
final channelId = channel.identifier ?? channel.key;
|
||||
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
appLogger.d('Tuning to channel: ${channel.displayName} ($channelId)');
|
||||
|
||||
final result = await client.tuneChannel(dvrKey, channelId);
|
||||
|
||||
if (result == null) {
|
||||
appLogger.e('Failed to tune channel $channelId');
|
||||
if (context.mounted) {
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(content: Text('Failed to tune to ${channel.displayName}')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final route = PageRouteBuilder<bool>(
|
||||
settings: const RouteSettings(name: kVideoPlayerRouteName),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||
metadata: result.metadata,
|
||||
isLive: true,
|
||||
liveChannelName: channel.displayName,
|
||||
liveStreamUrl: streamUrl,
|
||||
liveChannels: channels,
|
||||
liveCurrentChannelIndex: channels?.indexWhere(
|
||||
(ch) => (ch.identifier ?? ch.key) == channelId,
|
||||
),
|
||||
liveDvrKey: dvrKey,
|
||||
liveClient: client,
|
||||
),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
);
|
||||
|
||||
navigator.push<bool>(route);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import '../models/plex_library.dart';
|
||||
import '../navigation/navigation_tabs.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/libraries_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
@@ -338,14 +339,48 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Live TV (only if DVR available)
|
||||
if (context.watch<MultiServerProvider>().hasLiveTv) ...[
|
||||
_buildNavItem(
|
||||
icon: Symbols.live_tv_rounded,
|
||||
selectedIcon: Symbols.live_tv_rounded,
|
||||
label: Translations.of(context).navigation.liveTv,
|
||||
isSelected: NavigationTab.isTabAtIndex(
|
||||
NavigationTabId.liveTv,
|
||||
widget.selectedIndex,
|
||||
isOffline: widget.isOfflineMode,
|
||||
hasLiveTv: true,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused('liveTv'),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(NavigationTabId.liveTv, isOffline: widget.isOfflineMode, hasLiveTv: true),
|
||||
),
|
||||
focusNode: _focusTracker.get('liveTv'),
|
||||
isCollapsed: isCollapsed,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Search
|
||||
_buildNavItem(
|
||||
icon: Symbols.search_rounded,
|
||||
selectedIcon: Symbols.search_rounded,
|
||||
label: Translations.of(context).common.search,
|
||||
isSelected: widget.selectedIndex == 2,
|
||||
isSelected: NavigationTab.isTabAtIndex(
|
||||
NavigationTabId.search,
|
||||
widget.selectedIndex,
|
||||
isOffline: widget.isOfflineMode,
|
||||
hasLiveTv: context.read<MultiServerProvider>().hasLiveTv,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused(_kSearch),
|
||||
onTap: () => widget.onDestinationSelected(2),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(
|
||||
NavigationTabId.search,
|
||||
isOffline: widget.isOfflineMode,
|
||||
hasLiveTv: context.read<MultiServerProvider>().hasLiveTv,
|
||||
),
|
||||
),
|
||||
focusNode: _focusTracker.get(_kSearch),
|
||||
isCollapsed: isCollapsed,
|
||||
),
|
||||
@@ -354,42 +389,50 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
],
|
||||
|
||||
// Downloads
|
||||
_buildNavItem(
|
||||
icon: Symbols.download_rounded,
|
||||
selectedIcon: Symbols.download_rounded,
|
||||
label: Translations.of(context).navigation.downloads,
|
||||
isSelected: NavigationTab.isTabAtIndex(
|
||||
NavigationTabId.downloads,
|
||||
widget.selectedIndex,
|
||||
isOffline: widget.isOfflineMode,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused(_kDownloads),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(NavigationTabId.downloads, isOffline: widget.isOfflineMode),
|
||||
),
|
||||
focusNode: _focusTracker.get(_kDownloads),
|
||||
isCollapsed: isCollapsed,
|
||||
),
|
||||
Builder(builder: (context) {
|
||||
final hasLiveTv = context.read<MultiServerProvider>().hasLiveTv;
|
||||
return _buildNavItem(
|
||||
icon: Symbols.download_rounded,
|
||||
selectedIcon: Symbols.download_rounded,
|
||||
label: Translations.of(context).navigation.downloads,
|
||||
isSelected: NavigationTab.isTabAtIndex(
|
||||
NavigationTabId.downloads,
|
||||
widget.selectedIndex,
|
||||
isOffline: widget.isOfflineMode,
|
||||
hasLiveTv: hasLiveTv,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused(_kDownloads),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(NavigationTabId.downloads, isOffline: widget.isOfflineMode, hasLiveTv: hasLiveTv),
|
||||
),
|
||||
focusNode: _focusTracker.get(_kDownloads),
|
||||
isCollapsed: isCollapsed,
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Settings
|
||||
_buildNavItem(
|
||||
icon: Symbols.settings_rounded,
|
||||
selectedIcon: Symbols.settings_rounded,
|
||||
label: Translations.of(context).common.settings,
|
||||
isSelected: NavigationTab.isTabAtIndex(
|
||||
NavigationTabId.settings,
|
||||
widget.selectedIndex,
|
||||
isOffline: widget.isOfflineMode,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused(_kSettings),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(NavigationTabId.settings, isOffline: widget.isOfflineMode),
|
||||
),
|
||||
focusNode: _focusTracker.get(_kSettings),
|
||||
isCollapsed: isCollapsed,
|
||||
),
|
||||
Builder(builder: (context) {
|
||||
final hasLiveTv = context.read<MultiServerProvider>().hasLiveTv;
|
||||
return _buildNavItem(
|
||||
icon: Symbols.settings_rounded,
|
||||
selectedIcon: Symbols.settings_rounded,
|
||||
label: Translations.of(context).common.settings,
|
||||
isSelected: NavigationTab.isTabAtIndex(
|
||||
NavigationTabId.settings,
|
||||
widget.selectedIndex,
|
||||
isOffline: widget.isOfflineMode,
|
||||
hasLiveTv: hasLiveTv,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused(_kSettings),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(NavigationTabId.settings, isOffline: widget.isOfflineMode, hasLiveTv: hasLiveTv),
|
||||
),
|
||||
focusNode: _focusTracker.get(_kSettings),
|
||||
isCollapsed: isCollapsed,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -83,6 +83,12 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
/// Optional callback that returns a thumbnail URL for a given timestamp.
|
||||
final String Function(Duration time)? thumbnailUrlBuilder;
|
||||
|
||||
/// Whether this is a live TV stream
|
||||
final bool isLive;
|
||||
|
||||
/// Channel name for live TV display
|
||||
final String? liveChannelName;
|
||||
|
||||
const DesktopVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -127,6 +133,8 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
this.shaderService,
|
||||
this.onShaderChanged,
|
||||
this.thumbnailUrlBuilder,
|
||||
this.isLive = false,
|
||||
this.liveChannelName,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -411,10 +419,30 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
Widget _buildTopBarContent(BuildContext context, double leftPadding) {
|
||||
final topBar = Padding(
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 16),
|
||||
child: VideoControlsHeader(
|
||||
metadata: widget.metadata,
|
||||
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
||||
onBack: widget.onBack,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: VideoControlsHeader(
|
||||
metadata: widget.metadata,
|
||||
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
),
|
||||
if (widget.isLive) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
widget.liveChannelName != null ? '${t.liveTv.live} · ${widget.liveChannelName}' : t.liveTv.live,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -427,21 +455,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Row 1: Timeline with time indicators
|
||||
VideoTimelineBar(
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
onSeek: widget.onSeek,
|
||||
onSeekEnd: widget.onSeekEnd,
|
||||
horizontalLayout: true,
|
||||
focusNode: _timelineFocusNode,
|
||||
onKeyEvent: _handleTimelineKeyEvent,
|
||||
onFocusChange: _onFocusChange,
|
||||
enabled: canInteract,
|
||||
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Row 1: Timeline with time indicators (hidden for live TV)
|
||||
if (!widget.isLive) ...[
|
||||
VideoTimelineBar(
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
onSeek: widget.onSeek,
|
||||
onSeekEnd: widget.onSeekEnd,
|
||||
horizontalLayout: true,
|
||||
focusNode: _timelineFocusNode,
|
||||
onKeyEvent: _handleTimelineKeyEvent,
|
||||
onFocusChange: _onFocusChange,
|
||||
enabled: canInteract,
|
||||
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// Row 2: Playback controls and options
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -44,6 +44,12 @@ class MobileVideoControls extends StatelessWidget {
|
||||
/// Optional callback that returns a thumbnail URL for a given timestamp.
|
||||
final String Function(Duration time)? thumbnailUrlBuilder;
|
||||
|
||||
/// Whether this is a live TV stream
|
||||
final bool isLive;
|
||||
|
||||
/// Channel name for live TV display
|
||||
final String? liveChannelName;
|
||||
|
||||
const MobileVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -64,6 +70,8 @@ class MobileVideoControls extends StatelessWidget {
|
||||
this.canControl = true,
|
||||
this.hasFirstFrame,
|
||||
this.thumbnailUrlBuilder,
|
||||
this.isLive = false,
|
||||
this.liveChannelName,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -153,6 +161,34 @@ class MobileVideoControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(BuildContext context) {
|
||||
if (isLive) {
|
||||
// For live TV, show channel name instead of timeline
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
t.liveTv.live,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (liveChannelName != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
liveChannelName!,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildBottomBarContent(context));
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ Widget plexVideoControlsBuilder(
|
||||
ShaderService? shaderService,
|
||||
VoidCallback? onShaderChanged,
|
||||
String Function(Duration time)? thumbnailUrlBuilder,
|
||||
bool isLive = false,
|
||||
String? liveChannelName,
|
||||
}) {
|
||||
return PlexVideoControls(
|
||||
player: player,
|
||||
@@ -97,6 +99,8 @@ Widget plexVideoControlsBuilder(
|
||||
shaderService: shaderService,
|
||||
onShaderChanged: onShaderChanged,
|
||||
thumbnailUrlBuilder: thumbnailUrlBuilder,
|
||||
isLive: isLive,
|
||||
liveChannelName: liveChannelName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +144,12 @@ class PlexVideoControls extends StatefulWidget {
|
||||
/// Optional callback that returns a thumbnail URL for a given timestamp.
|
||||
final String Function(Duration time)? thumbnailUrlBuilder;
|
||||
|
||||
/// Whether this is a live TV stream (disables seek, progress, etc.)
|
||||
final bool isLive;
|
||||
|
||||
/// Channel name for live TV display
|
||||
final String? liveChannelName;
|
||||
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -162,6 +172,8 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.shaderService,
|
||||
this.onShaderChanged,
|
||||
this.thumbnailUrlBuilder,
|
||||
this.isLive = false,
|
||||
this.liveChannelName,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1748,6 +1760,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
canControl: widget.canControl,
|
||||
hasFirstFrame: widget.hasFirstFrame,
|
||||
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
|
||||
isLive: widget.isLive,
|
||||
liveChannelName: widget.liveChannelName,
|
||||
),
|
||||
)
|
||||
: Listener(
|
||||
@@ -1805,6 +1819,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
shaderService: widget.shaderService,
|
||||
onShaderChanged: widget.onShaderChanged,
|
||||
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
|
||||
isLive: widget.isLive,
|
||||
liveChannelName: widget.liveChannelName,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user