diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4d50a016..c920ee62 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 76269b14..4ccd740a 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 25a77486..f24048b4 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index d3a603c0..4af2e7dc 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 6741c342..cca62e33 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index e6f73655..afc4def8 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "컬렉션", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f4a651fb..403d24e5 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 9aedac5f..c9c4f43c 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 5cbbf0fe..2b53c3bb 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -65,6 +65,7 @@ class TranslationsDe with BaseTranslations 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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 51b135e5..20438c99 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -68,6 +68,7 @@ class Translations with BaseTranslations { 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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index ef065fea..88ceef68 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -65,6 +65,7 @@ class TranslationsEs with BaseTranslations 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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 7f6d1ee3..ad09853c 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -65,6 +65,7 @@ class TranslationsFr with BaseTranslations 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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index eca8118a..2ee8cbc5 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -65,6 +65,7 @@ class TranslationsIt with BaseTranslations 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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index e23a0461..25196a9d 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -65,6 +65,7 @@ class TranslationsKo with BaseTranslations 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' => '함께 보기', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 53b5585d..ef4ae1a8 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -65,6 +65,7 @@ class TranslationsNl with BaseTranslations 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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 638329d1..82398ce5 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -65,6 +65,7 @@ class TranslationsSv with BaseTranslations 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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 5b1e3f6c..eb4be950 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -65,6 +65,7 @@ class TranslationsZh with BaseTranslations 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' => '删除合集', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index d0590a67..bfd47379 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 3dff9b66..705bc4ea 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "下载", diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart new file mode 100644 index 00000000..880fa272 --- /dev/null +++ b/lib/models/livetv_channel.dart @@ -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 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'; +} diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart new file mode 100644 index 00000000..594185c4 --- /dev/null +++ b/lib/models/livetv_dvr.dart @@ -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 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 json) { + final mappings = []; + if (json['ChannelMapping'] != null) { + for (final item in json['ChannelMapping'] as List) { + try { + mappings.add(ChannelMapping.fromJson(item as Map)); + } 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 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?, + ); + } +} diff --git a/lib/models/livetv_program.dart b/lib/models/livetv_program.dart new file mode 100644 index 00000000..827725cd --- /dev/null +++ b/lib/models/livetv_program.dart @@ -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 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; + } +} diff --git a/lib/models/livetv_scheduled_recording.dart b/lib/models/livetv_scheduled_recording.dart new file mode 100644 index 00000000..017095c3 --- /dev/null +++ b/lib/models/livetv_scheduled_recording.dart @@ -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 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; + } +} diff --git a/lib/models/livetv_subscription.dart b/lib/models/livetv_subscription.dart new file mode 100644 index 00000000..707b3cfb --- /dev/null +++ b/lib/models/livetv_subscription.dart @@ -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 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 json) { + final settingsList = []; + if (json['Setting'] != null) { + for (final item in json['Setting'] as List) { + try { + settingsList.add(SubscriptionSetting.fromJson(item as Map)); + } 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? 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 json) { + List? 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}); +} diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index 8a13590c..4ea37b93 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -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 getVisibleTabs({required bool isOffline}) { - return allNavigationTabs.where((tab) => !isOffline || !tab.onlineOnly).toList(); + /// Get tabs filtered by offline mode and feature availability + static List 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, diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 48526ab3..1bb58d15 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -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 _liveTvServers = []; + List 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 checkLiveTvAvailability() async { + final newLiveTvServers = []; + + 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(); diff --git a/lib/screens/livetv/dvr_recordings_screen.dart b/lib/screens/livetv/dvr_recordings_screen.dart new file mode 100644 index 00000000..556cf2b1 --- /dev/null +++ b/lib/screens/livetv/dvr_recordings_screen.dart @@ -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 createState() => _DvrRecordingsScreenState(); +} + +class _DvrRecordingsScreenState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + + List _subscriptions = []; + List _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 _loadData() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + + if (liveTvServers.isEmpty) { + setState(() { + _isLoading = false; + _error = t.liveTv.noDvr; + }); + return; + } + + final allSubscriptions = []; + final allScheduled = []; + + 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 _deleteSubscription(LiveTvSubscription subscription) async { + final confirmed = await showDialog( + 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(); + 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 _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 = {}; + for (final setting in editableSettings) { + prefs[setting.id] = setting.value ?? setting.defaultValue ?? ''; + } + + final result = await showDialog?>( + context: context, + builder: (dialogContext) => _SubscriptionEditDialog( + subscription: subscription, + settings: editableSettings, + initialPrefs: prefs, + ), + ); + + if (result == null || !mounted) return; + + final multiServer = context.read(); + 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 settings; + final Map initialPrefs; + + const _SubscriptionEditDialog({ + required this.subscription, + required this.settings, + required this.initialPrefs, + }); + + @override + State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState(); +} + +class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { + late Map _prefs; + final Map _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( + 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; + }, + ), + ); + } +} diff --git a/lib/screens/livetv/epg_guide_screen.dart b/lib/screens/livetv/epg_guide_screen.dart new file mode 100644 index 00000000..8ab5fef0 --- /dev/null +++ b/lib/screens/livetv/epg_guide_screen.dart @@ -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 createState() => _EpgGuideScreenState(); +} + +class _EpgGuideScreenState extends State { + 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 _channels = []; + List _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 _loadData() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + + if (liveTvServers.isEmpty) { + setState(() { + _isLoading = false; + _error = t.liveTv.noDvr; + }); + return; + } + + final allChannels = []; + final allPrograms = []; + + 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 _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( + 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 = []; + 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(); + 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 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 = []; + 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 _tuneToChannel(LiveTvChannel channel) async { + final multiServer = context.read(); + + // 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, + ); + } +} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart new file mode 100644 index 00000000..9cbc056f --- /dev/null +++ b/lib/screens/livetv/live_tv_screen.dart @@ -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 createState() => _LiveTvScreenState(); +} + +class _LiveTvScreenState extends State { + List _channels = []; + Map _nowPlaying = {}; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadChannels(); + } + + Future _loadChannels() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + + if (liveTvServers.isEmpty) { + setState(() { + _isLoading = false; + _error = t.liveTv.noDvr; + }); + return; + } + + final allChannels = []; + + 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 _loadNowPlaying(List channels) async { + final multiServer = context.read(); + final nowPlaying = {}; + + 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 _tuneChannel(LiveTvChannel channel) async { + final multiServer = context.read(); + + // 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().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().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, + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index f4f2d7bc..c74e5767 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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 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 with RouteAware, WindowListener late List _screens; final GlobalKey> _discoverKey = GlobalKey(); final GlobalKey> _librariesKey = GlobalKey(); + final GlobalKey> _liveTvKey = GlobalKey(); final GlobalKey> _searchKey = GlobalKey(); final GlobalKey> _downloadsKey = GlobalKey(); final GlobalKey> _settingsKey = GlobalKey(); @@ -384,6 +388,14 @@ class _MainScreenState extends State with RouteAware, WindowListener _offlineModeProvider!.addListener(_handleOfflineStatusChanged); } + // Listen for Live TV / DVR availability changes + final multiServer = context.read(); + 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 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 with RouteAware, WindowListener windowManager.setPreventClose(false); } _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); + _multiServerProvider?.removeListener(_handleLiveTvChanged); _sidebarFocusScope.dispose(); _contentFocusScope.dispose(); @@ -507,14 +521,16 @@ class _MainScreenState extends State with RouteAware, WindowListener List _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().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 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 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 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 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 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 with RouteAware, WindowListener } } + /// Whether the Live TV tab is currently visible + bool get _hasLiveTv { + try { + return context.read().hasLiveTv; + } catch (_) { + return false; + } + } + /// Get navigation tabs filtered by offline mode List _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. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index c7ba1403..1b413c37 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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? 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 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 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 with WidgetsBindin Future _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 with WidgetsBindin } Future _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 with WidgetsBindin Future _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() : null; @@ -1556,6 +1615,69 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _navigateToEpisode(_previousEpisode!); } + bool _isSwitchingChannel = false; + + /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) + Future _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(); + 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 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 with WidgetsBindin thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null ? (Duration time) => _buildThumbnailUrl(context, time)! : null, + isLive: widget.isLive, + liveChannelName: _liveChannelName, ), ); }, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3dfebf45..da51e47e 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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> getDvrs() async { + return _wrapListApiCall( + () => _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)) + .toList(); + } + return []; + }, + 'Failed to get DVRs', + ); + } + + /// Check if this server has at least one DVR configured + Future hasDvr() async { + final dvrs = await getDvrs(); + return dvrs.isNotEmpty; + } + + /// Get EPG channels for a specific lineup + Future> getEpgChannels({String? lineup}) async { + final queryParams = {}; + if (lineup != null) queryParams['lineup'] = lineup; + + return _wrapListApiCall( + () => _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) + .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) + .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> getEpgGrid({ + String? lineup, + int? beginsAt, + int? endsAt, + }) async { + final queryParams = {}; + if (lineup != null) queryParams['lineup'] = lineup; + if (beginsAt != null) queryParams['beginsAt>'] = beginsAt; + if (endsAt != null) queryParams['endsAt<'] = endsAt; + + return _wrapListApiCall( + () => _dio.get('/livetv/epg', queryParameters: queryParams), + (response) { + final container = _getMediaContainer(response); + final programs = []; + if (container != null && container['Metadata'] != null) { + for (final item in container['Metadata'] as List) { + try { + programs.add(LiveTvProgram.fromJson(item as Map)); + } 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)); + } 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)['Part'] as List?; + if (parts != null && parts.isNotEmpty) { + streamPath = (parts[0] as Map)['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 reloadGuide(String dvrKey) async { + return _wrapBoolApiCall( + () => _dio.post('/livetv/dvrs/$dvrKey/reloadGuide'), + 'Failed to reload guide', + ); + } + + /// Get active live TV sessions + Future> getLiveTvSessions() async { + return _wrapListApiCall( + () => _dio.get('/livetv/sessions'), + _extractMetadataList, + 'Failed to get live TV sessions', + ); + } + + /// Get all DVR recording subscriptions + Future> getSubscriptions() async { + return _wrapListApiCall( + () => _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); + 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 createSubscription({ + required String type, + required int targetSectionID, + required int targetLibrarySectionID, + Map? prefs, + String? hint, + String? uri, + }) async { + try { + final queryParams = { + '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); + } + } + return null; + } catch (e) { + appLogger.e('Failed to create subscription', error: e); + return null; + } + } + + /// Delete a DVR recording subscription + Future deleteSubscription(String subscriptionId) async { + return _wrapBoolApiCall( + () => _dio.delete('/media/subscriptions/$subscriptionId'), + 'Failed to delete subscription', + ); + } + + /// Edit a DVR recording subscription's preferences + Future editSubscription(String subscriptionId, Map prefs) async { + final queryParams = {}; + 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> getScheduledRecordings() async { + return _wrapListApiCall( + () => _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)) + .toList(); + } + return []; + }, + 'Failed to get scheduled recordings', + ); + } + + /// Get subscription template for a program (used for recording setup) + Future?> 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 _handleEndpointSwitch(String newBaseUrl) async { if (config.baseUrl == newBaseUrl) { return; diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart new file mode 100644 index 00000000..ce90f556 --- /dev/null +++ b/lib/utils/live_tv_player_navigation.dart @@ -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 navigateToLiveTv( + BuildContext context, { + required PlexClient client, + required String dvrKey, + required LiveTvChannel channel, + List? 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( + 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(route); +} diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 457c1a88..eb1b1ce4 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -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 { const SizedBox(height: 8), + // Live TV (only if DVR available) + if (context.watch().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().hasLiveTv, + ), isFocused: _focusTracker.isFocused(_kSearch), - onTap: () => widget.onDestinationSelected(2), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor( + NavigationTabId.search, + isOffline: widget.isOfflineMode, + hasLiveTv: context.read().hasLiveTv, + ), + ), focusNode: _focusTracker.get(_kSearch), isCollapsed: isCollapsed, ), @@ -354,42 +389,50 @@ class SideNavigationRailState extends State { ], // 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().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().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, + ); + }), ], ), ), diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 928f780c..49423205 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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 { 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 { 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: [ diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 1fbebe2f..9eefba15 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -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)); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 300c95ce..2f5e16a3 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 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 with WindowListen shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + isLive: widget.isLive, + liveChannelName: widget.liveChannelName, ), ), ),