From 403b1f677640ab99e005cbd20e91a8f75efe3dbd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:27:49 +0100 Subject: [PATCH 01/18] feat: live tv --- lib/i18n/de.i18n.json | 33 +- lib/i18n/en.i18n.json | 33 +- lib/i18n/es.i18n.json | 33 +- lib/i18n/fr.i18n.json | 33 +- lib/i18n/it.i18n.json | 33 +- lib/i18n/ko.i18n.json | 33 +- lib/i18n/nl.i18n.json | 33 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 72 +- lib/i18n/strings_en.g.dart | 130 +++- lib/i18n/strings_es.g.dart | 72 +- lib/i18n/strings_fr.g.dart | 72 +- lib/i18n/strings_it.g.dart | 72 +- lib/i18n/strings_ko.g.dart | 72 +- lib/i18n/strings_nl.g.dart | 72 +- lib/i18n/strings_sv.g.dart | 72 +- lib/i18n/strings_zh.g.dart | 72 +- lib/i18n/sv.i18n.json | 33 +- lib/i18n/zh.i18n.json | 33 +- lib/models/livetv_channel.dart | 71 ++ lib/models/livetv_dvr.dart | 86 +++ lib/models/livetv_program.dart | 105 +++ lib/models/livetv_scheduled_recording.dart | 86 +++ lib/models/livetv_subscription.dart | 123 ++++ lib/navigation/navigation_tabs.dart | 22 +- lib/providers/multi_server_provider.dart | 55 ++ lib/screens/livetv/dvr_recordings_screen.dart | 422 ++++++++++++ lib/screens/livetv/epg_guide_screen.dart | 623 ++++++++++++++++++ lib/screens/livetv/live_tv_screen.dart | 461 +++++++++++++ lib/screens/main_screen.dart | 67 +- lib/screens/video_player_screen.dart | 134 +++- lib/services/plex_client.dart | 264 ++++++++ lib/utils/live_tv_player_navigation.dart | 65 ++ lib/widgets/side_navigation_rail.dart | 111 +++- .../desktop_video_controls.dart | 68 +- .../video_controls/mobile_video_controls.dart | 36 + .../video_controls/video_controls.dart | 16 + 37 files changed, 3715 insertions(+), 107 deletions(-) create mode 100644 lib/models/livetv_channel.dart create mode 100644 lib/models/livetv_dvr.dart create mode 100644 lib/models/livetv_program.dart create mode 100644 lib/models/livetv_scheduled_recording.dart create mode 100644 lib/models/livetv_subscription.dart create mode 100644 lib/screens/livetv/dvr_recordings_screen.dart create mode 100644 lib/screens/livetv/epg_guide_screen.dart create mode 100644 lib/screens/livetv/live_tv_screen.dart create mode 100644 lib/utils/live_tv_player_navigation.dart 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, ), ), ), From a1d78562d6cc3205a52ace2883590b2dca3dff26 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:52:51 +0100 Subject: [PATCH 02/18] fix(tv): playback --- lib/screens/video_player_screen.dart | 29 ++++- lib/services/plex_client.dart | 120 +++++++++++++++--- .../desktop_video_controls.dart | 88 +++++++------ .../sheets/video_settings_sheet.dart | 10 +- .../video_controls/video_controls.dart | 6 +- .../widgets/track_chapter_controls.dart | 5 + 6 files changed, 193 insertions(+), 65 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 1b413c37..98be3e79 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -704,6 +704,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Skip play queue in offline mode (requires server connection) if (widget.isOffline) return; + // Skip play queue for live TV (would interfere with tuner session) + if (widget.isLive) return; + // Only create play queues for episodes if (!widget.metadata.isEpisode) { return; @@ -839,12 +842,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { _hasFirstFrame.value = false; await player!.requestAudioFocus(); - - final client = widget.liveClient ?? _getClientForMetadata(context); - final plexHeaders = client.config.headers; + await _setLiveStreamOptions(); await player!.open( - Media(widget.liveStreamUrl!, headers: plexHeaders), + Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), play: true, ); @@ -1618,6 +1619,23 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isSwitchingChannel = false; /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) + /// Configure MPV/FFmpeg options for live streaming resilience. + /// Enables automatic reconnection on EOF and network errors. + Future _setLiveStreamOptions() async { + final p = player!; + // FFmpeg HTTP protocol reconnection + await p.setProperty('stream-lavf-o-append', 'reconnect=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_at_eof=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_streamed=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_on_network_error=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_delay_max=30'); + // Demuxer: retry up to 1000 times on stream reload failures + await p.setProperty('demuxer-lavf-o', 'max_reload=1000'); + // Re-open the stream URL when EOF is reached + await p.setProperty('loop-playlist', 'force'); + await p.setProperty('force-seekable', 'no'); + } + Future _switchLiveChannel(int delta) async { final channels = widget.liveChannels; if (channels == null || channels.isEmpty) return; @@ -1651,8 +1669,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token); + await _setLiveStreamOptions(); await player!.open( - Media(streamUrl, headers: client.config.headers), + Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index da51e47e..92ea58cd 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:dio/dio.dart'; @@ -2038,31 +2039,120 @@ class PlexClient { ); } - /// Tune to a live TV channel. Returns metadata and the stream URL path. + /// Generate 24-char random alphanumeric string (matching official client format) + static String _generateSessionIdentifier() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final rand = Random(); + return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join(); + } + + /// Tune to a live TV channel and set up the transcode session. + /// + /// Flow: tune → decision → return /start path (MKV-over-HTTP). Future<({PlexMetadata metadata, String streamPath})?> tuneChannel(String dvrKey, String channelIdentifier) async { try { + final sessionIdentifier = _generateSessionIdentifier(); + final response = await _dio.post( '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', + queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier}, ); - final metadataJson = _getFirstMetadataJson(response); - if (metadataJson == null) return null; + + if (response.statusCode != null && response.statusCode! >= 400) { + appLogger.w('Tune channel returned status ${response.statusCode}'); + return null; + } + + final container = _getMediaContainer(response); + if (container == null) return null; + + // Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata + Map? metadataJson; + final subscriptions = container['MediaSubscription'] as List?; + if (subscriptions != null && subscriptions.isNotEmpty) { + final sub = subscriptions[0] as Map; + final ops = sub['MediaGrabOperation'] as List?; + if (ops != null && ops.isNotEmpty) { + final op = ops[0] as Map; + final nested = op['Metadata']; + if (nested is Map) { + metadataJson = nested; + } + } + } + metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map?; + + if (metadataJson == null) { + appLogger.w('Tune channel: no metadata in response'); + 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?; - } + final sessionPath = metadataJson['key'] as String?; + if (sessionPath == null) { + appLogger.w('Tune channel: no session path in metadata key'); + return null; } - if (streamPath == null) return null; - return (metadata: metadata, streamPath: streamPath); - } catch (e) { - appLogger.e('Failed to tune channel', error: e); + // All identity goes in query params; the only HTTP header is Accept-Language + // (matching the official Plex client behaviour). + final allParams = { + 'hasMDE': '1', + 'path': sessionPath, + 'mediaIndex': '0', + 'partIndex': '0', + 'protocol': 'http', + 'fastSeek': '1', + 'directPlay': '0', + 'directStream': '1', + 'subtitleSize': '100', + 'audioBoost': '100', + 'location': 'lan', + 'addDebugOverlay': '0', + 'autoAdjustQuality': '0', + 'directStreamAudio': '1', + 'advancedSubtitles': 'text', + 'mediaBufferSize': '157286', + 'session': _generateSessionIdentifier(), + 'subtitles': 'auto', + 'copyts': '0', + 'Accept-Language': 'en', + 'X-Plex-Session-Identifier': sessionIdentifier, + 'X-Plex-Chunked': '1', + 'X-Plex-Incomplete-Segments': '1', + 'X-Plex-Product': config.product, + 'X-Plex-Version': config.version, + 'X-Plex-Client-Identifier': config.clientIdentifier, + 'X-Plex-Platform': config.platform, + 'X-Plex-Client-Profile-Name': 'Plex Desktop', + if (config.token != null) 'X-Plex-Token': config.token!, + }; + + // Manual query encoding — Dio encodes spaces as '+' but Plex requires '%20'. + final queryString = allParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + // Decision — bare Dio so no default X-Plex-* HTTP headers leak through. + final decisionDio = Dio(BaseOptions(headers: {'Accept-Language': 'en'})); + final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; + final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl)); + + if (decisionResponse.statusCode != 200) { + appLogger.w('Decision returned ${decisionResponse.statusCode}'); + return null; + } + + // Token is added by the caller via .withPlexToken() + final startParams = Map.from(allParams)..remove('X-Plex-Token'); + final startQuery = startParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + return (metadata: metadata, streamPath: '/video/:/transcode/universal/start?$startQuery'); + } catch (e, st) { + appLogger.e('Failed to tune channel', error: e, stackTrace: st); return null; } } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 49423205..ec9de319 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -570,51 +570,54 @@ class DesktopVideoControlsState extends State { semanticLabel: t.videoControls.nextButton, ), ), - // Finish time (hidden when too narrow to fit) - Expanded( - child: StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, posSnap) { - return StreamBuilder( - stream: widget.player.streams.duration, - initialData: widget.player.state.duration, - builder: (context, durSnap) { - return StreamBuilder( - stream: widget.player.streams.rate, - initialData: widget.player.state.rate, - builder: (context, rateSnap) { - final position = posSnap.data ?? Duration.zero; - final duration = durSnap.data ?? Duration.zero; - final remaining = duration - position; - final rate = rateSnap.data ?? 1.0; - if (remaining.inSeconds <= 0) return const SizedBox.shrink(); + // Finish time (hidden for live TV and when too narrow to fit) + if (widget.isLive) + const Spacer() + else + Expanded( + child: StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, posSnap) { + return StreamBuilder( + stream: widget.player.streams.duration, + initialData: widget.player.state.duration, + builder: (context, durSnap) { + return StreamBuilder( + stream: widget.player.streams.rate, + initialData: widget.player.state.rate, + builder: (context, rateSnap) { + final position = posSnap.data ?? Duration.zero; + final duration = durSnap.data ?? Duration.zero; + final remaining = duration - position; + final rate = rateSnap.data ?? 1.0; + if (remaining.inSeconds <= 0) return const SizedBox.shrink(); - final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate)); - const style = TextStyle(color: Colors.white70, fontSize: 13); + final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate)); + const style = TextStyle(color: Colors.white70, fontSize: 13); - return LayoutBuilder( - builder: (context, constraints) { - final tp = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - final textWidth = tp.width + 8; - tp.dispose(); - if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 8), - child: Text(text, style: style), - ); - }, - ); - }, - ); - }, - ); - }, + return LayoutBuilder( + builder: (context, constraints) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + final textWidth = tp.width + 8; + tp.dispose(); + if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(text, style: style), + ); + }, + ); + }, + ); + }, + ); + }, + ), ), - ), // Volume control VolumeControl( player: widget.player, @@ -652,6 +655,7 @@ class DesktopVideoControlsState extends State { onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, canControl: widget.canControl, + isLive: widget.isLive, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, ), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index d432ca37..a50d95a8 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -77,6 +77,9 @@ class VideoSettingsSheet extends StatefulWidget { /// Whether the user can control playback (false hides speed option in host-only mode). final bool canControl; + /// Whether this is a live TV stream (hides speed settings). + final bool isLive; + /// Optional shader service for MPV shader control final ShaderService? shaderService; @@ -89,6 +92,7 @@ class VideoSettingsSheet extends StatefulWidget { required this.audioSyncOffset, required this.subtitleSyncOffset, this.canControl = true, + this.isLive = false, this.shaderService, this.onShaderChanged, }); @@ -101,6 +105,7 @@ class VideoSettingsSheet extends StatefulWidget { VoidCallback? onOpen, VoidCallback? onClose, bool canControl = true, + bool isLive = false, ShaderService? shaderService, VoidCallback? onShaderChanged, }) { @@ -113,6 +118,7 @@ class VideoSettingsSheet extends StatefulWidget { audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset, canControl: canControl, + isLive: isLive, shaderService: shaderService, onShaderChanged: onShaderChanged, ), @@ -253,8 +259,8 @@ class _VideoSettingsSheetState extends State { return ListView( children: [ - // Playback Speed - only show if user can control playback - if (widget.canControl) + // Playback Speed - hidden for live TV and when user cannot control playback + if (widget.canControl && !widget.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 2f5e16a3..adf70a15 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -792,6 +792,9 @@ class _PlexVideoControlsState extends State with WindowListen } Future _loadPlaybackExtras() async { + // Live TV metadata uses EPG rating keys, not library items + if (widget.isLive) return; + try { appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}'); final client = _getClientForMetadata(); @@ -902,6 +905,7 @@ class _PlexVideoControlsState extends State with WindowListen onStartAutoHide: _startHideTimer, serverId: widget.metadata.serverId ?? '', canControl: widget.canControl, + isLive: widget.isLive, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, ); @@ -1167,7 +1171,7 @@ class _PlexVideoControlsState extends State with WindowListen /// Handle long-press start - activate 2x speed void _handleLongPressStart() { - if (!widget.canControl) return; // Respect Watch Together permissions + if (!widget.canControl || widget.isLive) return; setState(() { _isLongPressing = true; diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 6e26093b..ae21434c 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -60,6 +60,9 @@ class TrackChapterControls extends StatelessWidget { /// Whether the user can control playback (false in host-only mode for non-host). final bool canControl; + /// Whether this is a live TV stream (hides speed settings). + final bool isLive; + const TrackChapterControls({ super.key, required this.player, @@ -89,6 +92,7 @@ class TrackChapterControls extends StatelessWidget { this.onFocusChange, this.onNavigateLeft, this.canControl = true, + this.isLive = false, this.shaderService, this.onShaderChanged, }); @@ -194,6 +198,7 @@ class TrackChapterControls extends StatelessWidget { onOpen: onCancelAutoHide, onClose: onStartAutoHide, canControl: canControl, + isLive: isLive, shaderService: shaderService, onShaderChanged: onShaderChanged, ); From 2ae6385913aaa08f9922c9c46cc960f4de7256b3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:21:00 +0100 Subject: [PATCH 03/18] fix(tv): playback stopping --- lib/screens/video_player_screen.dart | 103 ++++++++++++++++------- lib/services/plex_client.dart | 32 ++++++- lib/utils/live_tv_player_navigation.dart | 2 + 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 98be3e79..d0902f04 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -76,6 +76,8 @@ class VideoPlayerScreen extends StatefulWidget { final int? liveCurrentChannelIndex; final String? liveDvrKey; final PlexClient? liveClient; + final String? liveSessionIdentifier; + final String? liveSessionPath; const VideoPlayerScreen({ super.key, @@ -91,6 +93,8 @@ class VideoPlayerScreen extends StatefulWidget { this.liveCurrentChannelIndex, this.liveDvrKey, this.liveClient, + this.liveSessionIdentifier, + this.liveSessionPath, }); @override @@ -132,6 +136,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Live TV channel navigation int _liveChannelIndex = -1; String? _liveChannelName; + String? _liveSessionIdentifier; + String? _liveSessionPath; + Timer? _liveTimelineTimer; // Auto-play next episode Timer? _autoPlayTimer; @@ -195,6 +202,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Initialize live TV channel tracking _liveChannelIndex = widget.liveCurrentChannelIndex ?? -1; _liveChannelName = widget.liveChannelName; + _liveSessionIdentifier = widget.liveSessionIdentifier; + _liveSessionPath = widget.liveSessionPath; // Initialize Play Next dialog focus nodes _playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel'); @@ -592,8 +601,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _initializeServices() async { if (!mounted || player == null) return; - // Skip progress tracking for live TV - if (widget.isLive) return; + // Live TV: send timeline heartbeats to keep transcode session alive + if (widget.isLive) { + _startLiveTimelineUpdates(); + return; + } // Get client (null in offline mode) final client = widget.isOffline ? null : _getClientForMetadata(context); @@ -844,10 +856,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await player!.requestAudioFocus(); await _setLiveStreamOptions(); - await player!.open( - Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), - play: true, - ); + await player!.open(Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), play: true); if (mounted) { setState(() { @@ -1258,10 +1267,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Store provider reference for use in dispose and notify remote try { _companionRemoteProvider = context.read(); - _companionRemoteProvider!.sendCommand( - RemoteCommandType.syncState, - data: {'playerActive': true}, - ); + _companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true}); } catch (_) {} } @@ -1282,10 +1288,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _savedOnHome = null; // Notify remote that player is no longer active - _companionRemoteProvider?.sendCommand( - RemoteCommandType.syncState, - data: {'playerActive': false}, - ); + _companionRemoteProvider?.sendCommand(RemoteCommandType.syncState, data: {'playerActive': false}); _companionRemoteProvider = null; } @@ -1323,7 +1326,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _onAudioTrackChanged(next); if (mounted) { - final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + final label = + 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; showAppSnackBar(context, label, duration: const Duration(seconds: 1)); } } @@ -1418,6 +1422,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker?.sendProgress('stopped'); _progressTracker?.stopTracking(); _progressTracker?.dispose(); + _sendLiveTimeline('stopped'); + _stopLiveTimelineUpdates(); // Remove PiP state listener, clear callback, and dispose video filter manager _videoPIPManager?.isPipActive.removeListener(_onPipStateChanged); @@ -1619,6 +1625,45 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isSwitchingChannel = false; /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) + /// Start periodic timeline heartbeats for live TV transcode session. + void _startLiveTimelineUpdates() { + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _sendLiveTimeline('playing'); + }); + // Send initial heartbeat immediately + _sendLiveTimeline('playing'); + } + + void _stopLiveTimelineUpdates() { + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = null; + } + + Future _sendLiveTimeline(String state) async { + final sessionId = _liveSessionIdentifier; + final sessionPath = _liveSessionPath; + if (sessionId == null || sessionPath == null) return; + + final client = widget.liveClient; + if (client == null) return; + + try { + final position = player?.state.position ?? Duration.zero; + final duration = player?.state.duration ?? Duration.zero; + await client.updateLiveTimeline( + ratingKey: widget.metadata.ratingKey, + sessionPath: sessionPath, + sessionIdentifier: sessionId, + state: state, + time: position.inMilliseconds, + duration: duration.inMilliseconds, + ); + } catch (e) { + appLogger.d('Live timeline update failed', error: e); + } + } + /// Configure MPV/FFmpeg options for live streaming resilience. /// Enables automatic reconnection on EOF and network errors. Future _setLiveStreamOptions() async { @@ -1631,8 +1676,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin await p.setProperty('stream-lavf-o-append', 'reconnect_delay_max=30'); // Demuxer: retry up to 1000 times on stream reload failures await p.setProperty('demuxer-lavf-o', 'max_reload=1000'); - // Re-open the stream URL when EOF is reached - await p.setProperty('loop-playlist', 'force'); await p.setProperty('force-seekable', 'no'); } @@ -1655,9 +1698,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin 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; + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? + multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -1670,15 +1713,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token); await _setLiveStreamOptions(); - await player!.open( - Media(streamUrl, headers: const {'Accept-Language': 'en'}), - play: true, - ); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true); setState(() { _liveChannelIndex = newIndex; _liveChannelName = channel.displayName; + _liveSessionIdentifier = result.sessionIdentifier; + _liveSessionPath = result.sessionPath; }); + + // Restart timeline heartbeats for the new session + _startLiveTimelineUpdates(); } catch (e) { appLogger.e('Failed to switch channel', error: e); } finally { @@ -1692,10 +1737,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _liveChannelIndex >= 0 && _liveChannelIndex < (widget.liveChannels!.length - 1); - bool get _hasPreviousChannel => - widget.isLive && - widget.liveChannels != null && - _liveChannelIndex > 0; + bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0; void _startAutoPlayTimer() { _autoPlayTimer?.cancel(); @@ -2408,7 +2450,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ), const SizedBox(width: 8), - Text(t.watchTogether.reconnectingToHost, style: const TextStyle(color: Colors.white, fontSize: 12)), + Text( + t.watchTogether.reconnectingToHost, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), ], ), ), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 92ea58cd..ebcae405 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1130,6 +1130,29 @@ class PlexClient { ); } + /// Send a live TV timeline heartbeat to keep the transcode session alive. + Future updateLiveTimeline({ + required String ratingKey, + required String sessionPath, + required String sessionIdentifier, + required String state, + required int time, + required int duration, + }) async { + await _dio.post( + '/:/timeline', + queryParameters: { + 'ratingKey': ratingKey, + 'key': sessionPath, + 'state': state, + 'hasMDE': '1', + 'time': time, + 'duration': duration, + 'X-Plex-Session-Identifier': sessionIdentifier, + }, + ); + } + /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { @@ -2049,7 +2072,7 @@ class PlexClient { /// Tune to a live TV channel and set up the transcode session. /// /// Flow: tune → decision → return /start path (MKV-over-HTTP). - Future<({PlexMetadata metadata, String streamPath})?> tuneChannel(String dvrKey, String channelIdentifier) async { + Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(String dvrKey, String channelIdentifier) async { try { final sessionIdentifier = _generateSessionIdentifier(); @@ -2150,7 +2173,12 @@ class PlexClient { .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); - return (metadata: metadata, streamPath: '/video/:/transcode/universal/start?$startQuery'); + return ( + metadata: metadata, + streamPath: '/video/:/transcode/universal/start?$startQuery', + sessionIdentifier: sessionIdentifier, + sessionPath: sessionPath, + ); } catch (e, st) { appLogger.e('Failed to tune channel', error: e, stackTrace: st); return null; diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index ce90f556..f55d6114 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -56,6 +56,8 @@ Future navigateToLiveTv( ), liveDvrKey: dvrKey, liveClient: client, + liveSessionIdentifier: result.sessionIdentifier, + liveSessionPath: result.sessionPath, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, From 80f26b07c079f144ff9f4ac4dffdfc11ed985df2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:16:10 +0100 Subject: [PATCH 04/18] feat(tv): inline EPG guide grid with provider endpoint discovery --- lib/i18n/en.i18n.json | 10 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_en.g.dart | 36 +- lib/models/livetv_program.dart | 17 +- lib/screens/livetv/epg_guide_screen.dart | 623 ------------- lib/screens/livetv/live_tv_screen.dart | 1082 ++++++++++++++++------ lib/services/plex_client.dart | 56 +- 7 files changed, 909 insertions(+), 919 deletions(-) delete mode 100644 lib/screens/livetv/epg_guide_screen.dart diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 4ccd740a..f76292cd 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -558,7 +558,15 @@ "premiere": "NEW", "reloadGuide": "Reload Guide", "guideReloaded": "Guide data reloaded", - "allChannels": "All Channels" + "allChannels": "All Channels", + "now": "Now", + "today": "Today", + "midnight": "Midnight", + "overnight": "Overnight", + "morning": "Morning", + "daytime": "Daytime", + "evening": "Evening", + "lateNight": "Late Night" }, "collections": { "title": "Collections", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index c9c4f43c..7431dbde 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: 6480 (720 per locale) +/// Strings: 6488 (720 per locale) /// -/// Built on 2026-02-11 at 15:19 UTC +/// Built on 2026-02-12 at 12:55 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 20438c99..b59f5ff7 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1728,6 +1728,30 @@ class TranslationsLiveTvEn { /// en: 'All Channels' String get allChannels => 'All Channels'; + + /// en: 'Now' + String get now => 'Now'; + + /// en: 'Today' + String get today => 'Today'; + + /// en: 'Midnight' + String get midnight => 'Midnight'; + + /// en: 'Overnight' + String get overnight => 'Overnight'; + + /// en: 'Morning' + String get morning => 'Morning'; + + /// en: 'Daytime' + String get daytime => 'Daytime'; + + /// en: 'Evening' + String get evening => 'Evening'; + + /// en: 'Late Night' + String get lateNight => 'Late Night'; } // Path: collections @@ -3149,6 +3173,14 @@ extension on Translations { 'liveTv.reloadGuide' => 'Reload Guide', 'liveTv.guideReloaded' => 'Guide data reloaded', 'liveTv.allChannels' => 'All Channels', + 'liveTv.now' => 'Now', + 'liveTv.today' => 'Today', + 'liveTv.midnight' => 'Midnight', + 'liveTv.overnight' => 'Overnight', + 'liveTv.morning' => 'Morning', + 'liveTv.daytime' => 'Daytime', + 'liveTv.evening' => 'Evening', + 'liveTv.lateNight' => 'Late Night', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'Collection is empty', @@ -3158,6 +3190,8 @@ extension on Translations { 'collections.deleted' => 'Collection deleted', 'collections.deleteFailed' => 'Failed to delete collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Failed to delete collection: ${error}', + _ => null, + } ?? switch (path) { 'collections.failedToLoadItems' => ({required Object error}) => 'Failed to load collection items: ${error}', 'collections.selectCollection' => 'Select Collection', 'collections.createNewCollection' => 'Create New Collection', @@ -3166,8 +3200,6 @@ 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', diff --git a/lib/models/livetv_program.dart b/lib/models/livetv_program.dart index 827725cd..9ef7275c 100644 --- a/lib/models/livetv_program.dart +++ b/lib/models/livetv_program.dart @@ -43,6 +43,10 @@ class LiveTvProgram { }); factory LiveTvProgram.fromJson(Map json) { + // Grid endpoint nests timing/channel info inside Media[0] and Channel[0] + final media = (json['Media'] as List?)?.firstOrNull as Map?; + final channel = (json['Channel'] as List?)?.firstOrNull as Map?; + return LiveTvProgram( key: json['key'] as String?, ratingKey: json['ratingKey'] as String?, @@ -51,16 +55,19 @@ class LiveTvProgram { 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(), + beginsAt: (json['beginsAt'] as num?)?.toInt() ?? (media?['beginsAt'] as num?)?.toInt(), + endsAt: (json['endsAt'] as num?)?.toInt() ?? (media?['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?, + thumb: json['thumb'] as String? ?? json['grandparentThumb'] as String?, art: json['art'] as String?, - channelIdentifier: json['channelIdentifier'] as String?, - channelCallSign: json['channelCallSign'] as String?, + channelIdentifier: json['channelIdentifier'] as String? + ?? media?['channelIdentifier']?.toString() + ?? channel?['id']?.toString(), + channelCallSign: json['channelCallSign'] as String? + ?? media?['channelCallSign'] as String?, live: json['live'] == true || json['live'] == 1 || json['live'] == '1', premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1', ); diff --git a/lib/screens/livetv/epg_guide_screen.dart b/lib/screens/livetv/epg_guide_screen.dart deleted file mode 100644 index 8ab5fef0..00000000 --- a/lib/screens/livetv/epg_guide_screen.dart +++ /dev/null @@ -1,623 +0,0 @@ -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 index 9cbc056f..2e6edb35 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -7,10 +9,11 @@ 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_image_helper.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 { @@ -21,18 +24,114 @@ class LiveTvScreen extends StatefulWidget { } class _LiveTvScreenState 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 = []; - Map _nowPlaying = {}; + List _programs = []; bool _isLoading = true; String? _error; + late DateTime _gridStart; + late DateTime _gridEnd; + + final ScrollController _headerHorizontalController = ScrollController(); + final ScrollController _gridHorizontalController = ScrollController(); + final ScrollController _channelVerticalController = ScrollController(); + bool _syncingScroll = false; + + Timer? _timeIndicatorTimer; + final _dayPickerKey = GlobalKey(); + @override void initState() { super.initState(); - _loadChannels(); + _initTimeRange(); + _loadData(); + + _gridHorizontalController.addListener(_syncGridToHeader); + _headerHorizontalController.addListener(_syncHeaderToGrid); + + _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); } - Future _loadChannels() async { + 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(); + _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)); + } + + void _shiftTimeRange(int hours) { + setState(() { + _gridStart = _gridStart.add(Duration(hours: hours)); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadData(); + } + + void _jumpToNow() { + _initTimeRange(); + _loadData(); + } + + void _jumpToDay(DateTime day) { + final now = DateTime.now(); + final isToday = day.year == now.year && + day.month == now.month && + day.day == now.day; + + if (isToday) { + _jumpToNow(); + return; + } + + setState(() { + // Start at midnight for non-today days + _gridStart = DateTime(day.year, day.month, day.day); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadData(); + } + + Future _loadData() async { if (!mounted) return; setState(() { _isLoading = true; @@ -52,6 +151,7 @@ class _LiveTvScreenState extends State { } final allChannels = []; + final allPrograms = []; for (final serverInfo in liveTvServers) { final client = multiServer.getClientForServer(serverInfo.serverId); @@ -59,9 +159,18 @@ class _LiveTvScreenState extends State { 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; @@ -70,15 +179,25 @@ class _LiveTvScreenState extends State { if (!mounted) return; - // Load "now playing" data - await _loadNowPlaying(allChannels); + appLogger.d('EPG loaded: ${allChannels.length} channels, ${allPrograms.length} programs'); + if (allChannels.isNotEmpty) { + final ch = allChannels.first; + appLogger.d('Sample channel: key=${ch.key}, identifier=${ch.identifier}'); + } + if (allPrograms.isNotEmpty) { + final p = allPrograms.first; + appLogger.d('Sample program: channelIdentifier=${p.channelIdentifier}, title=${p.title}'); + } setState(() { _channels = allChannels; + _programs = allPrograms; _isLoading = false; }); + + _scrollToNow(); } catch (e) { - appLogger.e('Failed to load Live TV channels', error: e); + appLogger.e('Failed to load Live TV data', error: e); if (mounted) { setState(() { _isLoading = false; @@ -88,44 +207,38 @@ class _LiveTvScreenState extends State { } } - 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 + 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), ); - - 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); - } + 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; } 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; + final serverInfo = multiServer.liveTvServers + .where((s) => s.serverId == channel.serverId) + .firstOrNull ?? + multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -141,12 +254,6 @@ class _LiveTvScreenState extends State { ); } - void _openGuide() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const EpgGuideScreen()), - ); - } - void _openRecordings() { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()), @@ -162,9 +269,9 @@ class _LiveTvScreenState extends State { title: Text(t.liveTv.title), actions: [ IconButton( - icon: const AppIcon(Symbols.menu_book_rounded), - tooltip: t.liveTv.guide, - onPressed: _openGuide, + icon: const AppIcon(Symbols.refresh_rounded), + tooltip: t.liveTv.reloadGuide, + onPressed: _loadData, ), IconButton( icon: const AppIcon(Symbols.fiber_dvr_rounded), @@ -180,12 +287,13 @@ class _LiveTvScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error), + 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, + onPressed: _loadData, icon: const AppIcon(Symbols.refresh_rounded), label: Text(t.common.retry), ), @@ -194,266 +302,676 @@ class _LiveTvScreenState extends State { ) : _channels.isEmpty ? Center(child: Text(t.liveTv.noChannels)) - : RefreshIndicator( - onRefresh: _loadChannels, - child: _buildChannelList(theme), - ), + : _buildGuideGrid(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( + Widget _buildGuideGrid(ThemeData theme) { + return Column( + children: [ + // Time navigation bar + _buildTimeNavigation(theme), + // Time header + Row( + children: [ + SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + Expanded( + child: SingleChildScrollView( + controller: _headerHorizontalController, 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, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), ), ), ), ], - 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), + ), + // Channel rows + program grid + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: ListView.builder( + controller: _channelVerticalController, + itemCount: _channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => + _buildChannelCell(_channels[index], theme), ), - child: Text( - t.liveTv.hd, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 9, + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + 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, + physics: const ClampingScrollPhysics(), + 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); + }, + ), + ), ), ), ), - ), - ], + ], + ), + ), + ], + ); + } + + String _dayLabel(DateTime day) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final target = DateTime(day.year, day.month, day.day); + + if (target == today) return t.liveTv.today; + + final format = MaterialLocalizations.of(context); + // formatFullDate gives "Monday, January 1, 2026" — extract weekday name + final full = format.formatFullDate(target); + return full.split(',').first; + } + + List<(String, int)> get _timeSlots => [ + (t.liveTv.midnight, 0), + (t.liveTv.overnight, 2), + (t.liveTv.morning, 6), + (t.liveTv.daytime, 12), + (t.liveTv.evening, 18), + (t.liveTv.lateNight, 22), + ]; + + RelativeRect _menuPosition() { + final renderBox = + _dayPickerKey.currentContext?.findRenderObject() as RenderBox?; + final overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + if (renderBox == null || overlay == null) return RelativeRect.fill; + + final buttonPos = renderBox.localToGlobal(Offset.zero); + final buttonSize = renderBox.size; + return RelativeRect.fromRect( + Rect.fromLTWH( + buttonPos.dx, + buttonPos.dy + buttonSize.height, + buttonSize.width, + 0, ), - subtitle: program != null - ? Row( + Offset.zero & overlay.size, + ); + } + + void _showDayPicker() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day); + final theme = Theme.of(context); + + final days = []; + for (var i = 0; i < 8; i++) { + days.add(today.add(Duration(days: i))); + } + + showMenu( + context: context, + position: _menuPosition(), + items: [ + PopupMenuItem( + value: 'now', + child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium), + ), + ...days.map((day) { + final isSelected = day == gridDay; + final label = _dayLabel(day); + return PopupMenuItem( + value: day, + child: Row( children: [ - if (program.isCurrentlyAiring) ...[ - _LiveBadge(small: true), - const SizedBox(width: 4), - ], Expanded( child: Text( - program.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: isSelected ? theme.colorScheme.primary : null, + ), ), ), + if (isSelected) + AppIcon(Symbols.check_rounded, + size: 18, color: theme.colorScheme.primary), ], - ) - : null, + ), + ); + }), + ], + ).then((value) { + if (value == null) return; + if (value is String && value == 'now') { + _jumpToNow(); + } else if (value is DateTime) { + _showTimeSlotPicker(value); + } + }); + } + + void _showTimeSlotPicker(DateTime day) { + final theme = Theme.of(context); + final label = _dayLabel(day).toUpperCase(); + + showMenu( + context: context, + position: _menuPosition(), + items: [ + PopupMenuItem( + value: -1, + child: Row( + children: [ + AppIcon(Symbols.chevron_left_rounded, + size: 20, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + Text(label, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + const PopupMenuDivider(), + ..._timeSlots.map((slot) { + return PopupMenuItem( + value: slot.$2, + child: Text(slot.$1, style: theme.textTheme.bodyMedium), + ); + }), + ], + ).then((value) { + if (value == null) return; + if (value == -1) { + // Back to day picker + _showDayPicker(); + return; + } + setState(() { + _gridStart = DateTime(day.year, day.month, day.day, value); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadData(); + }); + } + + Widget _buildTimeNavigation(ThemeData theme) { + final format = MaterialLocalizations.of(context); + final timeLabel = + format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart)); + final dayLabel = _dayLabel(_gridStart); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + ), + child: Row( + children: [ + IconButton( + icon: const AppIcon(Symbols.chevron_left_rounded), + onPressed: () => _shiftTimeRange(-2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayLabel, + style: theme.textTheme.labelLarge, + ), + const SizedBox(width: 2), + AppIcon(Symbols.arrow_drop_down_rounded, + size: 18, color: theme.colorScheme.onSurface), + ], + ), + ), + const SizedBox(width: 8), + Text( + timeLabel, + style: theme.textTheme.labelLarge, + ), + ], + ), + ), + IconButton( + icon: const AppIcon(Symbols.chevron_right_rounded), + onPressed: () => _shiftTimeRange(2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + ], + ), + ); + } + + 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), + _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 ?? ''); + + String? imageUrl; + if (channel.thumb != null && client != null) { + imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: channel.thumb, + maxWidth: _channelColumnWidth - 16, + maxHeight: _rowHeight - 16, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.logo, + ); + } + + return _ChannelCell( + rowHeight: _rowHeight, + channelColumnWidth: _channelColumnWidth, + imageUrl: imageUrl, + channel: channel, + theme: theme, onTap: () => _tuneChannel(channel), + fallbackBuilder: () => _buildChannelNameFallback(channel, theme), + ); + } + + Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + 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, + textAlign: TextAlign.center, + ), + ], + ); + } + + 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: 0, + bottom: 0, + 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; + final isPast = program.endsAt != null && + program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; + + return Opacity( + opacity: isPast ? 0.5 : 1.0, + child: Material( + color: isCurrentlyAiring + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(4), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showProgramDetails(channel, program), + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + program.grandparentTitle ?? 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.grandparentTitle != null) + Text( + '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', + style: theme.textTheme.labelSmall?.copyWith( + color: isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + 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(); + _tuneChannel(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), + ), + ], + ), + ], + ), + ); + }, ); } } -class _LiveBadge extends StatelessWidget { - final bool small; - const _LiveBadge({this.small = false}); +class _ChannelCell extends StatefulWidget { + final double rowHeight; + final double channelColumnWidth; + final String? imageUrl; + final LiveTvChannel channel; + final ThemeData theme; + final VoidCallback onTap; + final Widget Function() fallbackBuilder; + + const _ChannelCell({ + required this.rowHeight, + required this.channelColumnWidth, + required this.imageUrl, + required this.channel, + required this.theme, + required this.onTap, + required this.fallbackBuilder, + }); + + @override + State<_ChannelCell> createState() => _ChannelCellState(); +} + +class _ChannelCellState extends State<_ChannelCell> { + bool _hovered = 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, + final theme = widget.theme; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + child: Container( + height: widget.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: Stack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + opacity: _hovered ? 0.3 : 1.0, + duration: const Duration(milliseconds: 150), + child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty + ? Image.network( + widget.imageUrl!, + width: widget.channelColumnWidth - 16, + height: widget.rowHeight - 16, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + widget.fallbackBuilder(), + ) + : widget.fallbackBuilder(), + ), + if (_hovered) + AppIcon( + Symbols.play_arrow_rounded, + size: 32, + color: theme.colorScheme.onSurface, + ), + ], + ), + ), ), ), ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ebcae405..9131ef65 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1995,7 +1995,7 @@ class PlexClient { /// Get EPG channels for a specific lineup Future> getEpgChannels({String? lineup}) async { final queryParams = {}; - if (lineup != null) queryParams['lineup'] = lineup; + if (lineup != null) queryParams['lineup'] = Uri.decodeComponent(lineup); return _wrapListApiCall( () => _dio.get('/livetv/epg/channels', queryParameters: queryParams), @@ -2020,24 +2020,72 @@ class PlexClient { ); } + /// Cached EPG grid endpoint path (discovered from /media/providers) + String? _epgGridEndpoint; + + /// Discover the EPG grid endpoint from media providers + Future _getEpgGridEndpoint() async { + if (_epgGridEndpoint != null) return _epgGridEndpoint; + + try { + final response = await _dio.get('/media/providers'); + final container = _getMediaContainer(response); + if (container == null) return null; + + final providers = container['MediaProvider'] as List?; + if (providers == null) return null; + + for (final provider in providers) { + if (provider is! Map) continue; + final protocols = provider['protocols'] as String?; + if (protocols == null || !protocols.contains('livetv')) continue; + + final features = provider['Feature'] as List?; + if (features == null) continue; + for (final feature in features) { + if (feature is! Map) continue; + if (feature['type'] == 'grid') { + _epgGridEndpoint = feature['key'] as String?; + appLogger.d('Discovered EPG grid endpoint: $_epgGridEndpoint'); + return _epgGridEndpoint; + } + } + } + } catch (e) { + appLogger.e('Failed to discover EPG grid endpoint', error: e); + } + return null; + } + /// Get guide/program data for channels (EPG grid data) - /// Returns programs grouped in the MediaContainer + /// Discovers the grid endpoint from /media/providers on first call Future> getEpgGrid({ String? lineup, int? beginsAt, int? endsAt, }) async { + final gridEndpoint = await _getEpgGridEndpoint(); + if (gridEndpoint == null) { + appLogger.w('No EPG grid endpoint found'); + return []; + } + 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), + () => _dio.get(gridEndpoint, queryParameters: queryParams), (response) { final container = _getMediaContainer(response); + appLogger.d('getEpgGrid: container keys=${container?.keys.toList()}'); final programs = []; if (container != null && container['Metadata'] != null) { + final firstItem = (container['Metadata'] as List).firstOrNull; + if (firstItem is Map) { + appLogger.d('getEpgGrid: sample program keys=${firstItem.keys.toList()}'); + appLogger.d('getEpgGrid: Channel=${firstItem['Channel']}, Media=${firstItem['Media']}, beginsAt=${firstItem['beginsAt']}, endsAt=${firstItem['endsAt']}, duration=${firstItem['duration']}'); + } for (final item in container['Metadata'] as List) { try { programs.add(LiveTvProgram.fromJson(item as Map)); From eb644c0194f6e6bcd12fe8a8c31e1506e6f94b6d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:21:21 +0100 Subject: [PATCH 05/18] fix(tv): hide seek/skip controls during live playback --- .../desktop_video_controls.dart | 132 +++++++++--------- .../video_controls/mobile_video_controls.dart | 36 ++--- 2 files changed, 88 insertions(+), 80 deletions(-) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index ec9de319..08214eef 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -475,41 +475,43 @@ class DesktopVideoControlsState extends State { // Row 2: Playback controls and options Row( children: [ - // Previous item - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _prevItemFocusNode, - index: 0, - icon: Symbols.skip_previous_rounded, - color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl ? widget.onPrevious : null, - semanticLabel: t.videoControls.previousButton, + if (!widget.isLive) ...[ + // Previous item + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _prevItemFocusNode, + index: 0, + icon: Symbols.skip_previous_rounded, + color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl ? widget.onPrevious : null, + semanticLabel: t.videoControls.previousButton, + ), ), - ), - // Previous chapter - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _prevChapterFocusNode, - index: 1, - icon: Symbols.fast_rewind_rounded, - color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, - semanticLabel: t.videoControls.previousChapterButton, + // Previous chapter + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _prevChapterFocusNode, + index: 1, + icon: Symbols.fast_rewind_rounded, + color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, + semanticLabel: t.videoControls.previousChapterButton, + ), ), - ), - // Skip backward - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _skipBackFocusNode, - index: 2, - icon: widget.getReplayIcon(widget.seekTimeSmall), - onPressed: widget.canControl ? widget.onSeekBackward : null, - semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), + // Skip backward + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipBackFocusNode, + index: 2, + icon: widget.getReplayIcon(widget.seekTimeSmall), + onPressed: widget.canControl ? widget.onSeekBackward : null, + semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), + ), ), - ), + ], // Play/Pause Opacity( opacity: widget.canControl ? 1.0 : 0.5, @@ -535,41 +537,43 @@ class DesktopVideoControlsState extends State { }, ), ), - // Skip forward - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _skipForwardFocusNode, - index: 4, - icon: widget.getForwardIcon(widget.seekTimeSmall), - onPressed: widget.canControl ? widget.onSeekForward : null, - semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), + if (!widget.isLive) ...[ + // Skip forward + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipForwardFocusNode, + index: 4, + icon: widget.getForwardIcon(widget.seekTimeSmall), + onPressed: widget.canControl ? widget.onSeekForward : null, + semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), + ), ), - ), - // Next chapter - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _nextChapterFocusNode, - index: 5, - icon: Symbols.fast_forward_rounded, - color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, - semanticLabel: t.videoControls.nextChapterButton, + // Next chapter + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _nextChapterFocusNode, + index: 5, + icon: Symbols.fast_forward_rounded, + color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, + semanticLabel: t.videoControls.nextChapterButton, + ), ), - ), - // Next item - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _nextItemFocusNode, - index: 6, - icon: Symbols.skip_next_rounded, - color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl ? widget.onNext : null, - semanticLabel: t.videoControls.nextButton, + // Next item + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _nextItemFocusNode, + index: 6, + icon: Symbols.skip_next_rounded, + color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl ? widget.onNext : null, + semanticLabel: t.videoControls.nextButton, + ), ), - ), + ], // Finish time (hidden for live TV and when too narrow to fit) if (widget.isLive) const Spacer() diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 9eefba15..04dce8d3 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -124,14 +124,16 @@ class MobileVideoControls extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Previous episode button (greyed out when unavailable) - CircularControlButton( - semanticLabel: t.videoControls.previousButton, - icon: Symbols.skip_previous_rounded, - iconSize: 48, - onPressed: onPrevious, - ), - const SizedBox(width: 24), + if (!isLive) ...[ + // Previous episode button (greyed out when unavailable) + CircularControlButton( + semanticLabel: t.videoControls.previousButton, + icon: Symbols.skip_previous_rounded, + iconSize: 48, + onPressed: onPrevious, + ), + const SizedBox(width: 24), + ], CircularControlButton( semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, @@ -146,14 +148,16 @@ class MobileVideoControls extends StatelessWidget { } }, ), - const SizedBox(width: 24), - // Next episode button (greyed out when unavailable) - CircularControlButton( - semanticLabel: t.videoControls.nextButton, - icon: Symbols.skip_next_rounded, - iconSize: 48, - onPressed: onNext, - ), + if (!isLive) ...[ + const SizedBox(width: 24), + // Next episode button (greyed out when unavailable) + CircularControlButton( + semanticLabel: t.videoControls.nextButton, + icon: Symbols.skip_next_rounded, + iconSize: 48, + onPressed: onNext, + ), + ], ], ); }, From fbb9982f44a9029c713ea62955064264e0616a0e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:57:43 +0100 Subject: [PATCH 06/18] feat(tv): add poster to program details, translate EPG strings --- lib/i18n/de.i18n.json | 10 ++- lib/i18n/es.i18n.json | 10 ++- lib/i18n/fr.i18n.json | 10 ++- lib/i18n/it.i18n.json | 10 ++- lib/i18n/ko.i18n.json | 10 ++- lib/i18n/nl.i18n.json | 10 ++- lib/i18n/strings.g.dart | 4 +- lib/i18n/sv.i18n.json | 10 ++- lib/i18n/zh.i18n.json | 10 ++- lib/screens/livetv/live_tv_screen.dart | 118 +++++++++++++++++-------- 10 files changed, 154 insertions(+), 48 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index c920ee62..e15abdaf 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -558,7 +558,15 @@ "premiere": "NEU", "reloadGuide": "Programmführer neu laden", "guideReloaded": "Programmdaten neu geladen", - "allChannels": "Alle Kanäle" + "allChannels": "Alle Kanäle", + "now": "Jetzt", + "today": "Heute", + "midnight": "Mitternacht", + "overnight": "Nacht", + "morning": "Morgen", + "daytime": "Tagsüber", + "evening": "Abend", + "lateNight": "Spätnacht" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index f24048b4..6de2ccc7 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -558,7 +558,15 @@ "premiere": "NUEVO", "reloadGuide": "Recargar guía", "guideReloaded": "Datos de la guía recargados", - "allChannels": "Todos los canales" + "allChannels": "Todos los canales", + "now": "Ahora", + "today": "Hoy", + "midnight": "Medianoche", + "overnight": "Madrugada", + "morning": "Mañana", + "daytime": "Día", + "evening": "Noche", + "lateNight": "Trasnoche" }, "collections": { "title": "Colecciones", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4af2e7dc..4f160c23 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -558,7 +558,15 @@ "premiere": "NOUVEAU", "reloadGuide": "Recharger le guide", "guideReloaded": "Données du guide rechargées", - "allChannels": "Toutes les chaînes" + "allChannels": "Toutes les chaînes", + "now": "Maintenant", + "today": "Aujourd'hui", + "midnight": "Minuit", + "overnight": "Nuit", + "morning": "Matin", + "daytime": "Journée", + "evening": "Soirée", + "lateNight": "Nuit tardive" }, "collections": { "title": "Collections", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index cca62e33..ed731924 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -558,7 +558,15 @@ "premiere": "NUOVO", "reloadGuide": "Ricarica guida", "guideReloaded": "Dati della guida ricaricati", - "allChannels": "Tutti i canali" + "allChannels": "Tutti i canali", + "now": "Ora", + "today": "Oggi", + "midnight": "Mezzanotte", + "overnight": "Notte", + "morning": "Mattina", + "daytime": "Giorno", + "evening": "Sera", + "lateNight": "Notte tarda" }, "downloads": { "title": "Download", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index afc4def8..4e4aa519 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -558,7 +558,15 @@ "premiere": "신규", "reloadGuide": "편성표 새로고침", "guideReloaded": "편성표 데이터가 새로고침되었습니다", - "allChannels": "전체 채널" + "allChannels": "전체 채널", + "now": "지금", + "today": "오늘", + "midnight": "자정", + "overnight": "심야", + "morning": "아침", + "daytime": "낮", + "evening": "저녁", + "lateNight": "심야 방송" }, "collections": { "title": "컬렉션", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 403d24e5..3f692d37 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -558,7 +558,15 @@ "premiere": "NIEUW", "reloadGuide": "Gids herladen", "guideReloaded": "Gidsgegevens herladen", - "allChannels": "Alle zenders" + "allChannels": "Alle zenders", + "now": "Nu", + "today": "Vandaag", + "midnight": "Middernacht", + "overnight": "Nacht", + "morning": "Ochtend", + "daytime": "Overdag", + "evening": "Avond", + "lateNight": "Late avond" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 7431dbde..2886d958 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: 6488 (720 per locale) +/// Strings: 6552 (728 per locale) /// -/// Built on 2026-02-12 at 12:55 UTC +/// Built on 2026-02-12 at 13:29 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index bfd47379..6440424c 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -558,7 +558,15 @@ "premiere": "NY", "reloadGuide": "Ladda om programguide", "guideReloaded": "Programdata omladdad", - "allChannels": "Alla kanaler" + "allChannels": "Alla kanaler", + "now": "Nu", + "today": "Idag", + "midnight": "Midnatt", + "overnight": "Natt", + "morning": "Morgon", + "daytime": "Dagtid", + "evening": "Kväll", + "lateNight": "Sen kväll" }, "downloads": { "title": "Nedladdningar", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 705bc4ea..338a42bc 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -558,7 +558,15 @@ "premiere": "新", "reloadGuide": "重新加载节目指南", "guideReloaded": "节目指南已重新加载", - "allChannels": "所有频道" + "allChannels": "所有频道", + "now": "现在", + "today": "今天", + "midnight": "午夜", + "overnight": "凌晨", + "morning": "上午", + "daytime": "白天", + "evening": "晚上", + "lateNight": "深夜" }, "downloads": { "title": "下载", diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 2e6edb35..a0f1750d 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -724,7 +724,7 @@ class _LiveTvScreenState extends State { width: width.clamp(2.0, double.infinity), top: 0, bottom: 0, - child: _buildProgramBlock(channel, program, theme), + child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last), ), ); } @@ -746,7 +746,7 @@ class _LiveTvScreenState extends State { } Widget _buildProgramBlock( - LiveTvChannel channel, LiveTvProgram program, ThemeData theme) { + LiveTvChannel channel, LiveTvProgram program, ThemeData theme, {bool isLast = false}) { final isCurrentlyAiring = program.isCurrentlyAiring; final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -763,7 +763,10 @@ class _LiveTvScreenState extends State { onTap: () => _showProgramDetails(channel, program), child: Container( decoration: BoxDecoration( - border: Border(left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3))), + border: Border( + left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, + ), ), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: Column( @@ -816,6 +819,20 @@ class _LiveTvScreenState extends State { void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { final theme = Theme.of(context); + final multiServer = context.read(); + final client = multiServer.getClientForServer(channel.serverId ?? ''); + String? posterUrl; + if (program.thumb != null && client != null) { + posterUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: program.thumb, + maxWidth: 80, + maxHeight: 120, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.poster, + ); + } + showModalBottomSheet( context: context, builder: (sheetContext) { @@ -826,47 +843,72 @@ class _LiveTvScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (posterUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + posterUrl, + width: 80, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 14), + ], Expanded( - child: Text( - program.displayTitle, - style: theme.textTheme.titleMedium, + child: Column( + 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, + ), + ], + ], ), ), - 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: [ From ac1dae25b80c5ea44f76b84cc7f4345b41c9c058 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:46:15 +0100 Subject: [PATCH 07/18] feat(tv): add What's On tab with API-driven hubs and show schedule --- lib/i18n/de.i18n.json | 4 +- lib/i18n/en.i18n.json | 4 +- lib/i18n/es.i18n.json | 4 +- lib/i18n/fr.i18n.json | 4 +- lib/i18n/it.i18n.json | 4 +- lib/i18n/ko.i18n.json | 4 +- lib/i18n/nl.i18n.json | 4 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_de.g.dart | 24 +- lib/i18n/strings_en.g.dart | 8 +- lib/i18n/strings_es.g.dart | 24 +- lib/i18n/strings_fr.g.dart | 24 +- lib/i18n/strings_it.g.dart | 24 +- lib/i18n/strings_ko.g.dart | 24 +- lib/i18n/strings_nl.g.dart | 24 +- lib/i18n/strings_sv.g.dart | 24 +- lib/i18n/strings_zh.g.dart | 24 +- lib/i18n/sv.i18n.json | 4 +- lib/i18n/zh.i18n.json | 4 +- lib/models/livetv_hub_result.dart | 23 + lib/screens/livetv/live_tv_screen.dart | 984 ++---------------- .../livetv/live_tv_show_schedule_screen.dart | 388 +++++++ lib/screens/livetv/tabs/guide_tab.dart | 936 +++++++++++++++++ lib/screens/livetv/tabs/whats_on_tab.dart | 469 +++++++++ lib/services/plex_client.dart | 96 +- lib/utils/plex_image_helper.dart | 15 +- 26 files changed, 2212 insertions(+), 937 deletions(-) create mode 100644 lib/models/livetv_hub_result.dart create mode 100644 lib/screens/livetv/live_tv_show_schedule_screen.dart create mode 100644 lib/screens/livetv/tabs/guide_tab.dart create mode 100644 lib/screens/livetv/tabs/whats_on_tab.dart diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index e15abdaf..46d221df 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -542,7 +542,6 @@ "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", @@ -566,7 +565,8 @@ "morning": "Morgen", "daytime": "Tagsüber", "evening": "Abend", - "lateNight": "Spätnacht" + "lateNight": "Spätnacht", + "whatsOn": "Jetzt im TV" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index f76292cd..ccd29217 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -542,7 +542,6 @@ "tuneFailed": "Failed to tune channel", "loading": "Loading channels...", "nowPlaying": "Now Playing", - "whatsOnNow": "What's On Now", "record": "Record", "recordSeries": "Record Series", "cancelRecording": "Cancel Recording", @@ -566,7 +565,8 @@ "morning": "Morning", "daytime": "Daytime", "evening": "Evening", - "lateNight": "Late Night" + "lateNight": "Late Night", + "whatsOn": "What's On" }, "collections": { "title": "Collections", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 6de2ccc7..e4b28479 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -542,7 +542,6 @@ "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", @@ -566,7 +565,8 @@ "morning": "Mañana", "daytime": "Día", "evening": "Noche", - "lateNight": "Trasnoche" + "lateNight": "Trasnoche", + "whatsOn": "En emisión" }, "collections": { "title": "Colecciones", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4f160c23..7a7751e4 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -542,7 +542,6 @@ "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", @@ -566,7 +565,8 @@ "morning": "Matin", "daytime": "Journée", "evening": "Soirée", - "lateNight": "Nuit tardive" + "lateNight": "Nuit tardive", + "whatsOn": "En ce moment" }, "collections": { "title": "Collections", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index ed731924..33aa2e8b 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -542,7 +542,6 @@ "tuneFailed": "Impossibile sintonizzare il canale", "loading": "Caricamento canali...", "nowPlaying": "In riproduzione", - "whatsOnNow": "In onda adesso", "record": "Registra", "recordSeries": "Registra serie", "cancelRecording": "Annulla registrazione", @@ -566,7 +565,8 @@ "morning": "Mattina", "daytime": "Giorno", "evening": "Sera", - "lateNight": "Notte tarda" + "lateNight": "Notte tarda", + "whatsOn": "In onda ora" }, "downloads": { "title": "Download", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 4e4aa519..d6e395d5 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -542,7 +542,6 @@ "tuneFailed": "채널 튜닝에 실패했습니다", "loading": "채널 로딩 중...", "nowPlaying": "현재 재생 중", - "whatsOnNow": "지금 방송 중", "record": "녹화", "recordSeries": "시리즈 녹화", "cancelRecording": "녹화 취소", @@ -566,7 +565,8 @@ "morning": "아침", "daytime": "낮", "evening": "저녁", - "lateNight": "심야 방송" + "lateNight": "심야 방송", + "whatsOn": "지금 방송 중" }, "collections": { "title": "컬렉션", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 3f692d37..f3e6a78c 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -542,7 +542,6 @@ "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", @@ -566,7 +565,8 @@ "morning": "Ochtend", "daytime": "Overdag", "evening": "Avond", - "lateNight": "Late avond" + "lateNight": "Late avond", + "whatsOn": "Nu op TV" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 2886d958..5d6216f1 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -6,7 +6,7 @@ /// Locales: 9 /// Strings: 6552 (728 per locale) /// -/// Built on 2026-02-12 at 13:29 UTC +/// Built on 2026-02-12 at 14:47 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 2b53c3bb..bf26a3fc 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvDe implements TranslationsLiveTvEn { @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'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvDe implements TranslationsLiveTvEn { @override String get reloadGuide => 'Programmführer neu laden'; @override String get guideReloaded => 'Programmdaten neu geladen'; @override String get allChannels => 'Alle Kanäle'; + @override String get now => 'Jetzt'; + @override String get today => 'Heute'; + @override String get midnight => 'Mitternacht'; + @override String get overnight => 'Nacht'; + @override String get morning => 'Morgen'; + @override String get daytime => 'Tagsüber'; + @override String get evening => 'Abend'; + @override String get lateNight => 'Spätnacht'; + @override String get whatsOn => 'Jetzt im TV'; } // Path: downloads @@ -1685,7 +1693,6 @@ extension on TranslationsDe { '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', @@ -1702,6 +1709,15 @@ extension on TranslationsDe { 'liveTv.reloadGuide' => 'Programmführer neu laden', 'liveTv.guideReloaded' => 'Programmdaten neu geladen', 'liveTv.allChannels' => 'Alle Kanäle', + 'liveTv.now' => 'Jetzt', + 'liveTv.today' => 'Heute', + 'liveTv.midnight' => 'Mitternacht', + 'liveTv.overnight' => 'Nacht', + 'liveTv.morning' => 'Morgen', + 'liveTv.daytime' => 'Tagsüber', + 'liveTv.evening' => 'Abend', + 'liveTv.lateNight' => 'Spätnacht', + 'liveTv.whatsOn' => 'Jetzt im TV', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', @@ -1711,6 +1727,8 @@ extension on TranslationsDe { 'downloads.downloadNow' => 'Herunterladen', 'downloads.deleteDownload' => 'Download löschen', 'downloads.retryDownload' => 'Download wiederholen', + _ => null, + } ?? switch (path) { 'downloads.downloadQueued' => 'Download in Warteschlange', 'downloads.episodesQueued' => ({required Object count}) => '${count} Episoden zum Download hinzugefügt', 'downloads.downloadDeleted' => 'Download gelöscht', @@ -1719,8 +1737,6 @@ 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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index b59f5ff7..2b6898e0 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1678,9 +1678,6 @@ class TranslationsLiveTvEn { /// 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'; @@ -1752,6 +1749,9 @@ class TranslationsLiveTvEn { /// en: 'Late Night' String get lateNight => 'Late Night'; + + /// en: 'What's On' + String get whatsOn => 'What\'s On'; } // Path: collections @@ -3156,7 +3156,6 @@ extension on Translations { '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', @@ -3181,6 +3180,7 @@ extension on Translations { 'liveTv.daytime' => 'Daytime', 'liveTv.evening' => 'Evening', 'liveTv.lateNight' => 'Late Night', + 'liveTv.whatsOn' => 'What\'s On', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'Collection is empty', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 88ceef68..fe224961 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvEs implements TranslationsLiveTvEn { @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'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvEs implements TranslationsLiveTvEn { @override String get reloadGuide => 'Recargar guía'; @override String get guideReloaded => 'Datos de la guía recargados'; @override String get allChannels => 'Todos los canales'; + @override String get now => 'Ahora'; + @override String get today => 'Hoy'; + @override String get midnight => 'Medianoche'; + @override String get overnight => 'Madrugada'; + @override String get morning => 'Mañana'; + @override String get daytime => 'Día'; + @override String get evening => 'Noche'; + @override String get lateNight => 'Trasnoche'; + @override String get whatsOn => 'En emisión'; } // Path: collections @@ -1685,7 +1693,6 @@ extension on TranslationsEs { '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', @@ -1702,6 +1709,15 @@ extension on TranslationsEs { 'liveTv.reloadGuide' => 'Recargar guía', 'liveTv.guideReloaded' => 'Datos de la guía recargados', 'liveTv.allChannels' => 'Todos los canales', + 'liveTv.now' => 'Ahora', + 'liveTv.today' => 'Hoy', + 'liveTv.midnight' => 'Medianoche', + 'liveTv.overnight' => 'Madrugada', + 'liveTv.morning' => 'Mañana', + 'liveTv.daytime' => 'Día', + 'liveTv.evening' => 'Noche', + 'liveTv.lateNight' => 'Trasnoche', + 'liveTv.whatsOn' => 'En emisión', 'collections.title' => 'Colecciones', 'collections.collection' => 'Colección', 'collections.empty' => 'La colección está vacía', @@ -1711,6 +1727,8 @@ extension on TranslationsEs { 'collections.deleted' => 'Colección eliminada', 'collections.deleteFailed' => 'Error al eliminar la colección', 'collections.deleteFailedWithError' => ({required Object error}) => 'Error al eliminar la colección: ${error}', + _ => null, + } ?? switch (path) { 'collections.failedToLoadItems' => ({required Object error}) => 'Error al cargar los elementos de la colección: ${error}', 'collections.selectCollection' => 'Seleccionar Colección', 'collections.createNewCollection' => 'Crear Nueva Colección', @@ -1719,8 +1737,6 @@ 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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index ad09853c..04da2bfd 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvFr implements TranslationsLiveTvEn { @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'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvFr implements TranslationsLiveTvEn { @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'; + @override String get now => 'Maintenant'; + @override String get today => 'Aujourd\'hui'; + @override String get midnight => 'Minuit'; + @override String get overnight => 'Nuit'; + @override String get morning => 'Matin'; + @override String get daytime => 'Journée'; + @override String get evening => 'Soirée'; + @override String get lateNight => 'Nuit tardive'; + @override String get whatsOn => 'En ce moment'; } // Path: collections @@ -1685,7 +1693,6 @@ extension on TranslationsFr { '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', @@ -1702,6 +1709,15 @@ extension on TranslationsFr { 'liveTv.reloadGuide' => 'Recharger le guide', 'liveTv.guideReloaded' => 'Données du guide rechargées', 'liveTv.allChannels' => 'Toutes les chaînes', + 'liveTv.now' => 'Maintenant', + 'liveTv.today' => 'Aujourd\'hui', + 'liveTv.midnight' => 'Minuit', + 'liveTv.overnight' => 'Nuit', + 'liveTv.morning' => 'Matin', + 'liveTv.daytime' => 'Journée', + 'liveTv.evening' => 'Soirée', + 'liveTv.lateNight' => 'Nuit tardive', + 'liveTv.whatsOn' => 'En ce moment', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'La collection est vide', @@ -1711,6 +1727,8 @@ extension on TranslationsFr { 'collections.deleted' => 'Collection supprimée', 'collections.deleteFailed' => 'Échec de la suppression de la collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Échec de la suppression de la collection: ${error}', + _ => null, + } ?? switch (path) { 'collections.failedToLoadItems' => ({required Object error}) => 'Échec du chargement des éléments de la collection: ${error}', 'collections.selectCollection' => 'Sélectionner une collection', 'collections.createNewCollection' => 'Créer une nouvelle collection', @@ -1719,8 +1737,6 @@ 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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 2ee8cbc5..57ae6949 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvIt implements TranslationsLiveTvEn { @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'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvIt implements TranslationsLiveTvEn { @override String get reloadGuide => 'Ricarica guida'; @override String get guideReloaded => 'Dati della guida ricaricati'; @override String get allChannels => 'Tutti i canali'; + @override String get now => 'Ora'; + @override String get today => 'Oggi'; + @override String get midnight => 'Mezzanotte'; + @override String get overnight => 'Notte'; + @override String get morning => 'Mattina'; + @override String get daytime => 'Giorno'; + @override String get evening => 'Sera'; + @override String get lateNight => 'Notte tarda'; + @override String get whatsOn => 'In onda ora'; } // Path: downloads @@ -1685,7 +1693,6 @@ extension on TranslationsIt { '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', @@ -1702,6 +1709,15 @@ extension on TranslationsIt { 'liveTv.reloadGuide' => 'Ricarica guida', 'liveTv.guideReloaded' => 'Dati della guida ricaricati', 'liveTv.allChannels' => 'Tutti i canali', + 'liveTv.now' => 'Ora', + 'liveTv.today' => 'Oggi', + 'liveTv.midnight' => 'Mezzanotte', + 'liveTv.overnight' => 'Notte', + 'liveTv.morning' => 'Mattina', + 'liveTv.daytime' => 'Giorno', + 'liveTv.evening' => 'Sera', + 'liveTv.lateNight' => 'Notte tarda', + 'liveTv.whatsOn' => 'In onda ora', 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', @@ -1711,6 +1727,8 @@ extension on TranslationsIt { 'downloads.downloadNow' => 'Scarica', 'downloads.deleteDownload' => 'Elimina download', 'downloads.retryDownload' => 'Riprova download', + _ => null, + } ?? switch (path) { 'downloads.downloadQueued' => 'Download in coda', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodi in coda per il download', 'downloads.downloadDeleted' => 'Download eliminato', @@ -1719,8 +1737,6 @@ 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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 25196a9d..13370de1 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvKo implements TranslationsLiveTvEn { @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 => '녹화 취소'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvKo implements TranslationsLiveTvEn { @override String get reloadGuide => '편성표 새로고침'; @override String get guideReloaded => '편성표 데이터가 새로고침되었습니다'; @override String get allChannels => '전체 채널'; + @override String get now => '지금'; + @override String get today => '오늘'; + @override String get midnight => '자정'; + @override String get overnight => '심야'; + @override String get morning => '아침'; + @override String get daytime => '낮'; + @override String get evening => '저녁'; + @override String get lateNight => '심야 방송'; + @override String get whatsOn => '지금 방송 중'; } // Path: collections @@ -1685,7 +1693,6 @@ extension on TranslationsKo { 'liveTv.tuneFailed' => '채널 튜닝에 실패했습니다', 'liveTv.loading' => '채널 로딩 중...', 'liveTv.nowPlaying' => '현재 재생 중', - 'liveTv.whatsOnNow' => '지금 방송 중', 'liveTv.record' => '녹화', 'liveTv.recordSeries' => '시리즈 녹화', 'liveTv.cancelRecording' => '녹화 취소', @@ -1702,6 +1709,15 @@ extension on TranslationsKo { 'liveTv.reloadGuide' => '편성표 새로고침', 'liveTv.guideReloaded' => '편성표 데이터가 새로고침되었습니다', 'liveTv.allChannels' => '전체 채널', + 'liveTv.now' => '지금', + 'liveTv.today' => '오늘', + 'liveTv.midnight' => '자정', + 'liveTv.overnight' => '심야', + 'liveTv.morning' => '아침', + 'liveTv.daytime' => '낮', + 'liveTv.evening' => '저녁', + 'liveTv.lateNight' => '심야 방송', + 'liveTv.whatsOn' => '지금 방송 중', 'collections.title' => '컬렉션', 'collections.collection' => '컬렉션', 'collections.empty' => '컬렉션이 비어 있습니다', @@ -1711,6 +1727,8 @@ extension on TranslationsKo { 'collections.deleted' => '컬렉션 삭제됨', 'collections.deleteFailed' => '컬렉션 삭제 실패', 'collections.deleteFailedWithError' => ({required Object error}) => '컬렉션 삭제 실패: ${error}', + _ => null, + } ?? switch (path) { 'collections.failedToLoadItems' => ({required Object error}) => '컬렉션 항목 로드 실패: ${error}', 'collections.selectCollection' => '컬렉션 선택', 'collections.createNewCollection' => '새 컬렉션 생성', @@ -1719,8 +1737,6 @@ extension on TranslationsKo { 'collections.addedToCollection' => '컬렉션에 추가됨', 'collections.errorAddingToCollection' => '컬렉션에 추가 실패', 'collections.created' => '컬렉션 생성됨', - _ => null, - } ?? switch (path) { 'collections.removeFromCollection' => '컬렉션에서 제거', 'collections.removeFromCollectionConfirm' => ({required Object title}) => '${title}을/를 이 컬렉션에서 제거 하시겠습니까?', 'collections.removedFromCollection' => '컬렉션에서 제거됨', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index ef4ae1a8..77aa0893 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvNl implements TranslationsLiveTvEn { @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'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvNl implements TranslationsLiveTvEn { @override String get reloadGuide => 'Gids herladen'; @override String get guideReloaded => 'Gidsgegevens herladen'; @override String get allChannels => 'Alle zenders'; + @override String get now => 'Nu'; + @override String get today => 'Vandaag'; + @override String get midnight => 'Middernacht'; + @override String get overnight => 'Nacht'; + @override String get morning => 'Ochtend'; + @override String get daytime => 'Overdag'; + @override String get evening => 'Avond'; + @override String get lateNight => 'Late avond'; + @override String get whatsOn => 'Nu op TV'; } // Path: downloads @@ -1685,7 +1693,6 @@ extension on TranslationsNl { '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', @@ -1702,6 +1709,15 @@ extension on TranslationsNl { 'liveTv.reloadGuide' => 'Gids herladen', 'liveTv.guideReloaded' => 'Gidsgegevens herladen', 'liveTv.allChannels' => 'Alle zenders', + 'liveTv.now' => 'Nu', + 'liveTv.today' => 'Vandaag', + 'liveTv.midnight' => 'Middernacht', + 'liveTv.overnight' => 'Nacht', + 'liveTv.morning' => 'Ochtend', + 'liveTv.daytime' => 'Overdag', + 'liveTv.evening' => 'Avond', + 'liveTv.lateNight' => 'Late avond', + 'liveTv.whatsOn' => 'Nu op TV', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', @@ -1711,6 +1727,8 @@ extension on TranslationsNl { 'downloads.downloadNow' => 'Download', 'downloads.deleteDownload' => 'Download verwijderen', 'downloads.retryDownload' => 'Download opnieuw proberen', + _ => null, + } ?? switch (path) { 'downloads.downloadQueued' => 'Download in wachtrij', 'downloads.episodesQueued' => ({required Object count}) => '${count} afleveringen in wachtrij voor download', 'downloads.downloadDeleted' => 'Download verwijderd', @@ -1719,8 +1737,6 @@ 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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 82398ce5..b6f73848 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvSv implements TranslationsLiveTvEn { @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'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvSv implements TranslationsLiveTvEn { @override String get reloadGuide => 'Ladda om programguide'; @override String get guideReloaded => 'Programdata omladdad'; @override String get allChannels => 'Alla kanaler'; + @override String get now => 'Nu'; + @override String get today => 'Idag'; + @override String get midnight => 'Midnatt'; + @override String get overnight => 'Natt'; + @override String get morning => 'Morgon'; + @override String get daytime => 'Dagtid'; + @override String get evening => 'Kväll'; + @override String get lateNight => 'Sen kväll'; + @override String get whatsOn => 'På TV nu'; } // Path: downloads @@ -1685,7 +1693,6 @@ extension on TranslationsSv { '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', @@ -1702,6 +1709,15 @@ extension on TranslationsSv { 'liveTv.reloadGuide' => 'Ladda om programguide', 'liveTv.guideReloaded' => 'Programdata omladdad', 'liveTv.allChannels' => 'Alla kanaler', + 'liveTv.now' => 'Nu', + 'liveTv.today' => 'Idag', + 'liveTv.midnight' => 'Midnatt', + 'liveTv.overnight' => 'Natt', + 'liveTv.morning' => 'Morgon', + 'liveTv.daytime' => 'Dagtid', + 'liveTv.evening' => 'Kväll', + 'liveTv.lateNight' => 'Sen kväll', + 'liveTv.whatsOn' => 'På TV nu', 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', @@ -1711,6 +1727,8 @@ extension on TranslationsSv { 'downloads.downloadNow' => 'Ladda ner', 'downloads.deleteDownload' => 'Ta bort nedladdning', 'downloads.retryDownload' => 'Försök igen', + _ => null, + } ?? switch (path) { 'downloads.downloadQueued' => 'Nedladdning köad', 'downloads.episodesQueued' => ({required Object count}) => '${count} avsnitt köade för nedladdning', 'downloads.downloadDeleted' => 'Nedladdning borttagen', @@ -1719,8 +1737,6 @@ 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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index eb4be950..c6d5a383 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -783,7 +783,6 @@ class _TranslationsLiveTvZh implements TranslationsLiveTvEn { @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 => '取消录制'; @@ -800,6 +799,15 @@ class _TranslationsLiveTvZh implements TranslationsLiveTvEn { @override String get reloadGuide => '重新加载节目指南'; @override String get guideReloaded => '节目指南已重新加载'; @override String get allChannels => '所有频道'; + @override String get now => '现在'; + @override String get today => '今天'; + @override String get midnight => '午夜'; + @override String get overnight => '凌晨'; + @override String get morning => '上午'; + @override String get daytime => '白天'; + @override String get evening => '晚上'; + @override String get lateNight => '深夜'; + @override String get whatsOn => '正在播出'; } // Path: downloads @@ -1685,7 +1693,6 @@ extension on TranslationsZh { 'liveTv.tuneFailed' => '无法调谐频道', 'liveTv.loading' => '正在加载频道...', 'liveTv.nowPlaying' => '正在播放', - 'liveTv.whatsOnNow' => '正在播出', 'liveTv.record' => '录制', 'liveTv.recordSeries' => '录制系列', 'liveTv.cancelRecording' => '取消录制', @@ -1702,6 +1709,15 @@ extension on TranslationsZh { 'liveTv.reloadGuide' => '重新加载节目指南', 'liveTv.guideReloaded' => '节目指南已重新加载', 'liveTv.allChannels' => '所有频道', + 'liveTv.now' => '现在', + 'liveTv.today' => '今天', + 'liveTv.midnight' => '午夜', + 'liveTv.overnight' => '凌晨', + 'liveTv.morning' => '上午', + 'liveTv.daytime' => '白天', + 'liveTv.evening' => '晚上', + 'liveTv.lateNight' => '深夜', + 'liveTv.whatsOn' => '正在播出', 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', @@ -1711,6 +1727,8 @@ extension on TranslationsZh { 'downloads.downloadNow' => '下载', 'downloads.deleteDownload' => '删除下载', 'downloads.retryDownload' => '重试下载', + _ => null, + } ?? switch (path) { 'downloads.downloadQueued' => '下载已排队', 'downloads.episodesQueued' => ({required Object count}) => '${count} 集已加入下载队列', 'downloads.downloadDeleted' => '下载已删除', @@ -1719,8 +1737,6 @@ extension on TranslationsZh { 'downloads.noDownloadsTree' => '暂无下载', 'downloads.pauseAll' => '全部暂停', 'downloads.resumeAll' => '全部继续', - _ => null, - } ?? switch (path) { 'downloads.deleteAll' => '全部删除', 'playlists.title' => '播放列表', 'playlists.noPlaylists' => '未找到播放列表', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 6440424c..0bef2c48 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -542,7 +542,6 @@ "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", @@ -566,7 +565,8 @@ "morning": "Morgon", "daytime": "Dagtid", "evening": "Kväll", - "lateNight": "Sen kväll" + "lateNight": "Sen kväll", + "whatsOn": "På TV nu" }, "downloads": { "title": "Nedladdningar", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 338a42bc..1cd66dcd 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -542,7 +542,6 @@ "tuneFailed": "无法调谐频道", "loading": "正在加载频道...", "nowPlaying": "正在播放", - "whatsOnNow": "正在播出", "record": "录制", "recordSeries": "录制系列", "cancelRecording": "取消录制", @@ -566,7 +565,8 @@ "morning": "上午", "daytime": "白天", "evening": "晚上", - "lateNight": "深夜" + "lateNight": "深夜", + "whatsOn": "正在播出" }, "downloads": { "title": "下载", diff --git a/lib/models/livetv_hub_result.dart b/lib/models/livetv_hub_result.dart new file mode 100644 index 00000000..a5093feb --- /dev/null +++ b/lib/models/livetv_hub_result.dart @@ -0,0 +1,23 @@ +import 'plex_metadata.dart'; +import 'livetv_program.dart'; + +/// A hub from the live TV discover endpoint, with both display and EPG data. +class LiveTvHubResult { + final String title; + final String hubKey; + final List entries; + + LiveTvHubResult({ + required this.title, + required this.hubKey, + required this.entries, + }); +} + +/// A single item in a live TV hub, holding both display metadata and EPG timing. +class LiveTvHubEntry { + final PlexMetadata metadata; + final LiveTvProgram program; + + LiveTvHubEntry({required this.metadata, required this.program}); +} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index a0f1750d..3d1d9373 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -1,20 +1,18 @@ -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 '../../mixins/tab_navigation_mixin.dart'; import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; -import '../../utils/formatters.dart'; -import '../../utils/plex_image_helper.dart'; -import '../../utils/plex_url_helper.dart'; -import '../../utils/live_tv_player_navigation.dart'; +import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_tab_chip.dart'; import 'dvr_recordings_screen.dart'; +import 'tabs/guide_tab.dart'; +import 'tabs/whats_on_tab.dart'; class LiveTvScreen extends StatefulWidget { const LiveTvScreen({super.key}); @@ -23,115 +21,42 @@ class LiveTvScreen extends StatefulWidget { State createState() => _LiveTvScreenState(); } -class _LiveTvScreenState 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; +class _LiveTvScreenState extends State + with SingleTickerProviderStateMixin, TabNavigationMixin { + final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); + final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); List _channels = []; - List _programs = []; bool _isLoading = true; String? _error; - late DateTime _gridStart; - late DateTime _gridEnd; - - final ScrollController _headerHorizontalController = ScrollController(); - final ScrollController _gridHorizontalController = ScrollController(); - final ScrollController _channelVerticalController = ScrollController(); - bool _syncingScroll = false; - - Timer? _timeIndicatorTimer; - final _dayPickerKey = GlobalKey(); + @override + List get tabChipFocusNodes => [_guideTabFocusNode, _whatsOnTabFocusNode]; @override void initState() { super.initState(); - _initTimeRange(); - _loadData(); - - _gridHorizontalController.addListener(_syncGridToHeader); - _headerHorizontalController.addListener(_syncHeaderToGrid); - - _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; + suppressAutoFocus = true; + initTabNavigation(); + _loadChannels(); } @override void dispose() { - _gridHorizontalController.removeListener(_syncGridToHeader); - _headerHorizontalController.removeListener(_syncHeaderToGrid); - _headerHorizontalController.dispose(); - _gridHorizontalController.dispose(); - _channelVerticalController.dispose(); - _timeIndicatorTimer?.cancel(); + _guideTabFocusNode.dispose(); + _whatsOnTabFocusNode.dispose(); + disposeTabNavigation(); super.dispose(); } - void _initTimeRange() { - final now = DateTime.now(); - _gridStart = DateTime(now.year, now.month, now.day, now.hour); - if (now.minute >= 30) { - _gridStart = _gridStart.add(const Duration(minutes: 30)); + @override + void onTabChanged() { + if (!tabController.indexIsChanging) { + super.onTabChanged(); } - _gridStart = _gridStart.subtract(const Duration(hours: 1)); - _gridEnd = _gridStart.add(const Duration(hours: 6)); } - void _shiftTimeRange(int hours) { - setState(() { - _gridStart = _gridStart.add(Duration(hours: hours)); - _gridEnd = _gridStart.add(const Duration(hours: 6)); - }); - _loadData(); - } - - void _jumpToNow() { - _initTimeRange(); - _loadData(); - } - - void _jumpToDay(DateTime day) { - final now = DateTime.now(); - final isToday = day.year == now.year && - day.month == now.month && - day.day == now.day; - - if (isToday) { - _jumpToNow(); - return; - } - - setState(() { - // Start at midnight for non-today days - _gridStart = DateTime(day.year, day.month, day.day); - _gridEnd = _gridStart.add(const Duration(hours: 6)); - }); - _loadData(); - } - - Future _loadData() async { + Future _loadChannels() async { if (!mounted) return; setState(() { _isLoading = true; @@ -151,7 +76,6 @@ class _LiveTvScreenState extends State { } final allChannels = []; - final allPrograms = []; for (final serverInfo in liveTvServers) { final client = multiServer.getClientForServer(serverInfo.serverId); @@ -159,16 +83,6 @@ class _LiveTvScreenState extends State { 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); } allChannels.sort((a, b) { @@ -179,25 +93,14 @@ class _LiveTvScreenState extends State { if (!mounted) return; - appLogger.d('EPG loaded: ${allChannels.length} channels, ${allPrograms.length} programs'); - if (allChannels.isNotEmpty) { - final ch = allChannels.first; - appLogger.d('Sample channel: key=${ch.key}, identifier=${ch.identifier}'); - } - if (allPrograms.isNotEmpty) { - final p = allPrograms.first; - appLogger.d('Sample program: channelIdentifier=${p.channelIdentifier}, title=${p.title}'); - } + appLogger.d('Live TV: loaded ${allChannels.length} channels'); setState(() { _channels = allChannels; - _programs = allPrograms; _isLoading = false; }); - - _scrollToNow(); } catch (e) { - appLogger.e('Failed to load Live TV data', error: e); + appLogger.e('Failed to load Live TV channels', error: e); if (mounted) { setState(() { _isLoading = false; @@ -207,71 +110,80 @@ class _LiveTvScreenState extends State { } } - 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), - ); - } - }); - } - - 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; - } - - Future _tuneChannel(LiveTvChannel channel) async { - 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; - - await navigateToLiveTv( - context, - client: client, - dvrKey: serverInfo.dvrKey, - channel: channel, - channels: _channels, - ); - } - void _openRecordings() { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()), ); } + void _focusCurrentTab() { + setState(() { + suppressAutoFocus = false; + }); + } + + Widget _buildTabChip(String label, int index) { + final isSelected = tabController.index == index; + + return FocusableTabChip( + label: label, + isSelected: isSelected, + focusNode: getTabChipFocusNode(index), + onSelect: () { + if (isSelected) { + _focusCurrentTab(); + } else { + setState(() { + tabController.index = index; + }); + } + }, + onNavigateLeft: index > 0 + ? () { + final newIndex = index - 1; + setState(() { + suppressAutoFocus = true; + tabController.index = newIndex; + }); + getTabChipFocusNode(newIndex).requestFocus(); + } + : onTabBarBack, + onNavigateRight: index < tabCount - 1 + ? () { + final newIndex = index + 1; + setState(() { + suppressAutoFocus = true; + tabController.index = newIndex; + }); + getTabChipFocusNode(newIndex).requestFocus(); + } + : null, + onNavigateDown: _focusCurrentTab, + onBack: onTabBarBack, + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final useSideNav = PlatformDetector.shouldUseSideNavigation(context); return Scaffold( appBar: AppBar( - title: Text(t.liveTv.title), + title: useSideNav + ? Row( + children: [ + _buildTabChip(t.liveTv.guide, 0), + const SizedBox(width: 8), + _buildTabChip(t.liveTv.whatsOn, 1), + ], + ) + : Text(t.liveTv.title), actions: [ IconButton( icon: const AppIcon(Symbols.refresh_rounded), tooltip: t.liveTv.reloadGuide, - onPressed: _loadData, + onPressed: _loadChannels, ), IconButton( icon: const AppIcon(Symbols.fiber_dvr_rounded), @@ -293,7 +205,7 @@ class _LiveTvScreenState extends State { Text(_error!, style: theme.textTheme.bodyLarge), const SizedBox(height: 16), FilledButton.icon( - onPressed: _loadData, + onPressed: _loadChannels, icon: const AppIcon(Symbols.refresh_rounded), label: Text(t.common.retry), ), @@ -302,720 +214,34 @@ class _LiveTvScreenState extends State { ) : _channels.isEmpty ? Center(child: Text(t.liveTv.noChannels)) - : _buildGuideGrid(theme), - ); - } - - Widget _buildGuideGrid(ThemeData theme) { - return Column( - children: [ - // Time navigation bar - _buildTimeNavigation(theme), - // Time header - Row( - children: [ - SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), - Expanded( - child: SingleChildScrollView( - controller: _headerHorizontalController, - scrollDirection: Axis.horizontal, - physics: const ClampingScrollPhysics(), - child: SizedBox( - width: _totalGridWidth(), - height: _timeHeaderHeight, - child: _buildTimeHeader(theme), - ), - ), - ), - ], - ), - // Channel rows + program grid - Expanded( - child: Row( - children: [ - SizedBox( - width: _channelColumnWidth, - child: ListView.builder( - controller: _channelVerticalController, - itemCount: _channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) => - _buildChannelCell(_channels[index], theme), - ), - ), - Expanded( - child: NotificationListener( - onNotification: (notification) { - 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, - physics: const ClampingScrollPhysics(), - 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); - }, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ); - } - - String _dayLabel(DateTime day) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final target = DateTime(day.year, day.month, day.day); - - if (target == today) return t.liveTv.today; - - final format = MaterialLocalizations.of(context); - // formatFullDate gives "Monday, January 1, 2026" — extract weekday name - final full = format.formatFullDate(target); - return full.split(',').first; - } - - List<(String, int)> get _timeSlots => [ - (t.liveTv.midnight, 0), - (t.liveTv.overnight, 2), - (t.liveTv.morning, 6), - (t.liveTv.daytime, 12), - (t.liveTv.evening, 18), - (t.liveTv.lateNight, 22), - ]; - - RelativeRect _menuPosition() { - final renderBox = - _dayPickerKey.currentContext?.findRenderObject() as RenderBox?; - final overlay = - Overlay.of(context).context.findRenderObject() as RenderBox?; - if (renderBox == null || overlay == null) return RelativeRect.fill; - - final buttonPos = renderBox.localToGlobal(Offset.zero); - final buttonSize = renderBox.size; - return RelativeRect.fromRect( - Rect.fromLTWH( - buttonPos.dx, - buttonPos.dy + buttonSize.height, - buttonSize.width, - 0, - ), - Offset.zero & overlay.size, - ); - } - - void _showDayPicker() { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day); - final theme = Theme.of(context); - - final days = []; - for (var i = 0; i < 8; i++) { - days.add(today.add(Duration(days: i))); - } - - showMenu( - context: context, - position: _menuPosition(), - items: [ - PopupMenuItem( - value: 'now', - child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium), - ), - ...days.map((day) { - final isSelected = day == gridDay; - final label = _dayLabel(day); - return PopupMenuItem( - value: day, - child: Row( - children: [ - Expanded( - child: Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: isSelected ? theme.colorScheme.primary : null, - ), - ), - ), - if (isSelected) - AppIcon(Symbols.check_rounded, - size: 18, color: theme.colorScheme.primary), - ], - ), - ); - }), - ], - ).then((value) { - if (value == null) return; - if (value is String && value == 'now') { - _jumpToNow(); - } else if (value is DateTime) { - _showTimeSlotPicker(value); - } - }); - } - - void _showTimeSlotPicker(DateTime day) { - final theme = Theme.of(context); - final label = _dayLabel(day).toUpperCase(); - - showMenu( - context: context, - position: _menuPosition(), - items: [ - PopupMenuItem( - value: -1, - child: Row( - children: [ - AppIcon(Symbols.chevron_left_rounded, - size: 20, color: theme.colorScheme.onSurface), - const SizedBox(width: 8), - Text(label, - style: theme.textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold)), - ], - ), - ), - const PopupMenuDivider(), - ..._timeSlots.map((slot) { - return PopupMenuItem( - value: slot.$2, - child: Text(slot.$1, style: theme.textTheme.bodyMedium), - ); - }), - ], - ).then((value) { - if (value == null) return; - if (value == -1) { - // Back to day picker - _showDayPicker(); - return; - } - setState(() { - _gridStart = DateTime(day.year, day.month, day.day, value); - _gridEnd = _gridStart.add(const Duration(hours: 6)); - }); - _loadData(); - }); - } - - Widget _buildTimeNavigation(ThemeData theme) { - final format = MaterialLocalizations.of(context); - final timeLabel = - format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart)); - final dayLabel = _dayLabel(_gridStart); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - ), - ), - child: Row( - children: [ - IconButton( - icon: const AppIcon(Symbols.chevron_left_rounded), - onPressed: () => _shiftTimeRange(-2), - iconSize: 20, - visualDensity: VisualDensity.compact, - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - key: _dayPickerKey, - onTap: _showDayPicker, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - dayLabel, - style: theme.textTheme.labelLarge, - ), - const SizedBox(width: 2), - AppIcon(Symbols.arrow_drop_down_rounded, - size: 18, color: theme.colorScheme.onSurface), - ], - ), - ), - const SizedBox(width: 8), - Text( - timeLabel, - style: theme.textTheme.labelLarge, - ), - ], - ), - ), - IconButton( - icon: const AppIcon(Symbols.chevron_right_rounded), - onPressed: () => _shiftTimeRange(2), - iconSize: 20, - visualDensity: VisualDensity.compact, - ), - ], - ), - ); - } - - 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), - _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 ?? ''); - - String? imageUrl; - if (channel.thumb != null && client != null) { - imageUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: channel.thumb, - maxWidth: _channelColumnWidth - 16, - maxHeight: _rowHeight - 16, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), - imageType: ImageType.logo, - ); - } - - return _ChannelCell( - rowHeight: _rowHeight, - channelColumnWidth: _channelColumnWidth, - imageUrl: imageUrl, - channel: channel, - theme: theme, - onTap: () => _tuneChannel(channel), - fallbackBuilder: () => _buildChannelNameFallback(channel, theme), - ); - } - - Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - 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, - textAlign: TextAlign.center, - ), - ], - ); - } - - 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: 0, - bottom: 0, - child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last), - ), - ); - } - - 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, {bool isLast = false}) { - final isCurrentlyAiring = program.isCurrentlyAiring; - final isPast = program.endsAt != null && - program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; - - return Opacity( - opacity: isPast ? 0.5 : 1.0, - child: Material( - color: isCurrentlyAiring - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(4), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showProgramDetails(channel, program), - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - program.grandparentTitle ?? 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.grandparentTitle != null) - Text( - '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', - style: theme.textTheme.labelSmall?.copyWith( - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer - .withValues(alpha: 0.7) - : theme.colorScheme.onSurfaceVariant, - ), - 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); - - final multiServer = context.read(); - final client = multiServer.getClientForServer(channel.serverId ?? ''); - String? posterUrl; - if (program.thumb != null && client != null) { - posterUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: program.thumb, - maxWidth: 80, - maxHeight: 120, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), - imageType: ImageType.poster, - ); - } - - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterUrl != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.network( - posterUrl, - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ), - const SizedBox(width: 14), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + : Column( children: [ - Row( - children: [ - Expanded( - child: Text( - program.displayTitle, - style: theme.textTheme.titleMedium, + if (!useSideNav) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.liveTv.guide, 0), + const SizedBox(width: 8), + _buildTabChip(t.liveTv.whatsOn, 1), + ], ), ), - 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, ), - ], + Expanded( + child: TabBarView( + controller: tabController, + children: [ + GuideTab(channels: _channels), + WhatsOnTab(channels: _channels), + ], + ), + ), ], ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - if (program.isCurrentlyAiring) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - _tuneChannel(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), - ), - ], - ), - ], - ), - ); - }, - ); - } -} - -class _ChannelCell extends StatefulWidget { - final double rowHeight; - final double channelColumnWidth; - final String? imageUrl; - final LiveTvChannel channel; - final ThemeData theme; - final VoidCallback onTap; - final Widget Function() fallbackBuilder; - - const _ChannelCell({ - required this.rowHeight, - required this.channelColumnWidth, - required this.imageUrl, - required this.channel, - required this.theme, - required this.onTap, - required this.fallbackBuilder, - }); - - @override - State<_ChannelCell> createState() => _ChannelCellState(); -} - -class _ChannelCellState extends State<_ChannelCell> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final theme = widget.theme; - - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - child: Container( - height: widget.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: Stack( - alignment: Alignment.center, - children: [ - AnimatedOpacity( - opacity: _hovered ? 0.3 : 1.0, - duration: const Duration(milliseconds: 150), - child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty - ? Image.network( - widget.imageUrl!, - width: widget.channelColumnWidth - 16, - height: widget.rowHeight - 16, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => - widget.fallbackBuilder(), - ) - : widget.fallbackBuilder(), - ), - if (_hovered) - AppIcon( - Symbols.play_arrow_rounded, - size: 32, - color: theme.colorScheme.onSurface, - ), - ], - ), - ), - ), - ), ); } } diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart new file mode 100644 index 00000000..b66f2fd0 --- /dev/null +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -0,0 +1,388 @@ +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 '../../theme/mono_tokens.dart'; +import '../../utils/formatters.dart'; +import '../../utils/live_tv_player_navigation.dart'; +import '../../utils/plex_image_helper.dart'; +import '../../widgets/app_icon.dart'; + +/// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view. +class LiveTvShowScheduleScreen extends StatefulWidget { + /// The show title to filter for (grandparentTitle for episodes, title for movies). + final String showTitle; + + /// Server ID to scope the EPG query. + final String serverId; + + /// Full channel list for tuning. + final List channels; + + const LiveTvShowScheduleScreen({ + super.key, + required this.showTitle, + required this.serverId, + required this.channels, + }); + + @override + State createState() => _LiveTvShowScheduleScreenState(); +} + +class _LiveTvShowScheduleScreenState extends State { + List _programs = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSchedule(); + } + + Future _loadSchedule() async { + final multiServer = context.read(); + final client = multiServer.getClientForServer(widget.serverId); + if (client == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final now = DateTime.now(); + // Fetch a generous window: 1h ago (to catch currently airing) + 48h ahead + final beginsAt = now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000; + final endsAt = now.add(const Duration(hours: 48)).millisecondsSinceEpoch ~/ 1000; + + final programs = await client.getEpgGrid(beginsAt: beginsAt, endsAt: endsAt); + + // Filter for this show + final filtered = programs.where((p) { + if (p.grandparentTitle == widget.showTitle) return true; + if (p.grandparentTitle == null && p.title == widget.showTitle) return true; + return false; + }).toList(); + + // Sort by start time + filtered.sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); + + if (mounted) { + setState(() { + _programs = filtered; + _isLoading = false; + }); + } + } + + LiveTvChannel? _findChannel(String? channelIdentifier) { + if (channelIdentifier == null) return null; + return widget.channels.where((ch) { + return ch.identifier == channelIdentifier || ch.key == channelIdentifier; + }).firstOrNull; + } + + Future _tuneChannel(LiveTvChannel channel) async { + 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; + + await navigateToLiveTv( + context, + client: client, + dvrKey: serverInfo.dvrKey, + channel: channel, + channels: widget.channels, + ); + } + + void _showProgramDetails(LiveTvProgram program, LiveTvChannel? channel) { + final theme = Theme.of(context); + + final multiServer = context.read(); + final client = multiServer.getClientForServer(widget.serverId); + String? posterUrl; + if (program.thumb != null && client != null) { + posterUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: program.thumb, + maxWidth: 80, + maxHeight: 120, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.poster, + ); + } + + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + posterUrl, + width: 80, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 14), + ], + Expanded( + child: Column( + 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( + [ + if (channel != null) channel.displayName, + if (program.startTime != null && program.endTime != null) + '${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')}', + if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), + ].join(' · '), + 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 && channel != null) + FilledButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + _tuneChannel(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), + ), + ], + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.showTitle)), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _programs.isEmpty + ? Center(child: Text(t.liveTv.noPrograms)) + : ListView.builder( + itemCount: _programs.length, + itemBuilder: (context, index) { + final program = _programs[index]; + final channel = _findChannel(program.channelIdentifier); + return _ScheduleListTile( + program: program, + channel: channel, + onTap: () { + if (program.isCurrentlyAiring && channel != null) { + _tuneChannel(channel); + } else { + _showProgramDetails(program, channel); + } + }, + ); + }, + ), + ); + } +} + +class _ScheduleListTile extends StatelessWidget { + final LiveTvProgram program; + final LiveTvChannel? channel; + final VoidCallback onTap; + + const _ScheduleListTile({ + required this.program, + required this.channel, + required this.onTap, + }); + + String _formatTimeInfo() { + final now = DateTime.now(); + final start = program.startTime; + final end = program.endTime; + if (start == null) return ''; + + if (program.isCurrentlyAiring && end != null) { + final minutesLeft = end.difference(now).inMinutes; + return '${minutesLeft}min left'; + } + + final minutesUntil = start.difference(now).inMinutes; + if (minutesUntil <= 0) { + // Just started + return _formatAbsoluteTime(start, now); + } else if (minutesUntil < 90) { + return 'Starting in ${minutesUntil}min'; + } else { + return _formatAbsoluteTime(start, now); + } + } + + String _formatAbsoluteTime(DateTime start, DateTime now) { + final time = '${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}'; + final today = DateTime(now.year, now.month, now.day); + final startDay = DateTime(start.year, start.month, start.day); + final diff = startDay.difference(today).inDays; + + if (diff == 0) return 'Today at $time'; + if (diff == 1) return 'Tomorrow at $time'; + final weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][start.weekday - 1]; + return '$weekday at $time'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isLive = program.isCurrentlyAiring; + + // Title line: S#·E# — Episode Title, or just Title for non-episodes + String titleText; + if (program.parentIndex != null && program.index != null) { + titleText = 'S${program.parentIndex} · E${program.index} — ${program.title}'; + } else { + titleText = program.title; + } + + final timeInfo = _formatTimeInfo(); + final subtitle = [ + timeInfo, + if (program.summary != null && program.summary!.isNotEmpty) program.summary!, + ].join(' — '); + + return InkWell( + onTap: isLive ? onTap : null, + child: Container( + decoration: isLive + ? BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.08), + border: Border( + left: BorderSide(color: theme.colorScheme.primary, width: 3), + ), + ) + : null, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + titleText, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isLive) ...[ + const SizedBox(width: 8), + AppIcon(Symbols.play_circle_rounded, + size: 20, color: theme.colorScheme.primary), + ], + ], + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (channel != null) ...[ + const SizedBox(height: 2), + Text( + channel!.displayName, + style: theme.textTheme.labelSmall?.copyWith( + color: tokens(context).textMuted, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart new file mode 100644 index 00000000..21656f60 --- /dev/null +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -0,0 +1,936 @@ +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_image_helper.dart'; +import '../../../utils/live_tv_player_navigation.dart'; +import '../../../widgets/app_icon.dart'; + +class GuideTab extends StatefulWidget { + final List channels; + + const GuideTab({super.key, required this.channels}); + + @override + State createState() => _GuideTabState(); +} + +class _GuideTabState 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 _programs = []; + bool _isLoading = true; + + late DateTime _gridStart; + late DateTime _gridEnd; + + final ScrollController _headerHorizontalController = ScrollController(); + final ScrollController _gridHorizontalController = ScrollController(); + final ScrollController _channelVerticalController = ScrollController(); + bool _syncingScroll = false; + + Timer? _timeIndicatorTimer; + final _dayPickerKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _initTimeRange(); + _loadPrograms(); + + _gridHorizontalController.addListener(_syncGridToHeader); + _headerHorizontalController.addListener(_syncHeaderToGrid); + + _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(); + _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)); + } + + void _shiftTimeRange(int hours) { + setState(() { + _gridStart = _gridStart.add(Duration(hours: hours)); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadPrograms(); + } + + void _jumpToNow() { + _initTimeRange(); + _loadPrograms(); + } + + void _jumpToDay(DateTime day) { + final now = DateTime.now(); + final isToday = day.year == now.year && + day.month == now.month && + day.day == now.day; + + if (isToday) { + _jumpToNow(); + return; + } + + setState(() { + _gridStart = DateTime(day.year, day.month, day.day); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadPrograms(); + } + + Future _loadPrograms() async { + if (!mounted) return; + setState(() => _isLoading = true); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + final allPrograms = []; + + for (final serverInfo in liveTvServers) { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + + 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); + } + + if (!mounted) return; + + setState(() { + _programs = allPrograms; + _isLoading = false; + }); + + _scrollToNow(); + } catch (e) { + appLogger.e('Failed to load guide programs', error: e); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + 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), + ); + } + }); + } + + 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; + } + + Future _tuneChannel(LiveTvChannel channel) async { + 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; + + await navigateToLiveTv( + context, + client: client, + dvrKey: serverInfo.dvrKey, + channel: channel, + channels: widget.channels, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return _buildGuideGrid(theme); + } + + Widget _buildGuideGrid(ThemeData theme) { + return Column( + children: [ + _buildTimeNavigation(theme), + Row( + children: [ + SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + Expanded( + child: SingleChildScrollView( + controller: _headerHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), + ), + ), + ), + ], + ), + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: ListView.builder( + controller: _channelVerticalController, + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => + _buildChannelCell(widget.channels[index], theme), + ), + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + 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, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + child: ListView.builder( + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) { + final channel = widget.channels[index]; + final programs = _getProgramsForChannel(channel); + return _buildProgramRow(channel, programs, theme); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } + + String _dayLabel(DateTime day) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final target = DateTime(day.year, day.month, day.day); + + if (target == today) return t.liveTv.today; + + final format = MaterialLocalizations.of(context); + final full = format.formatFullDate(target); + return full.split(',').first; + } + + List<(String, int)> get _timeSlots => [ + (t.liveTv.midnight, 0), + (t.liveTv.overnight, 2), + (t.liveTv.morning, 6), + (t.liveTv.daytime, 12), + (t.liveTv.evening, 18), + (t.liveTv.lateNight, 22), + ]; + + RelativeRect _menuPosition() { + final renderBox = + _dayPickerKey.currentContext?.findRenderObject() as RenderBox?; + final overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + if (renderBox == null || overlay == null) return RelativeRect.fill; + + final buttonPos = renderBox.localToGlobal(Offset.zero); + final buttonSize = renderBox.size; + return RelativeRect.fromRect( + Rect.fromLTWH( + buttonPos.dx, + buttonPos.dy + buttonSize.height, + buttonSize.width, + 0, + ), + Offset.zero & overlay.size, + ); + } + + void _showDayPicker() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day); + final theme = Theme.of(context); + + final days = []; + for (var i = 0; i < 8; i++) { + days.add(today.add(Duration(days: i))); + } + + showMenu( + context: context, + position: _menuPosition(), + items: [ + PopupMenuItem( + value: 'now', + child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium), + ), + ...days.map((day) { + final isSelected = day == gridDay; + final label = _dayLabel(day); + return PopupMenuItem( + value: day, + child: Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: isSelected ? theme.colorScheme.primary : null, + ), + ), + ), + if (isSelected) + AppIcon(Symbols.check_rounded, + size: 18, color: theme.colorScheme.primary), + ], + ), + ); + }), + ], + ).then((value) { + if (value == null) return; + if (value is String && value == 'now') { + _jumpToNow(); + } else if (value is DateTime) { + _showTimeSlotPicker(value); + } + }); + } + + void _showTimeSlotPicker(DateTime day) { + final theme = Theme.of(context); + final label = _dayLabel(day).toUpperCase(); + + showMenu( + context: context, + position: _menuPosition(), + items: [ + PopupMenuItem( + value: -1, + child: Row( + children: [ + AppIcon(Symbols.chevron_left_rounded, + size: 20, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + Text(label, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + const PopupMenuDivider(), + ..._timeSlots.map((slot) { + return PopupMenuItem( + value: slot.$2, + child: Text(slot.$1, style: theme.textTheme.bodyMedium), + ); + }), + ], + ).then((value) { + if (value == null) return; + if (value == -1) { + _showDayPicker(); + return; + } + setState(() { + _gridStart = DateTime(day.year, day.month, day.day, value); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadPrograms(); + }); + } + + Widget _buildTimeNavigation(ThemeData theme) { + final format = MaterialLocalizations.of(context); + final timeLabel = + format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart)); + final dayLabel = _dayLabel(_gridStart); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + ), + child: Row( + children: [ + IconButton( + icon: const AppIcon(Symbols.chevron_left_rounded), + onPressed: () => _shiftTimeRange(-2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayLabel, + style: theme.textTheme.labelLarge, + ), + const SizedBox(width: 2), + AppIcon(Symbols.arrow_drop_down_rounded, + size: 18, color: theme.colorScheme.onSurface), + ], + ), + ), + const SizedBox(width: 8), + Text( + timeLabel, + style: theme.textTheme.labelLarge, + ), + ], + ), + ), + IconButton( + icon: const AppIcon(Symbols.chevron_right_rounded), + onPressed: () => _shiftTimeRange(2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + ], + ), + ); + } + + 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), + _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 ?? ''); + + String? imageUrl; + if (channel.thumb != null && client != null) { + imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: channel.thumb, + maxWidth: _channelColumnWidth - 16, + maxHeight: _rowHeight - 16, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.logo, + ); + } + + return _ChannelCell( + rowHeight: _rowHeight, + channelColumnWidth: _channelColumnWidth, + imageUrl: imageUrl, + channel: channel, + theme: theme, + onTap: () => _tuneChannel(channel), + fallbackBuilder: () => _buildChannelNameFallback(channel, theme), + ); + } + + Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + 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, + textAlign: TextAlign.center, + ), + ], + ); + } + + 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: 0, + bottom: 0, + child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last), + ), + ); + } + + 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, {bool isLast = false}) { + final isCurrentlyAiring = program.isCurrentlyAiring; + final isPast = program.endsAt != null && + program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; + + return Opacity( + opacity: isPast ? 0.5 : 1.0, + child: Material( + color: isCurrentlyAiring + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(4), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showProgramDetails(channel, program), + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + program.grandparentTitle ?? 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.grandparentTitle != null) + Text( + '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', + style: theme.textTheme.labelSmall?.copyWith( + color: isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + 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); + + final multiServer = context.read(); + final client = multiServer.getClientForServer(channel.serverId ?? ''); + String? posterUrl; + if (program.thumb != null && client != null) { + posterUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: program.thumb, + maxWidth: 80, + maxHeight: 120, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.poster, + ); + } + + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + posterUrl, + width: 80, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 14), + ], + Expanded( + child: Column( + 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(); + _tuneChannel(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), + ), + ], + ), + ], + ), + ); + }, + ); + } +} + +class _ChannelCell extends StatefulWidget { + final double rowHeight; + final double channelColumnWidth; + final String? imageUrl; + final LiveTvChannel channel; + final ThemeData theme; + final VoidCallback onTap; + final Widget Function() fallbackBuilder; + + const _ChannelCell({ + required this.rowHeight, + required this.channelColumnWidth, + required this.imageUrl, + required this.channel, + required this.theme, + required this.onTap, + required this.fallbackBuilder, + }); + + @override + State<_ChannelCell> createState() => _ChannelCellState(); +} + +class _ChannelCellState extends State<_ChannelCell> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = widget.theme; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + child: Container( + height: widget.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: Stack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + opacity: _hovered ? 0.3 : 1.0, + duration: const Duration(milliseconds: 150), + child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty + ? Image.network( + widget.imageUrl!, + width: widget.channelColumnWidth - 16, + height: widget.rowHeight - 16, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + widget.fallbackBuilder(), + ) + : widget.fallbackBuilder(), + ), + if (_hovered) + AppIcon( + Symbols.play_arrow_rounded, + size: 32, + color: theme.colorScheme.onSurface, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart new file mode 100644 index 00000000..88e08843 --- /dev/null +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -0,0 +1,469 @@ +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_hub_result.dart'; +import '../../../models/livetv_program.dart'; +import '../../../providers/multi_server_provider.dart'; +import '../../../providers/settings_provider.dart'; +import '../../../services/settings_service.dart' show LibraryDensity; +import '../../../theme/mono_tokens.dart'; +import '../../../utils/app_logger.dart'; +import '../../../utils/formatters.dart'; +import '../../../utils/layout_constants.dart'; +import '../../../utils/live_tv_player_navigation.dart'; +import '../../../utils/plex_image_helper.dart'; +import '../../../utils/provider_extensions.dart'; +import '../../../widgets/app_icon.dart'; +import '../../../widgets/horizontal_scroll_with_arrows.dart'; +import '../../../widgets/plex_optimized_image.dart'; +import '../live_tv_show_schedule_screen.dart'; + +class WhatsOnTab extends StatefulWidget { + final List channels; + + const WhatsOnTab({super.key, required this.channels}); + + @override + State createState() => _WhatsOnTabState(); +} + +class _WhatsOnTabState extends State { + List _hubs = []; + bool _isLoading = true; + Timer? _refreshTimer; + + @override + void initState() { + super.initState(); + _loadHubs(); + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { + if (mounted) _loadHubs(); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + Future _loadHubs() async { + if (!mounted) return; + setState(() => _isLoading = _hubs.isEmpty); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + final allHubs = []; + + for (final serverInfo in liveTvServers) { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + + final hubs = await client.getLiveTvHubs(); + allHubs.addAll(hubs); + } + + if (!mounted) return; + setState(() { + _hubs = allHubs; + _isLoading = false; + }); + } catch (e) { + appLogger.e('Failed to load live TV hubs', error: e); + if (mounted) setState(() => _isLoading = false); + } + } + + /// Find a channel by its identifier from the channel list. + LiveTvChannel? _findChannel(String? channelIdentifier) { + if (channelIdentifier == null) return null; + return widget.channels.where((ch) { + return ch.identifier == channelIdentifier || ch.key == channelIdentifier; + }).firstOrNull; + } + + Future _tuneChannel(LiveTvChannel channel) async { + 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; + + await navigateToLiveTv( + context, + client: client, + dvrKey: serverInfo.dvrKey, + channel: channel, + channels: widget.channels, + ); + } + + void _onItemTap(LiveTvHubEntry entry) { + final channel = _findChannel(entry.program.channelIdentifier); + + if (entry.program.isCurrentlyAiring && channel != null) { + // Live → play directly + _tuneChannel(channel); + } else if (entry.metadata.type.toLowerCase() == 'show') { + // Show with upcoming episodes → show full schedule + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LiveTvShowScheduleScreen( + showTitle: entry.metadata.title, + serverId: entry.metadata.serverId ?? '', + channels: widget.channels, + ), + ), + ); + } else { + // Individual program (episode, movie, etc.) → bottom sheet + _showProgramDetails(entry, channel); + } + } + + void _showProgramDetails(LiveTvHubEntry entry, LiveTvChannel? channel) { + final theme = Theme.of(context); + final program = entry.program; + final metadata = entry.metadata; + + final multiServer = context.read(); + final client = multiServer.getClientForServer(metadata.serverId ?? ''); + final posterImage = metadata.grandparentThumb ?? metadata.thumb; + String? posterUrl; + if (posterImage != null && client != null) { + posterUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: posterImage, + maxWidth: 80, + maxHeight: 120, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.poster, + ); + } + + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + posterUrl, + width: 80, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 14), + ], + Expanded( + child: Column( + 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( + [ + if (channel != null) channel.displayName, + if (program.startTime != null && program.endTime != null) + '${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')}', + if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), + ].join(' · '), + 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 && channel != null) + FilledButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + _tuneChannel(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), + ), + ], + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_hubs.isEmpty) { + return Center(child: Text(t.liveTv.noPrograms)); + } + + return ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 8), + clipBehavior: Clip.none, + itemCount: _hubs.length, + itemBuilder: (context, index) { + return _LiveTvHubSection( + hub: _hubs[index], + onTap: _onItemTap, + onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Hub section — horizontal scrolling row of poster cards (always 2:3 aspect) +// --------------------------------------------------------------------------- + +class _LiveTvHubSection extends StatelessWidget { + final LiveTvHubResult hub; + final void Function(LiveTvHubEntry) onTap; + final void Function(LiveTvHubEntry) onLongPress; + + const _LiveTvHubSection({ + required this.hub, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final densityScale = switch (settings.libraryDensity) { + LibraryDensity.compact => 0.8, + LibraryDensity.normal => 1.0, + LibraryDensity.comfortable => 1.15, + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Hub header + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon(Symbols.live_tv_rounded, fill: 1), + const SizedBox(width: 8), + Flexible( + child: Text( + hub.title, + style: Theme.of(context).textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + + // Horizontal cards — always poster (2:3) aspect + LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final baseCardWidth = (ScreenBreakpoints.isLargeDesktop(screenWidth) + ? 220.0 + : ScreenBreakpoints.isDesktop(screenWidth) + ? 200.0 + : ScreenBreakpoints.isWideTablet(screenWidth) + ? 190.0 + : 160.0) * + densityScale; + + final cardWidth = baseCardWidth; + final posterWidth = cardWidth - 16; + final posterHeight = posterWidth * 1.5; // 2:3 aspect + final containerHeight = posterHeight + 66; + + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + itemCount: hub.entries.length, + itemBuilder: (context, index) { + final entry = hub.entries[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _LiveTvPosterCard( + entry: entry, + width: cardWidth, + posterHeight: posterHeight, + onTap: () => onTap(entry), + onLongPress: () => onLongPress(entry), + ), + ); + }, + ), + ), + ); + }, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Poster card — always 2:3, shows poster image + title + subtitle +// --------------------------------------------------------------------------- + +class _LiveTvPosterCard extends StatelessWidget { + final LiveTvHubEntry entry; + final double width; + final double posterHeight; + final VoidCallback onTap; + final VoidCallback onLongPress; + + const _LiveTvPosterCard({ + required this.entry, + required this.width, + required this.posterHeight, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final metadata = entry.metadata; + // Always use poster image: show poster for episodes, thumb for others + final posterImage = metadata.grandparentThumb ?? metadata.thumb; + + return SizedBox( + width: width, + child: InkWell( + canRequestFocus: false, + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Poster + SizedBox( + width: double.infinity, + height: posterHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: PlexOptimizedImage.poster( + client: context.getClientWithFallback(metadata.serverId), + imagePath: posterImage, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 4), + // Title + Text( + metadata.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + height: 1.1, + ), + ), + // Subtitle + if (metadata.displaySubtitle != null) + Text( + metadata.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9131ef65..8a79d8c3 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; +import '../models/livetv_hub_result.dart'; import '../models/livetv_program.dart'; import '../models/livetv_scheduled_recording.dart'; import '../models/livetv_subscription.dart'; @@ -2023,6 +2024,9 @@ class PlexClient { /// Cached EPG grid endpoint path (discovered from /media/providers) String? _epgGridEndpoint; + /// Cached EPG provider identifier (e.g. "tv.plex.providers.epg.xmltv:21") + String? _epgProviderIdentifier; + /// Discover the EPG grid endpoint from media providers Future _getEpgGridEndpoint() async { if (_epgGridEndpoint != null) return _epgGridEndpoint; @@ -2040,13 +2044,15 @@ class PlexClient { final protocols = provider['protocols'] as String?; if (protocols == null || !protocols.contains('livetv')) continue; + _epgProviderIdentifier = provider['identifier'] as String?; + final features = provider['Feature'] as List?; if (features == null) continue; for (final feature in features) { if (feature is! Map) continue; if (feature['type'] == 'grid') { _epgGridEndpoint = feature['key'] as String?; - appLogger.d('Discovered EPG grid endpoint: $_epgGridEndpoint'); + appLogger.d('Discovered EPG grid endpoint: $_epgGridEndpoint (provider: $_epgProviderIdentifier)'); return _epgGridEndpoint; } } @@ -2110,6 +2116,94 @@ class PlexClient { ); } + /// Get live TV hubs (What's On Now, etc.) from the EPG provider's discover endpoint. + /// Returns hubs with both display metadata and EPG timing/channel data per item. + Future> getLiveTvHubs({int count = 12}) async { + await _getEpgGridEndpoint(); + if (_epgProviderIdentifier == null) return []; + + try { + final response = await _dio.get( + '/$_epgProviderIdentifier/hubs/discover', + queryParameters: { + 'count': count, + 'includeStations': 1, + 'includeRecentChannels': 1, + 'includeMeta': 1, + 'includeExternalMetadata': 1, + }, + ); + + final container = _getMediaContainer(response); + if (container != null && container['Hub'] != null) { + final hubs = []; + for (final hubJson in container['Hub'] as List) { + try { + final metadataList = hubJson['Metadata'] as List?; + if (metadataList == null || metadataList.isEmpty) continue; + + final entries = []; + for (final itemJson in metadataList) { + if (itemJson is! Map) continue; + + // Extract poster/art from Image array before parsing + _extractLiveTvImages(itemJson); + + try { + final metadata = PlexMetadata.fromJson(itemJson) + .copyWith(serverId: serverId, serverName: serverName); + final program = LiveTvProgram.fromJson(itemJson); + entries.add(LiveTvHubEntry(metadata: metadata, program: program)); + } catch (_) {} + } + + if (entries.isNotEmpty) { + hubs.add(LiveTvHubResult( + title: hubJson['title'] as String? ?? 'Unknown', + hubKey: hubJson['key'] as String? ?? '', + entries: entries, + )); + } + } catch (e) { + appLogger.w('Failed to parse live TV hub', error: e); + } + } + return hubs; + } + } catch (e) { + appLogger.e('Failed to get live TV hubs', error: e); + } + return []; + } + + /// Extract poster/art URLs from the Image array in EPG metadata items. + /// EPG items often have images only in the Image array (coverPoster, coverArt, etc.) + /// rather than in the standard thumb/art fields. + void _extractLiveTvImages(Map item) { + final images = item['Image'] as List?; + if (images == null) return; + + for (final img in images) { + if (img is! Map) continue; + final type = img['type'] as String?; + final url = img['url'] as String?; + if (url == null) continue; + + switch (type) { + case 'coverPoster': + // Always prefer coverPoster as thumb for poster display + item['thumb'] = url; + break; + case 'coverArt': + item['art'] ??= url; + break; + case 'background': + item['art'] ??= url; + break; + } + } + } + /// Generate 24-char random alphanumeric string (matching official client format) static String _generateSessionIdentifier() { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/lib/utils/plex_image_helper.dart b/lib/utils/plex_image_helper.dart index 7dd17591..d7c3bb4a 100644 --- a/lib/utils/plex_image_helper.dart +++ b/lib/utils/plex_image_helper.dart @@ -143,9 +143,20 @@ class PlexImageHelper { final basePath = thumbPath; - // If we can't/shouldn't transcode (already a full URL), just return it. + // External URLs (e.g. EPG provider images) — proxy through the server's + // photo transcoder so the Plex server fetches them on our behalf. if (basePath.startsWith('http://') || basePath.startsWith('https://')) { - return basePath; + if (client == null) return basePath; + final (width, height) = calculateOptimalDimensions( + maxWidth: maxWidth, + maxHeight: maxHeight, + devicePixelRatio: devicePixelRatio, + imageType: imageType, + ); + // Don't append Plex token to the inner URL — only on the outer request + final encodedUrl = Uri.encodeComponent(basePath); + final token = client.config.token; + return '${client.config.baseUrl}/photo/:/transcode?width=$width&height=$height&minSize=1&upscale=1&url=$encodedUrl&X-Plex-Token=$token'; } // If no client (offline mode), we can't build URLs for relative paths From 4aced4d9ee018b1159bce3734e220c611239f696 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:04:39 +0100 Subject: [PATCH 08/18] refactor(tv): use CustomAppBar on schedule and DVR screens --- lib/screens/livetv/dvr_recordings_screen.dart | 78 ++++++++++--------- .../livetv/live_tv_show_schedule_screen.dart | 52 +++++++------ 2 files changed, 72 insertions(+), 58 deletions(-) diff --git a/lib/screens/livetv/dvr_recordings_screen.dart b/lib/screens/livetv/dvr_recordings_screen.dart index 556cf2b1..78c87f84 100644 --- a/lib/screens/livetv/dvr_recordings_screen.dart +++ b/lib/screens/livetv/dvr_recordings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_scheduled_recording.dart'; import '../../models/livetv_subscription.dart'; @@ -10,6 +11,7 @@ import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/desktop_app_bar.dart'; /// Screen for managing DVR recording subscriptions and scheduled recordings class DvrRecordingsScreen extends StatefulWidget { @@ -169,44 +171,50 @@ class _DvrRecordingsScreenState extends State with SingleTi 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), + return Focus( + canRequestFocus: false, + onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + CustomAppBar( + title: Text(t.liveTv.recordings), + pinned: true, + 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), + ), + ], + ), + ) + : TabBarView( + controller: _tabController, + children: [ + _buildSubscriptionsTab(theme), + _buildScheduledTab(theme), + ], + ), ), ), - 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), - ], - ), - ), ); } diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index b66f2fd0..330e5161 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -11,6 +11,7 @@ import '../../utils/formatters.dart'; import '../../utils/live_tv_player_navigation.dart'; import '../../utils/plex_image_helper.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; /// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view. class LiveTvShowScheduleScreen extends StatefulWidget { @@ -234,30 +235,35 @@ class _LiveTvShowScheduleScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(widget.showTitle)), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _programs.isEmpty - ? Center(child: Text(t.liveTv.noPrograms)) - : ListView.builder( - itemCount: _programs.length, - itemBuilder: (context, index) { - final program = _programs[index]; - final channel = _findChannel(program.channelIdentifier); - return _ScheduleListTile( - program: program, - channel: channel, - onTap: () { - if (program.isCurrentlyAiring && channel != null) { - _tuneChannel(channel); - } else { - _showProgramDetails(program, channel); - } - }, - ); + return FocusedScrollScaffold( + title: Text(widget.showTitle), + slivers: [ + if (_isLoading) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (_programs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final program = _programs[index]; + final channel = _findChannel(program.channelIdentifier); + return _ScheduleListTile( + program: program, + channel: channel, + onTap: () { + if (program.isCurrentlyAiring && channel != null) { + _tuneChannel(channel); + } else { + _showProgramDetails(program, channel); + } }, - ), + ); + }, + childCount: _programs.length, + ), + ), + ], ); } } From ca906007649f969bdcc6923ea50dbe57f0795fe3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:47:51 +0100 Subject: [PATCH 09/18] refactor(tv): tune live channel inside player screen --- lib/screens/video_player_screen.dart | 29 +++++++++++++++-- lib/utils/live_tv_player_navigation.dart | 40 +++++++++--------------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d0902f04..eb2582c2 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -850,13 +850,36 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; // Live TV mode: bypass standard playback initialization - if (widget.isLive && widget.liveStreamUrl != null) { + if (widget.isLive) { try { _hasFirstFrame.value = false; await player!.requestAudioFocus(); await _setLiveStreamOptions(); - await player!.open(Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), play: true); + String streamUrl; + if (widget.liveStreamUrl != null) { + streamUrl = widget.liveStreamUrl!; + } else { + // Tune channel inside the player (shows loading spinner while tuning) + final channels = widget.liveChannels; + final channelIndex = _liveChannelIndex; + if (channels == null || channelIndex < 0 || channelIndex >= channels.length) { + throw Exception('No channel to tune'); + } + final channel = channels[channelIndex]; + final channelId = channel.identifier ?? channel.key; + final client = widget.liveClient!; + final result = await client.tuneChannel(widget.liveDvrKey!, channelId); + if (result == null) throw Exception('Failed to tune channel'); + + streamUrl = '${client.config.baseUrl}${result.streamPath}' + .withPlexToken(client.config.token); + + _liveSessionIdentifier = result.sessionIdentifier; + _liveSessionPath = result.sessionPath; + } + + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true); if (mounted) { setState(() { @@ -865,6 +888,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isPlayerInitialized = true; }); } + + _startLiveTimelineUpdates(); } catch (e) { appLogger.e('Failed to start live TV playback', error: e); if (mounted) { diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index f55d6114..d93ad1a1 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -1,16 +1,17 @@ import 'package:flutter/material.dart'; import '../models/livetv_channel.dart'; +import '../models/plex_metadata.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. +/// Navigate to the video player for a live TV channel. /// -/// 1. Calls `tuneChannel()` to get metadata + stream path -/// 2. Navigates to the VideoPlayerScreen with `isLive: true` +/// Pushes the player screen immediately with a placeholder metadata. +/// The actual tuning (tune POST + decision GET) happens inside the player, +/// which shows a loading spinner while it works. /// /// [channels] is the full channel list for channel up/down navigation. Future navigateToLiveTv( @@ -21,43 +22,30 @@ Future navigateToLiveTv( 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)'); + appLogger.d('Navigating to live 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 placeholder = PlexMetadata( + ratingKey: channelId, + key: channelId, + type: 'clip', + title: channel.displayName, + ); final route = PageRouteBuilder( settings: const RouteSettings(name: kVideoPlayerRouteName), pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( - metadata: result.metadata, + metadata: placeholder, isLive: true, liveChannelName: channel.displayName, - liveStreamUrl: streamUrl, + liveStreamUrl: null, liveChannels: channels, liveCurrentChannelIndex: channels?.indexWhere( (ch) => (ch.identifier ?? ch.key) == channelId, ), liveDvrKey: dvrKey, liveClient: client, - liveSessionIdentifier: result.sessionIdentifier, - liveSessionPath: result.sessionPath, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, From 88aca7aad3ce432370c75bb362d1fb86898cae88 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:09:43 +0100 Subject: [PATCH 10/18] feat(tv): add actions bottom sheet for non-live schedule items --- lib/i18n/de.i18n.json | 3 ++- lib/i18n/en.i18n.json | 3 ++- lib/i18n/es.i18n.json | 3 ++- lib/i18n/fr.i18n.json | 3 ++- lib/i18n/it.i18n.json | 3 ++- lib/i18n/ko.i18n.json | 3 ++- lib/i18n/nl.i18n.json | 3 ++- lib/i18n/strings.g.dart | 4 ++-- lib/i18n/strings_de.g.dart | 4 +++- lib/i18n/strings_en.g.dart | 6 +++++- lib/i18n/strings_es.g.dart | 4 +++- lib/i18n/strings_fr.g.dart | 4 +++- lib/i18n/strings_it.g.dart | 4 +++- lib/i18n/strings_ko.g.dart | 4 +++- lib/i18n/strings_nl.g.dart | 4 +++- lib/i18n/strings_sv.g.dart | 4 +++- lib/i18n/strings_zh.g.dart | 4 +++- lib/i18n/sv.i18n.json | 3 ++- lib/i18n/zh.i18n.json | 3 ++- .../livetv/live_tv_show_schedule_screen.dart | 15 +++++++++++++-- 20 files changed, 62 insertions(+), 22 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 46d221df..04f962f1 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -566,7 +566,8 @@ "daytime": "Tagsüber", "evening": "Abend", "lateNight": "Spätnacht", - "whatsOn": "Jetzt im TV" + "whatsOn": "Jetzt im TV", + "watchChannel": "Kanal ansehen" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index ccd29217..69b37cf0 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -566,7 +566,8 @@ "daytime": "Daytime", "evening": "Evening", "lateNight": "Late Night", - "whatsOn": "What's On" + "whatsOn": "What's On", + "watchChannel": "Watch Channel" }, "collections": { "title": "Collections", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index e4b28479..fbc80ad6 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -566,7 +566,8 @@ "daytime": "Día", "evening": "Noche", "lateNight": "Trasnoche", - "whatsOn": "En emisión" + "whatsOn": "En emisión", + "watchChannel": "Ver canal" }, "collections": { "title": "Colecciones", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 7a7751e4..b5e28757 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -566,7 +566,8 @@ "daytime": "Journée", "evening": "Soirée", "lateNight": "Nuit tardive", - "whatsOn": "En ce moment" + "whatsOn": "En ce moment", + "watchChannel": "Regarder la chaîne" }, "collections": { "title": "Collections", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 33aa2e8b..a0899e2a 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -566,7 +566,8 @@ "daytime": "Giorno", "evening": "Sera", "lateNight": "Notte tarda", - "whatsOn": "In onda ora" + "whatsOn": "In onda ora", + "watchChannel": "Guarda canale" }, "downloads": { "title": "Download", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index d6e395d5..1bb248b1 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -566,7 +566,8 @@ "daytime": "낮", "evening": "저녁", "lateNight": "심야 방송", - "whatsOn": "지금 방송 중" + "whatsOn": "지금 방송 중", + "watchChannel": "채널 시청" }, "collections": { "title": "컬렉션", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f3e6a78c..bf2c26c3 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -566,7 +566,8 @@ "daytime": "Overdag", "evening": "Avond", "lateNight": "Late avond", - "whatsOn": "Nu op TV" + "whatsOn": "Nu op TV", + "watchChannel": "Kanaal bekijken" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 5d6216f1..aefb9520 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: 6552 (728 per locale) +/// Strings: 6561 (729 per locale) /// -/// Built on 2026-02-12 at 14:47 UTC +/// Built on 2026-02-12 at 17:06 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 bf26a3fc..4cde9ea7 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvDe implements TranslationsLiveTvEn { @override String get evening => 'Abend'; @override String get lateNight => 'Spätnacht'; @override String get whatsOn => 'Jetzt im TV'; + @override String get watchChannel => 'Kanal ansehen'; } // Path: downloads @@ -1718,6 +1719,7 @@ extension on TranslationsDe { 'liveTv.evening' => 'Abend', 'liveTv.lateNight' => 'Spätnacht', 'liveTv.whatsOn' => 'Jetzt im TV', + 'liveTv.watchChannel' => 'Kanal ansehen', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', @@ -1726,9 +1728,9 @@ extension on TranslationsDe { 'downloads.noDownloadsDescription' => 'Heruntergeladene Inhalte werden hier für die Offline-Wiedergabe angezeigt', 'downloads.downloadNow' => 'Herunterladen', 'downloads.deleteDownload' => 'Download löschen', - 'downloads.retryDownload' => 'Download wiederholen', _ => null, } ?? switch (path) { + 'downloads.retryDownload' => 'Download wiederholen', 'downloads.downloadQueued' => 'Download in Warteschlange', 'downloads.episodesQueued' => ({required Object count}) => '${count} Episoden zum Download hinzugefügt', 'downloads.downloadDeleted' => 'Download gelöscht', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 2b6898e0..c6ea9bcc 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1752,6 +1752,9 @@ class TranslationsLiveTvEn { /// en: 'What's On' String get whatsOn => 'What\'s On'; + + /// en: 'Watch Channel' + String get watchChannel => 'Watch Channel'; } // Path: collections @@ -3181,6 +3184,7 @@ extension on Translations { 'liveTv.evening' => 'Evening', 'liveTv.lateNight' => 'Late Night', 'liveTv.whatsOn' => 'What\'s On', + 'liveTv.watchChannel' => 'Watch Channel', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'Collection is empty', @@ -3189,9 +3193,9 @@ extension on Translations { 'collections.deleteConfirm' => ({required Object title}) => 'Are you sure you want to delete "${title}"? This action cannot be undone.', 'collections.deleted' => 'Collection deleted', 'collections.deleteFailed' => 'Failed to delete collection', - 'collections.deleteFailedWithError' => ({required Object error}) => 'Failed to delete collection: ${error}', _ => null, } ?? switch (path) { + 'collections.deleteFailedWithError' => ({required Object error}) => 'Failed to delete collection: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => 'Failed to load collection items: ${error}', 'collections.selectCollection' => 'Select Collection', 'collections.createNewCollection' => 'Create New Collection', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index fe224961..afec841d 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvEs implements TranslationsLiveTvEn { @override String get evening => 'Noche'; @override String get lateNight => 'Trasnoche'; @override String get whatsOn => 'En emisión'; + @override String get watchChannel => 'Ver canal'; } // Path: collections @@ -1718,6 +1719,7 @@ extension on TranslationsEs { 'liveTv.evening' => 'Noche', 'liveTv.lateNight' => 'Trasnoche', 'liveTv.whatsOn' => 'En emisión', + 'liveTv.watchChannel' => 'Ver canal', 'collections.title' => 'Colecciones', 'collections.collection' => 'Colección', 'collections.empty' => 'La colección está vacía', @@ -1726,9 +1728,9 @@ extension on TranslationsEs { 'collections.deleteConfirm' => ({required Object title}) => '¿Estás seguro de que quieres eliminar "${title}"? Esta acción no se puede deshacer.', 'collections.deleted' => 'Colección eliminada', 'collections.deleteFailed' => 'Error al eliminar la colección', - 'collections.deleteFailedWithError' => ({required Object error}) => 'Error al eliminar la colección: ${error}', _ => null, } ?? switch (path) { + 'collections.deleteFailedWithError' => ({required Object error}) => 'Error al eliminar la colección: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => 'Error al cargar los elementos de la colección: ${error}', 'collections.selectCollection' => 'Seleccionar Colección', 'collections.createNewCollection' => 'Crear Nueva Colección', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 04da2bfd..4ddc3606 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvFr implements TranslationsLiveTvEn { @override String get evening => 'Soirée'; @override String get lateNight => 'Nuit tardive'; @override String get whatsOn => 'En ce moment'; + @override String get watchChannel => 'Regarder la chaîne'; } // Path: collections @@ -1718,6 +1719,7 @@ extension on TranslationsFr { 'liveTv.evening' => 'Soirée', 'liveTv.lateNight' => 'Nuit tardive', 'liveTv.whatsOn' => 'En ce moment', + 'liveTv.watchChannel' => 'Regarder la chaîne', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'La collection est vide', @@ -1726,9 +1728,9 @@ extension on TranslationsFr { 'collections.deleteConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir supprimer "${title}" ? Cette action ne peut pas être annulée.', 'collections.deleted' => 'Collection supprimée', 'collections.deleteFailed' => 'Échec de la suppression de la collection', - 'collections.deleteFailedWithError' => ({required Object error}) => 'Échec de la suppression de la collection: ${error}', _ => null, } ?? switch (path) { + 'collections.deleteFailedWithError' => ({required Object error}) => 'Échec de la suppression de la collection: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => 'Échec du chargement des éléments de la collection: ${error}', 'collections.selectCollection' => 'Sélectionner une collection', 'collections.createNewCollection' => 'Créer une nouvelle collection', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 57ae6949..f4bbb8ae 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvIt implements TranslationsLiveTvEn { @override String get evening => 'Sera'; @override String get lateNight => 'Notte tarda'; @override String get whatsOn => 'In onda ora'; + @override String get watchChannel => 'Guarda canale'; } // Path: downloads @@ -1718,6 +1719,7 @@ extension on TranslationsIt { 'liveTv.evening' => 'Sera', 'liveTv.lateNight' => 'Notte tarda', 'liveTv.whatsOn' => 'In onda ora', + 'liveTv.watchChannel' => 'Guarda canale', 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', @@ -1726,9 +1728,9 @@ extension on TranslationsIt { 'downloads.noDownloadsDescription' => 'I contenuti scaricati appariranno qui per la visualizzazione offline', 'downloads.downloadNow' => 'Scarica', 'downloads.deleteDownload' => 'Elimina download', - 'downloads.retryDownload' => 'Riprova download', _ => null, } ?? switch (path) { + 'downloads.retryDownload' => 'Riprova download', 'downloads.downloadQueued' => 'Download in coda', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodi in coda per il download', 'downloads.downloadDeleted' => 'Download eliminato', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 13370de1..a602edba 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvKo implements TranslationsLiveTvEn { @override String get evening => '저녁'; @override String get lateNight => '심야 방송'; @override String get whatsOn => '지금 방송 중'; + @override String get watchChannel => '채널 시청'; } // Path: collections @@ -1718,6 +1719,7 @@ extension on TranslationsKo { 'liveTv.evening' => '저녁', 'liveTv.lateNight' => '심야 방송', 'liveTv.whatsOn' => '지금 방송 중', + 'liveTv.watchChannel' => '채널 시청', 'collections.title' => '컬렉션', 'collections.collection' => '컬렉션', 'collections.empty' => '컬렉션이 비어 있습니다', @@ -1726,9 +1728,9 @@ extension on TranslationsKo { 'collections.deleteConfirm' => ({required Object title}) => '"${title}"을(를) 삭제 하시겠습니까? 이 작업은 되돌릴 수 없습니다.', 'collections.deleted' => '컬렉션 삭제됨', 'collections.deleteFailed' => '컬렉션 삭제 실패', - 'collections.deleteFailedWithError' => ({required Object error}) => '컬렉션 삭제 실패: ${error}', _ => null, } ?? switch (path) { + 'collections.deleteFailedWithError' => ({required Object error}) => '컬렉션 삭제 실패: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => '컬렉션 항목 로드 실패: ${error}', 'collections.selectCollection' => '컬렉션 선택', 'collections.createNewCollection' => '새 컬렉션 생성', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 77aa0893..aaf6cf4e 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvNl implements TranslationsLiveTvEn { @override String get evening => 'Avond'; @override String get lateNight => 'Late avond'; @override String get whatsOn => 'Nu op TV'; + @override String get watchChannel => 'Kanaal bekijken'; } // Path: downloads @@ -1718,6 +1719,7 @@ extension on TranslationsNl { 'liveTv.evening' => 'Avond', 'liveTv.lateNight' => 'Late avond', 'liveTv.whatsOn' => 'Nu op TV', + 'liveTv.watchChannel' => 'Kanaal bekijken', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', @@ -1726,9 +1728,9 @@ extension on TranslationsNl { 'downloads.noDownloadsDescription' => 'Gedownloade content verschijnt hier voor offline weergave', 'downloads.downloadNow' => 'Download', 'downloads.deleteDownload' => 'Download verwijderen', - 'downloads.retryDownload' => 'Download opnieuw proberen', _ => null, } ?? switch (path) { + 'downloads.retryDownload' => 'Download opnieuw proberen', 'downloads.downloadQueued' => 'Download in wachtrij', 'downloads.episodesQueued' => ({required Object count}) => '${count} afleveringen in wachtrij voor download', 'downloads.downloadDeleted' => 'Download verwijderd', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index b6f73848..1430911f 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvSv implements TranslationsLiveTvEn { @override String get evening => 'Kväll'; @override String get lateNight => 'Sen kväll'; @override String get whatsOn => 'På TV nu'; + @override String get watchChannel => 'Titta på kanal'; } // Path: downloads @@ -1718,6 +1719,7 @@ extension on TranslationsSv { 'liveTv.evening' => 'Kväll', 'liveTv.lateNight' => 'Sen kväll', 'liveTv.whatsOn' => 'På TV nu', + 'liveTv.watchChannel' => 'Titta på kanal', 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', @@ -1726,9 +1728,9 @@ extension on TranslationsSv { 'downloads.noDownloadsDescription' => 'Nedladdat innehåll visas här för offline-visning', 'downloads.downloadNow' => 'Ladda ner', 'downloads.deleteDownload' => 'Ta bort nedladdning', - 'downloads.retryDownload' => 'Försök igen', _ => null, } ?? switch (path) { + 'downloads.retryDownload' => 'Försök igen', 'downloads.downloadQueued' => 'Nedladdning köad', 'downloads.episodesQueued' => ({required Object count}) => '${count} avsnitt köade för nedladdning', 'downloads.downloadDeleted' => 'Nedladdning borttagen', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index c6d5a383..58ceccfe 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -808,6 +808,7 @@ class _TranslationsLiveTvZh implements TranslationsLiveTvEn { @override String get evening => '晚上'; @override String get lateNight => '深夜'; @override String get whatsOn => '正在播出'; + @override String get watchChannel => '观看频道'; } // Path: downloads @@ -1718,6 +1719,7 @@ extension on TranslationsZh { 'liveTv.evening' => '晚上', 'liveTv.lateNight' => '深夜', 'liveTv.whatsOn' => '正在播出', + 'liveTv.watchChannel' => '观看频道', 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', @@ -1726,9 +1728,9 @@ extension on TranslationsZh { 'downloads.noDownloadsDescription' => '下载的内容将在此处显示以供离线观看', 'downloads.downloadNow' => '下载', 'downloads.deleteDownload' => '删除下载', - 'downloads.retryDownload' => '重试下载', _ => null, } ?? switch (path) { + 'downloads.retryDownload' => '重试下载', 'downloads.downloadQueued' => '下载已排队', 'downloads.episodesQueued' => ({required Object count}) => '${count} 集已加入下载队列', 'downloads.downloadDeleted' => '下载已删除', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 0bef2c48..4c715e34 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -566,7 +566,8 @@ "daytime": "Dagtid", "evening": "Kväll", "lateNight": "Sen kväll", - "whatsOn": "På TV nu" + "whatsOn": "På TV nu", + "watchChannel": "Titta på kanal" }, "downloads": { "title": "Nedladdningar", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 1cd66dcd..be3b5d6a 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -566,7 +566,8 @@ "daytime": "白天", "evening": "晚上", "lateNight": "深夜", - "whatsOn": "正在播出" + "whatsOn": "正在播出", + "watchChannel": "观看频道" }, "downloads": { "title": "下载", diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 330e5161..5d6f8d04 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -215,7 +215,7 @@ class _LiveTvShowScheduleScreenState extends State { icon: const AppIcon(Symbols.play_arrow_rounded), label: Text(t.common.play), ), - const SizedBox(width: 8), + if (program.isCurrentlyAiring) const SizedBox(width: 8), OutlinedButton.icon( onPressed: () { Navigator.of(sheetContext).pop(); @@ -224,6 +224,17 @@ class _LiveTvShowScheduleScreenState extends State { icon: const AppIcon(Symbols.fiber_manual_record_rounded), label: Text(t.liveTv.record), ), + if (!program.isCurrentlyAiring && channel != null) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + _tuneChannel(channel); + }, + icon: const AppIcon(Symbols.live_tv_rounded), + label: Text(t.liveTv.watchChannel), + ), + ], ], ), ], @@ -333,7 +344,7 @@ class _ScheduleListTile extends StatelessWidget { ].join(' — '); return InkWell( - onTap: isLive ? onTap : null, + onTap: onTap, child: Container( decoration: isLive ? BoxDecoration( From daff76363b130a37cd85a6d5f5b123e4a59034b9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:29:49 +0100 Subject: [PATCH 11/18] refactor(tv): clean up bottom sheet --- .../livetv/live_tv_show_schedule_screen.dart | 130 +--------------- lib/screens/livetv/program_details_sheet.dart | 140 ++++++++++++++++++ lib/screens/livetv/tabs/whats_on_tab.dart | 119 +-------------- 3 files changed, 154 insertions(+), 235 deletions(-) create mode 100644 lib/screens/livetv/program_details_sheet.dart diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 5d6f8d04..f618e00e 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -2,7 +2,6 @@ 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'; @@ -10,8 +9,8 @@ import '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; import '../../utils/live_tv_player_navigation.dart'; import '../../utils/plex_image_helper.dart'; -import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import 'program_details_sheet.dart'; /// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view. class LiveTvShowScheduleScreen extends StatefulWidget { @@ -106,8 +105,6 @@ class _LiveTvShowScheduleScreenState extends State { } void _showProgramDetails(LiveTvProgram program, LiveTvChannel? channel) { - final theme = Theme.of(context); - final multiServer = context.read(); final client = multiServer.getClientForServer(widget.serverId); String? posterUrl; @@ -122,125 +119,12 @@ class _LiveTvShowScheduleScreenState extends State { ); } - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterUrl != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.network( - posterUrl, - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ), - const SizedBox(width: 14), - ], - Expanded( - child: Column( - 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( - [ - if (channel != null) channel.displayName, - if (program.startTime != null && program.endTime != null) - '${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')}', - if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), - ].join(' · '), - 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 && channel != null) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - _tuneChannel(channel); - }, - icon: const AppIcon(Symbols.play_arrow_rounded), - label: Text(t.common.play), - ), - if (program.isCurrentlyAiring) 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), - ), - if (!program.isCurrentlyAiring && channel != null) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - _tuneChannel(channel); - }, - icon: const AppIcon(Symbols.live_tv_rounded), - label: Text(t.liveTv.watchChannel), - ), - ], - ], - ), - ], - ), - ); - }, + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: channel != null ? () => _tuneChannel(channel) : null, ); } diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart new file mode 100644 index 00000000..1eac2144 --- /dev/null +++ b/lib/screens/livetv/program_details_sheet.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../i18n/strings.g.dart'; +import '../../models/livetv_channel.dart'; +import '../../models/livetv_program.dart'; +import '../../utils/formatters.dart'; +import '../../widgets/app_icon.dart'; + +/// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). +void showProgramDetailsSheet( + BuildContext context, { + required LiveTvProgram program, + required LiveTvChannel? channel, + required String? posterUrl, + required VoidCallback? onTuneChannel, +}) { + 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + posterUrl, + width: 80, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 14), + ], + Expanded( + child: Column( + 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( + [ + if (channel != null) channel.displayName, + if (program.startTime != null && program.endTime != null) + '${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')}', + if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), + ].join(' · '), + 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 && onTuneChannel != null) + FilledButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + onTuneChannel(); + }, + icon: const AppIcon(Symbols.play_arrow_rounded), + label: Text(t.common.play), + ), + if (program.isCurrentlyAiring) 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), + ), + if (!program.isCurrentlyAiring && onTuneChannel != null) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + Navigator.of(sheetContext).pop(); + onTuneChannel(); + }, + icon: const AppIcon(Symbols.live_tv_rounded), + label: Text(t.liveTv.watchChannel), + ), + ], + ], + ), + ], + ), + ); + }, + ); +} diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 88e08843..76d63d6a 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -4,16 +4,13 @@ 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_hub_result.dart'; -import '../../../models/livetv_program.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../providers/settings_provider.dart'; import '../../../services/settings_service.dart' show LibraryDensity; import '../../../theme/mono_tokens.dart'; import '../../../utils/app_logger.dart'; -import '../../../utils/formatters.dart'; import '../../../utils/layout_constants.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../utils/plex_image_helper.dart'; @@ -22,6 +19,7 @@ import '../../../widgets/app_icon.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/plex_optimized_image.dart'; import '../live_tv_show_schedule_screen.dart'; +import '../program_details_sheet.dart'; class WhatsOnTab extends StatefulWidget { final List channels; @@ -132,7 +130,6 @@ class _WhatsOnTabState extends State { } void _showProgramDetails(LiveTvHubEntry entry, LiveTvChannel? channel) { - final theme = Theme.of(context); final program = entry.program; final metadata = entry.metadata; @@ -151,114 +148,12 @@ class _WhatsOnTabState extends State { ); } - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterUrl != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.network( - posterUrl, - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ), - const SizedBox(width: 14), - ], - Expanded( - child: Column( - 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( - [ - if (channel != null) channel.displayName, - if (program.startTime != null && program.endTime != null) - '${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')}', - if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), - ].join(' · '), - 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 && channel != null) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - _tuneChannel(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), - ), - ], - ), - ], - ), - ); - }, + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: channel != null ? () => _tuneChannel(channel) : null, ); } From 7745b5a2f8769feb824b934929f9896714d44c42 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:23:12 +0100 Subject: [PATCH 12/18] fix(tv): focus --- lib/screens/livetv/dvr_recordings_screen.dart | 185 +++-- lib/screens/livetv/live_tv_screen.dart | 235 ++++-- .../livetv/live_tv_show_schedule_screen.dart | 83 +-- lib/screens/livetv/program_details_sheet.dart | 240 +++++-- lib/screens/livetv/tabs/guide_tab.dart | 667 ++++++++++++------ lib/screens/livetv/tabs/whats_on_tab.dart | 392 ++++++++-- 6 files changed, 1264 insertions(+), 538 deletions(-) diff --git a/lib/screens/livetv/dvr_recordings_screen.dart b/lib/screens/livetv/dvr_recordings_screen.dart index 78c87f84..7b1d625c 100644 --- a/lib/screens/livetv/dvr_recordings_screen.dart +++ b/lib/screens/livetv/dvr_recordings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_scheduled_recording.dart'; @@ -102,14 +103,8 @@ class _DvrRecordingsScreenState extends State with SingleTi 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), - ), + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel)), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)), ], ), ); @@ -117,9 +112,7 @@ class _DvrRecordingsScreenState extends State with SingleTi if (confirmed != true || !mounted) return; final multiServer = context.read(); - final client = subscription.serverId != null - ? multiServer.getClientForServer(subscription.serverId!) - : null; + final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null; if (client != null) { final success = await client.deleteSubscription(subscription.key); @@ -132,9 +125,7 @@ class _DvrRecordingsScreenState extends State with SingleTi Future _editSubscription(LiveTvSubscription subscription) async { // Filter to visible settings only - final editableSettings = subscription.settings - .where((s) => s.hidden != true) - .toList(); + final editableSettings = subscription.settings.where((s) => s.hidden != true).toList(); if (editableSettings.isEmpty) return; @@ -145,19 +136,14 @@ class _DvrRecordingsScreenState extends State with SingleTi final result = await showDialog?>( context: context, - builder: (dialogContext) => _SubscriptionEditDialog( - subscription: subscription, - settings: editableSettings, - initialPrefs: prefs, - ), + 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; + final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null; if (client != null) { final success = await client.editSubscription(subscription.key, result); @@ -192,27 +178,24 @@ class _DvrRecordingsScreenState extends State with SingleTi 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), - ), - ], + ? 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), ), - ) - : TabBarView( - controller: _tabController, - children: [ - _buildSubscriptionsTab(theme), - _buildScheduledTab(theme), - ], - ), + ], + ), + ) + : TabBarView( + controller: _tabController, + children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)], + ), ), ), ); @@ -228,42 +211,45 @@ class _DvrRecordingsScreenState extends State with SingleTi itemCount: _subscriptions.length, itemBuilder: (context, index) { final sub = _subscriptions[index]; - return _buildSubscriptionCard(sub, theme); + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + onSelect: () => _editSubscription(sub), + onBack: () => Navigator.pop(context), + child: _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, + return ExcludeFocus( + child: 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), ), - ) - : null, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (subscription.settings.isNotEmpty) IconButton( - icon: const AppIcon(Symbols.settings_rounded), - onPressed: () => _editSubscription(subscription), + icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error), + onPressed: () => _deleteSubscription(subscription), ), - IconButton( - icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error), - onPressed: () => _deleteSubscription(subscription), - ), - ], + ], + ), ), ), ); @@ -279,7 +265,13 @@ class _DvrRecordingsScreenState extends State with SingleTi itemCount: _scheduled.length, itemBuilder: (context, index) { final recording = _scheduled[index]; - return _buildScheduledCard(recording, theme); + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + onBack: () => Navigator.pop(context), + child: _buildScheduledCard(recording, theme), + ); }, ); } @@ -290,23 +282,19 @@ class _DvrRecordingsScreenState extends State with SingleTi ? '${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, + return ExcludeFocus( + child: 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), ), ), ), @@ -320,11 +308,7 @@ class _SubscriptionEditDialog extends StatefulWidget { final List settings; final Map initialPrefs; - const _SubscriptionEditDialog({ - required this.subscription, - required this.settings, - required this.initialPrefs, - }); + const _SubscriptionEditDialog({required this.subscription, required this.settings, required this.initialPrefs}); @override State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState(); @@ -364,14 +348,8 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { ), ), 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), - ), + TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)), + FilledButton(onPressed: () => Navigator.of(context).pop(_prefs), child: Text(t.common.save)), ], ); } @@ -413,10 +391,7 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { } // Default: text field - final controller = _textControllers.putIfAbsent( - setting.id, - () => TextEditingController(text: value), - ); + final controller = _textControllers.putIfAbsent(setting.id, () => TextEditingController(text: value)); return ListTile( title: Text(setting.label ?? setting.id), subtitle: TextField( diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 3d1d9373..1b4021a0 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/dpad_navigator.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../mixins/tab_navigation_mixin.dart'; @@ -21,10 +23,17 @@ class LiveTvScreen extends StatefulWidget { State createState() => _LiveTvScreenState(); } -class _LiveTvScreenState extends State - with SingleTickerProviderStateMixin, TabNavigationMixin { +class _LiveTvScreenState extends State with SingleTickerProviderStateMixin, TabNavigationMixin { final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); + final _guideTabKey = GlobalKey(); + final _whatsOnTabKey = GlobalKey(); + + // App bar action button focus + final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); + final _dvrButtonFocusNode = FocusNode(debugLabel: 'DvrButton'); + bool _isRefreshFocused = false; + bool _isDvrFocused = false; List _channels = []; bool _isLoading = true; @@ -38,6 +47,8 @@ class _LiveTvScreenState extends State super.initState(); suppressAutoFocus = true; initTabNavigation(); + _refreshButtonFocusNode.addListener(_onRefreshFocusChange); + _dvrButtonFocusNode.addListener(_onDvrFocusChange); _loadChannels(); } @@ -45,10 +56,22 @@ class _LiveTvScreenState extends State void dispose() { _guideTabFocusNode.dispose(); _whatsOnTabFocusNode.dispose(); + _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); + _refreshButtonFocusNode.dispose(); + _dvrButtonFocusNode.removeListener(_onDvrFocusChange); + _dvrButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } + void _onRefreshFocusChange() { + if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); + } + + void _onDvrFocusChange() { + if (mounted) setState(() => _isDvrFocused = _dvrButtonFocusNode.hasFocus); + } + @override void onTabChanged() { if (!tabController.indexIsChanging) { @@ -99,6 +122,12 @@ class _LiveTvScreenState extends State _channels = allChannels; _isLoading = false; }); + + if (allChannels.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusCurrentTab(); + }); + } } catch (e) { appLogger.e('Failed to load Live TV channels', error: e); if (mounted) { @@ -111,17 +140,76 @@ class _LiveTvScreenState extends State } void _openRecordings() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DvrRecordingsScreen()), - ); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DvrRecordingsScreen())); } void _focusCurrentTab() { + if (tabController.index == 0) { + _guideTabKey.currentState?.focusContent(); + } else if (tabController.index == 1) { + _whatsOnTabKey.currentState?.focusFirstHub(); + } setState(() { suppressAutoFocus = false; }); } + // --------------------------------------------------------------------------- + // Action button key handlers + // --------------------------------------------------------------------------- + + KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key.isLeftKey) { + getTabChipFocusNode(tabCount - 1).requestFocus(); + return KeyEventResult.handled; + } + if (key.isRightKey) { + _dvrButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isDownKey) { + _focusCurrentTab(); + return KeyEventResult.handled; + } + if (key.isUpKey) { + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _loadChannels(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleDvrKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key.isLeftKey) { + _refreshButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isRightKey || key.isUpKey) { + return KeyEventResult.handled; + } + if (key.isDownKey) { + _focusCurrentTab(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _openRecordings(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // --------------------------------------------------------------------------- + // Tab chips + // --------------------------------------------------------------------------- + Widget _buildTabChip(String label, int index) { final isSelected = tabController.index == index; @@ -157,12 +245,16 @@ class _LiveTvScreenState extends State }); getTabChipFocusNode(newIndex).requestFocus(); } - : null, + : () => _refreshButtonFocusNode.requestFocus(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); } + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -180,68 +272,97 @@ class _LiveTvScreenState extends State ) : Text(t.liveTv.title), actions: [ - IconButton( - icon: const AppIcon(Symbols.refresh_rounded), - tooltip: t.liveTv.reloadGuide, - onPressed: _loadChannels, + Focus( + focusNode: _refreshButtonFocusNode, + onKeyEvent: _handleRefreshKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const AppIcon(Symbols.refresh_rounded), + tooltip: t.liveTv.reloadGuide, + onPressed: _loadChannels, + ), + ), ), - IconButton( - icon: const AppIcon(Symbols.fiber_dvr_rounded), - tooltip: t.liveTv.recordings, - onPressed: _openRecordings, + Focus( + focusNode: _dvrButtonFocusNode, + onKeyEvent: _handleDvrKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isDvrFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: 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, + ? 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)) + : Column( + children: [ + if (!useSideNav) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.liveTv.guide, 0), + const SizedBox(width: 8), + _buildTabChip(t.liveTv.whatsOn, 1), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: tabController, 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), + GuideTab( + key: _guideTabKey, + channels: _channels, + onNavigateUp: focusTabBar, + onBack: onTabBarBack, + ), + WhatsOnTab( + key: _whatsOnTabKey, + channels: _channels, + onNavigateUp: focusTabBar, + onBack: onTabBarBack, ), ], ), - ) - : _channels.isEmpty - ? Center(child: Text(t.liveTv.noChannels)) - : Column( - children: [ - if (!useSideNav) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildTabChip(t.liveTv.guide, 0), - const SizedBox(width: 8), - _buildTabChip(t.liveTv.whatsOn, 1), - ], - ), - ), - ), - Expanded( - child: TabBarView( - controller: tabController, - children: [ - GuideTab(channels: _channels), - WhatsOnTab(channels: _channels), - ], - ), - ), - ], - ), + ), + ], + ), ); } } diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index f618e00e..ab68a9d1 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_wrapper.dart'; +import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../providers/multi_server_provider.dart'; @@ -9,6 +11,7 @@ import '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; import '../../utils/live_tv_player_navigation.dart'; import '../../utils/plex_image_helper.dart'; +import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import 'program_details_sheet.dart'; @@ -23,12 +26,7 @@ class LiveTvShowScheduleScreen extends StatefulWidget { /// Full channel list for tuning. final List channels; - const LiveTvShowScheduleScreen({ - super.key, - required this.showTitle, - required this.serverId, - required this.channels, - }); + const LiveTvShowScheduleScreen({super.key, required this.showTitle, required this.serverId, required this.channels}); @override State createState() => _LiveTvShowScheduleScreenState(); @@ -86,9 +84,8 @@ class _LiveTvShowScheduleScreenState extends State { Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - final serverInfo = multiServer.liveTvServers - .where((s) => s.serverId == channel.serverId) - .firstOrNull ?? + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -139,24 +136,27 @@ class _LiveTvShowScheduleScreenState extends State { SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) else SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final program = _programs[index]; - final channel = _findChannel(program.channelIdentifier); - return _ScheduleListTile( - program: program, - channel: channel, - onTap: () { - if (program.isCurrentlyAiring && channel != null) { - _tuneChannel(channel); - } else { - _showProgramDetails(program, channel); - } - }, - ); - }, - childCount: _programs.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final program = _programs[index]; + final channel = _findChannel(program.channelIdentifier); + final onTap = () { + if (program.isCurrentlyAiring && channel != null) { + _tuneChannel(channel); + } else { + _showProgramDetails(program, channel); + } + }; + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + useBackgroundFocus: true, + disableScale: true, + onSelect: onTap, + onBack: () => Navigator.pop(context), + child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), + ); + }, childCount: _programs.length), ), ], ); @@ -168,11 +168,7 @@ class _ScheduleListTile extends StatelessWidget { final LiveTvChannel? channel; final VoidCallback onTap; - const _ScheduleListTile({ - required this.program, - required this.channel, - required this.onTap, - }); + const _ScheduleListTile({required this.program, required this.channel, required this.onTap}); String _formatTimeInfo() { final now = DateTime.now(); @@ -228,14 +224,13 @@ class _ScheduleListTile extends StatelessWidget { ].join(' — '); return InkWell( + canRequestFocus: false, onTap: onTap, child: Container( decoration: isLive ? BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.08), - border: Border( - left: BorderSide(color: theme.colorScheme.primary, width: 3), - ), + border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)), ) : null, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), @@ -247,17 +242,14 @@ class _ScheduleListTile extends StatelessWidget { Expanded( child: Text( titleText, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (isLive) ...[ const SizedBox(width: 8), - AppIcon(Symbols.play_circle_rounded, - size: 20, color: theme.colorScheme.primary), + AppIcon(Symbols.play_circle_rounded, size: 20, color: theme.colorScheme.primary), ], ], ), @@ -265,21 +257,14 @@ class _ScheduleListTile extends StatelessWidget { const SizedBox(height: 4), Text( subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - ), + style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], if (channel != null) ...[ const SizedBox(height: 2), - Text( - channel!.displayName, - style: theme.textTheme.labelSmall?.copyWith( - color: tokens(context).textMuted, - ), - ), + Text(channel!.displayName, style: theme.textTheme.labelSmall?.copyWith(color: tokens(context).textMuted)), ], ], ), diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 1eac2144..ee8c23d6 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_bottom_sheet.dart'; /// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). void showProgramDetailsSheet( @@ -15,12 +17,178 @@ void showProgramDetailsSheet( required String? posterUrl, required VoidCallback? onTuneChannel, }) { - final theme = Theme.of(context); - showModalBottomSheet( context: context, builder: (sheetContext) { - return Padding( + return _ProgramDetailsSheetContent( + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: onTuneChannel, + ); + }, + ); +} + +class _ProgramDetailsSheetContent extends StatefulWidget { + final LiveTvProgram program; + final LiveTvChannel? channel; + final String? posterUrl; + final VoidCallback? onTuneChannel; + + const _ProgramDetailsSheetContent({ + required this.program, + required this.channel, + required this.posterUrl, + required this.onTuneChannel, + }); + + @override + State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState(); +} + +class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> { + final List _buttonFocusNodes = []; + + FocusNode get _initialFocusNode => _buttonFocusNodes.isNotEmpty ? _buttonFocusNodes.first : FocusNode(); + + @override + void initState() { + super.initState(); + _buildButtonFocusNodes(); + } + + @override + void dispose() { + for (final node in _buttonFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + void _buildButtonFocusNodes() { + int count = 0; + if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; + count++; // Record button always present + if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; + + for (int i = 0; i < count; i++) { + _buttonFocusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_$i')); + } + } + + void _focusButton(int index) { + if (index >= 0 && index < _buttonFocusNodes.length) { + _buttonFocusNodes[index].requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final program = widget.program; + final channel = widget.channel; + + // Build the list of action buttons with their focus wrappers + final buttons = []; + int buttonIndex = 0; + + if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: FilledButton.icon( + style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + icon: const AppIcon(Symbols.play_arrow_rounded), + label: Text(t.common.play), + ), + ), + ); + buttonIndex++; + } + + if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + buttons.add(const SizedBox(width: 8)); + } + + // Record button + { + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + // TODO: Record action + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + // TODO: Record action + }, + icon: const AppIcon(Symbols.fiber_manual_record_rounded), + label: Text(t.liveTv.record), + ), + ), + ); + buttonIndex++; + } + + if (!program.isCurrentlyAiring && widget.onTuneChannel != null) { + buttons.add(const SizedBox(width: 8)); + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + icon: const AppIcon(Symbols.live_tv_rounded), + label: Text(t.liveTv.watchChannel), + ), + ), + ); + buttonIndex++; + } + + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, @@ -29,11 +197,11 @@ void showProgramDetailsSheet( Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (posterUrl != null) ...[ + if (widget.posterUrl != null) ...[ ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( - posterUrl, + widget.posterUrl!, width: 80, height: 120, fit: BoxFit.cover, @@ -48,26 +216,14 @@ void showProgramDetailsSheet( children: [ Row( children: [ - Expanded( - child: Text( - program.displayTitle, - style: theme.textTheme.titleMedium, - ), - ), + 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), - ), + 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, - ), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), ), ), ], @@ -80,9 +236,7 @@ void showProgramDetailsSheet( '${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')}', if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), ].join(' · '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), if (program.summary != null && program.summary!.isNotEmpty) ...[ const SizedBox(height: 12), @@ -99,42 +253,10 @@ void showProgramDetailsSheet( ], ), const SizedBox(height: 16), - Row( - children: [ - if (program.isCurrentlyAiring && onTuneChannel != null) - FilledButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - onTuneChannel(); - }, - icon: const AppIcon(Symbols.play_arrow_rounded), - label: Text(t.common.play), - ), - if (program.isCurrentlyAiring) 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), - ), - if (!program.isCurrentlyAiring && onTuneChannel != null) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () { - Navigator.of(sheetContext).pop(); - onTuneChannel(); - }, - icon: const AppIcon(Symbols.live_tv_rounded), - label: Text(t.liveTv.watchChannel), - ), - ], - ], - ), + Row(children: buttons), ], ), - ); - }, - ); + ), + ); + } } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 21656f60..e1904f3e 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; @@ -13,17 +16,22 @@ import '../../../utils/formatters.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { final List channels; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; - const GuideTab({super.key, required this.channels}); + const GuideTab({super.key, required this.channels, this.onNavigateUp, this.onBack}); @override - State createState() => _GuideTabState(); + State createState() => GuideTabState(); } -class _GuideTabState extends State { +enum _GuideZone { timeNav, grid } + +class GuideTabState extends State { static const _slotWidth = 180.0; static const _channelColumnWidth = 140.0; static const _rowHeight = 64.0; @@ -39,11 +47,44 @@ class _GuideTabState extends State { final ScrollController _headerHorizontalController = ScrollController(); final ScrollController _gridHorizontalController = ScrollController(); final ScrollController _channelVerticalController = ScrollController(); + final ScrollController _gridVerticalController = ScrollController(); bool _syncingScroll = false; Timer? _timeIndicatorTimer; final _dayPickerKey = GlobalKey(); + // Focus state + final FocusNode _guideFocusNode = FocusNode(debugLabel: 'guide_tab'); + _GuideZone _focusZone = _GuideZone.timeNav; + int _timeNavIndex = 1; // 0=left arrow, 1=day picker, 2=right arrow + int _gridChannelIndex = 0; + int _gridColumn = 0; // 0=channel, 1=program + bool _hasFocus = false; + LiveTvProgram? _focusedProgram; + bool _pendingFocus = false; + + /// Focus into the guide content (called from tab bar navigation or initial load). + void focusContent() { + // If still loading programs, defer until the Focus widget is in the tree. + if (_isLoading) { + _pendingFocus = true; + return; + } + _pendingFocus = false; + _guideFocusNode.requestFocus(); + setState(() { + if (widget.channels.isNotEmpty) { + _focusZone = _GuideZone.grid; + _gridColumn = 0; + _gridChannelIndex = 0; + _focusedProgram = null; + } else { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + } + }); + } + @override void initState() { super.initState(); @@ -58,6 +99,27 @@ class _GuideTabState extends State { }); } + @override + void didUpdateWidget(GuideTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) { + _gridChannelIndex = widget.channels.length - 1; + } + } + + @override + void dispose() { + _guideFocusNode.dispose(); + _gridVerticalController.dispose(); + _gridHorizontalController.removeListener(_syncGridToHeader); + _headerHorizontalController.removeListener(_syncHeaderToGrid); + _headerHorizontalController.dispose(); + _gridHorizontalController.dispose(); + _channelVerticalController.dispose(); + _timeIndicatorTimer?.cancel(); + super.dispose(); + } + void _syncGridToHeader() { if (_syncingScroll) return; _syncingScroll = true; @@ -76,17 +138,6 @@ class _GuideTabState extends State { _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(); _gridStart = DateTime(now.year, now.month, now.day, now.hour); @@ -154,12 +205,20 @@ class _GuideTabState extends State { if (!mounted) return; + final shouldFocus = _pendingFocus; + setState(() { _programs = allPrograms; _isLoading = false; }); _scrollToNow(); + + if (shouldFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) focusContent(); + }); + } } catch (e) { appLogger.e('Failed to load guide programs', error: e); if (mounted) { @@ -215,6 +274,213 @@ class _GuideTabState extends State { ); } + // --------------------------------------------------------------------------- + // Focus key handling + // --------------------------------------------------------------------------- + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + // Back key + if (key.isBackKey) { + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + if (_focusZone == _GuideZone.grid) { + if (event is KeyUpEvent) { + setState(() { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + }); + } + return KeyEventResult.handled; + } + return handleBackKeyAction(event, () => widget.onBack?.call()); + } + + if (!event.isActionable) return KeyEventResult.ignored; + + if (_focusZone == _GuideZone.timeNav) { + return _handleTimeNavKey(key); + } else { + return _handleGridKey(key); + } + } + + KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) { + if (key.isLeftKey) { + if (_timeNavIndex > 0) { + setState(() => _timeNavIndex--); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + if (key.isRightKey) { + if (_timeNavIndex < 2) setState(() => _timeNavIndex++); + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (widget.channels.isNotEmpty) { + setState(() { + _focusZone = _GuideZone.grid; + _gridColumn = 0; + _focusedProgram = null; + }); + _scrollToChannel(_gridChannelIndex); + } + return KeyEventResult.handled; + } + if (key.isUpKey) { + widget.onNavigateUp?.call(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + switch (_timeNavIndex) { + case 0: + _shiftTimeRange(-2); + case 1: + _showDayPicker(); + case 2: + _shiftTimeRange(2); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleGridKey(LogicalKeyboardKey key) { + if (key.isUpKey) { + if (_gridChannelIndex > 0) { + setState(() { + _gridChannelIndex--; + if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); + }); + _scrollToChannel(_gridChannelIndex); + } else { + setState(() { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + }); + } + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (_gridChannelIndex < widget.channels.length - 1) { + setState(() { + _gridChannelIndex++; + if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); + }); + _scrollToChannel(_gridChannelIndex); + } + return KeyEventResult.handled; + } + if (key.isRightKey) { + if (_gridColumn == 0) { + final program = _findCurrentProgram(_gridChannelIndex); + if (program != null) { + setState(() { + _gridColumn = 1; + _focusedProgram = program; + }); + _scrollToProgramTime(program); + } + } + return KeyEventResult.handled; + } + if (key.isLeftKey) { + if (_gridColumn == 1) { + setState(() { + _gridColumn = 0; + _focusedProgram = null; + }); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) { + final channel = widget.channels[_gridChannelIndex]; + if (_gridColumn == 0) { + _tuneChannel(channel); + } else if (_focusedProgram != null) { + _showProgramDetails(channel, _focusedProgram!); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // --------------------------------------------------------------------------- + // Focus helpers + // --------------------------------------------------------------------------- + + LiveTvProgram? _findCurrentProgram(int channelIndex) { + if (channelIndex < 0 || channelIndex >= widget.channels.length) return null; + final channel = widget.channels[channelIndex]; + final programs = _getProgramsForChannel(channel); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Currently airing + for (final p in programs) { + if ((p.beginsAt ?? 0) <= now && (p.endsAt ?? 0) > now) return p; + } + // First future program + for (final p in programs) { + if ((p.endsAt ?? 0) > now) return p; + } + return programs.firstOrNull; + } + + void _scrollToChannel(int index) { + if (!_gridVerticalController.hasClients) return; + final targetTop = index * _rowHeight; + final targetBottom = targetTop + _rowHeight; + final viewportTop = _gridVerticalController.offset; + final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension; + + double? newOffset; + if (targetTop < viewportTop) { + newOffset = targetTop; + } else if (targetBottom > viewportBottom) { + newOffset = targetBottom - _gridVerticalController.position.viewportDimension; + } + + if (newOffset != null) { + final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent); + _gridVerticalController.jumpTo(clamped); + if (_channelVerticalController.hasClients) { + _channelVerticalController.jumpTo( + clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent), + ); + } + } + } + + void _scrollToProgramTime(LiveTvProgram? program) { + if (program == null || !_gridHorizontalController.hasClients) return; + + final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; + final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); + final startOffset = progStart - gridStartEpoch; + final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; + + final viewportWidth = _gridHorizontalController.position.viewportDimension; + final currentOffset = _gridHorizontalController.offset; + + if (left < currentOffset || left > currentOffset + viewportWidth - 100) { + final maxScroll = _gridHorizontalController.position.maxScrollExtent; + _gridHorizontalController.jumpTo((left - 50).clamp(0.0, maxScroll)); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -223,7 +489,12 @@ class _GuideTabState extends State { return const Center(child: CircularProgressIndicator()); } - return _buildGuideGrid(theme); + return Focus( + focusNode: _guideFocusNode, + onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + onKeyEvent: _handleKeyEvent, + child: _buildGuideGrid(theme), + ); } Widget _buildGuideGrid(ThemeData theme) { @@ -257,7 +528,7 @@ class _GuideTabState extends State { itemCount: widget.channels.length, itemExtent: _rowHeight, itemBuilder: (context, index) => - _buildChannelCell(widget.channels[index], theme), + _buildChannelCell(widget.channels[index], theme, index: index), ), ), Expanded( @@ -279,12 +550,13 @@ class _GuideTabState extends State { child: SizedBox( width: _totalGridWidth(), child: ListView.builder( + controller: _gridVerticalController, itemCount: widget.channels.length, itemExtent: _rowHeight, itemBuilder: (context, index) { final channel = widget.channels[index]; final programs = _getProgramsForChannel(channel); - return _buildProgramRow(channel, programs, theme); + return _buildProgramRow(channel, programs, theme, channelIndex: index); }, ), ), @@ -382,9 +654,13 @@ class _GuideTabState extends State { }), ], ).then((value) { - if (value == null) return; + if (value == null) { + _guideFocusNode.requestFocus(); + return; + } if (value is String && value == 'now') { _jumpToNow(); + _guideFocusNode.requestFocus(); } else if (value is DateTime) { _showTimeSlotPicker(value); } @@ -421,7 +697,10 @@ class _GuideTabState extends State { }), ], ).then((value) { - if (value == null) return; + if (value == null) { + _guideFocusNode.requestFocus(); + return; + } if (value == -1) { _showDayPicker(); return; @@ -431,9 +710,26 @@ class _GuideTabState extends State { _gridEnd = _gridStart.add(const Duration(hours: 6)); }); _loadPrograms(); + _guideFocusNode.requestFocus(); }); } + // --------------------------------------------------------------------------- + // Time navigation bar + // --------------------------------------------------------------------------- + + Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) { + final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index; + if (!isFocused) return child; + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + } + Widget _buildTimeNavigation(ThemeData theme) { final format = MaterialLocalizations.of(context); final timeLabel = @@ -449,30 +745,41 @@ class _GuideTabState extends State { ), child: Row( children: [ - IconButton( - icon: const AppIcon(Symbols.chevron_left_rounded), - onPressed: () => _shiftTimeRange(-2), - iconSize: 20, - visualDensity: VisualDensity.compact, + _timeNavFocusWrap( + index: 0, + theme: theme, + child: IconButton( + icon: const AppIcon(Symbols.chevron_left_rounded), + onPressed: () => _shiftTimeRange(-2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), ), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - GestureDetector( - key: _dayPickerKey, - onTap: _showDayPicker, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - dayLabel, - style: theme.textTheme.labelLarge, + _timeNavFocusWrap( + index: 1, + theme: theme, + child: GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayLabel, + style: theme.textTheme.labelLarge, + ), + const SizedBox(width: 2), + AppIcon(Symbols.arrow_drop_down_rounded, + size: 18, color: theme.colorScheme.onSurface), + ], ), - const SizedBox(width: 2), - AppIcon(Symbols.arrow_drop_down_rounded, - size: 18, color: theme.colorScheme.onSurface), - ], + ), ), ), const SizedBox(width: 8), @@ -483,17 +790,25 @@ class _GuideTabState extends State { ], ), ), - IconButton( - icon: const AppIcon(Symbols.chevron_right_rounded), - onPressed: () => _shiftTimeRange(2), - iconSize: 20, - visualDensity: VisualDensity.compact, + _timeNavFocusWrap( + index: 2, + theme: theme, + child: IconButton( + icon: const AppIcon(Symbols.chevron_right_rounded), + onPressed: () => _shiftTimeRange(2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), ), ], ), ); } + // --------------------------------------------------------------------------- + // Time header & now indicator + // --------------------------------------------------------------------------- + Widget _buildTimeHeader(ThemeData theme) { final slots = []; var current = _gridStart; @@ -545,7 +860,11 @@ class _GuideTabState extends State { ); } - Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) { + // --------------------------------------------------------------------------- + // Channel column + // --------------------------------------------------------------------------- + + Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); @@ -561,6 +880,8 @@ class _GuideTabState extends State { ); } + final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; + return _ChannelCell( rowHeight: _rowHeight, channelColumnWidth: _channelColumnWidth, @@ -568,6 +889,7 @@ class _GuideTabState extends State { channel: channel, theme: theme, onTap: () => _tuneChannel(channel), + isFocused: isFocused, fallbackBuilder: () => _buildChannelNameFallback(channel, theme), ); } @@ -595,8 +917,13 @@ class _GuideTabState extends State { ); } + // --------------------------------------------------------------------------- + // Program grid + // --------------------------------------------------------------------------- + Widget _buildProgramRow( - LiveTvChannel channel, List programs, ThemeData theme) { + LiveTvChannel channel, List programs, ThemeData theme, + {required int channelIndex}) { if (programs.isEmpty) { return Container( height: _rowHeight, @@ -620,6 +947,14 @@ class _GuideTabState extends State { final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + // Determine which program is focused in this row + final focusProg = (_hasFocus && + _focusZone == _GuideZone.grid && + _gridColumn == 1 && + _gridChannelIndex == channelIndex) + ? _focusedProgram + : null; + for (final program in programs) { final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); @@ -639,7 +974,11 @@ class _GuideTabState extends State { width: width.clamp(2.0, double.infinity), top: 0, bottom: 0, - child: _buildProgramBlock(channel, program, theme, isLast: program == programs.last), + child: _buildProgramBlock( + channel, program, theme, + isLast: program == programs.last, + isFocused: identical(program, focusProg), + ), ), ); } @@ -661,7 +1000,8 @@ class _GuideTabState extends State { } Widget _buildProgramBlock( - LiveTvChannel channel, LiveTvProgram program, ThemeData theme, {bool isLast = false}) { + LiveTvChannel channel, LiveTvProgram program, ThemeData theme, + {bool isLast = false, bool isFocused = false}) { final isCurrentlyAiring = program.isCurrentlyAiring; final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -669,71 +1009,83 @@ class _GuideTabState extends State { return Opacity( opacity: isPast ? 0.5 : 1.0, child: Material( - color: isCurrentlyAiring - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(4), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showProgramDetails(channel, program), - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), - right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - program.grandparentTitle ?? 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, + color: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.25) + : isCurrentlyAiring + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: isFocused + ? BorderSide(color: theme.colorScheme.primary, width: 2) + : BorderSide.none, + ), + child: InkWell( + canRequestFocus: false, + borderRadius: BorderRadius.circular(4), + onTap: () => _showProgramDetails(channel, program), + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, ), - if (program.grandparentTitle != null) + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', - style: theme.textTheme.labelSmall?.copyWith( - color: isCurrentlyAiring - ? theme.colorScheme.onPrimaryContainer - .withValues(alpha: 0.7) - : theme.colorScheme.onSurfaceVariant, + program.grandparentTitle ?? program.title, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: + isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal, + color: isFocused + ? theme.colorScheme.primary + : 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, + if (program.grandparentTitle != null) + Text( + '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', + style: theme.textTheme.labelSmall?.copyWith( + color: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - ), - ], + 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: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + ), + ], + ), ), ), ), - ), ); } void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { - final theme = Theme.of(context); - final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); String? posterUrl; @@ -748,109 +1100,12 @@ class _GuideTabState extends State { ); } - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterUrl != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.network( - posterUrl, - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ), - const SizedBox(width: 14), - ], - Expanded( - child: Column( - 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(); - _tuneChannel(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), - ), - ], - ), - ], - ), - ); - }, + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: () => _tuneChannel(channel), ); } } @@ -862,6 +1117,7 @@ class _ChannelCell extends StatefulWidget { final LiveTvChannel channel; final ThemeData theme; final VoidCallback onTap; + final bool isFocused; final Widget Function() fallbackBuilder; const _ChannelCell({ @@ -871,6 +1127,7 @@ class _ChannelCell extends StatefulWidget { required this.channel, required this.theme, required this.onTap, + required this.isFocused, required this.fallbackBuilder, }); @@ -884,13 +1141,17 @@ class _ChannelCellState extends State<_ChannelCell> { @override Widget build(BuildContext context) { final theme = widget.theme; + final showAction = _hovered || widget.isFocused; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Material( - color: Colors.transparent, + color: widget.isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.15) + : Colors.transparent, child: InkWell( + canRequestFocus: false, onTap: widget.onTap, child: Container( height: widget.rowHeight, @@ -907,7 +1168,7 @@ class _ChannelCellState extends State<_ChannelCell> { alignment: Alignment.center, children: [ AnimatedOpacity( - opacity: _hovered ? 0.3 : 1.0, + opacity: showAction ? 0.3 : 1.0, duration: const Duration(milliseconds: 150), child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty ? Image.network( @@ -920,7 +1181,7 @@ class _ChannelCellState extends State<_ChannelCell> { ) : widget.fallbackBuilder(), ), - if (_hovered) + if (showAction) AppIcon( Symbols.play_arrow_rounded, size: 32, diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 76d63d6a..c946c0d9 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -1,9 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; +import '../../../focus/locked_hub_controller.dart'; +import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_hub_result.dart'; import '../../../providers/multi_server_provider.dart'; @@ -16,6 +21,7 @@ import '../../../utils/live_tv_player_navigation.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/provider_extensions.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/focus_builders.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/plex_optimized_image.dart'; import '../live_tv_show_schedule_screen.dart'; @@ -23,17 +29,20 @@ import '../program_details_sheet.dart'; class WhatsOnTab extends StatefulWidget { final List channels; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; - const WhatsOnTab({super.key, required this.channels}); + const WhatsOnTab({super.key, required this.channels, this.onNavigateUp, this.onBack}); @override - State createState() => _WhatsOnTabState(); + State createState() => WhatsOnTabState(); } -class _WhatsOnTabState extends State { +class WhatsOnTabState extends State { List _hubs = []; bool _isLoading = true; Timer? _refreshTimer; + List> _hubKeys = []; @override void initState() { @@ -70,6 +79,7 @@ class _WhatsOnTabState extends State { if (!mounted) return; setState(() { _hubs = allHubs; + _hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>()); _isLoading = false; }); } catch (e) { @@ -78,6 +88,36 @@ class _WhatsOnTabState extends State { } } + /// Focus the first hub (called from parent when tab bar navigates down) + void focusFirstHub() { + if (_hubKeys.isNotEmpty) { + _hubKeys[0].currentState?.requestFocusFromMemory(); + } + } + + bool _handleVerticalNavigation(int hubIndex, bool isUp) { + if (_hubKeys.isEmpty) return false; + + if (isUp && hubIndex == 0) { + widget.onNavigateUp?.call(); + return true; + } + + final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; + + if (targetIndex < 0 || targetIndex >= _hubKeys.length) { + return true; // At boundary, consume the event + } + + final targetState = _hubKeys[targetIndex].currentState; + if (targetState != null) { + targetState.requestFocusFromMemory(); + return true; + } + + return false; + } + /// Find a channel by its identifier from the channel list. LiveTvChannel? _findChannel(String? channelIdentifier) { if (channelIdentifier == null) return null; @@ -88,9 +128,8 @@ class _WhatsOnTabState extends State { Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - final serverInfo = multiServer.liveTvServers - .where((s) => s.serverId == channel.serverId) - .firstOrNull ?? + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -173,9 +212,12 @@ class _WhatsOnTabState extends State { itemCount: _hubs.length, itemBuilder: (context, index) { return _LiveTvHubSection( + key: _hubKeys[index], hub: _hubs[index], onTap: _onItemTap, onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onBack: widget.onBack, ); }, ); @@ -184,21 +226,228 @@ class _WhatsOnTabState extends State { // --------------------------------------------------------------------------- // Hub section — horizontal scrolling row of poster cards (always 2:3 aspect) +// Uses locked focus pattern: single Focus node at hub level, visual index in state. // --------------------------------------------------------------------------- -class _LiveTvHubSection extends StatelessWidget { +class _LiveTvHubSection extends StatefulWidget { final LiveTvHubResult hub; final void Function(LiveTvHubEntry) onTap; final void Function(LiveTvHubEntry) onLongPress; + final bool Function(bool isUp)? onVerticalNavigation; + final VoidCallback? onBack; const _LiveTvHubSection({ + super.key, required this.hub, required this.onTap, required this.onLongPress, + this.onVerticalNavigation, + this.onBack, }); + @override + State<_LiveTvHubSection> createState() => _LiveTvHubSectionState(); +} + +class _LiveTvHubSectionState extends State<_LiveTvHubSection> { + static const _longPressDuration = Duration(milliseconds: 500); + + late FocusNode _hubFocusNode; + final ScrollController _scrollController = ScrollController(); + + int _focusedIndex = 0; + double _itemExtent = 0; + static const double _leadingPadding = 12.0; + + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + bool _longPressTriggered = false; + + @override + void initState() { + super.initState(); + _hubFocusNode = FocusNode(debugLabel: 'livetv_hub_${widget.hub.hubKey}'); + _hubFocusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(_LiveTvHubSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.hub.entries.length != oldWidget.hub.entries.length) { + final maxIndex = widget.hub.entries.isEmpty ? 0 : widget.hub.entries.length - 1; + if (_focusedIndex > maxIndex) { + _focusedIndex = maxIndex; + } + } + } + + @override + void dispose() { + _longPressTimer?.cancel(); + _hubFocusNode.removeListener(_onFocusChange); + _hubFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (!_hubFocusNode.hasFocus) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } + if (mounted) setState(() {}); + } + + void requestFocusAt(int index) { + if (widget.hub.entries.isEmpty) return; + + final clamped = index.clamp(0, widget.hub.entries.length - 1); + _focusedIndex = clamped; + HubFocusMemory.setForHub(widget.hub.hubKey, clamped); + _scrollToIndex(clamped); + _hubFocusNode.requestFocus(); + if (mounted) setState(() {}); + _scrollHubIntoView(); + } + + void requestFocusFromMemory() { + final index = HubFocusMemory.getForHub(widget.hub.hubKey, widget.hub.entries.length); + requestFocusAt(index); + } + + void _scrollHubIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); + } + + void _scrollToIndex(int index, {bool animate = true}) { + if (!_scrollController.hasClients || _itemExtent <= 0) return; + + final viewport = _scrollController.position.viewportDimension; + final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); + final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _scrollController.position.maxScrollExtent); + + if (animate) { + _scrollController.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } else { + _scrollController.jumpTo(desiredOffset); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + if (key.isSelectKey) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTriggered = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_longPressDuration, () { + if (!mounted) return; + if (_isSelectKeyDown) { + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _activateLongPress(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { + _activateCurrentItem(); + } + _isSelectKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + } + + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + + if (!event.isActionable) { + return KeyEventResult.ignored; + } + + final itemCount = widget.hub.entries.length; + if (itemCount == 0) return KeyEventResult.ignored; + + if (key.isLeftKey) { + if (_focusedIndex > 0) { + _focusedIndex--; + HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + _scrollToIndex(_focusedIndex); + setState(() {}); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + + if (key.isRightKey) { + if (_focusedIndex < itemCount - 1) { + _focusedIndex++; + HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + _scrollToIndex(_focusedIndex); + setState(() {}); + } + return KeyEventResult.handled; + } + + if (key.isUpKey) { + widget.onVerticalNavigation?.call(true); + return KeyEventResult.handled; + } + if (key.isDownKey) { + widget.onVerticalNavigation?.call(false); + return KeyEventResult.handled; + } + + if (key.isContextMenuKey) { + _activateLongPress(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _activateCurrentItem() { + if (_focusedIndex >= widget.hub.entries.length) return; + widget.onTap(widget.hub.entries[_focusedIndex]); + } + + void _activateLongPress() { + if (_focusedIndex >= widget.hub.entries.length) return; + widget.onLongPress(widget.hub.entries[_focusedIndex]); + } + + void _onItemTapped(int index) { + _focusedIndex = index; + HubFocusMemory.setForHub(widget.hub.hubKey, index); + _hubFocusNode.requestFocus(); + setState(() {}); + } + @override Widget build(BuildContext context) { + final hasFocus = _hubFocusNode.hasFocus; final settings = context.watch(); final densityScale = switch (settings.libraryDensity) { LibraryDensity.compact => 0.8, @@ -220,7 +469,7 @@ class _LiveTvHubSection extends StatelessWidget { const SizedBox(width: 8), Flexible( child: Text( - hub.title, + widget.hub.title, style: Theme.of(context).textTheme.titleLarge, overflow: TextOverflow.ellipsis, maxLines: 1, @@ -230,50 +479,67 @@ class _LiveTvHubSection extends StatelessWidget { ), ), - // Horizontal cards — always poster (2:3) aspect - LayoutBuilder( - builder: (context, constraints) { - final screenWidth = constraints.maxWidth; - final baseCardWidth = (ScreenBreakpoints.isLargeDesktop(screenWidth) - ? 220.0 - : ScreenBreakpoints.isDesktop(screenWidth) + // Horizontal cards with locked focus control + if (widget.hub.entries.isNotEmpty) + Focus( + focusNode: _hubFocusNode, + onKeyEvent: _handleKeyEvent, + child: LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final baseCardWidth = + (ScreenBreakpoints.isLargeDesktop(screenWidth) + ? 220.0 + : ScreenBreakpoints.isDesktop(screenWidth) ? 200.0 : ScreenBreakpoints.isWideTablet(screenWidth) - ? 190.0 - : 160.0) * - densityScale; + ? 190.0 + : 160.0) * + densityScale; - final cardWidth = baseCardWidth; - final posterWidth = cardWidth - 16; - final posterHeight = posterWidth * 1.5; // 2:3 aspect - final containerHeight = posterHeight + 66; + final cardWidth = baseCardWidth; + final posterWidth = cardWidth - 16; + final posterHeight = posterWidth * 1.5; // 2:3 aspect + final containerHeight = posterHeight + 66; + _itemExtent = cardWidth + 4; - return SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - itemCount: hub.entries.length, - itemBuilder: (context, index) { - final entry = hub.entries[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: _LiveTvPosterCard( - entry: entry, - width: cardWidth, - posterHeight: posterHeight, - onTap: () => onTap(entry), - onLongPress: () => onLongPress(entry), - ), - ); - }, - ), - ), - ); - }, - ), + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + itemCount: widget.hub.entries.length, + itemBuilder: (context, index) { + final entry = widget.hub.entries[index]; + final isItemFocused = hasFocus && index == _focusedIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _LiveTvPosterCard( + entry: entry, + width: cardWidth, + posterHeight: posterHeight, + isFocused: isItemFocused, + onTap: () { + _onItemTapped(index); + widget.onTap(entry); + }, + onLongPress: () { + _onItemTapped(index); + widget.onLongPress(entry); + }, + ), + ); + }, + ), + ), + ); + }, + ), + ), ], ); } @@ -287,6 +553,7 @@ class _LiveTvPosterCard extends StatelessWidget { final LiveTvHubEntry entry; final double width; final double posterHeight; + final bool isFocused; final VoidCallback onTap; final VoidCallback onLongPress; @@ -294,6 +561,7 @@ class _LiveTvPosterCard extends StatelessWidget { required this.entry, required this.width, required this.posterHeight, + required this.isFocused, required this.onTap, required this.onLongPress, }); @@ -304,13 +572,13 @@ class _LiveTvPosterCard extends StatelessWidget { // Always use poster image: show poster for episodes, thumb for others final posterImage = metadata.grandparentThumb ?? metadata.thumb; - return SizedBox( - width: width, - child: InkWell( - canRequestFocus: false, - onTap: onTap, - onLongPress: onLongPress, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), + return FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox( + width: width, child: Padding( padding: const EdgeInsets.all(8), child: Column( @@ -337,11 +605,7 @@ class _LiveTvPosterCard extends StatelessWidget { metadata.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - height: 1.1, - ), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1), ), // Subtitle if (metadata.displaySubtitle != null) @@ -349,11 +613,9 @@ class _LiveTvPosterCard extends StatelessWidget { metadata.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1), ), ], ), From 426f6ab6ba5967daedf6bbc433800d6ee4f7786d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:01:54 +0100 Subject: [PATCH 13/18] fix(tv): misc improvements --- lib/screens/livetv/live_tv_screen.dart | 20 ++++-- lib/screens/livetv/program_details_sheet.dart | 67 +++++++++---------- lib/screens/livetv/tabs/guide_tab.dart | 65 +++++++++--------- lib/screens/livetv/tabs/whats_on_tab.dart | 21 ++++-- lib/screens/video_player_screen.dart | 8 ++- lib/services/plex_client.dart | 14 ++-- .../desktop_video_controls.dart | 2 +- .../video_controls/mobile_video_controls.dart | 7 -- 8 files changed, 116 insertions(+), 88 deletions(-) diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 1b4021a0..c744dbfa 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -76,6 +76,14 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt void onTabChanged() { if (!tabController.indexIsChanging) { super.onTabChanged(); + // Pause/resume timers based on active tab + if (tabController.index == 0) { + _whatsOnTabKey.currentState?.pauseRefresh(); + _guideTabKey.currentState?.resumeRefresh(); + } else { + _guideTabKey.currentState?.pauseRefresh(); + _whatsOnTabKey.currentState?.resumeRefresh(); + } } } @@ -101,11 +109,15 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt final allChannels = []; for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; - final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - allChannels.addAll(channels); + final channels = await client.getEpgChannels(lineup: serverInfo.lineup); + allChannels.addAll(channels); + } catch (e) { + appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: e); + } } allChannels.sort((a, b) { diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index ee8c23d6..9284ff8c 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -69,7 +69,8 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent void _buildButtonFocusNodes() { int count = 0; if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; - count++; // Record button always present + // TODO: Implement recording + // count++; // Record button if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; for (int i = 0; i < count; i++) { @@ -122,39 +123,37 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent buttonIndex++; } - if (program.isCurrentlyAiring && widget.onTuneChannel != null) { - buttons.add(const SizedBox(width: 8)); - } - - // Record button - { - final idx = buttonIndex; - buttons.add( - FocusableWrapper( - focusNode: _buttonFocusNodes[idx], - onSelect: () { - Navigator.of(context).pop(); - // TODO: Record action - }, - onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, - onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, - onBack: () => Navigator.of(context).pop(), - borderRadius: 100, - useBackgroundFocus: true, - disableScale: true, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: () { - Navigator.of(context).pop(); - // TODO: Record action - }, - icon: const AppIcon(Symbols.fiber_manual_record_rounded), - label: Text(t.liveTv.record), - ), - ), - ); - buttonIndex++; - } + // TODO: Implement recording + // if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + // buttons.add(const SizedBox(width: 8)); + // } + // // Record button + // { + // final idx = buttonIndex; + // buttons.add( + // FocusableWrapper( + // focusNode: _buttonFocusNodes[idx], + // onSelect: () { + // Navigator.of(context).pop(); + // }, + // onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + // onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + // onBack: () => Navigator.of(context).pop(), + // borderRadius: 100, + // useBackgroundFocus: true, + // disableScale: true, + // child: OutlinedButton.icon( + // style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + // onPressed: () { + // Navigator.of(context).pop(); + // }, + // icon: const AppIcon(Symbols.fiber_manual_record_rounded), + // label: Text(t.liveTv.record), + // ), + // ), + // ); + // buttonIndex++; + // } if (!program.isCurrentlyAiring && widget.onTuneChannel != null) { buttons.add(const SizedBox(width: 8)); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index e1904f3e..37eafd80 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -11,11 +11,13 @@ import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; import '../../../providers/multi_server_provider.dart'; +import '../../../services/plex_client.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/plex_optimized_image.dart'; import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { @@ -99,6 +101,15 @@ class GuideTabState extends State { }); } + void pauseRefresh() => _timeIndicatorTimer?.cancel(); + + void resumeRefresh() { + _timeIndicatorTimer?.cancel(); + _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + @override void didUpdateWidget(GuideTab oldWidget) { super.didUpdateWidget(oldWidget); @@ -189,18 +200,22 @@ class GuideTabState extends State { final allPrograms = []; for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; - final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; - final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + 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); + final programs = await client.getEpgGrid( + lineup: serverInfo.lineup, + beginsAt: startEpoch, + endsAt: endEpoch, + ); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); + } } if (!mounted) return; @@ -868,24 +883,13 @@ class GuideTabState extends State { final multiServer = context.read(); final client = multiServer.getClientForServer(channel.serverId ?? ''); - String? imageUrl; - if (channel.thumb != null && client != null) { - imageUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: channel.thumb, - maxWidth: _channelColumnWidth - 16, - maxHeight: _rowHeight - 16, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), - imageType: ImageType.logo, - ); - } - final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; return _ChannelCell( rowHeight: _rowHeight, channelColumnWidth: _channelColumnWidth, - imageUrl: imageUrl, + channelThumb: channel.thumb, + client: client, channel: channel, theme: theme, onTap: () => _tuneChannel(channel), @@ -1113,7 +1117,8 @@ class GuideTabState extends State { class _ChannelCell extends StatefulWidget { final double rowHeight; final double channelColumnWidth; - final String? imageUrl; + final String? channelThumb; + final PlexClient? client; final LiveTvChannel channel; final ThemeData theme; final VoidCallback onTap; @@ -1123,7 +1128,8 @@ class _ChannelCell extends StatefulWidget { const _ChannelCell({ required this.rowHeight, required this.channelColumnWidth, - required this.imageUrl, + required this.channelThumb, + required this.client, required this.channel, required this.theme, required this.onTap, @@ -1170,14 +1176,13 @@ class _ChannelCellState extends State<_ChannelCell> { AnimatedOpacity( opacity: showAction ? 0.3 : 1.0, duration: const Duration(milliseconds: 150), - child: widget.imageUrl != null && widget.imageUrl!.isNotEmpty - ? Image.network( - widget.imageUrl!, + child: widget.channelThumb != null && widget.client != null + ? PlexOptimizedImage.thumb( + client: widget.client!, + imagePath: widget.channelThumb, width: widget.channelColumnWidth - 16, height: widget.rowHeight - 16, fit: BoxFit.contain, - errorBuilder: (_, _, _) => - widget.fallbackBuilder(), ) : widget.fallbackBuilder(), ), diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index c946c0d9..a969da95 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -53,6 +53,15 @@ class WhatsOnTabState extends State { }); } + void pauseRefresh() => _refreshTimer?.cancel(); + + void resumeRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { + if (mounted) _loadHubs(); + }); + } + @override void dispose() { _refreshTimer?.cancel(); @@ -69,11 +78,15 @@ class WhatsOnTabState extends State { final allHubs = []; for (final serverInfo in liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; - final hubs = await client.getLiveTvHubs(); - allHubs.addAll(hubs); + final hubs = await client.getLiveTvHubs(); + allHubs.addAll(hubs); + } catch (e) { + appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: e); + } } if (!mounted) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index eb2582c2..55cb3083 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -892,6 +892,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _startLiveTimelineUpdates(); } catch (e) { appLogger.e('Failed to start live TV playback', error: e); + _sendLiveTimeline('stopped'); if (mounted) { showErrorSnackBar(context, e.toString()); _handleBackButton(); @@ -1654,7 +1655,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin void _startLiveTimelineUpdates() { _liveTimelineTimer?.cancel(); _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { - _sendLiveTimeline('playing'); + final state = player?.state.playing == true ? 'playing' : 'paused'; + _sendLiveTimeline(state); }); // Send initial heartbeat immediately _sendLiveTimeline('playing'); @@ -1714,6 +1716,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isSwitchingChannel = true; + // Stop old session heartbeats and notify server + _stopLiveTimelineUpdates(); + await _sendLiveTimeline('stopped'); + final channel = channels[newIndex]; final channelId = channel.identifier ?? channel.key; appLogger.d('Switching to channel: ${channel.displayName} ($channelId)'); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8a79d8c3..c0f2a8fc 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2006,6 +2006,7 @@ class PlexClient { return (container['Channel'] as List) .map((json) => LiveTvChannel.fromJson(json as Map) .copyWith(serverId: serverId, serverName: serverName)) + .where((ch) => ch.key.isNotEmpty) .toList(); } // Also check for Metadata key (some endpoints return channels there) @@ -2013,6 +2014,7 @@ class PlexClient { return (container['Metadata'] as List) .map((json) => LiveTvChannel.fromJson(json as Map) .copyWith(serverId: serverId, serverName: serverName)) + .where((ch) => ch.key.isNotEmpty) .toList(); } return []; @@ -2084,14 +2086,8 @@ class PlexClient { () => _dio.get(gridEndpoint, queryParameters: queryParams), (response) { final container = _getMediaContainer(response); - appLogger.d('getEpgGrid: container keys=${container?.keys.toList()}'); final programs = []; if (container != null && container['Metadata'] != null) { - final firstItem = (container['Metadata'] as List).firstOrNull; - if (firstItem is Map) { - appLogger.d('getEpgGrid: sample program keys=${firstItem.keys.toList()}'); - appLogger.d('getEpgGrid: Channel=${firstItem['Channel']}, Media=${firstItem['Media']}, beginsAt=${firstItem['beginsAt']}, endsAt=${firstItem['endsAt']}, duration=${firstItem['duration']}'); - } for (final item in container['Metadata'] as List) { try { programs.add(LiveTvProgram.fromJson(item as Map)); @@ -2300,7 +2296,11 @@ class PlexClient { .join('&'); // Decision — bare Dio so no default X-Plex-* HTTP headers leak through. - final decisionDio = Dio(BaseOptions(headers: {'Accept-Language': 'en'})); + final decisionDio = Dio(BaseOptions( + headers: {'Accept-Language': 'en'}, + connectTimeout: ConnectionTimeouts.connect, + receiveTimeout: ConnectionTimeouts.receive, + )); final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl)); diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 08214eef..3b29e909 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -437,7 +437,7 @@ class DesktopVideoControlsState extends State { borderRadius: BorderRadius.circular(4), ), child: Text( - widget.liveChannelName != null ? '${t.liveTv.live} · ${widget.liveChannelName}' : t.liveTv.live, + t.liveTv.live, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), ), ), diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 04dce8d3..243650ec 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -182,13 +182,6 @@ class MobileVideoControls extends StatelessWidget { 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), - ), - ], ], ), ); From a4d4e2e1f827a1c72a72d0a2866a2302d4aa83b8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:16:44 +0100 Subject: [PATCH 14/18] fix(tv): query all EPG providers for multi-DVR support --- lib/screens/livetv/live_tv_screen.dart | 8 +- lib/screens/livetv/tabs/guide_tab.dart | 3 +- lib/screens/livetv/tabs/whats_on_tab.dart | 2 + lib/services/plex_client.dart | 202 ++++++++++++---------- 4 files changed, 121 insertions(+), 94 deletions(-) diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index c744dbfa..1b9515e2 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -107,6 +107,7 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt } final allChannels = []; + final seenChannels = {}; for (final serverInfo in liveTvServers) { try { @@ -114,7 +115,12 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt if (client == null) continue; final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - allChannels.addAll(channels); + for (final channel in channels) { + final dedupKey = '${serverInfo.serverId}:${channel.identifier ?? channel.key}'; + if (seenChannels.add(dedupKey)) { + allChannels.add(channel); + } + } } catch (e) { appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: e); } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 37eafd80..3660ac1d 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -198,8 +198,10 @@ class GuideTabState extends State { final multiServer = context.read(); final liveTvServers = multiServer.liveTvServers; final allPrograms = []; + final queriedServers = {}; for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; @@ -208,7 +210,6 @@ class GuideTabState extends State { final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; final programs = await client.getEpgGrid( - lineup: serverInfo.lineup, beginsAt: startEpoch, endsAt: endEpoch, ); diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index a969da95..7b991e93 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -76,8 +76,10 @@ class WhatsOnTabState extends State { final multiServer = context.read(); final liveTvServers = multiServer.liveTvServers; final allHubs = []; + final queriedServers = {}; for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index c0f2a8fc..f8fec703 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2023,153 +2023,171 @@ class PlexClient { ); } - /// Cached EPG grid endpoint path (discovered from /media/providers) - String? _epgGridEndpoint; + /// Cached EPG providers (discovered from /media/providers) + List<({String identifier, String gridEndpoint})>? _epgProviders; - /// Cached EPG provider identifier (e.g. "tv.plex.providers.epg.xmltv:21") - String? _epgProviderIdentifier; - - /// Discover the EPG grid endpoint from media providers - Future _getEpgGridEndpoint() async { - if (_epgGridEndpoint != null) return _epgGridEndpoint; + /// Discover all EPG providers from media providers + Future> _discoverEpgProviders() async { + if (_epgProviders != null) return _epgProviders!; try { final response = await _dio.get('/media/providers'); final container = _getMediaContainer(response); - if (container == null) return null; + if (container == null) return []; final providers = container['MediaProvider'] as List?; - if (providers == null) return null; + if (providers == null) return []; + + final results = <({String identifier, String gridEndpoint})>[]; for (final provider in providers) { if (provider is! Map) continue; final protocols = provider['protocols'] as String?; if (protocols == null || !protocols.contains('livetv')) continue; - _epgProviderIdentifier = provider['identifier'] as String?; + final identifier = provider['identifier'] as String?; + if (identifier == null) continue; final features = provider['Feature'] as List?; if (features == null) continue; for (final feature in features) { if (feature is! Map) continue; if (feature['type'] == 'grid') { - _epgGridEndpoint = feature['key'] as String?; - appLogger.d('Discovered EPG grid endpoint: $_epgGridEndpoint (provider: $_epgProviderIdentifier)'); - return _epgGridEndpoint; + final gridEndpoint = feature['key'] as String?; + if (gridEndpoint != null) { + results.add((identifier: identifier, gridEndpoint: gridEndpoint)); + appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); + } } } } + + _epgProviders = results; + if (results.isEmpty) { + appLogger.w('No EPG providers found'); + } + return results; } catch (e) { - appLogger.e('Failed to discover EPG grid endpoint', error: e); + appLogger.e('Failed to discover EPG providers', error: e); } - return null; + return []; } /// Get guide/program data for channels (EPG grid data) - /// Discovers the grid endpoint from /media/providers on first call + /// Discovers grid endpoints from /media/providers on first call and queries all providers Future> getEpgGrid({ - String? lineup, int? beginsAt, int? endsAt, }) async { - final gridEndpoint = await _getEpgGridEndpoint(); - if (gridEndpoint == null) { - appLogger.w('No EPG grid endpoint found'); - return []; - } + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; final queryParams = {}; if (beginsAt != null) queryParams['beginsAt>'] = beginsAt; if (endsAt != null) queryParams['endsAt<'] = endsAt; - return _wrapListApiCall( - () => _dio.get(gridEndpoint, 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) { + final allPrograms = []; + + for (final provider in providers) { + try { + final programs = await _wrapListApiCall( + () => _dio.get(provider.gridEndpoint, 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 (_) {} } } - } - } - return programs; - }, - 'Failed to get EPG grid', - ); + // 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 from ${provider.identifier}', + ); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); + } + } + + return allPrograms; } - /// Get live TV hubs (What's On Now, etc.) from the EPG provider's discover endpoint. + /// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints. /// Returns hubs with both display metadata and EPG timing/channel data per item. Future> getLiveTvHubs({int count = 12}) async { - await _getEpgGridEndpoint(); - if (_epgProviderIdentifier == null) return []; + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; - try { - final response = await _dio.get( - '/$_epgProviderIdentifier/hubs/discover', - queryParameters: { - 'count': count, - 'includeStations': 1, - 'includeRecentChannels': 1, - 'includeMeta': 1, - 'includeExternalMetadata': 1, - }, - ); + final allHubs = []; - final container = _getMediaContainer(response); - if (container != null && container['Hub'] != null) { - final hubs = []; - for (final hubJson in container['Hub'] as List) { - try { - final metadataList = hubJson['Metadata'] as List?; - if (metadataList == null || metadataList.isEmpty) continue; + for (final provider in providers) { + try { + final response = await _dio.get( + '/${provider.identifier}/hubs/discover', + queryParameters: { + 'count': count, + 'includeStations': 1, + 'includeRecentChannels': 1, + 'includeMeta': 1, + 'includeExternalMetadata': 1, + }, + ); - final entries = []; - for (final itemJson in metadataList) { - if (itemJson is! Map) continue; + final container = _getMediaContainer(response); + if (container != null && container['Hub'] != null) { + for (final hubJson in container['Hub'] as List) { + try { + final metadataList = hubJson['Metadata'] as List?; + if (metadataList == null || metadataList.isEmpty) continue; - // Extract poster/art from Image array before parsing - _extractLiveTvImages(itemJson); + final entries = []; + for (final itemJson in metadataList) { + if (itemJson is! Map) continue; - try { - final metadata = PlexMetadata.fromJson(itemJson) - .copyWith(serverId: serverId, serverName: serverName); - final program = LiveTvProgram.fromJson(itemJson); - entries.add(LiveTvHubEntry(metadata: metadata, program: program)); - } catch (_) {} + // Extract poster/art from Image array before parsing + _extractLiveTvImages(itemJson); + + try { + final metadata = PlexMetadata.fromJson(itemJson) + .copyWith(serverId: serverId, serverName: serverName); + final program = LiveTvProgram.fromJson(itemJson); + entries.add(LiveTvHubEntry(metadata: metadata, program: program)); + } catch (_) {} + } + + if (entries.isNotEmpty) { + allHubs.add(LiveTvHubResult( + title: hubJson['title'] as String? ?? 'Unknown', + hubKey: hubJson['key'] as String? ?? '', + entries: entries, + )); + } + } catch (e) { + appLogger.w('Failed to parse live TV hub', error: e); } - - if (entries.isNotEmpty) { - hubs.add(LiveTvHubResult( - title: hubJson['title'] as String? ?? 'Unknown', - hubKey: hubJson['key'] as String? ?? '', - entries: entries, - )); - } - } catch (e) { - appLogger.w('Failed to parse live TV hub', error: e); } } - return hubs; + } catch (e) { + appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e); } - } catch (e) { - appLogger.e('Failed to get live TV hubs', error: e); } - return []; + + return allHubs; } /// Extract poster/art URLs from the Image array in EPG metadata items. From f4d940af10e87566e13f871909bd118035c0f772 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:21:08 +0100 Subject: [PATCH 15/18] fix(tv): add diagnostic logging for multi-DVR issues Also remove suspicious Uri.decodeComponent on lineup parameter. --- lib/screens/livetv/live_tv_screen.dart | 7 +++++++ lib/screens/video_player_screen.dart | 3 ++- lib/services/plex_client.dart | 10 +++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 1b9515e2..41edfae2 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -109,12 +109,19 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt final allChannels = []; final seenChannels = {}; + appLogger.d('Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}'); + for (final serverInfo in liveTvServers) { try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; final channels = await client.getEpgChannels(lineup: serverInfo.lineup); + appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels'); + if (channels.isNotEmpty) { + final sample = channels.first; + appLogger.d('Sample channel: key=${sample.key} identifier=${sample.identifier} number=${sample.number} slug=${sample.slug}'); + } for (final channel in channels) { final dedupKey = '${serverInfo.serverId}:${channel.identifier ?? channel.key}'; if (seenChannels.add(dedupKey)) { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 55cb3083..71cd8a69 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -868,6 +868,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } final channel = channels[channelIndex]; final channelId = channel.identifier ?? channel.key; + appLogger.d('Tune: dvrKey=${widget.liveDvrKey} channelId=$channelId (identifier=${channel.identifier}, key=${channel.key})'); final client = widget.liveClient!; final result = await client.tuneChannel(widget.liveDvrKey!, channelId); if (result == null) throw Exception('Failed to tune channel'); @@ -1722,7 +1723,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final channel = channels[newIndex]; final channelId = channel.identifier ?? channel.key; - appLogger.d('Switching to channel: ${channel.displayName} ($channelId)'); + appLogger.d('Switching to channel: ${channel.displayName} ($channelId) (identifier=${channel.identifier}, key=${channel.key})'); setState(() => _hasFirstFrame.value = false); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index f8fec703..8d9c3de1 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1996,12 +1996,15 @@ class PlexClient { /// Get EPG channels for a specific lineup Future> getEpgChannels({String? lineup}) async { final queryParams = {}; - if (lineup != null) queryParams['lineup'] = Uri.decodeComponent(lineup); + 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'] is List && (container['Channel'] as List).isNotEmpty) { + appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); + } if (container != null && container['Channel'] != null) { return (container['Channel'] as List) .map((json) => LiveTvChannel.fromJson(json as Map) @@ -2063,6 +2066,7 @@ class PlexClient { } _epgProviders = results; + appLogger.d('Discovered ${results.length} EPG provider(s)'); if (results.isEmpty) { appLogger.w('No EPG providers found'); } @@ -2094,6 +2098,9 @@ class PlexClient { () => _dio.get(provider.gridEndpoint, queryParameters: queryParams), (response) { final container = _getMediaContainer(response); + if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { + appLogger.d('EPG grid sample from ${provider.identifier}: ${(container['Metadata'] as List).first}'); + } final programs = []; if (container != null && container['Metadata'] != null) { for (final item in container['Metadata'] as List) { @@ -2118,6 +2125,7 @@ class PlexClient { }, 'Failed to get EPG grid from ${provider.identifier}', ); + appLogger.d('EPG grid from ${provider.identifier}: ${programs.length} programs'); allPrograms.addAll(programs); } catch (e) { appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); From fd53acbb92fedae4210dae903754e7dc88d37b75 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:23:02 +0100 Subject: [PATCH 16/18] fix(tv): use channel key for tuning and EPG matching --- lib/models/livetv_channel.dart | 2 +- lib/screens/livetv/live_tv_screen.dart | 6 +----- lib/screens/livetv/tabs/guide_tab.dart | 3 +-- lib/screens/video_player_screen.dart | 10 ++++------ lib/utils/live_tv_player_navigation.dart | 9 ++++----- 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 880fa272..71bc39bb 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -40,7 +40,7 @@ class LiveTvChannel { 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?, + number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString(), hd: json['hd'] == true || json['hd'] == 1, lineup: json['lineup'] as String?, slug: json['slug'] as String?, diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 41edfae2..3ec5c8a8 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -118,12 +118,8 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt final channels = await client.getEpgChannels(lineup: serverInfo.lineup); appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels'); - if (channels.isNotEmpty) { - final sample = channels.first; - appLogger.d('Sample channel: key=${sample.key} identifier=${sample.identifier} number=${sample.number} slug=${sample.slug}'); - } for (final channel in channels) { - final dedupKey = '${serverInfo.serverId}:${channel.identifier ?? channel.key}'; + final dedupKey = '${serverInfo.serverId}:${channel.key}'; if (seenChannels.add(dedupKey)) { allChannels.add(channel); } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 3660ac1d..61279572 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -258,8 +258,7 @@ class GuideTabState extends State { } List _getProgramsForChannel(LiveTvChannel channel) { - final channelId = channel.identifier ?? channel.key; - return _programs.where((p) => p.channelIdentifier == channelId).toList() + return _programs.where((p) => p.channelIdentifier == channel.key).toList() ..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 71cd8a69..d6071653 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -867,10 +867,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin throw Exception('No channel to tune'); } final channel = channels[channelIndex]; - final channelId = channel.identifier ?? channel.key; - appLogger.d('Tune: dvrKey=${widget.liveDvrKey} channelId=$channelId (identifier=${channel.identifier}, key=${channel.key})'); + appLogger.d('Tune: dvrKey=${widget.liveDvrKey} channelKey=${channel.key}'); final client = widget.liveClient!; - final result = await client.tuneChannel(widget.liveDvrKey!, channelId); + final result = await client.tuneChannel(widget.liveDvrKey!, channel.key); if (result == null) throw Exception('Failed to tune channel'); streamUrl = '${client.config.baseUrl}${result.streamPath}' @@ -1722,8 +1721,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _sendLiveTimeline('stopped'); final channel = channels[newIndex]; - final channelId = channel.identifier ?? channel.key; - appLogger.d('Switching to channel: ${channel.displayName} ($channelId) (identifier=${channel.identifier}, key=${channel.key})'); + appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})'); setState(() => _hasFirstFrame.value = false); @@ -1739,7 +1737,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) return; - final result = await client.tuneChannel(serverInfo.dvrKey, channelId); + final result = await client.tuneChannel(serverInfo.dvrKey, channel.key); if (result == null || !mounted) return; final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token); diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index d93ad1a1..7b047115 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -21,14 +21,13 @@ Future navigateToLiveTv( required LiveTvChannel channel, List? channels, }) async { - final channelId = channel.identifier ?? channel.key; final navigator = Navigator.of(context); - appLogger.d('Navigating to live channel: ${channel.displayName} ($channelId)'); + appLogger.d('Navigating to live channel: ${channel.displayName} (${channel.key})'); final placeholder = PlexMetadata( - ratingKey: channelId, - key: channelId, + ratingKey: channel.key, + key: channel.key, type: 'clip', title: channel.displayName, ); @@ -42,7 +41,7 @@ Future navigateToLiveTv( liveStreamUrl: null, liveChannels: channels, liveCurrentChannelIndex: channels?.indexWhere( - (ch) => (ch.identifier ?? ch.key) == channelId, + (ch) => ch.key == channel.key, ), liveDvrKey: dvrKey, liveClient: client, From fa06f23b427f58bd0ea61987657f3742c97b4934 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:21:05 +0100 Subject: [PATCH 17/18] fix(tv): guide UX fixes --- lib/models/livetv_dvr.dart | 2 +- lib/screens/livetv/live_tv_screen.dart | 36 ++++- lib/screens/livetv/tabs/guide_tab.dart | 190 ++++++++++++++----------- 3 files changed, 140 insertions(+), 88 deletions(-) diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart index 594185c4..f087e0fb 100644 --- a/lib/models/livetv_dvr.dart +++ b/lib/models/livetv_dvr.dart @@ -79,7 +79,7 @@ class ChannelMapping { return ChannelMapping( channelKey: json['channelKey'] as String?, deviceIdentifier: json['deviceIdentifier'] as String?, - enabled: json['enabled'] == true || json['enabled'] == 1, + enabled: json['enabled'] == true || json['enabled'] == 1 || json['enabled'] == '1', lineupIdentifier: json['lineupIdentifier'] as String?, ); } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 3ec5c8a8..51a28af9 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -111,14 +111,46 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt appLogger.d('Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}'); + // Build a set of enabled channel keys per server from DVR mappings + final enabledKeysByServer = >{}; + final queriedServers = {}; + for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + final dvrs = await client.getDvrs(); + final enabledKeys = {}; + bool hasMappings = false; + for (final dvr in dvrs) { + if (dvr.channelMappings.isNotEmpty) { + hasMappings = true; + for (final m in dvr.channelMappings) { + if (m.enabled == true && m.channelKey != null) { + enabledKeys.add(m.channelKey!); + } + } + } + } + if (hasMappings) { + enabledKeysByServer[serverInfo.serverId] = enabledKeys; + } + } catch (e) { + appLogger.e('Failed to load DVR mappings for server ${serverInfo.serverId}', error: e); + } + } + for (final serverInfo in liveTvServers) { try { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels'); + final enabledKeys = enabledKeysByServer[serverInfo.serverId]; + appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)'); for (final channel in channels) { + // Skip disabled channels if DVR has mapping data + if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue; final dedupKey = '${serverInfo.serverId}:${channel.key}'; if (seenChannels.add(dedupKey)) { allChannels.add(channel); @@ -144,7 +176,7 @@ class _LiveTvScreenState extends State with SingleTickerProviderSt _isLoading = false; }); - if (allChannels.isNotEmpty) { + if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusCurrentTab(); }); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 61279572..08689f42 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -516,75 +516,120 @@ class GuideTabState extends State { return Column( children: [ _buildTimeNavigation(theme), - Row( - children: [ - SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), - Expanded( - child: SingleChildScrollView( - controller: _headerHorizontalController, - scrollDirection: Axis.horizontal, - physics: const ClampingScrollPhysics(), - child: SizedBox( - width: _totalGridWidth(), - height: _timeHeaderHeight, - child: _buildTimeHeader(theme), - ), - ), - ), - ], - ), Expanded( - child: Row( - children: [ - SizedBox( - width: _channelColumnWidth, - child: ListView.builder( - controller: _channelVerticalController, - itemCount: widget.channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) => - _buildChannelCell(widget.channels[index], theme, index: index), - ), - ), - Expanded( - child: NotificationListener( - onNotification: (notification) { - 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, - physics: const ClampingScrollPhysics(), - child: SizedBox( - width: _totalGridWidth(), - child: ListView.builder( - controller: _gridVerticalController, - itemCount: widget.channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) { - final channel = widget.channels[index]; - final programs = _getProgramsForChannel(channel); - return _buildProgramRow(channel, programs, theme, channelIndex: index); - }, + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, child) { + return Stack( + children: [ + child!, + _buildNowIndicatorOverlay(theme), + ], + ); + }, + child: Column( + children: [ + Row( + children: [ + SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + Expanded( + child: SingleChildScrollView( + controller: _headerHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), + ), ), ), + ], + ), + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: ListView.builder( + controller: _channelVerticalController, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => + _buildChannelCell(widget.channels[index], theme, index: index), + ), + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + 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, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + child: ListView.builder( + controller: _gridVerticalController, + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) { + final channel = widget.channels[index]; + final programs = _getProgramsForChannel(channel); + return _buildProgramRow(channel, programs, theme, channelIndex: index); + }, + ), + ), + ), + ), + ), + ], ), ), - ), - ], + ], + ), ), ), ], ); } + Widget _buildNowIndicatorOverlay(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 nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; + final scrollOffset = _gridHorizontalController.hasClients + ? _gridHorizontalController.offset + : 0.0; + final left = _channelColumnWidth + nowOffset - scrollOffset; + + // Hide when scrolled behind the channel column + if (left < _channelColumnWidth) return const SizedBox.shrink(); + + final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight; + + return Positioned( + left: left, + top: 0, + height: gridHeight, + child: IgnorePointer( + child: Container(width: 2, color: Colors.red), + ), + ); + } + String _dayLabel(DateTime day) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -851,29 +896,9 @@ class GuideTabState extends State { current = current.add(const Duration(minutes: _minutesPerSlot)); } - return Stack( - children: [ - Row(children: slots), - _buildNowIndicator(theme), - ], - ); + return Row(children: slots); } - 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), - ); - } // --------------------------------------------------------------------------- // Channel column @@ -994,12 +1019,7 @@ class GuideTabState extends State { bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), ), ), - child: Stack( - children: [ - ...blocks, - _buildNowIndicator(theme), - ], - ), + child: Stack(children: blocks), ); } From b3ff48feca56e4a179aa177f2a7780a5a5189c7f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 14 Feb 2026 03:33:58 +0100 Subject: [PATCH 18/18] fix(tv): live playback and timeline keepalive Disable MKV Cues seeking for live streams, fix timeline params. --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 33 ++++++++++++++++- .../plezy/exoplayer/ExoPlayerPlugin.kt | 3 +- lib/mpv/player/player.dart | 2 +- lib/mpv/player/player_android.dart | 3 +- lib/mpv/player/player_native.dart | 2 +- lib/screens/video_player_screen.dart | 36 +++++++++++++++---- lib/services/plex_client.dart | 7 +++- 7 files changed, 73 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 456c359c..fac7b8c1 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -39,8 +39,10 @@ import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView import io.github.peerless2012.ass.media.AssHandler @@ -662,13 +664,42 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Public API - fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean) { + fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false) { if (!isInitialized) return currentMediaUri = uri currentHeaders = headers externalSubtitles.clear() + if (isLive) { + // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells + // MatroskaExtractor to not seek for them, treating the stream as unseekable + // so data flows immediately without hanging. + val dataSourceFactory = if (!headers.isNullOrEmpty()) { + DefaultDataSource.Factory(activity, + androidx.media3.datasource.DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(headers)) + } else { + DefaultDataSource.Factory(activity) + } + + val extractorsFactory = androidx.media3.extractor.ExtractorsFactory { + arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)) + } + + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(MediaItem.fromUri(uri)) + + exoPlayer?.apply { + setMediaSource(mediaSource, startPositionMs) + prepare() + playWhenReady = autoPlay + } + + Log.d(TAG, "Opened live: $uri, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay") + return + } + val mediaItemBuilder = MediaItem.Builder() .setUri(uri) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 947f7d0f..44726539 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -175,6 +175,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val headers = call.argument>("headers") val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L val autoPlay = call.argument("autoPlay") ?: true + val isLive = call.argument("isLive") ?: false if (uri == null) { result.error("INVALID_ARGS", "Missing 'uri'", null) @@ -196,7 +197,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) } else { - playerCore?.open(uri, headers, startPositionMs, autoPlay) + playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive) } result.success(null) } ?: result.error("NO_ACTIVITY", "Activity not available", null) diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 9786a1a8..64f51521 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -61,7 +61,7 @@ abstract class Player { /// /// [media] - The media source to open. /// [play] - Whether to start playback immediately (default: true). - Future open(Media media, {bool play = true}); + Future open(Media media, {bool play = true, bool isLive = false}); /// Start or resume playback. Future play(); diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index f25fc605..07f5d7c4 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -62,7 +62,7 @@ class PlayerAndroid extends PlayerBase { // ============================================ @override - Future open(Media media, {bool play = true}) async { + Future open(Media media, {bool play = true, bool isLive = false}) async { checkDisposed(); await _ensureInitialized(); @@ -74,6 +74,7 @@ class PlayerAndroid extends PlayerBase { 'headers': media.headers, 'startPositionMs': media.start?.inMilliseconds ?? 0, 'autoPlay': play, + 'isLive': isLive, }); } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 3e63444e..7f3acd7b 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -96,7 +96,7 @@ class PlayerNative extends PlayerBase { } @override - Future open(Media media, {bool play = true}) async { + Future open(Media media, {bool play = true, bool isLive = false}) async { checkDisposed(); await _ensureInitialized(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d6071653..de7e3fce 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -139,6 +139,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin String? _liveSessionIdentifier; String? _liveSessionPath; Timer? _liveTimelineTimer; + DateTime? _livePlaybackStartTime; + String? _liveRatingKey; + int? _liveDurationMs; // Auto-play next episode Timer? _autoPlayTimer; @@ -877,9 +880,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin _liveSessionIdentifier = result.sessionIdentifier; _liveSessionPath = result.sessionPath; + _liveRatingKey = result.metadata.ratingKey; + _liveDurationMs = result.metadata.duration; } - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true); + _livePlaybackStartTime = DateTime.now(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); if (mounted) { setState(() { @@ -1676,15 +1682,27 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (client == null) return; try { - final position = player?.state.position ?? Duration.zero; - final duration = player?.state.duration ?? Duration.zero; + // Use the program ratingKey from tune metadata, not the channel key + final ratingKey = _liveRatingKey ?? widget.metadata.ratingKey; + + // playbackTime: wall-clock ms since playback started + final playbackTime = _livePlaybackStartTime != null + ? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds + : 0; + + // For live TV, player position/duration are unreliable (often 0). + // Use playbackTime as time, and program duration from tune metadata. + final time = playbackTime; + final duration = _liveDurationMs ?? 0; + await client.updateLiveTimeline( - ratingKey: widget.metadata.ratingKey, + ratingKey: ratingKey, sessionPath: sessionPath, sessionIdentifier: sessionId, state: state, - time: position.inMilliseconds, - duration: duration.inMilliseconds, + time: time, + duration: duration, + playbackTime: playbackTime, ); } catch (e) { appLogger.d('Live timeline update failed', error: e); @@ -1743,7 +1761,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token); await _setLiveStreamOptions(); - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + + _livePlaybackStartTime = DateTime.now(); + _liveRatingKey = result.metadata.ratingKey; + _liveDurationMs = result.metadata.duration; setState(() { _liveChannelIndex = newIndex; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8d9c3de1..b43006bb 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1139,8 +1139,9 @@ class PlexClient { required String state, required int time, required int duration, + required int playbackTime, }) async { - await _dio.post( + final response = await _dio.get( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, @@ -1149,9 +1150,13 @@ class PlexClient { 'hasMDE': '1', 'time': time, 'duration': duration, + 'playbackTime': playbackTime, 'X-Plex-Session-Identifier': sessionIdentifier, }, ); + if (response.statusCode != null && response.statusCode != 200) { + appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); + } } /// Remove item from Continue Watching (On Deck) without affecting watch status or progress