feat(tv): add What's On tab with API-driven hubs and show schedule

This commit is contained in:
edde746
2026-02-12 16:46:15 +01:00
parent fbb9982f44
commit ac1dae25b8
26 changed files with 2212 additions and 937 deletions
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -542,7 +542,6 @@
"tuneFailed": "채널 튜닝에 실패했습니다",
"loading": "채널 로딩 중...",
"nowPlaying": "현재 재생 중",
"whatsOnNow": "지금 방송 중",
"record": "녹화",
"recordSeries": "시리즈 녹화",
"cancelRecording": "녹화 취소",
@@ -566,7 +565,8 @@
"morning": "아침",
"daytime": "낮",
"evening": "저녁",
"lateNight": "심야 방송"
"lateNight": "심야 방송",
"whatsOn": "지금 방송 중"
},
"collections": {
"title": "컬렉션",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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
+20 -4
View File
@@ -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',
+4 -4
View File
@@ -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',
+20 -4
View File
@@ -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',
+20 -4
View File
@@ -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',
+20 -4
View File
@@ -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',
+20 -4
View File
@@ -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' => '컬렉션에서 제거됨',
+20 -4
View File
@@ -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',
+20 -4
View File
@@ -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',
+20 -4
View File
@@ -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' => '未找到播放列表',
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -542,7 +542,6 @@
"tuneFailed": "无法调谐频道",
"loading": "正在加载频道...",
"nowPlaying": "正在播放",
"whatsOnNow": "正在播出",
"record": "录制",
"recordSeries": "录制系列",
"cancelRecording": "取消录制",
@@ -566,7 +565,8 @@
"morning": "上午",
"daytime": "白天",
"evening": "晚上",
"lateNight": "深夜"
"lateNight": "深夜",
"whatsOn": "正在播出"
},
"downloads": {
"title": "下载",
+23
View File
@@ -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<LiveTvHubEntry> 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});
}
File diff suppressed because it is too large Load Diff
@@ -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<LiveTvChannel> channels;
const LiveTvShowScheduleScreen({
super.key,
required this.showTitle,
required this.serverId,
required this.channels,
});
@override
State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState();
}
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
List<LiveTvProgram> _programs = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSchedule();
}
Future<void> _loadSchedule() async {
final multiServer = context.read<MultiServerProvider>();
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<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers
.where((s) => s.serverId == channel.serverId)
.firstOrNull ??
multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return;
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) return;
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<MultiServerProvider>();
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,
),
),
],
],
),
),
);
}
}
+936
View File
@@ -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<LiveTvChannel> channels;
const GuideTab({super.key, required this.channels});
@override
State<GuideTab> createState() => _GuideTabState();
}
class _GuideTabState extends State<GuideTab> {
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<LiveTvProgram> _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<void> _loadPrograms() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allPrograms = <LiveTvProgram>[];
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<LiveTvProgram> _getProgramsForChannel(LiveTvChannel channel) {
final channelId = channel.identifier ?? channel.key;
return _programs.where((p) => p.channelIdentifier == channelId).toList()
..sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
}
double _totalGridWidth() {
final totalMinutes = _gridEnd.difference(_gridStart).inMinutes;
return (totalMinutes / _minutesPerSlot) * _slotWidth;
}
Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers
.where((s) => s.serverId == channel.serverId)
.firstOrNull ??
multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return;
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) return;
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<ScrollNotification>(
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 = <DateTime>[];
for (var i = 0; i < 8; i++) {
days.add(today.add(Duration(days: i)));
}
showMenu<Object>(
context: context,
position: _menuPosition(),
items: [
PopupMenuItem<String>(
value: 'now',
child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium),
),
...days.map((day) {
final isSelected = day == gridDay;
final label = _dayLabel(day);
return PopupMenuItem<DateTime>(
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<int>(
context: context,
position: _menuPosition(),
items: [
PopupMenuItem<int>(
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<int>(
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 = <Widget>[];
var current = _gridStart;
while (current.isBefore(_gridEnd)) {
final timeStr =
'${current.hour.toString().padLeft(2, '0')}:${current.minute.toString().padLeft(2, '0')}';
slots.add(
SizedBox(
width: _slotWidth,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
timeStr,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
);
current = current.add(const Duration(minutes: _minutesPerSlot));
}
return Stack(
children: [
Row(children: slots),
_buildNowIndicator(theme),
],
);
}
Widget _buildNowIndicator(ThemeData theme) {
final now = DateTime.now();
if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) {
return const SizedBox.shrink();
}
final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble();
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
return Positioned(
left: offset,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.red),
);
}
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme) {
final multiServer = context.read<MultiServerProvider>();
final client = multiServer.getClientForServer(channel.serverId ?? '');
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<LiveTvProgram> programs, ThemeData theme) {
if (programs.isEmpty) {
return Container(
height: _rowHeight,
decoration: BoxDecoration(
border: Border(
bottom:
BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
),
),
child: Center(
child: Text(
t.liveTv.noPrograms,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
);
}
final blocks = <Widget>[];
final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
for (final program in programs) {
final progStart =
(program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch);
final progEnd =
(program.endsAt ?? gridEndEpoch).clamp(gridStartEpoch, gridEndEpoch);
if (progEnd <= progStart) continue;
final startOffset = progStart - gridStartEpoch;
final duration = progEnd - progStart;
final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth;
final width = (duration / (_minutesPerSlot * 60)) * _slotWidth;
blocks.add(
Positioned(
left: left,
width: width.clamp(2.0, double.infinity),
top: 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<MultiServerProvider>();
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,
),
],
),
),
),
),
);
}
}
+469
View File
@@ -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<LiveTvChannel> channels;
const WhatsOnTab({super.key, required this.channels});
@override
State<WhatsOnTab> createState() => _WhatsOnTabState();
}
class _WhatsOnTabState extends State<WhatsOnTab> {
List<LiveTvHubResult> _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<void> _loadHubs() async {
if (!mounted) return;
setState(() => _isLoading = _hubs.isEmpty);
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allHubs = <LiveTvHubResult>[];
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<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
final serverInfo = multiServer.liveTvServers
.where((s) => s.serverId == channel.serverId)
.firstOrNull ??
multiServer.liveTvServers.firstOrNull;
if (serverInfo == null) return;
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) return;
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<MultiServerProvider>();
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<SettingsProvider>();
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,
),
),
],
),
),
),
);
}
}
+95 -1
View File
@@ -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<String?> _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<List<LiveTvHubResult>> 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 = <LiveTvHubResult>[];
for (final hubJson in container['Hub'] as List) {
try {
final metadataList = hubJson['Metadata'] as List?;
if (metadataList == null || metadataList.isEmpty) continue;
final entries = <LiveTvHubEntry>[];
for (final itemJson in metadataList) {
if (itemJson is! Map<String, dynamic>) 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';
+13 -2
View File
@@ -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