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] 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