diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 456c359c..fac7b8c1 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -39,8 +39,10 @@ import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView import io.github.peerless2012.ass.media.AssHandler @@ -662,13 +664,42 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Public API - fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean) { + fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false) { if (!isInitialized) return currentMediaUri = uri currentHeaders = headers externalSubtitles.clear() + if (isLive) { + // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells + // MatroskaExtractor to not seek for them, treating the stream as unseekable + // so data flows immediately without hanging. + val dataSourceFactory = if (!headers.isNullOrEmpty()) { + DefaultDataSource.Factory(activity, + androidx.media3.datasource.DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(headers)) + } else { + DefaultDataSource.Factory(activity) + } + + val extractorsFactory = androidx.media3.extractor.ExtractorsFactory { + arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)) + } + + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(MediaItem.fromUri(uri)) + + exoPlayer?.apply { + setMediaSource(mediaSource, startPositionMs) + prepare() + playWhenReady = autoPlay + } + + Log.d(TAG, "Opened live: $uri, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay") + return + } + val mediaItemBuilder = MediaItem.Builder() .setUri(uri) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 947f7d0f..44726539 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -175,6 +175,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val headers = call.argument>("headers") val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L val autoPlay = call.argument("autoPlay") ?: true + val isLive = call.argument("isLive") ?: false if (uri == null) { result.error("INVALID_ARGS", "Missing 'uri'", null) @@ -196,7 +197,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) } else { - playerCore?.open(uri, headers, startPositionMs, autoPlay) + playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive) } result.success(null) } ?: result.error("NO_ACTIVITY", "Activity not available", null) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 869f6225..9bc65931 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Mediatheken", - "downloads": "Downloads" + "downloads": "Downloads", + "liveTv": "Live-TV" + }, + "liveTv": { + "title": "Live-TV", + "channels": "Kanäle", + "guide": "Programmführer", + "recordings": "Aufnahmen", + "subscriptions": "Aufnahmeregeln", + "scheduled": "Geplant", + "noChannels": "Keine Kanäle verfügbar", + "noDvr": "Kein DVR auf einem Server konfiguriert", + "tuneFailed": "Kanal konnte nicht eingestellt werden", + "loading": "Kanäle werden geladen...", + "nowPlaying": "Läuft gerade", + "record": "Aufnehmen", + "recordSeries": "Serie aufnehmen", + "cancelRecording": "Aufnahme abbrechen", + "deleteSubscription": "Aufnahmeregel löschen", + "deleteSubscriptionConfirm": "Möchten Sie diese Aufnahmeregel wirklich löschen?", + "subscriptionDeleted": "Aufnahmeregel gelöscht", + "noPrograms": "Keine Programmdaten verfügbar", + "noRecordings": "Keine Aufnahmen geplant", + "noSubscriptions": "Keine Aufnahmeregeln", + "channelNumber": "Kanal ${number}", + "live": "LIVE", + "hd": "HD", + "premiere": "NEU", + "reloadGuide": "Programmführer neu laden", + "guideReloaded": "Programmdaten neu geladen", + "allChannels": "Alle Kanäle", + "now": "Jetzt", + "today": "Heute", + "midnight": "Mitternacht", + "overnight": "Nacht", + "morning": "Morgen", + "daytime": "Tagsüber", + "evening": "Abend", + "lateNight": "Spätnacht", + "whatsOn": "Jetzt im TV", + "watchChannel": "Kanal ansehen" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index b1a80f8c..5a6b1b93 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Libraries", - "downloads": "Downloads" + "downloads": "Downloads", + "liveTv": "Live TV" + }, + "liveTv": { + "title": "Live TV", + "channels": "Channels", + "guide": "Guide", + "recordings": "Recordings", + "subscriptions": "Subscriptions", + "scheduled": "Scheduled", + "noChannels": "No channels available", + "noDvr": "No DVR configured on any server", + "tuneFailed": "Failed to tune channel", + "loading": "Loading channels...", + "nowPlaying": "Now Playing", + "record": "Record", + "recordSeries": "Record Series", + "cancelRecording": "Cancel Recording", + "deleteSubscription": "Delete Recording Rule", + "deleteSubscriptionConfirm": "Are you sure you want to delete this recording rule?", + "subscriptionDeleted": "Recording rule deleted", + "noPrograms": "No program data available", + "noRecordings": "No recordings scheduled", + "noSubscriptions": "No recording rules", + "channelNumber": "Ch. ${number}", + "live": "LIVE", + "hd": "HD", + "premiere": "NEW", + "reloadGuide": "Reload Guide", + "guideReloaded": "Guide data reloaded", + "allChannels": "All Channels", + "now": "Now", + "today": "Today", + "midnight": "Midnight", + "overnight": "Overnight", + "morning": "Morning", + "daytime": "Daytime", + "evening": "Evening", + "lateNight": "Late Night", + "whatsOn": "What's On", + "watchChannel": "Watch Channel" }, "collections": { "title": "Collections", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 9ce4ff85..e27b5bb6 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Bibliotecas", - "downloads": "Descargas" + "downloads": "Descargas", + "liveTv": "TV en vivo" + }, + "liveTv": { + "title": "TV en vivo", + "channels": "Canales", + "guide": "Guía", + "recordings": "Grabaciones", + "subscriptions": "Reglas de grabación", + "scheduled": "Programadas", + "noChannels": "No hay canales disponibles", + "noDvr": "No hay DVR configurado en ningún servidor", + "tuneFailed": "Error al sintonizar el canal", + "loading": "Cargando canales...", + "nowPlaying": "Reproduciendo ahora", + "record": "Grabar", + "recordSeries": "Grabar serie", + "cancelRecording": "Cancelar grabación", + "deleteSubscription": "Eliminar regla de grabación", + "deleteSubscriptionConfirm": "¿Estás seguro de que quieres eliminar esta regla de grabación?", + "subscriptionDeleted": "Regla de grabación eliminada", + "noPrograms": "No hay datos de programación disponibles", + "noRecordings": "No hay grabaciones programadas", + "noSubscriptions": "No hay reglas de grabación", + "channelNumber": "Canal ${number}", + "live": "EN VIVO", + "hd": "HD", + "premiere": "NUEVO", + "reloadGuide": "Recargar guía", + "guideReloaded": "Datos de la guía recargados", + "allChannels": "Todos los canales", + "now": "Ahora", + "today": "Hoy", + "midnight": "Medianoche", + "overnight": "Madrugada", + "morning": "Mañana", + "daytime": "Día", + "evening": "Noche", + "lateNight": "Trasnoche", + "whatsOn": "En emisión", + "watchChannel": "Ver canal" }, "collections": { "title": "Colecciones", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index f7e07ba6..c1a70154 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Bibliothèques", - "downloads": "Téléchargements" + "downloads": "Téléchargements", + "liveTv": "TV en direct" + }, + "liveTv": { + "title": "TV en direct", + "channels": "Chaînes", + "guide": "Guide", + "recordings": "Enregistrements", + "subscriptions": "Règles d'enregistrement", + "scheduled": "Programmés", + "noChannels": "Aucune chaîne disponible", + "noDvr": "Aucun DVR configuré sur les serveurs", + "tuneFailed": "Impossible de syntoniser la chaîne", + "loading": "Chargement des chaînes...", + "nowPlaying": "En cours de lecture", + "record": "Enregistrer", + "recordSeries": "Enregistrer la série", + "cancelRecording": "Annuler l'enregistrement", + "deleteSubscription": "Supprimer la règle d'enregistrement", + "deleteSubscriptionConfirm": "Voulez-vous vraiment supprimer cette règle d'enregistrement ?", + "subscriptionDeleted": "Règle d'enregistrement supprimée", + "noPrograms": "Aucune donnée de programme disponible", + "noRecordings": "Aucun enregistrement programmé", + "noSubscriptions": "Aucune règle d'enregistrement", + "channelNumber": "Ch. ${number}", + "live": "EN DIRECT", + "hd": "HD", + "premiere": "NOUVEAU", + "reloadGuide": "Recharger le guide", + "guideReloaded": "Données du guide rechargées", + "allChannels": "Toutes les chaînes", + "now": "Maintenant", + "today": "Aujourd'hui", + "midnight": "Minuit", + "overnight": "Nuit", + "morning": "Matin", + "daytime": "Journée", + "evening": "Soirée", + "lateNight": "Nuit tardive", + "whatsOn": "En ce moment", + "watchChannel": "Regarder la chaîne" }, "collections": { "title": "Collections", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 9175fe71..cc4dd52e 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Librerie", - "downloads": "Download" + "downloads": "Download", + "liveTv": "TV in diretta" + }, + "liveTv": { + "title": "TV in diretta", + "channels": "Canali", + "guide": "Guida", + "recordings": "Registrazioni", + "subscriptions": "Regole di registrazione", + "scheduled": "Programmati", + "noChannels": "Nessun canale disponibile", + "noDvr": "Nessun DVR configurato su nessun server", + "tuneFailed": "Impossibile sintonizzare il canale", + "loading": "Caricamento canali...", + "nowPlaying": "In riproduzione", + "record": "Registra", + "recordSeries": "Registra serie", + "cancelRecording": "Annulla registrazione", + "deleteSubscription": "Elimina regola di registrazione", + "deleteSubscriptionConfirm": "Sei sicuro di voler eliminare questa regola di registrazione?", + "subscriptionDeleted": "Regola di registrazione eliminata", + "noPrograms": "Nessun dato di programma disponibile", + "noRecordings": "Nessuna registrazione programmata", + "noSubscriptions": "Nessuna regola di registrazione", + "channelNumber": "Canale ${number}", + "live": "IN DIRETTA", + "hd": "HD", + "premiere": "NUOVO", + "reloadGuide": "Ricarica guida", + "guideReloaded": "Dati della guida ricaricati", + "allChannels": "Tutti i canali", + "now": "Ora", + "today": "Oggi", + "midnight": "Mezzanotte", + "overnight": "Notte", + "morning": "Mattina", + "daytime": "Giorno", + "evening": "Sera", + "lateNight": "Notte tarda", + "whatsOn": "In onda ora", + "watchChannel": "Guarda canale" }, "downloads": { "title": "Download", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index c77a3e19..c5ad1f49 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "미디어 라이브러리", - "downloads": "다운로드" + "downloads": "다운로드", + "liveTv": "실시간 TV" + }, + "liveTv": { + "title": "실시간 TV", + "channels": "채널", + "guide": "편성표", + "recordings": "녹화", + "subscriptions": "녹화 규칙", + "scheduled": "예약됨", + "noChannels": "사용 가능한 채널이 없습니다", + "noDvr": "서버에 DVR이 구성되어 있지 않습니다", + "tuneFailed": "채널 튜닝에 실패했습니다", + "loading": "채널 로딩 중...", + "nowPlaying": "현재 재생 중", + "record": "녹화", + "recordSeries": "시리즈 녹화", + "cancelRecording": "녹화 취소", + "deleteSubscription": "녹화 규칙 삭제", + "deleteSubscriptionConfirm": "이 녹화 규칙을 삭제하시겠습니까?", + "subscriptionDeleted": "녹화 규칙이 삭제되었습니다", + "noPrograms": "프로그램 데이터가 없습니다", + "noRecordings": "예약된 녹화가 없습니다", + "noSubscriptions": "녹화 규칙이 없습니다", + "channelNumber": "채널 ${number}", + "live": "실시간", + "hd": "HD", + "premiere": "신규", + "reloadGuide": "편성표 새로고침", + "guideReloaded": "편성표 데이터가 새로고침되었습니다", + "allChannels": "전체 채널", + "now": "지금", + "today": "오늘", + "midnight": "자정", + "overnight": "심야", + "morning": "아침", + "daytime": "낮", + "evening": "저녁", + "lateNight": "심야 방송", + "whatsOn": "지금 방송 중", + "watchChannel": "채널 시청" }, "collections": { "title": "컬렉션", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f2cc57fb..6363e51e 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Bibliotheken", - "downloads": "Downloads" + "downloads": "Downloads", + "liveTv": "Live TV" + }, + "liveTv": { + "title": "Live TV", + "channels": "Zenders", + "guide": "Gids", + "recordings": "Opnames", + "subscriptions": "Opnameregels", + "scheduled": "Gepland", + "noChannels": "Geen zenders beschikbaar", + "noDvr": "Geen DVR geconfigureerd op een server", + "tuneFailed": "Kan zender niet afstemmen", + "loading": "Zenders laden...", + "nowPlaying": "Nu aan het afspelen", + "record": "Opnemen", + "recordSeries": "Serie opnemen", + "cancelRecording": "Opname annuleren", + "deleteSubscription": "Opnameregel verwijderen", + "deleteSubscriptionConfirm": "Weet je zeker dat je deze opnameregel wilt verwijderen?", + "subscriptionDeleted": "Opnameregel verwijderd", + "noPrograms": "Geen programmagegevens beschikbaar", + "noRecordings": "Geen opnames gepland", + "noSubscriptions": "Geen opnameregels", + "channelNumber": "Kanaal ${number}", + "live": "LIVE", + "hd": "HD", + "premiere": "NIEUW", + "reloadGuide": "Gids herladen", + "guideReloaded": "Gidsgegevens herladen", + "allChannels": "Alle zenders", + "now": "Nu", + "today": "Vandaag", + "midnight": "Middernacht", + "overnight": "Nacht", + "morning": "Ochtend", + "daytime": "Overdag", + "evening": "Avond", + "lateNight": "Late avond", + "whatsOn": "Nu op TV", + "watchChannel": "Kanaal bekijken" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 6c0845ba..cd0be2f8 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 6228 (692 per locale) +/// Strings: 6570 (730 per locale) /// -/// Built on 2026-02-14 at 20:37 UTC +/// Built on 2026-02-14 at 21:28 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 37e06390..fb04bcb2 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -65,6 +65,7 @@ class TranslationsDe with BaseTranslations implements T @override late final _TranslationsLogsDe logs = _TranslationsLogsDe._(_root); @override late final _TranslationsLicensesDe licenses = _TranslationsLicensesDe._(_root); @override late final _TranslationsNavigationDe navigation = _TranslationsNavigationDe._(_root); + @override late final _TranslationsLiveTvDe liveTv = _TranslationsLiveTvDe._(_root); @override late final _TranslationsDownloadsDe downloads = _TranslationsDownloadsDe._(_root); @override late final _TranslationsPlaylistsDe playlists = _TranslationsPlaylistsDe._(_root); @override late final _TranslationsCollectionsDe collections = _TranslationsCollectionsDe._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationDe implements TranslationsNavigationEn { // Translations @override String get libraries => 'Mediatheken'; @override String get downloads => 'Downloads'; + @override String get liveTv => 'Live-TV'; +} + +// Path: liveTv +class _TranslationsLiveTvDe implements TranslationsLiveTvEn { + _TranslationsLiveTvDe._(this._root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Live-TV'; + @override String get channels => 'Kanäle'; + @override String get guide => 'Programmführer'; + @override String get recordings => 'Aufnahmen'; + @override String get subscriptions => 'Aufnahmeregeln'; + @override String get scheduled => 'Geplant'; + @override String get noChannels => 'Keine Kanäle verfügbar'; + @override String get noDvr => 'Kein DVR auf einem Server konfiguriert'; + @override String get tuneFailed => 'Kanal konnte nicht eingestellt werden'; + @override String get loading => 'Kanäle werden geladen...'; + @override String get nowPlaying => 'Läuft gerade'; + @override String get record => 'Aufnehmen'; + @override String get recordSeries => 'Serie aufnehmen'; + @override String get cancelRecording => 'Aufnahme abbrechen'; + @override String get deleteSubscription => 'Aufnahmeregel löschen'; + @override String get deleteSubscriptionConfirm => 'Möchten Sie diese Aufnahmeregel wirklich löschen?'; + @override String get subscriptionDeleted => 'Aufnahmeregel gelöscht'; + @override String get noPrograms => 'Keine Programmdaten verfügbar'; + @override String get noRecordings => 'Keine Aufnahmen geplant'; + @override String get noSubscriptions => 'Keine Aufnahmeregeln'; + @override String channelNumber({required Object number}) => 'Kanal ${number}'; + @override String get live => 'LIVE'; + @override String get hd => 'HD'; + @override String get premiere => 'NEU'; + @override String get reloadGuide => 'Programmführer neu laden'; + @override String get guideReloaded => 'Programmdaten neu geladen'; + @override String get allChannels => 'Alle Kanäle'; + @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'; + @override String get watchChannel => 'Kanal ansehen'; } // Path: downloads @@ -1636,6 +1684,44 @@ extension on TranslationsDe { 'licenses.licensesCount' => ({required Object count}) => '${count} Lizenzen', 'navigation.libraries' => 'Mediatheken', 'navigation.downloads' => 'Downloads', + 'navigation.liveTv' => 'Live-TV', + 'liveTv.title' => 'Live-TV', + 'liveTv.channels' => 'Kanäle', + 'liveTv.guide' => 'Programmführer', + 'liveTv.recordings' => 'Aufnahmen', + 'liveTv.subscriptions' => 'Aufnahmeregeln', + 'liveTv.scheduled' => 'Geplant', + 'liveTv.noChannels' => 'Keine Kanäle verfügbar', + 'liveTv.noDvr' => 'Kein DVR auf einem Server konfiguriert', + 'liveTv.tuneFailed' => 'Kanal konnte nicht eingestellt werden', + 'liveTv.loading' => 'Kanäle werden geladen...', + 'liveTv.nowPlaying' => 'Läuft gerade', + 'liveTv.record' => 'Aufnehmen', + 'liveTv.recordSeries' => 'Serie aufnehmen', + 'liveTv.cancelRecording' => 'Aufnahme abbrechen', + 'liveTv.deleteSubscription' => 'Aufnahmeregel löschen', + 'liveTv.deleteSubscriptionConfirm' => 'Möchten Sie diese Aufnahmeregel wirklich löschen?', + 'liveTv.subscriptionDeleted' => 'Aufnahmeregel gelöscht', + 'liveTv.noPrograms' => 'Keine Programmdaten verfügbar', + 'liveTv.noRecordings' => 'Keine Aufnahmen geplant', + 'liveTv.noSubscriptions' => 'Keine Aufnahmeregeln', + 'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}', + 'liveTv.live' => 'LIVE', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NEU', + 'liveTv.reloadGuide' => 'Programmführer neu laden', + 'liveTv.guideReloaded' => 'Programmdaten neu geladen', + 'liveTv.allChannels' => 'Alle Kanäle', + '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', + 'liveTv.watchChannel' => 'Kanal ansehen', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', @@ -1643,6 +1729,8 @@ extension on TranslationsDe { 'downloads.noDownloads' => 'Noch keine Downloads', 'downloads.noDownloadsDescription' => 'Heruntergeladene Inhalte werden hier für die Offline-Wiedergabe angezeigt', 'downloads.downloadNow' => 'Herunterladen', + _ => null, + } ?? switch (path) { 'downloads.deleteDownload' => 'Download löschen', 'downloads.retryDownload' => 'Download wiederholen', 'downloads.downloadQueued' => 'Download in Warteschlange', @@ -1681,8 +1769,6 @@ extension on TranslationsDe { 'playlists.errorRemoving' => 'Konnte nicht aus der Wiedergabeliste entfernt werden', 'playlists.playlist' => 'Wiedergabeliste', 'collections.title' => 'Sammlungen', - _ => null, - } ?? switch (path) { 'collections.collection' => 'Sammlung', 'collections.empty' => 'Sammlung ist leer', 'collections.unknownLibrarySection' => 'Löschen nicht möglich: Unbekannte Bibliothekssektion', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index f668c94b..d274c3f9 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -68,6 +68,7 @@ class Translations with BaseTranslations { late final TranslationsLogsEn logs = TranslationsLogsEn._(_root); late final TranslationsLicensesEn licenses = TranslationsLicensesEn._(_root); late final TranslationsNavigationEn navigation = TranslationsNavigationEn._(_root); + late final TranslationsLiveTvEn liveTv = TranslationsLiveTvEn._(_root); late final TranslationsCollectionsEn collections = TranslationsCollectionsEn._(_root); late final TranslationsPlaylistsEn playlists = TranslationsPlaylistsEn._(_root); late final TranslationsWatchTogetherEn watchTogether = TranslationsWatchTogetherEn._(_root); @@ -1634,6 +1635,129 @@ class TranslationsNavigationEn { /// en: 'Downloads' String get downloads => 'Downloads'; + + /// en: 'Live TV' + String get liveTv => 'Live TV'; +} + +// Path: liveTv +class TranslationsLiveTvEn { + TranslationsLiveTvEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Live TV' + String get title => 'Live TV'; + + /// en: 'Channels' + String get channels => 'Channels'; + + /// en: 'Guide' + String get guide => 'Guide'; + + /// en: 'Recordings' + String get recordings => 'Recordings'; + + /// en: 'Subscriptions' + String get subscriptions => 'Subscriptions'; + + /// en: 'Scheduled' + String get scheduled => 'Scheduled'; + + /// en: 'No channels available' + String get noChannels => 'No channels available'; + + /// en: 'No DVR configured on any server' + String get noDvr => 'No DVR configured on any server'; + + /// en: 'Failed to tune channel' + String get tuneFailed => 'Failed to tune channel'; + + /// en: 'Loading channels...' + String get loading => 'Loading channels...'; + + /// en: 'Now Playing' + String get nowPlaying => 'Now Playing'; + + /// en: 'Record' + String get record => 'Record'; + + /// en: 'Record Series' + String get recordSeries => 'Record Series'; + + /// en: 'Cancel Recording' + String get cancelRecording => 'Cancel Recording'; + + /// en: 'Delete Recording Rule' + String get deleteSubscription => 'Delete Recording Rule'; + + /// en: 'Are you sure you want to delete this recording rule?' + String get deleteSubscriptionConfirm => 'Are you sure you want to delete this recording rule?'; + + /// en: 'Recording rule deleted' + String get subscriptionDeleted => 'Recording rule deleted'; + + /// en: 'No program data available' + String get noPrograms => 'No program data available'; + + /// en: 'No recordings scheduled' + String get noRecordings => 'No recordings scheduled'; + + /// en: 'No recording rules' + String get noSubscriptions => 'No recording rules'; + + /// en: 'Ch. ${number}' + String channelNumber({required Object number}) => 'Ch. ${number}'; + + /// en: 'LIVE' + String get live => 'LIVE'; + + /// en: 'HD' + String get hd => 'HD'; + + /// en: 'NEW' + String get premiere => 'NEW'; + + /// en: 'Reload Guide' + String get reloadGuide => 'Reload Guide'; + + /// en: 'Guide data reloaded' + String get guideReloaded => 'Guide data reloaded'; + + /// en: 'All Channels' + String get allChannels => 'All Channels'; + + /// en: 'Now' + String get now => 'Now'; + + /// en: 'Today' + String get today => 'Today'; + + /// en: 'Midnight' + String get midnight => 'Midnight'; + + /// en: 'Overnight' + String get overnight => 'Overnight'; + + /// en: 'Morning' + String get morning => 'Morning'; + + /// en: 'Daytime' + String get daytime => 'Daytime'; + + /// en: 'Evening' + String get evening => 'Evening'; + + /// en: 'Late Night' + String get lateNight => 'Late Night'; + + /// en: 'What's On' + String get whatsOn => 'What\'s On'; + + /// en: 'Watch Channel' + String get watchChannel => 'Watch Channel'; } // Path: collections @@ -3027,6 +3151,44 @@ extension on Translations { 'licenses.licensesCount' => ({required Object count}) => '${count} licenses', 'navigation.libraries' => 'Libraries', 'navigation.downloads' => 'Downloads', + 'navigation.liveTv' => 'Live TV', + 'liveTv.title' => 'Live TV', + 'liveTv.channels' => 'Channels', + 'liveTv.guide' => 'Guide', + 'liveTv.recordings' => 'Recordings', + 'liveTv.subscriptions' => 'Subscriptions', + 'liveTv.scheduled' => 'Scheduled', + 'liveTv.noChannels' => 'No channels available', + 'liveTv.noDvr' => 'No DVR configured on any server', + 'liveTv.tuneFailed' => 'Failed to tune channel', + 'liveTv.loading' => 'Loading channels...', + 'liveTv.nowPlaying' => 'Now Playing', + 'liveTv.record' => 'Record', + 'liveTv.recordSeries' => 'Record Series', + 'liveTv.cancelRecording' => 'Cancel Recording', + 'liveTv.deleteSubscription' => 'Delete Recording Rule', + 'liveTv.deleteSubscriptionConfirm' => 'Are you sure you want to delete this recording rule?', + 'liveTv.subscriptionDeleted' => 'Recording rule deleted', + 'liveTv.noPrograms' => 'No program data available', + 'liveTv.noRecordings' => 'No recordings scheduled', + 'liveTv.noSubscriptions' => 'No recording rules', + 'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}', + 'liveTv.live' => 'LIVE', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NEW', + 'liveTv.reloadGuide' => 'Reload Guide', + 'liveTv.guideReloaded' => 'Guide data reloaded', + 'liveTv.allChannels' => 'All Channels', + 'liveTv.now' => 'Now', + 'liveTv.today' => 'Today', + 'liveTv.midnight' => 'Midnight', + 'liveTv.overnight' => 'Overnight', + 'liveTv.morning' => 'Morning', + 'liveTv.daytime' => 'Daytime', + 'liveTv.evening' => 'Evening', + 'liveTv.lateNight' => 'Late Night', + 'liveTv.whatsOn' => 'What\'s On', + 'liveTv.watchChannel' => 'Watch Channel', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'Collection is empty', @@ -3034,6 +3196,8 @@ extension on Translations { 'collections.deleteCollection' => 'Delete Collection', 'collections.deleteConfirm' => ({required Object title}) => 'Are you sure you want to delete "${title}"? This action cannot be undone.', 'collections.deleted' => 'Collection deleted', + _ => null, + } ?? switch (path) { 'collections.deleteFailed' => 'Failed to delete collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Failed to delete collection: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => 'Failed to load collection items: ${error}', @@ -3072,8 +3236,6 @@ extension on Translations { 'playlists.errorCreating' => 'Failed to create playlist', 'playlists.errorDeleting' => 'Failed to delete playlist', 'playlists.errorLoading' => 'Failed to load playlists', - _ => null, - } ?? switch (path) { 'playlists.errorAdding' => 'Failed to add to playlist', 'playlists.errorReordering' => 'Failed to reorder playlist item', 'playlists.errorRemoving' => 'Failed to remove from playlist', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 8edc95f9..42e9b0f8 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -65,6 +65,7 @@ class TranslationsEs with BaseTranslations implements T @override late final _TranslationsLogsEs logs = _TranslationsLogsEs._(_root); @override late final _TranslationsLicensesEs licenses = _TranslationsLicensesEs._(_root); @override late final _TranslationsNavigationEs navigation = _TranslationsNavigationEs._(_root); + @override late final _TranslationsLiveTvEs liveTv = _TranslationsLiveTvEs._(_root); @override late final _TranslationsCollectionsEs collections = _TranslationsCollectionsEs._(_root); @override late final _TranslationsPlaylistsEs playlists = _TranslationsPlaylistsEs._(_root); @override late final _TranslationsWatchTogetherEs watchTogether = _TranslationsWatchTogetherEs._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationEs implements TranslationsNavigationEn { // Translations @override String get libraries => 'Bibliotecas'; @override String get downloads => 'Descargas'; + @override String get liveTv => 'TV en vivo'; +} + +// Path: liveTv +class _TranslationsLiveTvEs implements TranslationsLiveTvEn { + _TranslationsLiveTvEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'TV en vivo'; + @override String get channels => 'Canales'; + @override String get guide => 'Guía'; + @override String get recordings => 'Grabaciones'; + @override String get subscriptions => 'Reglas de grabación'; + @override String get scheduled => 'Programadas'; + @override String get noChannels => 'No hay canales disponibles'; + @override String get noDvr => 'No hay DVR configurado en ningún servidor'; + @override String get tuneFailed => 'Error al sintonizar el canal'; + @override String get loading => 'Cargando canales...'; + @override String get nowPlaying => 'Reproduciendo ahora'; + @override String get record => 'Grabar'; + @override String get recordSeries => 'Grabar serie'; + @override String get cancelRecording => 'Cancelar grabación'; + @override String get deleteSubscription => 'Eliminar regla de grabación'; + @override String get deleteSubscriptionConfirm => '¿Estás seguro de que quieres eliminar esta regla de grabación?'; + @override String get subscriptionDeleted => 'Regla de grabación eliminada'; + @override String get noPrograms => 'No hay datos de programación disponibles'; + @override String get noRecordings => 'No hay grabaciones programadas'; + @override String get noSubscriptions => 'No hay reglas de grabación'; + @override String channelNumber({required Object number}) => 'Canal ${number}'; + @override String get live => 'EN VIVO'; + @override String get hd => 'HD'; + @override String get premiere => 'NUEVO'; + @override String get reloadGuide => 'Recargar guía'; + @override String get guideReloaded => 'Datos de la guía recargados'; + @override String get allChannels => 'Todos los canales'; + @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'; + @override String get watchChannel => 'Ver canal'; } // Path: collections @@ -1636,6 +1684,44 @@ extension on TranslationsEs { 'licenses.licensesCount' => ({required Object count}) => '${count} licencias', 'navigation.libraries' => 'Bibliotecas', 'navigation.downloads' => 'Descargas', + 'navigation.liveTv' => 'TV en vivo', + 'liveTv.title' => 'TV en vivo', + 'liveTv.channels' => 'Canales', + 'liveTv.guide' => 'Guía', + 'liveTv.recordings' => 'Grabaciones', + 'liveTv.subscriptions' => 'Reglas de grabación', + 'liveTv.scheduled' => 'Programadas', + 'liveTv.noChannels' => 'No hay canales disponibles', + 'liveTv.noDvr' => 'No hay DVR configurado en ningún servidor', + 'liveTv.tuneFailed' => 'Error al sintonizar el canal', + 'liveTv.loading' => 'Cargando canales...', + 'liveTv.nowPlaying' => 'Reproduciendo ahora', + 'liveTv.record' => 'Grabar', + 'liveTv.recordSeries' => 'Grabar serie', + 'liveTv.cancelRecording' => 'Cancelar grabación', + 'liveTv.deleteSubscription' => 'Eliminar regla de grabación', + 'liveTv.deleteSubscriptionConfirm' => '¿Estás seguro de que quieres eliminar esta regla de grabación?', + 'liveTv.subscriptionDeleted' => 'Regla de grabación eliminada', + 'liveTv.noPrograms' => 'No hay datos de programación disponibles', + 'liveTv.noRecordings' => 'No hay grabaciones programadas', + 'liveTv.noSubscriptions' => 'No hay reglas de grabación', + 'liveTv.channelNumber' => ({required Object number}) => 'Canal ${number}', + 'liveTv.live' => 'EN VIVO', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NUEVO', + 'liveTv.reloadGuide' => 'Recargar guía', + 'liveTv.guideReloaded' => 'Datos de la guía recargados', + 'liveTv.allChannels' => 'Todos los canales', + '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', + 'liveTv.watchChannel' => 'Ver canal', 'collections.title' => 'Colecciones', 'collections.collection' => 'Colección', 'collections.empty' => 'La colección está vacía', @@ -1643,6 +1729,8 @@ extension on TranslationsEs { 'collections.deleteCollection' => 'Eliminar Colección', 'collections.deleteConfirm' => ({required Object title}) => '¿Estás seguro de que quieres eliminar "${title}"? Esta acción no se puede deshacer.', 'collections.deleted' => 'Colección eliminada', + _ => null, + } ?? switch (path) { 'collections.deleteFailed' => 'Error al eliminar la colección', 'collections.deleteFailedWithError' => ({required Object error}) => 'Error al eliminar la colección: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => 'Error al cargar los elementos de la colección: ${error}', @@ -1681,8 +1769,6 @@ extension on TranslationsEs { 'playlists.errorCreating' => 'Error al crear la lista', 'playlists.errorDeleting' => 'Error al eliminar la lista', 'playlists.errorLoading' => 'Error al cargar las listas', - _ => null, - } ?? switch (path) { 'playlists.errorAdding' => 'Error al añadir a la lista', 'playlists.errorReordering' => 'Error al reordenar los elementos de la lista', 'playlists.errorRemoving' => 'Error al eliminar de la lista', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 00b556b3..5c49a61c 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -65,6 +65,7 @@ class TranslationsFr with BaseTranslations implements T @override late final _TranslationsLogsFr logs = _TranslationsLogsFr._(_root); @override late final _TranslationsLicensesFr licenses = _TranslationsLicensesFr._(_root); @override late final _TranslationsNavigationFr navigation = _TranslationsNavigationFr._(_root); + @override late final _TranslationsLiveTvFr liveTv = _TranslationsLiveTvFr._(_root); @override late final _TranslationsCollectionsFr collections = _TranslationsCollectionsFr._(_root); @override late final _TranslationsPlaylistsFr playlists = _TranslationsPlaylistsFr._(_root); @override late final _TranslationsWatchTogetherFr watchTogether = _TranslationsWatchTogetherFr._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationFr implements TranslationsNavigationEn { // Translations @override String get libraries => 'Bibliothèques'; @override String get downloads => 'Téléchargements'; + @override String get liveTv => 'TV en direct'; +} + +// Path: liveTv +class _TranslationsLiveTvFr implements TranslationsLiveTvEn { + _TranslationsLiveTvFr._(this._root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'TV en direct'; + @override String get channels => 'Chaînes'; + @override String get guide => 'Guide'; + @override String get recordings => 'Enregistrements'; + @override String get subscriptions => 'Règles d\'enregistrement'; + @override String get scheduled => 'Programmés'; + @override String get noChannels => 'Aucune chaîne disponible'; + @override String get noDvr => 'Aucun DVR configuré sur les serveurs'; + @override String get tuneFailed => 'Impossible de syntoniser la chaîne'; + @override String get loading => 'Chargement des chaînes...'; + @override String get nowPlaying => 'En cours de lecture'; + @override String get record => 'Enregistrer'; + @override String get recordSeries => 'Enregistrer la série'; + @override String get cancelRecording => 'Annuler l\'enregistrement'; + @override String get deleteSubscription => 'Supprimer la règle d\'enregistrement'; + @override String get deleteSubscriptionConfirm => 'Voulez-vous vraiment supprimer cette règle d\'enregistrement ?'; + @override String get subscriptionDeleted => 'Règle d\'enregistrement supprimée'; + @override String get noPrograms => 'Aucune donnée de programme disponible'; + @override String get noRecordings => 'Aucun enregistrement programmé'; + @override String get noSubscriptions => 'Aucune règle d\'enregistrement'; + @override String channelNumber({required Object number}) => 'Ch. ${number}'; + @override String get live => 'EN DIRECT'; + @override String get hd => 'HD'; + @override String get premiere => 'NOUVEAU'; + @override String get reloadGuide => 'Recharger le guide'; + @override String get guideReloaded => 'Données du guide rechargées'; + @override String get allChannels => 'Toutes les chaînes'; + @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'; + @override String get watchChannel => 'Regarder la chaîne'; } // Path: collections @@ -1636,6 +1684,44 @@ extension on TranslationsFr { 'licenses.licensesCount' => ({required Object count}) => '${count} licences', 'navigation.libraries' => 'Bibliothèques', 'navigation.downloads' => 'Téléchargements', + 'navigation.liveTv' => 'TV en direct', + 'liveTv.title' => 'TV en direct', + 'liveTv.channels' => 'Chaînes', + 'liveTv.guide' => 'Guide', + 'liveTv.recordings' => 'Enregistrements', + 'liveTv.subscriptions' => 'Règles d\'enregistrement', + 'liveTv.scheduled' => 'Programmés', + 'liveTv.noChannels' => 'Aucune chaîne disponible', + 'liveTv.noDvr' => 'Aucun DVR configuré sur les serveurs', + 'liveTv.tuneFailed' => 'Impossible de syntoniser la chaîne', + 'liveTv.loading' => 'Chargement des chaînes...', + 'liveTv.nowPlaying' => 'En cours de lecture', + 'liveTv.record' => 'Enregistrer', + 'liveTv.recordSeries' => 'Enregistrer la série', + 'liveTv.cancelRecording' => 'Annuler l\'enregistrement', + 'liveTv.deleteSubscription' => 'Supprimer la règle d\'enregistrement', + 'liveTv.deleteSubscriptionConfirm' => 'Voulez-vous vraiment supprimer cette règle d\'enregistrement ?', + 'liveTv.subscriptionDeleted' => 'Règle d\'enregistrement supprimée', + 'liveTv.noPrograms' => 'Aucune donnée de programme disponible', + 'liveTv.noRecordings' => 'Aucun enregistrement programmé', + 'liveTv.noSubscriptions' => 'Aucune règle d\'enregistrement', + 'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}', + 'liveTv.live' => 'EN DIRECT', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NOUVEAU', + 'liveTv.reloadGuide' => 'Recharger le guide', + 'liveTv.guideReloaded' => 'Données du guide rechargées', + 'liveTv.allChannels' => 'Toutes les chaînes', + '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', + 'liveTv.watchChannel' => 'Regarder la chaîne', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'La collection est vide', @@ -1643,6 +1729,8 @@ extension on TranslationsFr { 'collections.deleteCollection' => 'Supprimer la collection', 'collections.deleteConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir supprimer "${title}" ? Cette action ne peut pas être annulée.', 'collections.deleted' => 'Collection supprimée', + _ => null, + } ?? switch (path) { 'collections.deleteFailed' => 'Échec de la suppression de la collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Échec de la suppression de la collection: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => 'Échec du chargement des éléments de la collection: ${error}', @@ -1681,8 +1769,6 @@ extension on TranslationsFr { 'playlists.errorCreating' => 'Échec de la création de playlist', 'playlists.errorDeleting' => 'Échec de suppression de playlist', 'playlists.errorLoading' => 'Échec de chargement de playlists', - _ => null, - } ?? switch (path) { 'playlists.errorAdding' => 'Échec d\'ajout dans la playlist', 'playlists.errorReordering' => 'Échec de réordonnacement d\'élément de playlist', 'playlists.errorRemoving' => 'Échec de suppression depuis la playlist', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 1a926403..3cbaf80e 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -65,6 +65,7 @@ class TranslationsIt with BaseTranslations implements T @override late final _TranslationsLogsIt logs = _TranslationsLogsIt._(_root); @override late final _TranslationsLicensesIt licenses = _TranslationsLicensesIt._(_root); @override late final _TranslationsNavigationIt navigation = _TranslationsNavigationIt._(_root); + @override late final _TranslationsLiveTvIt liveTv = _TranslationsLiveTvIt._(_root); @override late final _TranslationsDownloadsIt downloads = _TranslationsDownloadsIt._(_root); @override late final _TranslationsPlaylistsIt playlists = _TranslationsPlaylistsIt._(_root); @override late final _TranslationsCollectionsIt collections = _TranslationsCollectionsIt._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationIt implements TranslationsNavigationEn { // Translations @override String get libraries => 'Librerie'; @override String get downloads => 'Download'; + @override String get liveTv => 'TV in diretta'; +} + +// Path: liveTv +class _TranslationsLiveTvIt implements TranslationsLiveTvEn { + _TranslationsLiveTvIt._(this._root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'TV in diretta'; + @override String get channels => 'Canali'; + @override String get guide => 'Guida'; + @override String get recordings => 'Registrazioni'; + @override String get subscriptions => 'Regole di registrazione'; + @override String get scheduled => 'Programmati'; + @override String get noChannels => 'Nessun canale disponibile'; + @override String get noDvr => 'Nessun DVR configurato su nessun server'; + @override String get tuneFailed => 'Impossibile sintonizzare il canale'; + @override String get loading => 'Caricamento canali...'; + @override String get nowPlaying => 'In riproduzione'; + @override String get record => 'Registra'; + @override String get recordSeries => 'Registra serie'; + @override String get cancelRecording => 'Annulla registrazione'; + @override String get deleteSubscription => 'Elimina regola di registrazione'; + @override String get deleteSubscriptionConfirm => 'Sei sicuro di voler eliminare questa regola di registrazione?'; + @override String get subscriptionDeleted => 'Regola di registrazione eliminata'; + @override String get noPrograms => 'Nessun dato di programma disponibile'; + @override String get noRecordings => 'Nessuna registrazione programmata'; + @override String get noSubscriptions => 'Nessuna regola di registrazione'; + @override String channelNumber({required Object number}) => 'Canale ${number}'; + @override String get live => 'IN DIRETTA'; + @override String get hd => 'HD'; + @override String get premiere => 'NUOVO'; + @override String get reloadGuide => 'Ricarica guida'; + @override String get guideReloaded => 'Dati della guida ricaricati'; + @override String get allChannels => 'Tutti i canali'; + @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'; + @override String get watchChannel => 'Guarda canale'; } // Path: downloads @@ -1636,6 +1684,44 @@ extension on TranslationsIt { 'licenses.licensesCount' => ({required Object count}) => '${count} licenze', 'navigation.libraries' => 'Librerie', 'navigation.downloads' => 'Download', + 'navigation.liveTv' => 'TV in diretta', + 'liveTv.title' => 'TV in diretta', + 'liveTv.channels' => 'Canali', + 'liveTv.guide' => 'Guida', + 'liveTv.recordings' => 'Registrazioni', + 'liveTv.subscriptions' => 'Regole di registrazione', + 'liveTv.scheduled' => 'Programmati', + 'liveTv.noChannels' => 'Nessun canale disponibile', + 'liveTv.noDvr' => 'Nessun DVR configurato su nessun server', + 'liveTv.tuneFailed' => 'Impossibile sintonizzare il canale', + 'liveTv.loading' => 'Caricamento canali...', + 'liveTv.nowPlaying' => 'In riproduzione', + 'liveTv.record' => 'Registra', + 'liveTv.recordSeries' => 'Registra serie', + 'liveTv.cancelRecording' => 'Annulla registrazione', + 'liveTv.deleteSubscription' => 'Elimina regola di registrazione', + 'liveTv.deleteSubscriptionConfirm' => 'Sei sicuro di voler eliminare questa regola di registrazione?', + 'liveTv.subscriptionDeleted' => 'Regola di registrazione eliminata', + 'liveTv.noPrograms' => 'Nessun dato di programma disponibile', + 'liveTv.noRecordings' => 'Nessuna registrazione programmata', + 'liveTv.noSubscriptions' => 'Nessuna regola di registrazione', + 'liveTv.channelNumber' => ({required Object number}) => 'Canale ${number}', + 'liveTv.live' => 'IN DIRETTA', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NUOVO', + 'liveTv.reloadGuide' => 'Ricarica guida', + 'liveTv.guideReloaded' => 'Dati della guida ricaricati', + 'liveTv.allChannels' => 'Tutti i canali', + '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', + 'liveTv.watchChannel' => 'Guarda canale', 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', @@ -1643,6 +1729,8 @@ extension on TranslationsIt { 'downloads.noDownloads' => 'Nessun download', 'downloads.noDownloadsDescription' => 'I contenuti scaricati appariranno qui per la visualizzazione offline', 'downloads.downloadNow' => 'Scarica', + _ => null, + } ?? switch (path) { 'downloads.deleteDownload' => 'Elimina download', 'downloads.retryDownload' => 'Riprova download', 'downloads.downloadQueued' => 'Download in coda', @@ -1681,8 +1769,6 @@ extension on TranslationsIt { 'playlists.errorRemoving' => 'Errore durante la rimozione dalla playlist', 'playlists.playlist' => 'Playlist', 'collections.title' => 'Raccolte', - _ => null, - } ?? switch (path) { 'collections.collection' => 'Raccolta', 'collections.empty' => 'La raccolta è vuota', 'collections.unknownLibrarySection' => 'Impossibile eliminare: sezione libreria sconosciuta', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 50d239b2..270e9e14 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -65,6 +65,7 @@ class TranslationsKo with BaseTranslations implements T @override late final _TranslationsLogsKo logs = _TranslationsLogsKo._(_root); @override late final _TranslationsLicensesKo licenses = _TranslationsLicensesKo._(_root); @override late final _TranslationsNavigationKo navigation = _TranslationsNavigationKo._(_root); + @override late final _TranslationsLiveTvKo liveTv = _TranslationsLiveTvKo._(_root); @override late final _TranslationsCollectionsKo collections = _TranslationsCollectionsKo._(_root); @override late final _TranslationsPlaylistsKo playlists = _TranslationsPlaylistsKo._(_root); @override late final _TranslationsWatchTogetherKo watchTogether = _TranslationsWatchTogetherKo._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationKo implements TranslationsNavigationEn { // Translations @override String get libraries => '미디어 라이브러리'; @override String get downloads => '다운로드'; + @override String get liveTv => '실시간 TV'; +} + +// Path: liveTv +class _TranslationsLiveTvKo implements TranslationsLiveTvEn { + _TranslationsLiveTvKo._(this._root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get title => '실시간 TV'; + @override String get channels => '채널'; + @override String get guide => '편성표'; + @override String get recordings => '녹화'; + @override String get subscriptions => '녹화 규칙'; + @override String get scheduled => '예약됨'; + @override String get noChannels => '사용 가능한 채널이 없습니다'; + @override String get noDvr => '서버에 DVR이 구성되어 있지 않습니다'; + @override String get tuneFailed => '채널 튜닝에 실패했습니다'; + @override String get loading => '채널 로딩 중...'; + @override String get nowPlaying => '현재 재생 중'; + @override String get record => '녹화'; + @override String get recordSeries => '시리즈 녹화'; + @override String get cancelRecording => '녹화 취소'; + @override String get deleteSubscription => '녹화 규칙 삭제'; + @override String get deleteSubscriptionConfirm => '이 녹화 규칙을 삭제하시겠습니까?'; + @override String get subscriptionDeleted => '녹화 규칙이 삭제되었습니다'; + @override String get noPrograms => '프로그램 데이터가 없습니다'; + @override String get noRecordings => '예약된 녹화가 없습니다'; + @override String get noSubscriptions => '녹화 규칙이 없습니다'; + @override String channelNumber({required Object number}) => '채널 ${number}'; + @override String get live => '실시간'; + @override String get hd => 'HD'; + @override String get premiere => '신규'; + @override String get reloadGuide => '편성표 새로고침'; + @override String get guideReloaded => '편성표 데이터가 새로고침되었습니다'; + @override String get allChannels => '전체 채널'; + @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 => '지금 방송 중'; + @override String get watchChannel => '채널 시청'; } // Path: collections @@ -1636,6 +1684,44 @@ extension on TranslationsKo { 'licenses.licensesCount' => ({required Object count}) => '${count} 개의 라이선스', 'navigation.libraries' => '미디어 라이브러리', 'navigation.downloads' => '다운로드', + 'navigation.liveTv' => '실시간 TV', + 'liveTv.title' => '실시간 TV', + 'liveTv.channels' => '채널', + 'liveTv.guide' => '편성표', + 'liveTv.recordings' => '녹화', + 'liveTv.subscriptions' => '녹화 규칙', + 'liveTv.scheduled' => '예약됨', + 'liveTv.noChannels' => '사용 가능한 채널이 없습니다', + 'liveTv.noDvr' => '서버에 DVR이 구성되어 있지 않습니다', + 'liveTv.tuneFailed' => '채널 튜닝에 실패했습니다', + 'liveTv.loading' => '채널 로딩 중...', + 'liveTv.nowPlaying' => '현재 재생 중', + 'liveTv.record' => '녹화', + 'liveTv.recordSeries' => '시리즈 녹화', + 'liveTv.cancelRecording' => '녹화 취소', + 'liveTv.deleteSubscription' => '녹화 규칙 삭제', + 'liveTv.deleteSubscriptionConfirm' => '이 녹화 규칙을 삭제하시겠습니까?', + 'liveTv.subscriptionDeleted' => '녹화 규칙이 삭제되었습니다', + 'liveTv.noPrograms' => '프로그램 데이터가 없습니다', + 'liveTv.noRecordings' => '예약된 녹화가 없습니다', + 'liveTv.noSubscriptions' => '녹화 규칙이 없습니다', + 'liveTv.channelNumber' => ({required Object number}) => '채널 ${number}', + 'liveTv.live' => '실시간', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => '신규', + 'liveTv.reloadGuide' => '편성표 새로고침', + 'liveTv.guideReloaded' => '편성표 데이터가 새로고침되었습니다', + 'liveTv.allChannels' => '전체 채널', + 'liveTv.now' => '지금', + 'liveTv.today' => '오늘', + 'liveTv.midnight' => '자정', + 'liveTv.overnight' => '심야', + 'liveTv.morning' => '아침', + 'liveTv.daytime' => '낮', + 'liveTv.evening' => '저녁', + 'liveTv.lateNight' => '심야 방송', + 'liveTv.whatsOn' => '지금 방송 중', + 'liveTv.watchChannel' => '채널 시청', 'collections.title' => '컬렉션', 'collections.collection' => '컬렉션', 'collections.empty' => '컬렉션이 비어 있습니다', @@ -1643,6 +1729,8 @@ extension on TranslationsKo { 'collections.deleteCollection' => '컬렉션 삭제', 'collections.deleteConfirm' => ({required Object title}) => '"${title}"을(를) 삭제 하시겠습니까? 이 작업은 되돌릴 수 없습니다.', 'collections.deleted' => '컬렉션 삭제됨', + _ => null, + } ?? switch (path) { 'collections.deleteFailed' => '컬렉션 삭제 실패', 'collections.deleteFailedWithError' => ({required Object error}) => '컬렉션 삭제 실패: ${error}', 'collections.failedToLoadItems' => ({required Object error}) => '컬렉션 항목 로드 실패: ${error}', @@ -1681,8 +1769,6 @@ extension on TranslationsKo { 'playlists.errorCreating' => '재생 목록 생성 실패', 'playlists.errorDeleting' => '재생 목록 삭제 실패', 'playlists.errorLoading' => '재생 목록 로드 실패', - _ => null, - } ?? switch (path) { 'playlists.errorAdding' => '재생 목록에 추가 실패', 'playlists.errorReordering' => '재생 목록 항목 재정렬 실패', 'playlists.errorRemoving' => '재생 목록에서 제거 실패', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 89451ef9..ebe16553 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -65,6 +65,7 @@ class TranslationsNl with BaseTranslations implements T @override late final _TranslationsLogsNl logs = _TranslationsLogsNl._(_root); @override late final _TranslationsLicensesNl licenses = _TranslationsLicensesNl._(_root); @override late final _TranslationsNavigationNl navigation = _TranslationsNavigationNl._(_root); + @override late final _TranslationsLiveTvNl liveTv = _TranslationsLiveTvNl._(_root); @override late final _TranslationsDownloadsNl downloads = _TranslationsDownloadsNl._(_root); @override late final _TranslationsPlaylistsNl playlists = _TranslationsPlaylistsNl._(_root); @override late final _TranslationsCollectionsNl collections = _TranslationsCollectionsNl._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationNl implements TranslationsNavigationEn { // Translations @override String get libraries => 'Bibliotheken'; @override String get downloads => 'Downloads'; + @override String get liveTv => 'Live TV'; +} + +// Path: liveTv +class _TranslationsLiveTvNl implements TranslationsLiveTvEn { + _TranslationsLiveTvNl._(this._root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Live TV'; + @override String get channels => 'Zenders'; + @override String get guide => 'Gids'; + @override String get recordings => 'Opnames'; + @override String get subscriptions => 'Opnameregels'; + @override String get scheduled => 'Gepland'; + @override String get noChannels => 'Geen zenders beschikbaar'; + @override String get noDvr => 'Geen DVR geconfigureerd op een server'; + @override String get tuneFailed => 'Kan zender niet afstemmen'; + @override String get loading => 'Zenders laden...'; + @override String get nowPlaying => 'Nu aan het afspelen'; + @override String get record => 'Opnemen'; + @override String get recordSeries => 'Serie opnemen'; + @override String get cancelRecording => 'Opname annuleren'; + @override String get deleteSubscription => 'Opnameregel verwijderen'; + @override String get deleteSubscriptionConfirm => 'Weet je zeker dat je deze opnameregel wilt verwijderen?'; + @override String get subscriptionDeleted => 'Opnameregel verwijderd'; + @override String get noPrograms => 'Geen programmagegevens beschikbaar'; + @override String get noRecordings => 'Geen opnames gepland'; + @override String get noSubscriptions => 'Geen opnameregels'; + @override String channelNumber({required Object number}) => 'Kanaal ${number}'; + @override String get live => 'LIVE'; + @override String get hd => 'HD'; + @override String get premiere => 'NIEUW'; + @override String get reloadGuide => 'Gids herladen'; + @override String get guideReloaded => 'Gidsgegevens herladen'; + @override String get allChannels => 'Alle zenders'; + @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'; + @override String get watchChannel => 'Kanaal bekijken'; } // Path: downloads @@ -1636,6 +1684,44 @@ extension on TranslationsNl { 'licenses.licensesCount' => ({required Object count}) => '${count} licenties', 'navigation.libraries' => 'Bibliotheken', 'navigation.downloads' => 'Downloads', + 'navigation.liveTv' => 'Live TV', + 'liveTv.title' => 'Live TV', + 'liveTv.channels' => 'Zenders', + 'liveTv.guide' => 'Gids', + 'liveTv.recordings' => 'Opnames', + 'liveTv.subscriptions' => 'Opnameregels', + 'liveTv.scheduled' => 'Gepland', + 'liveTv.noChannels' => 'Geen zenders beschikbaar', + 'liveTv.noDvr' => 'Geen DVR geconfigureerd op een server', + 'liveTv.tuneFailed' => 'Kan zender niet afstemmen', + 'liveTv.loading' => 'Zenders laden...', + 'liveTv.nowPlaying' => 'Nu aan het afspelen', + 'liveTv.record' => 'Opnemen', + 'liveTv.recordSeries' => 'Serie opnemen', + 'liveTv.cancelRecording' => 'Opname annuleren', + 'liveTv.deleteSubscription' => 'Opnameregel verwijderen', + 'liveTv.deleteSubscriptionConfirm' => 'Weet je zeker dat je deze opnameregel wilt verwijderen?', + 'liveTv.subscriptionDeleted' => 'Opnameregel verwijderd', + 'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar', + 'liveTv.noRecordings' => 'Geen opnames gepland', + 'liveTv.noSubscriptions' => 'Geen opnameregels', + 'liveTv.channelNumber' => ({required Object number}) => 'Kanaal ${number}', + 'liveTv.live' => 'LIVE', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NIEUW', + 'liveTv.reloadGuide' => 'Gids herladen', + 'liveTv.guideReloaded' => 'Gidsgegevens herladen', + 'liveTv.allChannels' => 'Alle zenders', + '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', + 'liveTv.watchChannel' => 'Kanaal bekijken', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', @@ -1643,6 +1729,8 @@ extension on TranslationsNl { 'downloads.noDownloads' => 'Nog geen downloads', 'downloads.noDownloadsDescription' => 'Gedownloade content verschijnt hier voor offline weergave', 'downloads.downloadNow' => 'Download', + _ => null, + } ?? switch (path) { 'downloads.deleteDownload' => 'Download verwijderen', 'downloads.retryDownload' => 'Download opnieuw proberen', 'downloads.downloadQueued' => 'Download in wachtrij', @@ -1681,8 +1769,6 @@ extension on TranslationsNl { 'playlists.errorRemoving' => 'Fout bij verwijderen uit afspeellijst', 'playlists.playlist' => 'Afspeellijst', 'collections.title' => 'Collecties', - _ => null, - } ?? switch (path) { 'collections.collection' => 'Collectie', 'collections.empty' => 'Collectie is leeg', 'collections.unknownLibrarySection' => 'Kan niet verwijderen: onbekende bibliotheeksectie', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index e948b574..15bc0c4d 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -65,6 +65,7 @@ class TranslationsSv with BaseTranslations implements T @override late final _TranslationsLogsSv logs = _TranslationsLogsSv._(_root); @override late final _TranslationsLicensesSv licenses = _TranslationsLicensesSv._(_root); @override late final _TranslationsNavigationSv navigation = _TranslationsNavigationSv._(_root); + @override late final _TranslationsLiveTvSv liveTv = _TranslationsLiveTvSv._(_root); @override late final _TranslationsDownloadsSv downloads = _TranslationsDownloadsSv._(_root); @override late final _TranslationsPlaylistsSv playlists = _TranslationsPlaylistsSv._(_root); @override late final _TranslationsCollectionsSv collections = _TranslationsCollectionsSv._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationSv implements TranslationsNavigationEn { // Translations @override String get libraries => 'Bibliotek'; @override String get downloads => 'Nedladdningar'; + @override String get liveTv => 'Live-TV'; +} + +// Path: liveTv +class _TranslationsLiveTvSv implements TranslationsLiveTvEn { + _TranslationsLiveTvSv._(this._root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Live-TV'; + @override String get channels => 'Kanaler'; + @override String get guide => 'Programguide'; + @override String get recordings => 'Inspelningar'; + @override String get subscriptions => 'Inspelningsregler'; + @override String get scheduled => 'Schemalagda'; + @override String get noChannels => 'Inga kanaler tillgängliga'; + @override String get noDvr => 'Ingen DVR konfigurerad på någon server'; + @override String get tuneFailed => 'Kunde inte ställa in kanalen'; + @override String get loading => 'Laddar kanaler...'; + @override String get nowPlaying => 'Spelas nu'; + @override String get record => 'Spela in'; + @override String get recordSeries => 'Spela in serie'; + @override String get cancelRecording => 'Avbryt inspelning'; + @override String get deleteSubscription => 'Ta bort inspelningsregel'; + @override String get deleteSubscriptionConfirm => 'Är du säker på att du vill ta bort denna inspelningsregel?'; + @override String get subscriptionDeleted => 'Inspelningsregel borttagen'; + @override String get noPrograms => 'Ingen programdata tillgänglig'; + @override String get noRecordings => 'Inga inspelningar schemalagda'; + @override String get noSubscriptions => 'Inga inspelningsregler'; + @override String channelNumber({required Object number}) => 'Kanal ${number}'; + @override String get live => 'LIVE'; + @override String get hd => 'HD'; + @override String get premiere => 'NY'; + @override String get reloadGuide => 'Ladda om programguide'; + @override String get guideReloaded => 'Programdata omladdad'; + @override String get allChannels => 'Alla kanaler'; + @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'; + @override String get watchChannel => 'Titta på kanal'; } // Path: downloads @@ -1636,6 +1684,44 @@ extension on TranslationsSv { 'licenses.licensesCount' => ({required Object count}) => '${count} licenser', 'navigation.libraries' => 'Bibliotek', 'navigation.downloads' => 'Nedladdningar', + 'navigation.liveTv' => 'Live-TV', + 'liveTv.title' => 'Live-TV', + 'liveTv.channels' => 'Kanaler', + 'liveTv.guide' => 'Programguide', + 'liveTv.recordings' => 'Inspelningar', + 'liveTv.subscriptions' => 'Inspelningsregler', + 'liveTv.scheduled' => 'Schemalagda', + 'liveTv.noChannels' => 'Inga kanaler tillgängliga', + 'liveTv.noDvr' => 'Ingen DVR konfigurerad på någon server', + 'liveTv.tuneFailed' => 'Kunde inte ställa in kanalen', + 'liveTv.loading' => 'Laddar kanaler...', + 'liveTv.nowPlaying' => 'Spelas nu', + 'liveTv.record' => 'Spela in', + 'liveTv.recordSeries' => 'Spela in serie', + 'liveTv.cancelRecording' => 'Avbryt inspelning', + 'liveTv.deleteSubscription' => 'Ta bort inspelningsregel', + 'liveTv.deleteSubscriptionConfirm' => 'Är du säker på att du vill ta bort denna inspelningsregel?', + 'liveTv.subscriptionDeleted' => 'Inspelningsregel borttagen', + 'liveTv.noPrograms' => 'Ingen programdata tillgänglig', + 'liveTv.noRecordings' => 'Inga inspelningar schemalagda', + 'liveTv.noSubscriptions' => 'Inga inspelningsregler', + 'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}', + 'liveTv.live' => 'LIVE', + 'liveTv.hd' => 'HD', + 'liveTv.premiere' => 'NY', + 'liveTv.reloadGuide' => 'Ladda om programguide', + 'liveTv.guideReloaded' => 'Programdata omladdad', + 'liveTv.allChannels' => 'Alla kanaler', + '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', + 'liveTv.watchChannel' => 'Titta på kanal', 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', @@ -1643,6 +1729,8 @@ extension on TranslationsSv { 'downloads.noDownloads' => 'Inga nedladdningar ännu', 'downloads.noDownloadsDescription' => 'Nedladdat innehåll visas här för offline-visning', 'downloads.downloadNow' => 'Ladda ner', + _ => null, + } ?? switch (path) { 'downloads.deleteDownload' => 'Ta bort nedladdning', 'downloads.retryDownload' => 'Försök igen', 'downloads.downloadQueued' => 'Nedladdning köad', @@ -1681,8 +1769,6 @@ extension on TranslationsSv { 'playlists.errorRemoving' => 'Det gick inte att ta bort från spellista', 'playlists.playlist' => 'Spellista', 'collections.title' => 'Samlingar', - _ => null, - } ?? switch (path) { 'collections.collection' => 'Samling', 'collections.empty' => 'Samlingen är tom', 'collections.unknownLibrarySection' => 'Kan inte ta bort: okänd bibliotekssektion', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index d206148d..c76f2c06 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -65,6 +65,7 @@ class TranslationsZh with BaseTranslations implements T @override late final _TranslationsLogsZh logs = _TranslationsLogsZh._(_root); @override late final _TranslationsLicensesZh licenses = _TranslationsLicensesZh._(_root); @override late final _TranslationsNavigationZh navigation = _TranslationsNavigationZh._(_root); + @override late final _TranslationsLiveTvZh liveTv = _TranslationsLiveTvZh._(_root); @override late final _TranslationsDownloadsZh downloads = _TranslationsDownloadsZh._(_root); @override late final _TranslationsPlaylistsZh playlists = _TranslationsPlaylistsZh._(_root); @override late final _TranslationsCollectionsZh collections = _TranslationsCollectionsZh._(_root); @@ -762,6 +763,53 @@ class _TranslationsNavigationZh implements TranslationsNavigationEn { // Translations @override String get libraries => '媒体库'; @override String get downloads => '下载'; + @override String get liveTv => '电视直播'; +} + +// Path: liveTv +class _TranslationsLiveTvZh implements TranslationsLiveTvEn { + _TranslationsLiveTvZh._(this._root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get title => '电视直播'; + @override String get channels => '频道'; + @override String get guide => '节目指南'; + @override String get recordings => '录制'; + @override String get subscriptions => '录制规则'; + @override String get scheduled => '已计划'; + @override String get noChannels => '没有可用的频道'; + @override String get noDvr => '没有服务器配置了DVR'; + @override String get tuneFailed => '无法调谐频道'; + @override String get loading => '正在加载频道...'; + @override String get nowPlaying => '正在播放'; + @override String get record => '录制'; + @override String get recordSeries => '录制系列'; + @override String get cancelRecording => '取消录制'; + @override String get deleteSubscription => '删除录制规则'; + @override String get deleteSubscriptionConfirm => '确定要删除此录制规则吗?'; + @override String get subscriptionDeleted => '录制规则已删除'; + @override String get noPrograms => '没有可用的节目数据'; + @override String get noRecordings => '没有计划的录制'; + @override String get noSubscriptions => '没有录制规则'; + @override String channelNumber({required Object number}) => '频道 ${number}'; + @override String get live => '直播'; + @override String get hd => '高清'; + @override String get premiere => '新'; + @override String get reloadGuide => '重新加载节目指南'; + @override String get guideReloaded => '节目指南已重新加载'; + @override String get allChannels => '所有频道'; + @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 => '正在播出'; + @override String get watchChannel => '观看频道'; } // Path: downloads @@ -1636,6 +1684,44 @@ extension on TranslationsZh { 'licenses.licensesCount' => ({required Object count}) => '${count} 个许可证', 'navigation.libraries' => '媒体库', 'navigation.downloads' => '下载', + 'navigation.liveTv' => '电视直播', + 'liveTv.title' => '电视直播', + 'liveTv.channels' => '频道', + 'liveTv.guide' => '节目指南', + 'liveTv.recordings' => '录制', + 'liveTv.subscriptions' => '录制规则', + 'liveTv.scheduled' => '已计划', + 'liveTv.noChannels' => '没有可用的频道', + 'liveTv.noDvr' => '没有服务器配置了DVR', + 'liveTv.tuneFailed' => '无法调谐频道', + 'liveTv.loading' => '正在加载频道...', + 'liveTv.nowPlaying' => '正在播放', + 'liveTv.record' => '录制', + 'liveTv.recordSeries' => '录制系列', + 'liveTv.cancelRecording' => '取消录制', + 'liveTv.deleteSubscription' => '删除录制规则', + 'liveTv.deleteSubscriptionConfirm' => '确定要删除此录制规则吗?', + 'liveTv.subscriptionDeleted' => '录制规则已删除', + 'liveTv.noPrograms' => '没有可用的节目数据', + 'liveTv.noRecordings' => '没有计划的录制', + 'liveTv.noSubscriptions' => '没有录制规则', + 'liveTv.channelNumber' => ({required Object number}) => '频道 ${number}', + 'liveTv.live' => '直播', + 'liveTv.hd' => '高清', + 'liveTv.premiere' => '新', + 'liveTv.reloadGuide' => '重新加载节目指南', + 'liveTv.guideReloaded' => '节目指南已重新加载', + 'liveTv.allChannels' => '所有频道', + 'liveTv.now' => '现在', + 'liveTv.today' => '今天', + 'liveTv.midnight' => '午夜', + 'liveTv.overnight' => '凌晨', + 'liveTv.morning' => '上午', + 'liveTv.daytime' => '白天', + 'liveTv.evening' => '晚上', + 'liveTv.lateNight' => '深夜', + 'liveTv.whatsOn' => '正在播出', + 'liveTv.watchChannel' => '观看频道', 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', @@ -1643,6 +1729,8 @@ extension on TranslationsZh { 'downloads.noDownloads' => '暂无下载', 'downloads.noDownloadsDescription' => '下载的内容将在此处显示以供离线观看', 'downloads.downloadNow' => '下载', + _ => null, + } ?? switch (path) { 'downloads.deleteDownload' => '删除下载', 'downloads.retryDownload' => '重试下载', 'downloads.downloadQueued' => '下载已排队', @@ -1681,8 +1769,6 @@ extension on TranslationsZh { 'playlists.errorRemoving' => '从播放列表中移除失败', 'playlists.playlist' => '播放列表', 'collections.title' => '合集', - _ => null, - } ?? switch (path) { 'collections.collection' => '合集', 'collections.empty' => '合集为空', 'collections.unknownLibrarySection' => '无法删除:未知的媒体库分区', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 2b4e1922..50ec6a98 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "Bibliotek", - "downloads": "Nedladdningar" + "downloads": "Nedladdningar", + "liveTv": "Live-TV" + }, + "liveTv": { + "title": "Live-TV", + "channels": "Kanaler", + "guide": "Programguide", + "recordings": "Inspelningar", + "subscriptions": "Inspelningsregler", + "scheduled": "Schemalagda", + "noChannels": "Inga kanaler tillgängliga", + "noDvr": "Ingen DVR konfigurerad på någon server", + "tuneFailed": "Kunde inte ställa in kanalen", + "loading": "Laddar kanaler...", + "nowPlaying": "Spelas nu", + "record": "Spela in", + "recordSeries": "Spela in serie", + "cancelRecording": "Avbryt inspelning", + "deleteSubscription": "Ta bort inspelningsregel", + "deleteSubscriptionConfirm": "Är du säker på att du vill ta bort denna inspelningsregel?", + "subscriptionDeleted": "Inspelningsregel borttagen", + "noPrograms": "Ingen programdata tillgänglig", + "noRecordings": "Inga inspelningar schemalagda", + "noSubscriptions": "Inga inspelningsregler", + "channelNumber": "Kanal ${number}", + "live": "LIVE", + "hd": "HD", + "premiere": "NY", + "reloadGuide": "Ladda om programguide", + "guideReloaded": "Programdata omladdad", + "allChannels": "Alla kanaler", + "now": "Nu", + "today": "Idag", + "midnight": "Midnatt", + "overnight": "Natt", + "morning": "Morgon", + "daytime": "Dagtid", + "evening": "Kväll", + "lateNight": "Sen kväll", + "whatsOn": "På TV nu", + "watchChannel": "Titta på kanal" }, "downloads": { "title": "Nedladdningar", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 42621fc3..185d9f8b 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -528,7 +528,47 @@ }, "navigation": { "libraries": "媒体库", - "downloads": "下载" + "downloads": "下载", + "liveTv": "电视直播" + }, + "liveTv": { + "title": "电视直播", + "channels": "频道", + "guide": "节目指南", + "recordings": "录制", + "subscriptions": "录制规则", + "scheduled": "已计划", + "noChannels": "没有可用的频道", + "noDvr": "没有服务器配置了DVR", + "tuneFailed": "无法调谐频道", + "loading": "正在加载频道...", + "nowPlaying": "正在播放", + "record": "录制", + "recordSeries": "录制系列", + "cancelRecording": "取消录制", + "deleteSubscription": "删除录制规则", + "deleteSubscriptionConfirm": "确定要删除此录制规则吗?", + "subscriptionDeleted": "录制规则已删除", + "noPrograms": "没有可用的节目数据", + "noRecordings": "没有计划的录制", + "noSubscriptions": "没有录制规则", + "channelNumber": "频道 ${number}", + "live": "直播", + "hd": "高清", + "premiere": "新", + "reloadGuide": "重新加载节目指南", + "guideReloaded": "节目指南已重新加载", + "allChannels": "所有频道", + "now": "现在", + "today": "今天", + "midnight": "午夜", + "overnight": "凌晨", + "morning": "上午", + "daytime": "白天", + "evening": "晚上", + "lateNight": "深夜", + "whatsOn": "正在播出", + "watchChannel": "观看频道" }, "downloads": { "title": "下载", diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart new file mode 100644 index 00000000..71bc39bb --- /dev/null +++ b/lib/models/livetv_channel.dart @@ -0,0 +1,71 @@ +/// Represents a Live TV channel from the EPG +class LiveTvChannel { + final String key; + final String? identifier; + final String? callSign; + final String? title; + final String? thumb; + final String? art; + final String? number; + final bool hd; + final String? lineup; + final String? slug; + final bool? drm; + + // Multi-server support + final String? serverId; + final String? serverName; + + LiveTvChannel({ + required this.key, + this.identifier, + this.callSign, + this.title, + this.thumb, + this.art, + this.number, + this.hd = false, + this.lineup, + this.slug, + this.drm, + this.serverId, + this.serverName, + }); + + factory LiveTvChannel.fromJson(Map json) { + return LiveTvChannel( + key: json['key'] as String? ?? json['ratingKey'] as String? ?? '', + identifier: json['identifier'] as String? ?? json['channelIdentifier'] as String?, + callSign: json['callSign'] as String?, + title: json['title'] as String? ?? json['callSign'] as String?, + thumb: json['thumb'] as String?, + art: json['art'] as String?, + number: json['number'] as String? ?? json['channelNumber'] as String? ?? json['channelVcn']?.toString(), + hd: json['hd'] == true || json['hd'] == 1, + lineup: json['lineup'] as String?, + slug: json['slug'] as String?, + drm: json['drm'] == true || json['drm'] == 1, + ); + } + + LiveTvChannel copyWith({String? serverId, String? serverName}) { + return LiveTvChannel( + key: key, + identifier: identifier, + callSign: callSign, + title: title, + thumb: thumb, + art: art, + number: number, + hd: hd, + lineup: lineup, + slug: slug, + drm: drm, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + ); + } + + /// Display name: prefer callSign, fallback to title + String get displayName => callSign ?? title ?? 'Channel $number'; +} diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart new file mode 100644 index 00000000..f087e0fb --- /dev/null +++ b/lib/models/livetv_dvr.dart @@ -0,0 +1,86 @@ +/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider) +class LiveTvDvr { + final String key; + final String uuid; + final String? make; + final String? model; + final String? modelNumber; + final String? firmware; + final int? tuners; + final String? lineup; + final String? lineupTitle; + final String? lineupURL; + final String? country; + final String? language; + final String? status; + final List channelMappings; + + LiveTvDvr({ + required this.key, + required this.uuid, + this.make, + this.model, + this.modelNumber, + this.firmware, + this.tuners, + this.lineup, + this.lineupTitle, + this.lineupURL, + this.country, + this.language, + this.status, + this.channelMappings = const [], + }); + + factory LiveTvDvr.fromJson(Map json) { + final mappings = []; + if (json['ChannelMapping'] != null) { + for (final item in json['ChannelMapping'] as List) { + try { + mappings.add(ChannelMapping.fromJson(item as Map)); + } catch (_) {} + } + } + + return LiveTvDvr( + key: json['key'] as String? ?? '', + uuid: json['uuid'] as String? ?? '', + make: json['make'] as String?, + model: json['model'] as String?, + modelNumber: json['modelNumber'] as String?, + firmware: json['firmware'] as String?, + tuners: (json['tuners'] as num?)?.toInt(), + lineup: json['lineup'] as String?, + lineupTitle: json['lineupTitle'] as String?, + lineupURL: json['lineupURL'] as String?, + country: json['country'] as String?, + language: json['language'] as String?, + status: json['status'] as String?, + channelMappings: mappings, + ); + } +} + +/// Represents a channel mapping within a DVR device +class ChannelMapping { + final String? channelKey; + final String? deviceIdentifier; + final bool? enabled; + final String? lineupIdentifier; + + ChannelMapping({ + this.channelKey, + this.deviceIdentifier, + this.enabled, + this.lineupIdentifier, + }); + + factory ChannelMapping.fromJson(Map json) { + return ChannelMapping( + channelKey: json['channelKey'] as String?, + deviceIdentifier: json['deviceIdentifier'] as String?, + enabled: json['enabled'] == true || json['enabled'] == 1 || json['enabled'] == '1', + lineupIdentifier: json['lineupIdentifier'] as String?, + ); + } +} 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/models/livetv_program.dart b/lib/models/livetv_program.dart new file mode 100644 index 00000000..9ef7275c --- /dev/null +++ b/lib/models/livetv_program.dart @@ -0,0 +1,112 @@ +/// Represents an EPG program entry (what's on a channel at a given time) +class LiveTvProgram { + final String? key; + final String? ratingKey; + final String? guid; + final String title; + final String? summary; + final String? type; + final int? year; + final int? beginsAt; // epoch seconds + final int? endsAt; // epoch seconds + final String? grandparentTitle; // series name for episodes + final String? parentTitle; // season name + final int? index; // episode number + final int? parentIndex; // season number + final String? thumb; + final String? art; + final String? channelIdentifier; + final String? channelCallSign; + final bool? live; + final bool? premiere; + + LiveTvProgram({ + this.key, + this.ratingKey, + this.guid, + required this.title, + this.summary, + this.type, + this.year, + this.beginsAt, + this.endsAt, + this.grandparentTitle, + this.parentTitle, + this.index, + this.parentIndex, + this.thumb, + this.art, + this.channelIdentifier, + this.channelCallSign, + this.live, + this.premiere, + }); + + factory LiveTvProgram.fromJson(Map json) { + // Grid endpoint nests timing/channel info inside Media[0] and Channel[0] + final media = (json['Media'] as List?)?.firstOrNull as Map?; + final channel = (json['Channel'] as List?)?.firstOrNull as Map?; + + return LiveTvProgram( + key: json['key'] as String?, + ratingKey: json['ratingKey'] as String?, + guid: json['guid'] as String?, + title: json['title'] as String? ?? 'Unknown Program', + summary: json['summary'] as String?, + type: json['type'] as String?, + year: (json['year'] as num?)?.toInt(), + beginsAt: (json['beginsAt'] as num?)?.toInt() ?? (media?['beginsAt'] as num?)?.toInt(), + endsAt: (json['endsAt'] as num?)?.toInt() ?? (media?['endsAt'] as num?)?.toInt(), + grandparentTitle: json['grandparentTitle'] as String?, + parentTitle: json['parentTitle'] as String?, + index: (json['index'] as num?)?.toInt(), + parentIndex: (json['parentIndex'] as num?)?.toInt(), + thumb: json['thumb'] as String? ?? json['grandparentThumb'] as String?, + art: json['art'] as String?, + channelIdentifier: json['channelIdentifier'] as String? + ?? media?['channelIdentifier']?.toString() + ?? channel?['id']?.toString(), + channelCallSign: json['channelCallSign'] as String? + ?? media?['channelCallSign'] as String?, + live: json['live'] == true || json['live'] == 1 || json['live'] == '1', + premiere: json['premiere'] == true || json['premiere'] == 1 || json['premiere'] == '1', + ); + } + + /// Start time as DateTime + DateTime? get startTime => beginsAt != null ? DateTime.fromMillisecondsSinceEpoch(beginsAt! * 1000) : null; + + /// End time as DateTime + DateTime? get endTime => endsAt != null ? DateTime.fromMillisecondsSinceEpoch(endsAt! * 1000) : null; + + /// Duration in minutes + int get durationMinutes { + if (beginsAt == null || endsAt == null) return 0; + return ((endsAt! - beginsAt!) / 60).round(); + } + + /// Whether this program is currently airing + bool get isCurrentlyAiring { + if (beginsAt == null || endsAt == null) return false; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return now >= beginsAt! && now < endsAt!; + } + + /// Progress through the program (0.0 to 1.0) + double get progress { + if (beginsAt == null || endsAt == null) return 0.0; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (now < beginsAt!) return 0.0; + if (now >= endsAt!) return 1.0; + return (now - beginsAt!) / (endsAt! - beginsAt!); + } + + /// Display title including series info for episodes + String get displayTitle { + if (grandparentTitle != null && index != null) { + final seasonEpisode = parentIndex != null ? 'S${parentIndex}E$index' : 'E$index'; + return '$grandparentTitle - $seasonEpisode - $title'; + } + return title; + } +} diff --git a/lib/models/livetv_scheduled_recording.dart b/lib/models/livetv_scheduled_recording.dart new file mode 100644 index 00000000..017095c3 --- /dev/null +++ b/lib/models/livetv_scheduled_recording.dart @@ -0,0 +1,86 @@ +/// Represents an upcoming scheduled recording from the DVR +class ScheduledRecording { + final String? key; + final String? ratingKey; + final String? guid; + final String title; + final String? summary; + final String? type; + final int? beginsAt; + final int? endsAt; + final String? grandparentTitle; + final String? parentTitle; + final int? index; + final int? parentIndex; + final String? thumb; + final String? art; + final String? channelIdentifier; + final String? channelCallSign; + final String? subscriptionID; + final String? status; + + ScheduledRecording({ + this.key, + this.ratingKey, + this.guid, + required this.title, + this.summary, + this.type, + this.beginsAt, + this.endsAt, + this.grandparentTitle, + this.parentTitle, + this.index, + this.parentIndex, + this.thumb, + this.art, + this.channelIdentifier, + this.channelCallSign, + this.subscriptionID, + this.status, + }); + + factory ScheduledRecording.fromJson(Map json) { + return ScheduledRecording( + key: json['key'] as String?, + ratingKey: json['ratingKey'] as String?, + guid: json['guid'] as String?, + title: json['title'] as String? ?? 'Unknown', + summary: json['summary'] as String?, + type: json['type'] as String?, + beginsAt: (json['beginsAt'] as num?)?.toInt(), + endsAt: (json['endsAt'] as num?)?.toInt(), + grandparentTitle: json['grandparentTitle'] as String?, + parentTitle: json['parentTitle'] as String?, + index: (json['index'] as num?)?.toInt(), + parentIndex: (json['parentIndex'] as num?)?.toInt(), + thumb: json['thumb'] as String?, + art: json['art'] as String?, + channelIdentifier: json['channelIdentifier'] as String?, + channelCallSign: json['channelCallSign'] as String?, + subscriptionID: json['subscriptionID'] as String?, + status: json['status'] as String?, + ); + } + + /// Start time as DateTime + DateTime? get startTime => beginsAt != null ? DateTime.fromMillisecondsSinceEpoch(beginsAt! * 1000) : null; + + /// End time as DateTime + DateTime? get endTime => endsAt != null ? DateTime.fromMillisecondsSinceEpoch(endsAt! * 1000) : null; + + /// Duration in minutes + int get durationMinutes { + if (beginsAt == null || endsAt == null) return 0; + return ((endsAt! - beginsAt!) / 60).round(); + } + + /// Display title including series info for episodes + String get displayTitle { + if (grandparentTitle != null && index != null) { + final seasonEpisode = parentIndex != null ? 'S${parentIndex}E$index' : 'E$index'; + return '$grandparentTitle - $seasonEpisode - $title'; + } + return title; + } +} diff --git a/lib/models/livetv_subscription.dart b/lib/models/livetv_subscription.dart new file mode 100644 index 00000000..707b3cfb --- /dev/null +++ b/lib/models/livetv_subscription.dart @@ -0,0 +1,123 @@ +/// Represents a DVR recording subscription (recording rule) +class LiveTvSubscription { + final String key; + final String? ratingKey; + final String? guid; + final String title; + final String? summary; + final String? type; + final String? thumb; + final String? art; + final int? targetLibrarySectionID; + final int? targetSectionID; + final int? createdAt; + final List settings; + + // Multi-server support + final String? serverId; + + LiveTvSubscription({ + required this.key, + this.ratingKey, + this.guid, + required this.title, + this.summary, + this.type, + this.thumb, + this.art, + this.targetLibrarySectionID, + this.targetSectionID, + this.createdAt, + this.settings = const [], + this.serverId, + }); + + factory LiveTvSubscription.fromJson(Map json) { + final settingsList = []; + if (json['Setting'] != null) { + for (final item in json['Setting'] as List) { + try { + settingsList.add(SubscriptionSetting.fromJson(item as Map)); + } catch (_) {} + } + } + + return LiveTvSubscription( + key: json['key'] as String? ?? '', + ratingKey: json['ratingKey'] as String?, + guid: json['guid'] as String?, + title: json['title'] as String? ?? 'Unknown', + summary: json['summary'] as String?, + type: json['type'] as String?, + thumb: json['thumb'] as String?, + art: json['art'] as String?, + targetLibrarySectionID: (json['targetLibrarySectionID'] as num?)?.toInt(), + targetSectionID: (json['targetSectionID'] as num?)?.toInt(), + createdAt: (json['createdAt'] as num?)?.toInt(), + settings: settingsList, + ); + } + + /// Creation time as DateTime + DateTime? get createdAtTime => + createdAt != null ? DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000) : null; +} + +/// Represents a setting within a DVR subscription +class SubscriptionSetting { + final String id; + final String? label; + final String? summary; + final String type; // e.g., "bool", "enum", "int" + final String? value; + final String? defaultValue; + final bool? hidden; + final bool? advanced; + final List? enumValues; + + SubscriptionSetting({ + required this.id, + this.label, + this.summary, + required this.type, + this.value, + this.defaultValue, + this.hidden, + this.advanced, + this.enumValues, + }); + + factory SubscriptionSetting.fromJson(Map json) { + List? options; + if (json['enumValues'] != null) { + final parts = (json['enumValues'] as String).split('|'); + options = parts.map((part) { + final kv = part.split(':'); + return SubscriptionSettingOption( + value: kv[0], + label: kv.length > 1 ? kv[1] : kv[0], + ); + }).toList(); + } + + return SubscriptionSetting( + id: json['id'] as String? ?? '', + label: json['label'] as String?, + summary: json['summary'] as String?, + type: json['type'] as String? ?? 'text', + value: json['value']?.toString(), + defaultValue: json['default']?.toString(), + hidden: json['hidden'] == true || json['hidden'] == 1, + advanced: json['advanced'] == true || json['advanced'] == 1, + enumValues: options, + ); + } +} + +/// Represents an option in an enum-type subscription setting +class SubscriptionSettingOption { + final String value; + final String label; + + SubscriptionSettingOption({required this.value, required this.label}); +} diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 9786a1a8..64f51521 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -61,7 +61,7 @@ abstract class Player { /// /// [media] - The media source to open. /// [play] - Whether to start playback immediately (default: true). - Future open(Media media, {bool play = true}); + Future open(Media media, {bool play = true, bool isLive = false}); /// Start or resume playback. Future play(); diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index f25fc605..07f5d7c4 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -62,7 +62,7 @@ class PlayerAndroid extends PlayerBase { // ============================================ @override - Future open(Media media, {bool play = true}) async { + Future open(Media media, {bool play = true, bool isLive = false}) async { checkDisposed(); await _ensureInitialized(); @@ -74,6 +74,7 @@ class PlayerAndroid extends PlayerBase { 'headers': media.headers, 'startPositionMs': media.start?.inMilliseconds ?? 0, 'autoPlay': play, + 'isLive': isLive, }); } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 3e63444e..7f3acd7b 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -96,7 +96,7 @@ class PlayerNative extends PlayerBase { } @override - Future open(Media media, {bool play = true}) async { + Future open(Media media, {bool play = true, bool isLive = false}) async { checkDisposed(); await _ensureInitialized(); diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index 8a13590c..4ea37b93 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -5,7 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; /// Navigation tab identifiers -enum NavigationTabId { discover, libraries, search, downloads, settings } +enum NavigationTabId { discover, libraries, liveTv, search, downloads, settings } /// Represents a navigation tab with its configuration class NavigationTab { @@ -21,25 +21,30 @@ class NavigationTab { } /// Get the index for a tab ID in the visible tabs list - static int indexFor(NavigationTabId id, {required bool isOffline}) { - final tabs = getVisibleTabs(isOffline: isOffline); + static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false}) { + final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv); return tabs.indexWhere((tab) => tab.id == id); } - /// Get tabs filtered by offline mode - static List getVisibleTabs({required bool isOffline}) { - return allNavigationTabs.where((tab) => !isOffline || !tab.onlineOnly).toList(); + /// Get tabs filtered by offline mode and feature availability + static List getVisibleTabs({required bool isOffline, bool hasLiveTv = false}) { + return allNavigationTabs.where((tab) { + if (isOffline && tab.onlineOnly) return false; + if (tab.id == NavigationTabId.liveTv && !hasLiveTv) return false; + return true; + }).toList(); } /// Check if a visual index corresponds to a specific tab ID - static bool isTabAtIndex(NavigationTabId id, int index, {required bool isOffline}) { - return indexFor(id, isOffline: isOffline) == index; + static bool isTabAtIndex(NavigationTabId id, int index, {required bool isOffline, bool hasLiveTv = false}) { + return indexFor(id, isOffline: isOffline, hasLiveTv: hasLiveTv) == index; } } // Label getters (must be top-level for const constructor) String _getHomeLabel() => t.common.home; String _getLibrariesLabel() => t.navigation.libraries; +String _getLiveTvLabel() => t.navigation.liveTv; String _getSearchLabel() => t.common.search; String _getDownloadsLabel() => t.navigation.downloads; String _getSettingsLabel() => t.common.settings; @@ -53,6 +58,7 @@ const allNavigationTabs = [ icon: Symbols.video_library_rounded, getLabel: _getLibrariesLabel, ), + NavigationTab(id: NavigationTabId.liveTv, onlineOnly: true, icon: Symbols.live_tv_rounded, getLabel: _getLiveTvLabel), NavigationTab(id: NavigationTabId.search, onlineOnly: true, icon: Symbols.search_rounded, getLabel: _getSearchLabel), NavigationTab( id: NavigationTabId.downloads, diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 48526ab3..1bb58d15 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -6,16 +6,35 @@ import '../services/multi_server_manager.dart'; import '../services/plex_auth_service.dart'; import '../utils/app_logger.dart'; +/// Cached info about a DVR-enabled server +class LiveTvServerInfo { + final String serverId; + final String dvrKey; + final String? lineup; + + LiveTvServerInfo({required this.serverId, required this.dvrKey, this.lineup}); +} + /// Provider for multi-server Plex connections /// Manages multiple PlexClient instances and provides data aggregation class MultiServerProvider extends ChangeNotifier { final MultiServerManager _serverManager; final DataAggregationService _aggregationService; + /// Whether any connected server has Live TV / DVR + bool _hasLiveTv = false; + bool get hasLiveTv => _hasLiveTv; + + /// Info about servers with DVR capability + final List _liveTvServers = []; + List get liveTvServers => List.unmodifiable(_liveTvServers); + MultiServerProvider(this._serverManager, this._aggregationService) { // Listen to server status changes _serverManager.statusStream.listen((_) { notifyListeners(); + // Re-check live TV availability when servers come online + checkLiveTvAvailability(); }); } @@ -92,6 +111,42 @@ class MultiServerProvider extends ChangeNotifier { // notifyListeners() will be called automatically via status stream } + /// Check all online servers for DVR/Live TV availability + Future checkLiveTvAvailability() async { + final newLiveTvServers = []; + + for (final serverId in onlineServerIds) { + final client = getClientForServer(serverId); + if (client == null) continue; + + try { + final dvrs = await client.getDvrs(); + for (final dvr in dvrs) { + newLiveTvServers.add(LiveTvServerInfo( + serverId: serverId, + dvrKey: dvr.key, + lineup: dvr.lineup, + )); + } + } catch (e) { + appLogger.d('LiveTV check failed for server $serverId', error: e); + } + } + + final hadLiveTv = _hasLiveTv; + final oldServerIds = _liveTvServers.map((s) => s.serverId).toSet(); + final newServerIds = newLiveTvServers.map((s) => s.serverId).toSet(); + _liveTvServers + ..clear() + ..addAll(newLiveTvServers); + _hasLiveTv = newLiveTvServers.isNotEmpty; + + // Notify when availability changes OR when the server set changes + if (hadLiveTv != _hasLiveTv || !oldServerIds.containsAll(newServerIds) || !newServerIds.containsAll(oldServerIds)) { + notifyListeners(); + } + } + @override void dispose() { _serverManager.dispose(); diff --git a/lib/screens/livetv/dvr_recordings_screen.dart b/lib/screens/livetv/dvr_recordings_screen.dart new file mode 100644 index 00000000..7b1d625c --- /dev/null +++ b/lib/screens/livetv/dvr_recordings_screen.dart @@ -0,0 +1,405 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../focus/focusable_wrapper.dart'; +import '../../focus/key_event_utils.dart'; +import '../../i18n/strings.g.dart'; +import '../../models/livetv_scheduled_recording.dart'; +import '../../models/livetv_subscription.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/formatters.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/desktop_app_bar.dart'; + +/// Screen for managing DVR recording subscriptions and scheduled recordings +class DvrRecordingsScreen extends StatefulWidget { + const DvrRecordingsScreen({super.key}); + + @override + State createState() => _DvrRecordingsScreenState(); +} + +class _DvrRecordingsScreenState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + + List _subscriptions = []; + List _scheduled = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _loadData(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadData() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + + if (liveTvServers.isEmpty) { + setState(() { + _isLoading = false; + _error = t.liveTv.noDvr; + }); + return; + } + + final allSubscriptions = []; + final allScheduled = []; + + for (final serverInfo in liveTvServers) { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + + final subs = await client.getSubscriptions(); + allSubscriptions.addAll(subs); + + final scheduled = await client.getScheduledRecordings(); + allScheduled.addAll(scheduled); + } + + // Sort scheduled by start time + allScheduled.sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0)); + + if (!mounted) return; + setState(() { + _subscriptions = allSubscriptions; + _scheduled = allScheduled; + _isLoading = false; + }); + } catch (e) { + appLogger.e('Failed to load DVR data', error: e); + if (mounted) { + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + } + + Future _deleteSubscription(LiveTvSubscription subscription) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.liveTv.deleteSubscription), + content: Text(t.liveTv.deleteSubscriptionConfirm), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel)), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final multiServer = context.read(); + final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null; + + if (client != null) { + final success = await client.deleteSubscription(subscription.key); + if (success && mounted) { + showSnackBar(context, t.liveTv.subscriptionDeleted); + await _loadData(); + } + } + } + + Future _editSubscription(LiveTvSubscription subscription) async { + // Filter to visible settings only + final editableSettings = subscription.settings.where((s) => s.hidden != true).toList(); + + if (editableSettings.isEmpty) return; + + final prefs = {}; + for (final setting in editableSettings) { + prefs[setting.id] = setting.value ?? setting.defaultValue ?? ''; + } + + final result = await showDialog?>( + context: context, + builder: (dialogContext) => + _SubscriptionEditDialog(subscription: subscription, settings: editableSettings, initialPrefs: prefs), + ); + + if (result == null || !mounted) return; + + final multiServer = context.read(); + final client = subscription.serverId != null ? multiServer.getClientForServer(subscription.serverId!) : null; + + if (client != null) { + final success = await client.editSubscription(subscription.key, result); + if (success) { + await _loadData(); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Focus( + canRequestFocus: false, + onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + CustomAppBar( + title: Text(t.liveTv.recordings), + pinned: true, + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: t.liveTv.subscriptions), + Tab(text: t.liveTv.scheduled), + ], + ), + ), + ], + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _loadData, + icon: const AppIcon(Symbols.refresh_rounded), + label: Text(t.common.retry), + ), + ], + ), + ) + : TabBarView( + controller: _tabController, + children: [_buildSubscriptionsTab(theme), _buildScheduledTab(theme)], + ), + ), + ), + ); + } + + Widget _buildSubscriptionsTab(ThemeData theme) { + if (_subscriptions.isEmpty) { + return Center(child: Text(t.liveTv.noSubscriptions)); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _subscriptions.length, + itemBuilder: (context, index) { + final sub = _subscriptions[index]; + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + onSelect: () => _editSubscription(sub), + onBack: () => Navigator.pop(context), + child: _buildSubscriptionCard(sub, theme), + ); + }, + ); + } + + Widget _buildSubscriptionCard(LiveTvSubscription subscription, ThemeData theme) { + return ExcludeFocus( + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: const AppIcon(Symbols.fiber_dvr_rounded, size: 32), + title: Text(subscription.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: subscription.type != null + ? Text( + subscription.type!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (subscription.settings.isNotEmpty) + IconButton( + icon: const AppIcon(Symbols.settings_rounded), + onPressed: () => _editSubscription(subscription), + ), + IconButton( + icon: AppIcon(Symbols.delete_rounded, color: theme.colorScheme.error), + onPressed: () => _deleteSubscription(subscription), + ), + ], + ), + ), + ), + ); + } + + Widget _buildScheduledTab(ThemeData theme) { + if (_scheduled.isEmpty) { + return Center(child: Text(t.liveTv.noRecordings)); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _scheduled.length, + itemBuilder: (context, index) { + final recording = _scheduled[index]; + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + onBack: () => Navigator.pop(context), + child: _buildScheduledCard(recording, theme), + ); + }, + ); + } + + Widget _buildScheduledCard(ScheduledRecording recording, ThemeData theme) { + final startTime = recording.startTime; + final timeStr = startTime != null + ? '${startTime.month}/${startTime.day} ${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}' + : ''; + + return ExcludeFocus( + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: const AppIcon(Symbols.fiber_manual_record_rounded, size: 32, color: Colors.red), + title: Text(recording.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + [ + if (recording.channelCallSign != null) recording.channelCallSign!, + timeStr, + if (recording.durationMinutes > 0) formatDurationTextual(recording.durationMinutes * 60000), + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ), + ); + } +} + +/// Dialog for editing subscription settings +class _SubscriptionEditDialog extends StatefulWidget { + final LiveTvSubscription subscription; + final List settings; + final Map initialPrefs; + + const _SubscriptionEditDialog({required this.subscription, required this.settings, required this.initialPrefs}); + + @override + State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState(); +} + +class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { + late Map _prefs; + final Map _textControllers = {}; + + @override + void initState() { + super.initState(); + _prefs = Map.from(widget.initialPrefs); + } + + @override + void dispose() { + for (final controller in _textControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.subscription.title), + content: SizedBox( + width: 400, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.settings.map((setting) { + return _buildSettingRow(setting); + }).toList(), + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)), + FilledButton(onPressed: () => Navigator.of(context).pop(_prefs), child: Text(t.common.save)), + ], + ); + } + + Widget _buildSettingRow(SubscriptionSetting setting) { + final value = _prefs[setting.id] ?? setting.defaultValue ?? ''; + + if (setting.type == 'bool') { + return SwitchListTile( + title: Text(setting.label ?? setting.id), + subtitle: setting.summary != null ? Text(setting.summary!) : null, + value: value == '1' || value == 'true', + onChanged: (newValue) { + setState(() { + _prefs[setting.id] = newValue ? '1' : '0'; + }); + }, + ); + } + + if (setting.type == 'enum' && setting.enumValues != null) { + return ListTile( + title: Text(setting.label ?? setting.id), + subtitle: setting.summary != null ? Text(setting.summary!) : null, + trailing: DropdownButton( + value: setting.enumValues!.any((e) => e.value == value) ? value : null, + items: setting.enumValues!.map((option) { + return DropdownMenuItem(value: option.value, child: Text(option.label)); + }).toList(), + onChanged: (newValue) { + if (newValue != null) { + setState(() { + _prefs[setting.id] = newValue; + }); + } + }, + ), + ); + } + + // Default: text field + final controller = _textControllers.putIfAbsent(setting.id, () => TextEditingController(text: value)); + return ListTile( + title: Text(setting.label ?? setting.id), + subtitle: TextField( + controller: controller, + onChanged: (newValue) { + _prefs[setting.id] = newValue; + }, + ), + ); + } +} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart new file mode 100644 index 00000000..51a28af9 --- /dev/null +++ b/lib/screens/livetv/live_tv_screen.dart @@ -0,0 +1,421 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../focus/dpad_navigator.dart'; +import '../../i18n/strings.g.dart'; +import '../../models/livetv_channel.dart'; +import '../../mixins/tab_navigation_mixin.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../utils/app_logger.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}); + + @override + State createState() => _LiveTvScreenState(); +} + +class _LiveTvScreenState extends State with SingleTickerProviderStateMixin, TabNavigationMixin { + final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); + final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); + final _guideTabKey = GlobalKey(); + final _whatsOnTabKey = GlobalKey(); + + // App bar action button focus + final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); + final _dvrButtonFocusNode = FocusNode(debugLabel: 'DvrButton'); + bool _isRefreshFocused = false; + bool _isDvrFocused = false; + + List _channels = []; + bool _isLoading = true; + String? _error; + + @override + List get tabChipFocusNodes => [_guideTabFocusNode, _whatsOnTabFocusNode]; + + @override + void initState() { + super.initState(); + suppressAutoFocus = true; + initTabNavigation(); + _refreshButtonFocusNode.addListener(_onRefreshFocusChange); + _dvrButtonFocusNode.addListener(_onDvrFocusChange); + _loadChannels(); + } + + @override + void dispose() { + _guideTabFocusNode.dispose(); + _whatsOnTabFocusNode.dispose(); + _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); + _refreshButtonFocusNode.dispose(); + _dvrButtonFocusNode.removeListener(_onDvrFocusChange); + _dvrButtonFocusNode.dispose(); + disposeTabNavigation(); + super.dispose(); + } + + void _onRefreshFocusChange() { + if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); + } + + void _onDvrFocusChange() { + if (mounted) setState(() => _isDvrFocused = _dvrButtonFocusNode.hasFocus); + } + + @override + void onTabChanged() { + if (!tabController.indexIsChanging) { + super.onTabChanged(); + // Pause/resume timers based on active tab + if (tabController.index == 0) { + _whatsOnTabKey.currentState?.pauseRefresh(); + _guideTabKey.currentState?.resumeRefresh(); + } else { + _guideTabKey.currentState?.pauseRefresh(); + _whatsOnTabKey.currentState?.resumeRefresh(); + } + } + } + + Future _loadChannels() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final multiServer = context.read(); + final liveTvServers = multiServer.liveTvServers; + + if (liveTvServers.isEmpty) { + setState(() { + _isLoading = false; + _error = t.liveTv.noDvr; + }); + return; + } + + final allChannels = []; + final seenChannels = {}; + + appLogger.d('Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}'); + + // Build a set of enabled channel keys per server from DVR mappings + final enabledKeysByServer = >{}; + final queriedServers = {}; + for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + final dvrs = await client.getDvrs(); + final enabledKeys = {}; + bool hasMappings = false; + for (final dvr in dvrs) { + if (dvr.channelMappings.isNotEmpty) { + hasMappings = true; + for (final m in dvr.channelMappings) { + if (m.enabled == true && m.channelKey != null) { + enabledKeys.add(m.channelKey!); + } + } + } + } + if (hasMappings) { + enabledKeysByServer[serverInfo.serverId] = enabledKeys; + } + } catch (e) { + appLogger.e('Failed to load DVR mappings for server ${serverInfo.serverId}', error: e); + } + } + + for (final serverInfo in liveTvServers) { + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + + final channels = await client.getEpgChannels(lineup: serverInfo.lineup); + final enabledKeys = enabledKeysByServer[serverInfo.serverId]; + appLogger.d('Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)'); + for (final channel in channels) { + // Skip disabled channels if DVR has mapping data + if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue; + final dedupKey = '${serverInfo.serverId}:${channel.key}'; + if (seenChannels.add(dedupKey)) { + allChannels.add(channel); + } + } + } catch (e) { + appLogger.e('Failed to load channels from server ${serverInfo.serverId}', error: e); + } + } + + allChannels.sort((a, b) { + final aNum = double.tryParse(a.number ?? '') ?? 999999; + final bNum = double.tryParse(b.number ?? '') ?? 999999; + return aNum.compareTo(bNum); + }); + + if (!mounted) return; + + appLogger.d('Live TV: loaded ${allChannels.length} channels'); + + setState(() { + _channels = allChannels; + _isLoading = false; + }); + + if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusCurrentTab(); + }); + } + } catch (e) { + appLogger.e('Failed to load Live TV channels', error: e); + if (mounted) { + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + } + + void _openRecordings() { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DvrRecordingsScreen())); + } + + void _focusCurrentTab() { + if (tabController.index == 0) { + _guideTabKey.currentState?.focusContent(); + } else if (tabController.index == 1) { + _whatsOnTabKey.currentState?.focusFirstHub(); + } + setState(() { + suppressAutoFocus = false; + }); + } + + // --------------------------------------------------------------------------- + // Action button key handlers + // --------------------------------------------------------------------------- + + KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key.isLeftKey) { + getTabChipFocusNode(tabCount - 1).requestFocus(); + return KeyEventResult.handled; + } + if (key.isRightKey) { + _dvrButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isDownKey) { + _focusCurrentTab(); + return KeyEventResult.handled; + } + if (key.isUpKey) { + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _loadChannels(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleDvrKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key.isLeftKey) { + _refreshButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isRightKey || key.isUpKey) { + return KeyEventResult.handled; + } + if (key.isDownKey) { + _focusCurrentTab(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _openRecordings(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // --------------------------------------------------------------------------- + // Tab chips + // --------------------------------------------------------------------------- + + Widget _buildTabChip(String label, int index) { + final isSelected = tabController.index == index; + + 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(); + } + : () => _refreshButtonFocusNode.requestFocus(), + onNavigateDown: _focusCurrentTab, + onBack: onTabBarBack, + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final useSideNav = PlatformDetector.shouldUseSideNavigation(context); + + return Scaffold( + appBar: AppBar( + title: useSideNav + ? Row( + children: [ + _buildTabChip(t.liveTv.guide, 0), + const SizedBox(width: 8), + _buildTabChip(t.liveTv.whatsOn, 1), + ], + ) + : Text(t.liveTv.title), + actions: [ + Focus( + focusNode: _refreshButtonFocusNode, + onKeyEvent: _handleRefreshKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const AppIcon(Symbols.refresh_rounded), + tooltip: t.liveTv.reloadGuide, + onPressed: _loadChannels, + ), + ), + ), + Focus( + focusNode: _dvrButtonFocusNode, + onKeyEvent: _handleDvrKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isDvrFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const AppIcon(Symbols.fiber_dvr_rounded), + tooltip: t.liveTv.recordings, + onPressed: _openRecordings, + ), + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text(_error!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _loadChannels, + icon: const AppIcon(Symbols.refresh_rounded), + label: Text(t.common.retry), + ), + ], + ), + ) + : _channels.isEmpty + ? Center(child: Text(t.liveTv.noChannels)) + : Column( + children: [ + if (!useSideNav) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.liveTv.guide, 0), + const SizedBox(width: 8), + _buildTabChip(t.liveTv.whatsOn, 1), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: tabController, + children: [ + GuideTab( + key: _guideTabKey, + channels: _channels, + onNavigateUp: focusTabBar, + onBack: onTabBarBack, + ), + WhatsOnTab( + key: _whatsOnTabKey, + channels: _channels, + onNavigateUp: focusTabBar, + onBack: onTabBarBack, + ), + ], + ), + ), + ], + ), + ); + } +} 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..ab68a9d1 --- /dev/null +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../focus/focusable_wrapper.dart'; +import '../../i18n/strings.g.dart'; +import '../../models/livetv_channel.dart'; +import '../../models/livetv_program.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../theme/mono_tokens.dart'; +import '../../utils/formatters.dart'; +import '../../utils/live_tv_player_navigation.dart'; +import '../../utils/plex_image_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import 'program_details_sheet.dart'; + +/// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view. +class LiveTvShowScheduleScreen extends StatefulWidget { + /// 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 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, + ); + } + + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: channel != null ? () => _tuneChannel(channel) : null, + ); + } + + @override + Widget build(BuildContext context) { + return FocusedScrollScaffold( + title: Text(widget.showTitle), + slivers: [ + if (_isLoading) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (_programs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final program = _programs[index]; + final channel = _findChannel(program.channelIdentifier); + final onTap = () { + if (program.isCurrentlyAiring && channel != null) { + _tuneChannel(channel); + } else { + _showProgramDetails(program, channel); + } + }; + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + useBackgroundFocus: true, + disableScale: true, + onSelect: onTap, + onBack: () => Navigator.pop(context), + child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), + ); + }, childCount: _programs.length), + ), + ], + ); + } +} + +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( + canRequestFocus: false, + onTap: onTap, + child: Container( + decoration: isLive + ? BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.08), + border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)), + ) + : 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/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart new file mode 100644 index 00000000..9284ff8c --- /dev/null +++ b/lib/screens/livetv/program_details_sheet.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../focus/focusable_wrapper.dart'; +import '../../i18n/strings.g.dart'; +import '../../models/livetv_channel.dart'; +import '../../models/livetv_program.dart'; +import '../../utils/formatters.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_bottom_sheet.dart'; + +/// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). +void showProgramDetailsSheet( + BuildContext context, { + required LiveTvProgram program, + required LiveTvChannel? channel, + required String? posterUrl, + required VoidCallback? onTuneChannel, +}) { + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return _ProgramDetailsSheetContent( + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: onTuneChannel, + ); + }, + ); +} + +class _ProgramDetailsSheetContent extends StatefulWidget { + final LiveTvProgram program; + final LiveTvChannel? channel; + final String? posterUrl; + final VoidCallback? onTuneChannel; + + const _ProgramDetailsSheetContent({ + required this.program, + required this.channel, + required this.posterUrl, + required this.onTuneChannel, + }); + + @override + State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState(); +} + +class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> { + final List _buttonFocusNodes = []; + + FocusNode get _initialFocusNode => _buttonFocusNodes.isNotEmpty ? _buttonFocusNodes.first : FocusNode(); + + @override + void initState() { + super.initState(); + _buildButtonFocusNodes(); + } + + @override + void dispose() { + for (final node in _buttonFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + void _buildButtonFocusNodes() { + int count = 0; + if (widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; + // TODO: Implement recording + // count++; // Record button + if (!widget.program.isCurrentlyAiring && widget.onTuneChannel != null) count++; + + for (int i = 0; i < count; i++) { + _buttonFocusNodes.add(FocusNode(debugLabel: 'program_sheet_btn_$i')); + } + } + + void _focusButton(int index) { + if (index >= 0 && index < _buttonFocusNodes.length) { + _buttonFocusNodes[index].requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final program = widget.program; + final channel = widget.channel; + + // Build the list of action buttons with their focus wrappers + final buttons = []; + int buttonIndex = 0; + + if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: FilledButton.icon( + style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + icon: const AppIcon(Symbols.play_arrow_rounded), + label: Text(t.common.play), + ), + ), + ); + buttonIndex++; + } + + // TODO: Implement recording + // if (program.isCurrentlyAiring && widget.onTuneChannel != null) { + // buttons.add(const SizedBox(width: 8)); + // } + // // Record button + // { + // final idx = buttonIndex; + // buttons.add( + // FocusableWrapper( + // focusNode: _buttonFocusNodes[idx], + // onSelect: () { + // Navigator.of(context).pop(); + // }, + // onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + // onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + // onBack: () => Navigator.of(context).pop(), + // borderRadius: 100, + // useBackgroundFocus: true, + // disableScale: true, + // child: OutlinedButton.icon( + // style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + // onPressed: () { + // Navigator.of(context).pop(); + // }, + // icon: const AppIcon(Symbols.fiber_manual_record_rounded), + // label: Text(t.liveTv.record), + // ), + // ), + // ); + // buttonIndex++; + // } + + if (!program.isCurrentlyAiring && widget.onTuneChannel != null) { + buttons.add(const SizedBox(width: 8)); + final idx = buttonIndex; + buttons.add( + FocusableWrapper( + focusNode: _buttonFocusNodes[idx], + onSelect: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, + onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, + onBack: () => Navigator.of(context).pop(), + borderRadius: 100, + useBackgroundFocus: true, + disableScale: true, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + widget.onTuneChannel!(); + }, + icon: const AppIcon(Symbols.live_tv_rounded), + label: Text(t.liveTv.watchChannel), + ), + ), + ); + buttonIndex++; + } + + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.posterUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + widget.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: buttons), + ], + ), + ), + ); + } +} diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart new file mode 100644 index 00000000..08689f42 --- /dev/null +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -0,0 +1,1222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; +import '../../../i18n/strings.g.dart'; +import '../../../models/livetv_channel.dart'; +import '../../../models/livetv_program.dart'; +import '../../../providers/multi_server_provider.dart'; +import '../../../services/plex_client.dart'; +import '../../../utils/app_logger.dart'; +import '../../../utils/formatters.dart'; +import '../../../utils/plex_image_helper.dart'; +import '../../../utils/live_tv_player_navigation.dart'; +import '../../../widgets/app_icon.dart'; +import '../../../widgets/plex_optimized_image.dart'; +import '../program_details_sheet.dart'; + +class GuideTab extends StatefulWidget { + final List channels; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; + + const GuideTab({super.key, required this.channels, this.onNavigateUp, this.onBack}); + + @override + State createState() => GuideTabState(); +} + +enum _GuideZone { timeNav, grid } + +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(); + final ScrollController _gridVerticalController = ScrollController(); + bool _syncingScroll = false; + + Timer? _timeIndicatorTimer; + final _dayPickerKey = GlobalKey(); + + // Focus state + final FocusNode _guideFocusNode = FocusNode(debugLabel: 'guide_tab'); + _GuideZone _focusZone = _GuideZone.timeNav; + int _timeNavIndex = 1; // 0=left arrow, 1=day picker, 2=right arrow + int _gridChannelIndex = 0; + int _gridColumn = 0; // 0=channel, 1=program + bool _hasFocus = false; + LiveTvProgram? _focusedProgram; + bool _pendingFocus = false; + + /// Focus into the guide content (called from tab bar navigation or initial load). + void focusContent() { + // If still loading programs, defer until the Focus widget is in the tree. + if (_isLoading) { + _pendingFocus = true; + return; + } + _pendingFocus = false; + _guideFocusNode.requestFocus(); + setState(() { + if (widget.channels.isNotEmpty) { + _focusZone = _GuideZone.grid; + _gridColumn = 0; + _gridChannelIndex = 0; + _focusedProgram = null; + } else { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + } + }); + } + + @override + void initState() { + super.initState(); + _initTimeRange(); + _loadPrograms(); + + _gridHorizontalController.addListener(_syncGridToHeader); + _headerHorizontalController.addListener(_syncHeaderToGrid); + + _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + + void pauseRefresh() => _timeIndicatorTimer?.cancel(); + + void resumeRefresh() { + _timeIndicatorTimer?.cancel(); + _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void didUpdateWidget(GuideTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.channels.isNotEmpty && _gridChannelIndex >= widget.channels.length) { + _gridChannelIndex = widget.channels.length - 1; + } + } + + @override + void dispose() { + _guideFocusNode.dispose(); + _gridVerticalController.dispose(); + _gridHorizontalController.removeListener(_syncGridToHeader); + _headerHorizontalController.removeListener(_syncHeaderToGrid); + _headerHorizontalController.dispose(); + _gridHorizontalController.dispose(); + _channelVerticalController.dispose(); + _timeIndicatorTimer?.cancel(); + super.dispose(); + } + + void _syncGridToHeader() { + if (_syncingScroll) return; + _syncingScroll = true; + 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; + } + + 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 = []; + final queriedServers = {}; + + for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + + final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; + final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + + final programs = await client.getEpgGrid( + beginsAt: startEpoch, + endsAt: endEpoch, + ); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); + } + } + + if (!mounted) return; + + final shouldFocus = _pendingFocus; + + setState(() { + _programs = allPrograms; + _isLoading = false; + }); + + _scrollToNow(); + + if (shouldFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) focusContent(); + }); + } + } catch (e) { + appLogger.e('Failed to load guide programs', error: e); + if (mounted) { + 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) { + return _programs.where((p) => p.channelIdentifier == channel.key).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, + ); + } + + // --------------------------------------------------------------------------- + // Focus key handling + // --------------------------------------------------------------------------- + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + // Back key + if (key.isBackKey) { + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + if (_focusZone == _GuideZone.grid) { + if (event is KeyUpEvent) { + setState(() { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + }); + } + return KeyEventResult.handled; + } + return handleBackKeyAction(event, () => widget.onBack?.call()); + } + + if (!event.isActionable) return KeyEventResult.ignored; + + if (_focusZone == _GuideZone.timeNav) { + return _handleTimeNavKey(key); + } else { + return _handleGridKey(key); + } + } + + KeyEventResult _handleTimeNavKey(LogicalKeyboardKey key) { + if (key.isLeftKey) { + if (_timeNavIndex > 0) { + setState(() => _timeNavIndex--); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + if (key.isRightKey) { + if (_timeNavIndex < 2) setState(() => _timeNavIndex++); + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (widget.channels.isNotEmpty) { + setState(() { + _focusZone = _GuideZone.grid; + _gridColumn = 0; + _focusedProgram = null; + }); + _scrollToChannel(_gridChannelIndex); + } + return KeyEventResult.handled; + } + if (key.isUpKey) { + widget.onNavigateUp?.call(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + switch (_timeNavIndex) { + case 0: + _shiftTimeRange(-2); + case 1: + _showDayPicker(); + case 2: + _shiftTimeRange(2); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleGridKey(LogicalKeyboardKey key) { + if (key.isUpKey) { + if (_gridChannelIndex > 0) { + setState(() { + _gridChannelIndex--; + if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); + }); + _scrollToChannel(_gridChannelIndex); + } else { + setState(() { + _focusZone = _GuideZone.timeNav; + _timeNavIndex = 1; + }); + } + return KeyEventResult.handled; + } + if (key.isDownKey) { + if (_gridChannelIndex < widget.channels.length - 1) { + setState(() { + _gridChannelIndex++; + if (_gridColumn == 1) _focusedProgram = _findCurrentProgram(_gridChannelIndex); + }); + _scrollToChannel(_gridChannelIndex); + } + return KeyEventResult.handled; + } + if (key.isRightKey) { + if (_gridColumn == 0) { + final program = _findCurrentProgram(_gridChannelIndex); + if (program != null) { + setState(() { + _gridColumn = 1; + _focusedProgram = program; + }); + _scrollToProgramTime(program); + } + } + return KeyEventResult.handled; + } + if (key.isLeftKey) { + if (_gridColumn == 1) { + setState(() { + _gridColumn = 0; + _focusedProgram = null; + }); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) { + final channel = widget.channels[_gridChannelIndex]; + if (_gridColumn == 0) { + _tuneChannel(channel); + } else if (_focusedProgram != null) { + _showProgramDetails(channel, _focusedProgram!); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // --------------------------------------------------------------------------- + // Focus helpers + // --------------------------------------------------------------------------- + + LiveTvProgram? _findCurrentProgram(int channelIndex) { + if (channelIndex < 0 || channelIndex >= widget.channels.length) return null; + final channel = widget.channels[channelIndex]; + final programs = _getProgramsForChannel(channel); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Currently airing + for (final p in programs) { + if ((p.beginsAt ?? 0) <= now && (p.endsAt ?? 0) > now) return p; + } + // First future program + for (final p in programs) { + if ((p.endsAt ?? 0) > now) return p; + } + return programs.firstOrNull; + } + + void _scrollToChannel(int index) { + if (!_gridVerticalController.hasClients) return; + final targetTop = index * _rowHeight; + final targetBottom = targetTop + _rowHeight; + final viewportTop = _gridVerticalController.offset; + final viewportBottom = viewportTop + _gridVerticalController.position.viewportDimension; + + double? newOffset; + if (targetTop < viewportTop) { + newOffset = targetTop; + } else if (targetBottom > viewportBottom) { + newOffset = targetBottom - _gridVerticalController.position.viewportDimension; + } + + if (newOffset != null) { + final clamped = newOffset.clamp(0.0, _gridVerticalController.position.maxScrollExtent); + _gridVerticalController.jumpTo(clamped); + if (_channelVerticalController.hasClients) { + _channelVerticalController.jumpTo( + clamped.clamp(0.0, _channelVerticalController.position.maxScrollExtent), + ); + } + } + } + + void _scrollToProgramTime(LiveTvProgram? program) { + if (program == null || !_gridHorizontalController.hasClients) return; + + final gridStartEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; + final gridEndEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; + final progStart = (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); + final startOffset = progStart - gridStartEpoch; + final left = (startOffset / (_minutesPerSlot * 60)) * _slotWidth; + + final viewportWidth = _gridHorizontalController.position.viewportDimension; + final currentOffset = _gridHorizontalController.offset; + + if (left < currentOffset || left > currentOffset + viewportWidth - 100) { + final maxScroll = _gridHorizontalController.position.maxScrollExtent; + _gridHorizontalController.jumpTo((left - 50).clamp(0.0, maxScroll)); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return Focus( + focusNode: _guideFocusNode, + onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + onKeyEvent: _handleKeyEvent, + child: _buildGuideGrid(theme), + ); + } + + Widget _buildGuideGrid(ThemeData theme) { + return Column( + children: [ + _buildTimeNavigation(theme), + Expanded( + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, child) { + return Stack( + children: [ + child!, + _buildNowIndicatorOverlay(theme), + ], + ); + }, + child: Column( + children: [ + Row( + children: [ + SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + Expanded( + child: SingleChildScrollView( + controller: _headerHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), + ), + ), + ), + ], + ), + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: ListView.builder( + controller: _channelVerticalController, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => + _buildChannelCell(widget.channels[index], theme, index: index), + ), + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollUpdateNotification && + notification.metrics.axis == Axis.vertical) { + if (_channelVerticalController.hasClients) { + _channelVerticalController + .jumpTo(notification.metrics.pixels); + } + } + return false; + }, + child: SingleChildScrollView( + controller: _gridHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + child: ListView.builder( + controller: _gridVerticalController, + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) { + final channel = widget.channels[index]; + final programs = _getProgramsForChannel(channel); + return _buildProgramRow(channel, programs, theme, channelIndex: index); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildNowIndicatorOverlay(ThemeData theme) { + final now = DateTime.now(); + if (now.isBefore(_gridStart) || now.isAfter(_gridEnd)) { + return const SizedBox.shrink(); + } + final minutesSinceStart = now.difference(_gridStart).inMinutes.toDouble(); + final nowOffset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; + final scrollOffset = _gridHorizontalController.hasClients + ? _gridHorizontalController.offset + : 0.0; + final left = _channelColumnWidth + nowOffset - scrollOffset; + + // Hide when scrolled behind the channel column + if (left < _channelColumnWidth) return const SizedBox.shrink(); + + final gridHeight = _timeHeaderHeight + widget.channels.length * _rowHeight; + + return Positioned( + left: left, + top: 0, + height: gridHeight, + child: IgnorePointer( + child: Container(width: 2, color: Colors.red), + ), + ); + } + + String _dayLabel(DateTime day) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + 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) { + _guideFocusNode.requestFocus(); + return; + } + if (value is String && value == 'now') { + _jumpToNow(); + _guideFocusNode.requestFocus(); + } 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) { + _guideFocusNode.requestFocus(); + return; + } + if (value == -1) { + _showDayPicker(); + return; + } + setState(() { + _gridStart = DateTime(day.year, day.month, day.day, value); + _gridEnd = _gridStart.add(const Duration(hours: 6)); + }); + _loadPrograms(); + _guideFocusNode.requestFocus(); + }); + } + + // --------------------------------------------------------------------------- + // Time navigation bar + // --------------------------------------------------------------------------- + + Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) { + final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index; + if (!isFocused) return child; + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + } + + Widget _buildTimeNavigation(ThemeData theme) { + final format = MaterialLocalizations.of(context); + final timeLabel = + 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: [ + _timeNavFocusWrap( + index: 0, + theme: theme, + child: IconButton( + icon: const AppIcon(Symbols.chevron_left_rounded), + onPressed: () => _shiftTimeRange(-2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _timeNavFocusWrap( + index: 1, + theme: theme, + child: GestureDetector( + key: _dayPickerKey, + onTap: _showDayPicker, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayLabel, + style: theme.textTheme.labelLarge, + ), + const SizedBox(width: 2), + AppIcon(Symbols.arrow_drop_down_rounded, + size: 18, color: theme.colorScheme.onSurface), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + timeLabel, + style: theme.textTheme.labelLarge, + ), + ], + ), + ), + _timeNavFocusWrap( + index: 2, + theme: theme, + child: IconButton( + icon: const AppIcon(Symbols.chevron_right_rounded), + onPressed: () => _shiftTimeRange(2), + iconSize: 20, + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Time header & now indicator + // --------------------------------------------------------------------------- + + Widget _buildTimeHeader(ThemeData theme) { + final slots = []; + var current = _gridStart; + + 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 Row(children: slots); + } + + + // --------------------------------------------------------------------------- + // Channel column + // --------------------------------------------------------------------------- + + Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { + final multiServer = context.read(); + final client = multiServer.getClientForServer(channel.serverId ?? ''); + + final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; + + return _ChannelCell( + rowHeight: _rowHeight, + channelColumnWidth: _channelColumnWidth, + channelThumb: channel.thumb, + client: client, + channel: channel, + theme: theme, + onTap: () => _tuneChannel(channel), + isFocused: isFocused, + 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, + ), + ], + ); + } + + // --------------------------------------------------------------------------- + // Program grid + // --------------------------------------------------------------------------- + + Widget _buildProgramRow( + LiveTvChannel channel, List programs, ThemeData theme, + {required int channelIndex}) { + 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; + + // Determine which program is focused in this row + final focusProg = (_hasFocus && + _focusZone == _GuideZone.grid && + _gridColumn == 1 && + _gridChannelIndex == channelIndex) + ? _focusedProgram + : null; + + for (final program in programs) { + final progStart = + (program.beginsAt ?? gridStartEpoch).clamp(gridStartEpoch, gridEndEpoch); + 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, + isFocused: identical(program, focusProg), + ), + ), + ); + } + + return Container( + height: _rowHeight, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + ), + child: Stack(children: blocks), + ); + } + + Widget _buildProgramBlock( + LiveTvChannel channel, LiveTvProgram program, ThemeData theme, + {bool isLast = false, bool isFocused = false}) { + final isCurrentlyAiring = program.isCurrentlyAiring; + final isPast = program.endsAt != null && + program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000; + + return Opacity( + opacity: isPast ? 0.5 : 1.0, + child: Material( + color: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.25) + : isCurrentlyAiring + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: isFocused + ? BorderSide(color: theme.colorScheme.primary, width: 2) + : BorderSide.none, + ), + child: InkWell( + canRequestFocus: false, + borderRadius: BorderRadius.circular(4), + onTap: () => _showProgramDetails(channel, program), + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), + right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none, + ), + ), + 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: isFocused + ? theme.colorScheme.primary + : 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: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : 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: isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : isCurrentlyAiring + ? theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + ), + ], + ), + ), + ), + ), + ); + } + + void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { + final 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, + ); + } + + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: () => _tuneChannel(channel), + ); + } +} + +class _ChannelCell extends StatefulWidget { + final double rowHeight; + final double channelColumnWidth; + final String? channelThumb; + final PlexClient? client; + final LiveTvChannel channel; + final ThemeData theme; + final VoidCallback onTap; + final bool isFocused; + final Widget Function() fallbackBuilder; + + const _ChannelCell({ + required this.rowHeight, + required this.channelColumnWidth, + required this.channelThumb, + required this.client, + required this.channel, + required this.theme, + required this.onTap, + required this.isFocused, + 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; + final showAction = _hovered || widget.isFocused; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Material( + color: widget.isFocused + ? theme.colorScheme.primary.withValues(alpha: 0.15) + : Colors.transparent, + child: InkWell( + canRequestFocus: false, + 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: showAction ? 0.3 : 1.0, + duration: const Duration(milliseconds: 150), + child: widget.channelThumb != null && widget.client != null + ? PlexOptimizedImage.thumb( + client: widget.client!, + imagePath: widget.channelThumb, + width: widget.channelColumnWidth - 16, + height: widget.rowHeight - 16, + fit: BoxFit.contain, + ) + : widget.fallbackBuilder(), + ), + if (showAction) + 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..7b991e93 --- /dev/null +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -0,0 +1,641 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; +import '../../../focus/locked_hub_controller.dart'; +import '../../../i18n/strings.g.dart'; +import '../../../models/livetv_channel.dart'; +import '../../../models/livetv_hub_result.dart'; +import '../../../providers/multi_server_provider.dart'; +import '../../../providers/settings_provider.dart'; +import '../../../services/settings_service.dart' show LibraryDensity; +import '../../../theme/mono_tokens.dart'; +import '../../../utils/app_logger.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/focus_builders.dart'; +import '../../../widgets/horizontal_scroll_with_arrows.dart'; +import '../../../widgets/plex_optimized_image.dart'; +import '../live_tv_show_schedule_screen.dart'; +import '../program_details_sheet.dart'; + +class WhatsOnTab extends StatefulWidget { + final List channels; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; + + const WhatsOnTab({super.key, required this.channels, this.onNavigateUp, this.onBack}); + + @override + State createState() => WhatsOnTabState(); +} + +class WhatsOnTabState extends State { + List _hubs = []; + bool _isLoading = true; + Timer? _refreshTimer; + List> _hubKeys = []; + + @override + void initState() { + super.initState(); + _loadHubs(); + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) { + if (mounted) _loadHubs(); + }); + } + + void pauseRefresh() => _refreshTimer?.cancel(); + + void resumeRefresh() { + _refreshTimer?.cancel(); + _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 = []; + final queriedServers = {}; + + for (final serverInfo in liveTvServers) { + if (!queriedServers.add(serverInfo.serverId)) continue; + try { + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) continue; + + final hubs = await client.getLiveTvHubs(); + allHubs.addAll(hubs); + } catch (e) { + appLogger.e('Failed to load hubs from server ${serverInfo.serverId}', error: e); + } + } + + if (!mounted) return; + setState(() { + _hubs = allHubs; + _hubKeys = List.generate(allHubs.length, (_) => GlobalKey<_LiveTvHubSectionState>()); + _isLoading = false; + }); + } catch (e) { + appLogger.e('Failed to load live TV hubs', error: e); + if (mounted) setState(() => _isLoading = false); + } + } + + /// Focus the first hub (called from parent when tab bar navigates down) + void focusFirstHub() { + if (_hubKeys.isNotEmpty) { + _hubKeys[0].currentState?.requestFocusFromMemory(); + } + } + + bool _handleVerticalNavigation(int hubIndex, bool isUp) { + if (_hubKeys.isEmpty) return false; + + if (isUp && hubIndex == 0) { + widget.onNavigateUp?.call(); + return true; + } + + final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; + + if (targetIndex < 0 || targetIndex >= _hubKeys.length) { + return true; // At boundary, consume the event + } + + final targetState = _hubKeys[targetIndex].currentState; + if (targetState != null) { + targetState.requestFocusFromMemory(); + return true; + } + + return false; + } + + /// Find a channel by its identifier from the channel list. + LiveTvChannel? _findChannel(String? channelIdentifier) { + if (channelIdentifier == null) return null; + 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 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, + ); + } + + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: channel != null ? () => _tuneChannel(channel) : null, + ); + } + + @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( + key: _hubKeys[index], + hub: _hubs[index], + onTap: _onItemTap, + onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onBack: widget.onBack, + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Hub section — horizontal scrolling row of poster cards (always 2:3 aspect) +// Uses locked focus pattern: single Focus node at hub level, visual index in state. +// --------------------------------------------------------------------------- + +class _LiveTvHubSection extends StatefulWidget { + final LiveTvHubResult hub; + final void Function(LiveTvHubEntry) onTap; + final void Function(LiveTvHubEntry) onLongPress; + final bool Function(bool isUp)? onVerticalNavigation; + final VoidCallback? onBack; + + const _LiveTvHubSection({ + super.key, + required this.hub, + required this.onTap, + required this.onLongPress, + this.onVerticalNavigation, + this.onBack, + }); + + @override + State<_LiveTvHubSection> createState() => _LiveTvHubSectionState(); +} + +class _LiveTvHubSectionState extends State<_LiveTvHubSection> { + static const _longPressDuration = Duration(milliseconds: 500); + + late FocusNode _hubFocusNode; + final ScrollController _scrollController = ScrollController(); + + int _focusedIndex = 0; + double _itemExtent = 0; + static const double _leadingPadding = 12.0; + + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + bool _longPressTriggered = false; + + @override + void initState() { + super.initState(); + _hubFocusNode = FocusNode(debugLabel: 'livetv_hub_${widget.hub.hubKey}'); + _hubFocusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(_LiveTvHubSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.hub.entries.length != oldWidget.hub.entries.length) { + final maxIndex = widget.hub.entries.isEmpty ? 0 : widget.hub.entries.length - 1; + if (_focusedIndex > maxIndex) { + _focusedIndex = maxIndex; + } + } + } + + @override + void dispose() { + _longPressTimer?.cancel(); + _hubFocusNode.removeListener(_onFocusChange); + _hubFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (!_hubFocusNode.hasFocus) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } + if (mounted) setState(() {}); + } + + void requestFocusAt(int index) { + if (widget.hub.entries.isEmpty) return; + + final clamped = index.clamp(0, widget.hub.entries.length - 1); + _focusedIndex = clamped; + HubFocusMemory.setForHub(widget.hub.hubKey, clamped); + _scrollToIndex(clamped); + _hubFocusNode.requestFocus(); + if (mounted) setState(() {}); + _scrollHubIntoView(); + } + + void requestFocusFromMemory() { + final index = HubFocusMemory.getForHub(widget.hub.hubKey, widget.hub.entries.length); + requestFocusAt(index); + } + + void _scrollHubIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Scrollable.ensureVisible( + context, + alignment: 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); + } + + void _scrollToIndex(int index, {bool animate = true}) { + if (!_scrollController.hasClients || _itemExtent <= 0) return; + + final viewport = _scrollController.position.viewportDimension; + final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); + final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _scrollController.position.maxScrollExtent); + + if (animate) { + _scrollController.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } else { + _scrollController.jumpTo(desiredOffset); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + if (key.isSelectKey) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTriggered = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_longPressDuration, () { + if (!mounted) return; + if (_isSelectKeyDown) { + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _activateLongPress(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { + _activateCurrentItem(); + } + _isSelectKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + } + + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + + if (!event.isActionable) { + return KeyEventResult.ignored; + } + + final itemCount = widget.hub.entries.length; + if (itemCount == 0) return KeyEventResult.ignored; + + if (key.isLeftKey) { + if (_focusedIndex > 0) { + _focusedIndex--; + HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + _scrollToIndex(_focusedIndex); + setState(() {}); + } else { + widget.onBack?.call(); + } + return KeyEventResult.handled; + } + + if (key.isRightKey) { + if (_focusedIndex < itemCount - 1) { + _focusedIndex++; + HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + _scrollToIndex(_focusedIndex); + setState(() {}); + } + return KeyEventResult.handled; + } + + if (key.isUpKey) { + widget.onVerticalNavigation?.call(true); + return KeyEventResult.handled; + } + if (key.isDownKey) { + widget.onVerticalNavigation?.call(false); + return KeyEventResult.handled; + } + + if (key.isContextMenuKey) { + _activateLongPress(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _activateCurrentItem() { + if (_focusedIndex >= widget.hub.entries.length) return; + widget.onTap(widget.hub.entries[_focusedIndex]); + } + + void _activateLongPress() { + if (_focusedIndex >= widget.hub.entries.length) return; + widget.onLongPress(widget.hub.entries[_focusedIndex]); + } + + void _onItemTapped(int index) { + _focusedIndex = index; + HubFocusMemory.setForHub(widget.hub.hubKey, index); + _hubFocusNode.requestFocus(); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final hasFocus = _hubFocusNode.hasFocus; + final settings = context.watch(); + final densityScale = switch (settings.libraryDensity) { + LibraryDensity.compact => 0.8, + 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( + widget.hub.title, + style: Theme.of(context).textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + + // Horizontal cards with locked focus control + if (widget.hub.entries.isNotEmpty) + Focus( + focusNode: _hubFocusNode, + onKeyEvent: _handleKeyEvent, + child: LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final baseCardWidth = + (ScreenBreakpoints.isLargeDesktop(screenWidth) + ? 220.0 + : ScreenBreakpoints.isDesktop(screenWidth) + ? 200.0 + : ScreenBreakpoints.isWideTablet(screenWidth) + ? 190.0 + : 160.0) * + densityScale; + + final cardWidth = baseCardWidth; + final posterWidth = cardWidth - 16; + final posterHeight = posterWidth * 1.5; // 2:3 aspect + final containerHeight = posterHeight + 66; + _itemExtent = cardWidth + 4; + + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + itemCount: widget.hub.entries.length, + itemBuilder: (context, index) { + final entry = widget.hub.entries[index]; + final isItemFocused = hasFocus && index == _focusedIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _LiveTvPosterCard( + entry: entry, + width: cardWidth, + posterHeight: posterHeight, + isFocused: isItemFocused, + onTap: () { + _onItemTapped(index); + widget.onTap(entry); + }, + onLongPress: () { + _onItemTapped(index); + widget.onLongPress(entry); + }, + ), + ); + }, + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Poster card — always 2:3, shows poster image + title + subtitle +// --------------------------------------------------------------------------- + +class _LiveTvPosterCard extends StatelessWidget { + final LiveTvHubEntry entry; + final double width; + final double posterHeight; + final bool isFocused; + final VoidCallback onTap; + final VoidCallback onLongPress; + + const _LiveTvPosterCard({ + required this.entry, + required this.width, + required this.posterHeight, + required this.isFocused, + 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 FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox( + width: width, + 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/screens/main_screen.dart b/lib/screens/main_screen.dart index c58330d6..0c5e8f1f 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -37,6 +37,7 @@ import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import 'discover_screen.dart'; import 'libraries/libraries_screen.dart'; +import 'livetv/live_tv_screen.dart'; import 'search_screen.dart'; import 'downloads/downloads_screen.dart'; import 'settings/settings_screen.dart'; @@ -93,6 +94,8 @@ class _MainScreenState extends State with RouteAware, WindowListener bool _autoSwitchedToDownloads = false; OfflineModeProvider? _offlineModeProvider; + MultiServerProvider? _multiServerProvider; + bool _lastHasLiveTv = false; /// Whether a reconnection attempt is in progress bool _isReconnecting = false; @@ -103,6 +106,7 @@ class _MainScreenState extends State with RouteAware, WindowListener late List _screens; final GlobalKey> _discoverKey = GlobalKey(); final GlobalKey> _librariesKey = GlobalKey(); + final GlobalKey> _liveTvKey = GlobalKey(); final GlobalKey> _searchKey = GlobalKey(); final GlobalKey> _downloadsKey = GlobalKey(); final GlobalKey> _settingsKey = GlobalKey(); @@ -388,6 +392,14 @@ class _MainScreenState extends State with RouteAware, WindowListener _offlineModeProvider!.addListener(_handleOfflineStatusChanged); } + // Listen for Live TV / DVR availability changes + final multiServer = context.read(); + if (multiServer != _multiServerProvider) { + _multiServerProvider?.removeListener(_handleLiveTvChanged); + _multiServerProvider = multiServer; + _multiServerProvider!.addListener(_handleLiveTvChanged); + } + // Wire up Companion Remote command routing (desktop only, once) if (!_companionRemoteSetup && PlatformDetector.isDesktop(context)) { _companionRemoteSetup = true; @@ -406,40 +418,41 @@ class _MainScreenState extends State with RouteAware, WindowListener }; final receiver = CompanionRemoteReceiver.instance; - final tabCount = _getVisibleTabs(_isOffline).length; receiver.onTabNext = () { + final tabCount = _getVisibleTabs(_isOffline).length; _selectTab((_currentIndex + 1) % tabCount); }; receiver.onTabPrevious = () { + final tabCount = _getVisibleTabs(_isOffline).length; _selectTab((_currentIndex - 1 + tabCount) % tabCount); }; receiver.onTabDiscover = () { - final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) _selectTab(idx); }; receiver.onTabLibraries = () { - final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) _selectTab(idx); }; receiver.onTabSearch = () { - final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) _selectTab(idx); }; receiver.onTabDownloads = () { - final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) _selectTab(idx); }; receiver.onTabSettings = () { - final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) _selectTab(idx); }; receiver.onHome = () { - final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) _selectTab(idx); }; receiver.onSearchAction = (query) { - final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline); + final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (idx >= 0) { _selectTab(idx); if (query != null && query.isNotEmpty) { @@ -462,6 +475,7 @@ class _MainScreenState extends State with RouteAware, WindowListener windowManager.setPreventClose(false); } _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); + _multiServerProvider?.removeListener(_handleLiveTvChanged); _sidebarFocusScope.dispose(); _contentFocusScope.dispose(); @@ -511,14 +525,16 @@ class _MainScreenState extends State with RouteAware, WindowListener List _buildScreens(bool offline) { // In offline mode, only show Downloads and Settings - // In online mode, show all 5 screens if (offline) { return [DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey)]; } + final hasLiveTv = context.read().hasLiveTv; + return [ DiscoverScreen(key: _discoverKey, onBecameVisible: _onDiscoverBecameVisible), LibrariesScreen(key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged), + if (hasLiveTv) LiveTvScreen(key: _liveTvKey), SearchScreen(key: _searchKey), DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey), @@ -557,6 +573,20 @@ class _MainScreenState extends State with RouteAware, WindowListener }); } + void _handleLiveTvChanged() { + final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; + if (hasLiveTv == _lastHasLiveTv) return; + _lastHasLiveTv = hasLiveTv; + + setState(() { + final currentTabId = _tabIdForIndex(_isOffline, _currentIndex); + _screens = _buildScreens(_isOffline); + // Restore the correct tab index after rebuilding + final newIndex = NavigationTab.indexFor(currentTabId, isOffline: _isOffline, hasLiveTv: hasLiveTv); + _currentIndex = newIndex >= 0 ? newIndex : 0; + }); + } + void _handleOfflineStatusChanged() { final newOffline = _offlineModeProvider?.isOffline ?? widget.isOfflineMode; @@ -586,7 +616,7 @@ class _MainScreenState extends State with RouteAware, WindowListener // Coming back online: restore the last online tab if we forced a switch to Downloads. if (_autoSwitchedToDownloads) { final restoredTab = _lastOnlineTabId ?? NavigationTabId.discover; - final restoredIndex = NavigationTab.indexFor(restoredTab, isOffline: _isOffline); + final restoredIndex = NavigationTab.indexFor(restoredTab, isOffline: _isOffline, hasLiveTv: _hasLiveTv); _currentIndex = restoredIndex >= 0 ? restoredIndex : 0; } else { _currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline); @@ -641,7 +671,7 @@ class _MainScreenState extends State with RouteAware, WindowListener }); } // When content regains focus while on Settings, restore focus to last focused setting - final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline); + final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv); if (_currentIndex == settingsIndex) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_settingsKey.currentState case final FocusableTab focusable) { @@ -795,7 +825,7 @@ class _MainScreenState extends State with RouteAware, WindowListener }); // Handle screen-specific logic - final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline); + final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv); // Skip online-only screen logic in offline mode if (!_isOffline) { @@ -821,7 +851,7 @@ class _MainScreenState extends State with RouteAware, WindowListener } } // Focus search input when selecting Search tab - if (index == 2) { + if (NavigationTab.isTabAtIndex(NavigationTabId.search, index, isOffline: _isOffline, hasLiveTv: _hasLiveTv)) { if (_searchKey.currentState case final SearchInputFocusable searchable) { searchable.focusSearchInput(); } @@ -854,9 +884,18 @@ class _MainScreenState extends State with RouteAware, WindowListener } } + /// Whether the Live TV tab is currently visible + bool get _hasLiveTv { + try { + return context.read().hasLiveTv; + } catch (_) { + return false; + } + } + /// Get navigation tabs filtered by offline mode List _getVisibleTabs(bool isOffline) { - return NavigationTab.getVisibleTabs(isOffline: isOffline); + return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv); } /// Get the tab ID for a given index, clamping to the available range. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 8293eb1c..5acc008d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -14,6 +14,7 @@ import '../mpv/mpv.dart'; import '../mpv/player/player_android.dart'; import '../../services/plex_client.dart'; +import '../models/livetv_channel.dart'; import '../services/plex_api_cache.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; @@ -68,6 +69,17 @@ class VideoPlayerScreen extends StatefulWidget { final int selectedMediaIndex; final bool isOffline; + // Live TV fields + final bool isLive; + final String? liveChannelName; + final String? liveStreamUrl; + final List? liveChannels; + final int? liveCurrentChannelIndex; + final String? liveDvrKey; + final PlexClient? liveClient; + final String? liveSessionIdentifier; + final String? liveSessionPath; + const VideoPlayerScreen({ super.key, required this.metadata, @@ -75,6 +87,15 @@ class VideoPlayerScreen extends StatefulWidget { this.preferredSubtitleTrack, this.selectedMediaIndex = 0, this.isOffline = false, + this.isLive = false, + this.liveChannelName, + this.liveStreamUrl, + this.liveChannels, + this.liveCurrentChannelIndex, + this.liveDvrKey, + this.liveClient, + this.liveSessionIdentifier, + this.liveSessionPath, }); @override @@ -114,6 +135,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isHandlingBack = false; bool _hasThumbnails = false; + // Live TV channel navigation + int _liveChannelIndex = -1; + String? _liveChannelName; + String? _liveSessionIdentifier; + String? _liveSessionPath; + Timer? _liveTimelineTimer; + DateTime? _livePlaybackStartTime; + String? _liveRatingKey; + int? _liveDurationMs; + // Auto-play next episode Timer? _autoPlayTimer; int _autoPlayCountdown = 5; @@ -173,6 +204,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin _activeRatingKey = widget.metadata.ratingKey; _activeMediaIndex = widget.selectedMediaIndex; + // Initialize live TV channel tracking + _liveChannelIndex = widget.liveCurrentChannelIndex ?? -1; + _liveChannelName = widget.liveChannelName; + _liveSessionIdentifier = widget.liveSessionIdentifier; + _liveSessionPath = widget.liveSessionPath; + // Initialize Play Next dialog focus nodes _playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel'); _playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm'); @@ -574,6 +611,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _initializeServices() async { if (!mounted || player == null) return; + // Live TV: send timeline heartbeats to keep transcode session alive + if (widget.isLive) { + _startLiveTimelineUpdates(); + return; + } + // Get client (null in offline mode) final client = widget.isOffline ? null : _getClientForMetadata(context); @@ -683,6 +726,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Skip play queue in offline mode (requires server connection) if (widget.isOffline) return; + // Skip play queue for live TV (would interfere with tuner session) + if (widget.isLive) return; + // Only create play queues for episodes if (!widget.metadata.isEpisode) { return; @@ -742,7 +788,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } Future _loadAdjacentEpisodes() async { - if (!mounted) return; + if (!mounted || widget.isLive) return; if (widget.isOffline) { // Offline mode: find next/previous from downloaded episodes @@ -813,6 +859,61 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _startPlayback() async { if (!mounted) return; + // Live TV mode: bypass standard playback initialization + if (widget.isLive) { + try { + _hasFirstFrame.value = false; + await player!.requestAudioFocus(); + await _setLiveStreamOptions(); + + String streamUrl; + if (widget.liveStreamUrl != null) { + streamUrl = widget.liveStreamUrl!; + } else { + // Tune channel inside the player (shows loading spinner while tuning) + final channels = widget.liveChannels; + final channelIndex = _liveChannelIndex; + if (channels == null || channelIndex < 0 || channelIndex >= channels.length) { + throw Exception('No channel to tune'); + } + final channel = channels[channelIndex]; + appLogger.d('Tune: dvrKey=${widget.liveDvrKey} channelKey=${channel.key}'); + final client = widget.liveClient!; + final result = await client.tuneChannel(widget.liveDvrKey!, channel.key); + if (result == null) throw Exception('Failed to tune channel'); + + streamUrl = '${client.config.baseUrl}${result.streamPath}' + .withPlexToken(client.config.token); + + _liveSessionIdentifier = result.sessionIdentifier; + _liveSessionPath = result.sessionPath; + _liveRatingKey = result.metadata.ratingKey; + _liveDurationMs = result.metadata.duration; + } + + _livePlaybackStartTime = DateTime.now(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + + if (mounted) { + setState(() { + _availableVersions = []; + _currentMediaInfo = null; + _isPlayerInitialized = true; + }); + } + + _startLiveTimelineUpdates(); + } catch (e) { + appLogger.e('Failed to start live TV playback', error: e); + _sendLiveTimeline('stopped'); + if (mounted) { + showErrorSnackBar(context, e.toString()); + _handleBackButton(); + } + } + return; + } + // Capture providers before async gaps final offlineWatchService = widget.isOffline ? context.read() : null; @@ -1205,10 +1306,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Store provider reference for use in dispose and notify remote try { _companionRemoteProvider = context.read(); - _companionRemoteProvider!.sendCommand( - RemoteCommandType.syncState, - data: {'playerActive': true}, - ); + _companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true}); } catch (_) {} } @@ -1229,10 +1327,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _savedOnHome = null; // Notify remote that player is no longer active - _companionRemoteProvider?.sendCommand( - RemoteCommandType.syncState, - data: {'playerActive': false}, - ); + _companionRemoteProvider?.sendCommand(RemoteCommandType.syncState, data: {'playerActive': false}); _companionRemoteProvider = null; } @@ -1270,7 +1365,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _onAudioTrackChanged(next); if (mounted) { - final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + final label = + 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; showAppSnackBar(context, label, duration: const Duration(seconds: 1)); } } @@ -1365,6 +1461,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker?.sendProgress('stopped'); _progressTracker?.stopTracking(); _progressTracker?.dispose(); + _sendLiveTimeline('stopped'); + _stopLiveTimelineUpdates(); // Remove PiP state listener, clear callback, and dispose video filter manager _videoPIPManager?.isPipActive.removeListener(_onPipStateChanged); @@ -1564,6 +1662,143 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _navigateToEpisode(_previousEpisode!); } + bool _isSwitchingChannel = false; + + /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) + /// Start periodic timeline heartbeats for live TV transcode session. + void _startLiveTimelineUpdates() { + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { + final state = player?.state.playing == true ? 'playing' : 'paused'; + _sendLiveTimeline(state); + }); + // Send initial heartbeat immediately + _sendLiveTimeline('playing'); + } + + void _stopLiveTimelineUpdates() { + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = null; + } + + Future _sendLiveTimeline(String state) async { + final sessionId = _liveSessionIdentifier; + final sessionPath = _liveSessionPath; + if (sessionId == null || sessionPath == null) return; + + final client = widget.liveClient; + if (client == null) return; + + try { + // Use the program ratingKey from tune metadata, not the channel key + final ratingKey = _liveRatingKey ?? widget.metadata.ratingKey; + + // playbackTime: wall-clock ms since playback started + final playbackTime = _livePlaybackStartTime != null + ? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds + : 0; + + // For live TV, player position/duration are unreliable (often 0). + // Use playbackTime as time, and program duration from tune metadata. + final time = playbackTime; + final duration = _liveDurationMs ?? 0; + + await client.updateLiveTimeline( + ratingKey: ratingKey, + sessionPath: sessionPath, + sessionIdentifier: sessionId, + state: state, + time: time, + duration: duration, + playbackTime: playbackTime, + ); + } catch (e) { + appLogger.d('Live timeline update failed', error: e); + } + } + + /// Configure MPV/FFmpeg options for live streaming resilience. + /// Enables automatic reconnection on EOF and network errors. + Future _setLiveStreamOptions() async { + final p = player!; + // FFmpeg HTTP protocol reconnection + await p.setProperty('stream-lavf-o-append', 'reconnect=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_at_eof=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_streamed=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_on_network_error=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_delay_max=30'); + // Demuxer: retry up to 1000 times on stream reload failures + await p.setProperty('demuxer-lavf-o', 'max_reload=1000'); + await p.setProperty('force-seekable', 'no'); + } + + Future _switchLiveChannel(int delta) async { + final channels = widget.liveChannels; + if (channels == null || channels.isEmpty) return; + if (_isSwitchingChannel) return; // debounce concurrent switches + + final newIndex = _liveChannelIndex + delta; + if (newIndex < 0 || newIndex >= channels.length) return; + + _isSwitchingChannel = true; + + // Stop old session heartbeats and notify server + _stopLiveTimelineUpdates(); + await _sendLiveTimeline('stopped'); + + final channel = channels[newIndex]; + appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})'); + + setState(() => _hasFirstFrame.value = false); + + try { + // Look up the correct client/DVR for this channel's server + final multiServer = context.read(); + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? + multiServer.liveTvServers.firstOrNull; + + if (serverInfo == null) return; + + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) return; + + final result = await client.tuneChannel(serverInfo.dvrKey, channel.key); + if (result == null || !mounted) return; + + final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token); + + await _setLiveStreamOptions(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + + _livePlaybackStartTime = DateTime.now(); + _liveRatingKey = result.metadata.ratingKey; + _liveDurationMs = result.metadata.duration; + + setState(() { + _liveChannelIndex = newIndex; + _liveChannelName = channel.displayName; + _liveSessionIdentifier = result.sessionIdentifier; + _liveSessionPath = result.sessionPath; + }); + + // Restart timeline heartbeats for the new session + _startLiveTimelineUpdates(); + } catch (e) { + appLogger.e('Failed to switch channel', error: e); + } finally { + _isSwitchingChannel = false; + } + } + + bool get _hasNextChannel => + widget.isLive && + widget.liveChannels != null && + _liveChannelIndex >= 0 && + _liveChannelIndex < (widget.liveChannels!.length - 1); + + bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0; + void _startAutoPlayTimer() { _autoPlayTimer?.cancel(); _autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -2001,8 +2236,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin controls: (context) => plexVideoControlsBuilder( player!, widget.metadata, - onNext: (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null, - onPrevious: (_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null, + onNext: widget.isLive + ? (_hasNextChannel ? () => _switchLiveChannel(1) : null) + : ((_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null), + onPrevious: widget.isLive + ? (_hasPreviousChannel ? () => _switchLiveChannel(-1) : null) + : ((_previousEpisode != null && _canNavigateEpisodes()) ? _playPrevious : null), availableVersions: _availableVersions, selectedMediaIndex: widget.selectedMediaIndex, onTogglePIPMode: _togglePIPMode, @@ -2033,6 +2272,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null ? (Duration time) => _buildThumbnailUrl(context, time)! : null, + isLive: widget.isLive, + liveChannelName: _liveChannelName, ), ); }, @@ -2269,7 +2510,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ), const SizedBox(width: 8), - Text(t.watchTogether.reconnectingToHost, style: const TextStyle(color: Colors.white, fontSize: 12)), + Text( + t.watchTogether.reconnectingToHost, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), ], ), ), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index cac47cfd..2ac0f06c 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,8 +1,15 @@ import 'dart:convert'; +import 'dart:math'; import 'dart:ui' show VoidCallback; 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'; import '../models/plex_config.dart'; import '../models/play_queue_response.dart'; import '../models/plex_file_info.dart'; @@ -1129,6 +1136,34 @@ class PlexClient { ); } + /// Send a live TV timeline heartbeat to keep the transcode session alive. + Future updateLiveTimeline({ + required String ratingKey, + required String sessionPath, + required String sessionIdentifier, + required String state, + required int time, + required int duration, + required int playbackTime, + }) async { + final response = await _dio.get( + '/:/timeline', + queryParameters: { + 'ratingKey': ratingKey, + 'key': sessionPath, + 'state': state, + 'hasMDE': '1', + 'time': time, + 'duration': duration, + 'playbackTime': playbackTime, + 'X-Plex-Session-Identifier': sessionIdentifier, + }, + ); + if (response.statusCode != null && response.statusCode != 200) { + appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); + } + } + /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { @@ -1941,6 +1976,526 @@ class PlexClient { } } + // ============================================================================ + // Live TV / DVR Methods + // ============================================================================ + + /// Get all DVR devices configured on this server + Future> getDvrs() async { + return _wrapListApiCall( + () => _dio.get('/livetv/dvrs'), + (response) { + final container = _getMediaContainer(response); + if (container != null && container['Dvr'] != null) { + return (container['Dvr'] as List) + .map((json) => LiveTvDvr.fromJson(json as Map)) + .toList(); + } + return []; + }, + 'Failed to get DVRs', + ); + } + + /// Check if this server has at least one DVR configured + Future hasDvr() async { + final dvrs = await getDvrs(); + return dvrs.isNotEmpty; + } + + /// Get EPG channels for a specific lineup + Future> getEpgChannels({String? lineup}) async { + final queryParams = {}; + if (lineup != null) queryParams['lineup'] = lineup; + + return _wrapListApiCall( + () => _dio.get('/livetv/epg/channels', queryParameters: queryParams), + (response) { + final container = _getMediaContainer(response); + if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { + appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); + } + if (container != null && container['Channel'] != null) { + return (container['Channel'] as List) + .map((json) => LiveTvChannel.fromJson(json as Map) + .copyWith(serverId: serverId, serverName: serverName)) + .where((ch) => ch.key.isNotEmpty) + .toList(); + } + // Also check for Metadata key (some endpoints return channels there) + if (container != null && container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => LiveTvChannel.fromJson(json as Map) + .copyWith(serverId: serverId, serverName: serverName)) + .where((ch) => ch.key.isNotEmpty) + .toList(); + } + return []; + }, + 'Failed to get EPG channels', + ); + } + + /// Cached EPG providers (discovered from /media/providers) + List<({String identifier, String gridEndpoint})>? _epgProviders; + + /// Discover all EPG providers from media providers + Future> _discoverEpgProviders() async { + if (_epgProviders != null) return _epgProviders!; + + try { + final response = await _dio.get('/media/providers'); + final container = _getMediaContainer(response); + if (container == null) return []; + + final providers = container['MediaProvider'] as List?; + if (providers == null) return []; + + final results = <({String identifier, String gridEndpoint})>[]; + + for (final provider in providers) { + if (provider is! Map) continue; + final protocols = provider['protocols'] as String?; + if (protocols == null || !protocols.contains('livetv')) continue; + + final identifier = provider['identifier'] as String?; + if (identifier == null) continue; + + final features = provider['Feature'] as List?; + if (features == null) continue; + for (final feature in features) { + if (feature is! Map) continue; + if (feature['type'] == 'grid') { + final gridEndpoint = feature['key'] as String?; + if (gridEndpoint != null) { + results.add((identifier: identifier, gridEndpoint: gridEndpoint)); + appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); + } + } + } + } + + _epgProviders = results; + appLogger.d('Discovered ${results.length} EPG provider(s)'); + if (results.isEmpty) { + appLogger.w('No EPG providers found'); + } + return results; + } catch (e) { + appLogger.e('Failed to discover EPG providers', error: e); + } + return []; + } + + /// Get guide/program data for channels (EPG grid data) + /// Discovers grid endpoints from /media/providers on first call and queries all providers + Future> getEpgGrid({ + int? beginsAt, + int? endsAt, + }) async { + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; + + final queryParams = {}; + if (beginsAt != null) queryParams['beginsAt>'] = beginsAt; + if (endsAt != null) queryParams['endsAt<'] = endsAt; + + final allPrograms = []; + + for (final provider in providers) { + try { + final programs = await _wrapListApiCall( + () => _dio.get(provider.gridEndpoint, queryParameters: queryParams), + (response) { + final container = _getMediaContainer(response); + if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { + appLogger.d('EPG grid sample from ${provider.identifier}: ${(container['Metadata'] as List).first}'); + } + final programs = []; + if (container != null && container['Metadata'] != null) { + for (final item in container['Metadata'] as List) { + try { + programs.add(LiveTvProgram.fromJson(item as Map)); + } catch (_) {} + } + } + // Some responses nest programs inside Hub entries + if (container != null && container['Hub'] != null) { + for (final hub in container['Hub'] as List) { + if (hub is Map && hub['Metadata'] != null) { + for (final item in hub['Metadata'] as List) { + try { + programs.add(LiveTvProgram.fromJson(item as Map)); + } catch (_) {} + } + } + } + } + return programs; + }, + 'Failed to get EPG grid from ${provider.identifier}', + ); + appLogger.d('EPG grid from ${provider.identifier}: ${programs.length} programs'); + allPrograms.addAll(programs); + } catch (e) { + appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); + } + } + + return allPrograms; + } + + /// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints. + /// Returns hubs with both display metadata and EPG timing/channel data per item. + Future> getLiveTvHubs({int count = 12}) async { + final providers = await _discoverEpgProviders(); + if (providers.isEmpty) return []; + + final allHubs = []; + + for (final provider in providers) { + try { + final response = await _dio.get( + '/${provider.identifier}/hubs/discover', + queryParameters: { + 'count': count, + 'includeStations': 1, + 'includeRecentChannels': 1, + 'includeMeta': 1, + 'includeExternalMetadata': 1, + }, + ); + + final container = _getMediaContainer(response); + if (container != null && container['Hub'] != null) { + for (final hubJson in container['Hub'] as List) { + try { + final metadataList = hubJson['Metadata'] as List?; + if (metadataList == null || metadataList.isEmpty) continue; + + 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) { + allHubs.add(LiveTvHubResult( + title: hubJson['title'] as String? ?? 'Unknown', + hubKey: hubJson['key'] as String? ?? '', + entries: entries, + )); + } + } catch (e) { + appLogger.w('Failed to parse live TV hub', error: e); + } + } + } + } catch (e) { + appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e); + } + } + + return allHubs; + } + + /// 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'; + final rand = Random(); + return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join(); + } + + /// Tune to a live TV channel and set up the transcode session. + /// + /// Flow: tune → decision → return /start path (MKV-over-HTTP). + Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(String dvrKey, String channelIdentifier) async { + try { + final sessionIdentifier = _generateSessionIdentifier(); + + final response = await _dio.post( + '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', + queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier}, + ); + + if (response.statusCode != null && response.statusCode! >= 400) { + appLogger.w('Tune channel returned status ${response.statusCode}'); + return null; + } + + final container = _getMediaContainer(response); + if (container == null) return null; + + // Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata + Map? metadataJson; + final subscriptions = container['MediaSubscription'] as List?; + if (subscriptions != null && subscriptions.isNotEmpty) { + final sub = subscriptions[0] as Map; + final ops = sub['MediaGrabOperation'] as List?; + if (ops != null && ops.isNotEmpty) { + final op = ops[0] as Map; + final nested = op['Metadata']; + if (nested is Map) { + metadataJson = nested; + } + } + } + metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map?; + + if (metadataJson == null) { + appLogger.w('Tune channel: no metadata in response'); + return null; + } + + final metadata = _createTaggedMetadata(metadataJson); + + final sessionPath = metadataJson['key'] as String?; + if (sessionPath == null) { + appLogger.w('Tune channel: no session path in metadata key'); + return null; + } + + // All identity goes in query params; the only HTTP header is Accept-Language + // (matching the official Plex client behaviour). + final allParams = { + 'hasMDE': '1', + 'path': sessionPath, + 'mediaIndex': '0', + 'partIndex': '0', + 'protocol': 'http', + 'fastSeek': '1', + 'directPlay': '0', + 'directStream': '1', + 'subtitleSize': '100', + 'audioBoost': '100', + 'location': 'lan', + 'addDebugOverlay': '0', + 'autoAdjustQuality': '0', + 'directStreamAudio': '1', + 'advancedSubtitles': 'text', + 'mediaBufferSize': '157286', + 'session': _generateSessionIdentifier(), + 'subtitles': 'auto', + 'copyts': '0', + 'Accept-Language': 'en', + 'X-Plex-Session-Identifier': sessionIdentifier, + 'X-Plex-Chunked': '1', + 'X-Plex-Incomplete-Segments': '1', + 'X-Plex-Product': config.product, + 'X-Plex-Version': config.version, + 'X-Plex-Client-Identifier': config.clientIdentifier, + 'X-Plex-Platform': config.platform, + 'X-Plex-Client-Profile-Name': 'Plex Desktop', + if (config.token != null) 'X-Plex-Token': config.token!, + }; + + // Manual query encoding — Dio encodes spaces as '+' but Plex requires '%20'. + final queryString = allParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + // Decision — bare Dio so no default X-Plex-* HTTP headers leak through. + final decisionDio = Dio(BaseOptions( + headers: {'Accept-Language': 'en'}, + connectTimeout: ConnectionTimeouts.connect, + receiveTimeout: ConnectionTimeouts.receive, + )); + final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; + final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl)); + + if (decisionResponse.statusCode != 200) { + appLogger.w('Decision returned ${decisionResponse.statusCode}'); + return null; + } + + // Token is added by the caller via .withPlexToken() + final startParams = Map.from(allParams)..remove('X-Plex-Token'); + final startQuery = startParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + return ( + metadata: metadata, + streamPath: '/video/:/transcode/universal/start?$startQuery', + sessionIdentifier: sessionIdentifier, + sessionPath: sessionPath, + ); + } catch (e, st) { + appLogger.e('Failed to tune channel', error: e, stackTrace: st); + return null; + } + } + + /// Reload the DVR guide data + Future reloadGuide(String dvrKey) async { + return _wrapBoolApiCall( + () => _dio.post('/livetv/dvrs/$dvrKey/reloadGuide'), + 'Failed to reload guide', + ); + } + + /// Get active live TV sessions + Future> getLiveTvSessions() async { + return _wrapListApiCall( + () => _dio.get('/livetv/sessions'), + _extractMetadataList, + 'Failed to get live TV sessions', + ); + } + + /// Get all DVR recording subscriptions + Future> getSubscriptions() async { + return _wrapListApiCall( + () => _dio.get('/media/subscriptions'), + (response) { + final container = _getMediaContainer(response); + if (container != null && container['MediaSubscription'] != null) { + return (container['MediaSubscription'] as List) + .map((json) { + final sub = LiveTvSubscription.fromJson(json as Map); + return LiveTvSubscription( + key: sub.key, ratingKey: sub.ratingKey, guid: sub.guid, + title: sub.title, summary: sub.summary, type: sub.type, + thumb: sub.thumb, art: sub.art, + targetLibrarySectionID: sub.targetLibrarySectionID, + targetSectionID: sub.targetSectionID, createdAt: sub.createdAt, + settings: sub.settings, serverId: serverId, + ); + }) + .toList(); + } + return []; + }, + 'Failed to get subscriptions', + ); + } + + /// Create a DVR recording subscription + Future createSubscription({ + required String type, + required int targetSectionID, + required int targetLibrarySectionID, + Map? prefs, + String? hint, + String? uri, + }) async { + try { + final queryParams = { + 'type': type, + 'targetSectionID': targetSectionID, + 'targetLibrarySectionID': targetLibrarySectionID, + }; + if (hint != null) queryParams['hint'] = hint; + if (uri != null) queryParams['uri'] = uri; + if (prefs != null) { + for (final entry in prefs.entries) { + queryParams['prefs[${entry.key}]'] = entry.value; + } + } + + final response = await _dio.post('/media/subscriptions', queryParameters: queryParams); + final container = _getMediaContainer(response); + if (container != null && container['MediaSubscription'] != null) { + final subs = container['MediaSubscription'] as List; + if (subs.isNotEmpty) { + return LiveTvSubscription.fromJson(subs.first as Map); + } + } + return null; + } catch (e) { + appLogger.e('Failed to create subscription', error: e); + return null; + } + } + + /// Delete a DVR recording subscription + Future deleteSubscription(String subscriptionId) async { + return _wrapBoolApiCall( + () => _dio.delete('/media/subscriptions/$subscriptionId'), + 'Failed to delete subscription', + ); + } + + /// Edit a DVR recording subscription's preferences + Future editSubscription(String subscriptionId, Map prefs) async { + final queryParams = {}; + for (final entry in prefs.entries) { + queryParams['prefs[${entry.key}]'] = entry.value; + } + return _wrapBoolApiCall( + () => _dio.put('/media/subscriptions/$subscriptionId', queryParameters: queryParams), + 'Failed to edit subscription', + ); + } + + /// Get scheduled DVR recordings + Future> getScheduledRecordings() async { + return _wrapListApiCall( + () => _dio.get('/media/subscriptions/scheduled'), + (response) { + final container = _getMediaContainer(response); + if (container != null && container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => ScheduledRecording.fromJson(json as Map)) + .toList(); + } + return []; + }, + 'Failed to get scheduled recordings', + ); + } + + /// Get subscription template for a program (used for recording setup) + Future?> getSubscriptionTemplate(String guid) async { + try { + final response = await _dio.get( + '/media/subscriptions/template', + queryParameters: {'guid': guid}, + ); + return _getMediaContainer(response); + } catch (e) { + appLogger.e('Failed to get subscription template', error: e); + return null; + } + } + Future _handleEndpointSwitch(String newBaseUrl) async { if (config.baseUrl == newBaseUrl) { return; diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart new file mode 100644 index 00000000..7b047115 --- /dev/null +++ b/lib/utils/live_tv_player_navigation.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import '../models/livetv_channel.dart'; +import '../models/plex_metadata.dart'; +import '../screens/video_player_screen.dart'; +import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; + +/// Navigate to the video player for a live TV channel. +/// +/// Pushes the player screen immediately with a placeholder metadata. +/// The actual tuning (tune POST + decision GET) happens inside the player, +/// which shows a loading spinner while it works. +/// +/// [channels] is the full channel list for channel up/down navigation. +Future navigateToLiveTv( + BuildContext context, { + required PlexClient client, + required String dvrKey, + required LiveTvChannel channel, + List? channels, +}) async { + final navigator = Navigator.of(context); + + appLogger.d('Navigating to live channel: ${channel.displayName} (${channel.key})'); + + final placeholder = PlexMetadata( + ratingKey: channel.key, + key: channel.key, + type: 'clip', + title: channel.displayName, + ); + + final route = PageRouteBuilder( + settings: const RouteSettings(name: kVideoPlayerRouteName), + pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + metadata: placeholder, + isLive: true, + liveChannelName: channel.displayName, + liveStreamUrl: null, + liveChannels: channels, + liveCurrentChannelIndex: channels?.indexWhere( + (ch) => ch.key == channel.key, + ), + liveDvrKey: dvrKey, + liveClient: client, + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ); + + navigator.push(route); +} 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 diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index ce3d8542..1cbd366a 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -13,6 +13,7 @@ import '../models/plex_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; @@ -351,14 +352,48 @@ class SideNavigationRailState extends State { const SizedBox(height: 8), + // Live TV (only if DVR available) + if (context.watch().hasLiveTv) ...[ + _buildNavItem( + icon: Symbols.live_tv_rounded, + selectedIcon: Symbols.live_tv_rounded, + label: Translations.of(context).navigation.liveTv, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.liveTv, + widget.selectedIndex, + isOffline: widget.isOfflineMode, + hasLiveTv: true, + ), + isFocused: _focusTracker.isFocused('liveTv'), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor(NavigationTabId.liveTv, isOffline: widget.isOfflineMode, hasLiveTv: true), + ), + focusNode: _focusTracker.get('liveTv'), + isCollapsed: isCollapsed, + ), + + const SizedBox(height: 8), + ], + // Search _buildNavItem( icon: Symbols.search_rounded, selectedIcon: Symbols.search_rounded, label: Translations.of(context).common.search, - isSelected: widget.selectedIndex == 2, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.search, + widget.selectedIndex, + isOffline: widget.isOfflineMode, + hasLiveTv: context.read().hasLiveTv, + ), isFocused: _focusTracker.isFocused(_kSearch), - onTap: () => widget.onDestinationSelected(2), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor( + NavigationTabId.search, + isOffline: widget.isOfflineMode, + hasLiveTv: context.read().hasLiveTv, + ), + ), focusNode: _focusTracker.get(_kSearch), isCollapsed: isCollapsed, ), @@ -367,42 +402,50 @@ class SideNavigationRailState extends State { ], // Downloads - _buildNavItem( - icon: Symbols.download_rounded, - selectedIcon: Symbols.download_rounded, - label: Translations.of(context).navigation.downloads, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.downloads, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - ), - isFocused: _focusTracker.isFocused(_kDownloads), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor(NavigationTabId.downloads, isOffline: widget.isOfflineMode), - ), - focusNode: _focusTracker.get(_kDownloads), - isCollapsed: isCollapsed, - ), + Builder(builder: (context) { + final hasLiveTv = context.read().hasLiveTv; + return _buildNavItem( + icon: Symbols.download_rounded, + selectedIcon: Symbols.download_rounded, + label: Translations.of(context).navigation.downloads, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.downloads, + widget.selectedIndex, + isOffline: widget.isOfflineMode, + hasLiveTv: hasLiveTv, + ), + isFocused: _focusTracker.isFocused(_kDownloads), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor(NavigationTabId.downloads, isOffline: widget.isOfflineMode, hasLiveTv: hasLiveTv), + ), + focusNode: _focusTracker.get(_kDownloads), + isCollapsed: isCollapsed, + ); + }), const SizedBox(height: 8), // Settings - _buildNavItem( - icon: Symbols.settings_rounded, - selectedIcon: Symbols.settings_rounded, - label: Translations.of(context).common.settings, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.settings, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - ), - isFocused: _focusTracker.isFocused(_kSettings), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor(NavigationTabId.settings, isOffline: widget.isOfflineMode), - ), - focusNode: _focusTracker.get(_kSettings), - isCollapsed: isCollapsed, - ), + Builder(builder: (context) { + final hasLiveTv = context.read().hasLiveTv; + return _buildNavItem( + icon: Symbols.settings_rounded, + selectedIcon: Symbols.settings_rounded, + label: Translations.of(context).common.settings, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.settings, + widget.selectedIndex, + isOffline: widget.isOfflineMode, + hasLiveTv: hasLiveTv, + ), + isFocused: _focusTracker.isFocused(_kSettings), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor(NavigationTabId.settings, isOffline: widget.isOfflineMode, hasLiveTv: hasLiveTv), + ), + focusNode: _focusTracker.get(_kSettings), + isCollapsed: isCollapsed, + ); + }), ], ), ), diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 3c1073c7..72a5473b 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -83,6 +83,12 @@ class DesktopVideoControls extends StatefulWidget { /// Optional callback that returns a thumbnail URL for a given timestamp. final String Function(Duration time)? thumbnailUrlBuilder; + /// Whether this is a live TV stream + final bool isLive; + + /// Channel name for live TV display + final String? liveChannelName; + const DesktopVideoControls({ super.key, required this.player, @@ -127,6 +133,8 @@ class DesktopVideoControls extends StatefulWidget { this.shaderService, this.onShaderChanged, this.thumbnailUrlBuilder, + this.isLive = false, + this.liveChannelName, }); @override @@ -411,10 +419,30 @@ class DesktopVideoControlsState extends State { Widget _buildTopBarContent(BuildContext context, double leftPadding) { final topBar = Padding( padding: EdgeInsets.only(left: leftPadding, right: 16), - child: VideoControlsHeader( - metadata: widget.metadata, - style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine, - onBack: widget.onBack, + child: Row( + children: [ + Expanded( + child: VideoControlsHeader( + metadata: widget.metadata, + style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine, + onBack: widget.onBack, + ), + ), + if (widget.isLive) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + t.liveTv.live, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ], ), ); @@ -427,67 +455,71 @@ class DesktopVideoControlsState extends State { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( children: [ - // Row 1: Timeline with time indicators - VideoTimelineBar( - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - onSeek: widget.onSeek, - onSeekEnd: widget.onSeekEnd, - horizontalLayout: true, - focusNode: _timelineFocusNode, - onKeyEvent: _handleTimelineKeyEvent, - onFocusChange: _onFocusChange, - enabled: canInteract, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, - ), - const SizedBox(height: 4), + // Row 1: Timeline with time indicators (hidden for live TV) + if (!widget.isLive) ...[ + VideoTimelineBar( + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + onSeek: widget.onSeek, + onSeekEnd: widget.onSeekEnd, + horizontalLayout: true, + focusNode: _timelineFocusNode, + onKeyEvent: _handleTimelineKeyEvent, + onFocusChange: _onFocusChange, + enabled: canInteract, + thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + ), + const SizedBox(height: 4), + ], // Row 2: Playback controls and options Row( children: [ - // Previous item - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _prevItemFocusNode, - index: 0, - icon: Symbols.skip_previous_rounded, - color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl ? widget.onPrevious : null, - semanticLabel: t.videoControls.previousButton, + if (!widget.isLive) ...[ + // Previous item + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _prevItemFocusNode, + index: 0, + icon: Symbols.skip_previous_rounded, + color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl ? widget.onPrevious : null, + semanticLabel: t.videoControls.previousButton, + ), ), - ), - // Previous chapter - StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, posSnapshot) { - final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero); - return Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _prevChapterFocusNode, - index: 1, - icon: Symbols.fast_rewind_rounded, - color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, - semanticLabel: t.videoControls.previousChapterButton, - tooltip: prevLabel, - ), - ); - }, - ), - // Skip backward - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _skipBackFocusNode, - index: 2, - icon: widget.getReplayIcon(widget.seekTimeSmall), - onPressed: widget.canControl ? widget.onSeekBackward : null, - semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), + // Previous chapter + StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, posSnapshot) { + final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero); + return Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _prevChapterFocusNode, + index: 1, + icon: Symbols.fast_rewind_rounded, + color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, + semanticLabel: t.videoControls.previousChapterButton, + tooltip: prevLabel, + ), + ); + }, ), - ), + // Skip backward + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipBackFocusNode, + index: 2, + icon: widget.getReplayIcon(widget.seekTimeSmall), + onPressed: widget.canControl ? widget.onSeekBackward : null, + semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), + ), + ), + ], // Play/Pause Opacity( opacity: widget.canControl ? 1.0 : 0.5, @@ -513,94 +545,99 @@ class DesktopVideoControlsState extends State { }, ), ), - // Skip forward - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _skipForwardFocusNode, - index: 4, - icon: widget.getForwardIcon(widget.seekTimeSmall), - onPressed: widget.canControl ? widget.onSeekForward : null, - semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), + if (!widget.isLive) ...[ + // Skip forward + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipForwardFocusNode, + index: 4, + icon: widget.getForwardIcon(widget.seekTimeSmall), + onPressed: widget.canControl ? widget.onSeekForward : null, + semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), + ), ), - ), - // Next chapter - StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, posSnapshot) { - final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero); - return Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _nextChapterFocusNode, - index: 5, - icon: Symbols.fast_forward_rounded, - color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, - semanticLabel: t.videoControls.nextChapterButton, - tooltip: nextLabel, - ), - ); - }, - ), - // Next item - Opacity( - opacity: widget.canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _nextItemFocusNode, - index: 6, - icon: Symbols.skip_next_rounded, - color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl ? widget.onNext : null, - semanticLabel: t.videoControls.nextButton, - ), - ), - // Finish time (hidden when too narrow to fit) - Expanded( - child: StreamBuilder( + // Next chapter + StreamBuilder( stream: widget.player.streams.position, initialData: widget.player.state.position, - builder: (context, posSnap) { - return StreamBuilder( - stream: widget.player.streams.duration, - initialData: widget.player.state.duration, - builder: (context, durSnap) { - return StreamBuilder( - stream: widget.player.streams.rate, - initialData: widget.player.state.rate, - builder: (context, rateSnap) { - final position = posSnap.data ?? Duration.zero; - final duration = durSnap.data ?? Duration.zero; - final remaining = duration - position; - final rate = rateSnap.data ?? 1.0; - if (remaining.inSeconds <= 0) return const SizedBox.shrink(); - - final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate)); - const style = TextStyle(color: Colors.white70, fontSize: 13); - - return LayoutBuilder( - builder: (context, constraints) { - final tp = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - final textWidth = tp.width + 8; - tp.dispose(); - if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 8), - child: Text(text, style: style), - ); - }, - ); - }, - ); - }, + builder: (context, posSnapshot) { + final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero); + return Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _nextChapterFocusNode, + index: 5, + icon: Symbols.fast_forward_rounded, + color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, + semanticLabel: t.videoControls.nextChapterButton, + tooltip: nextLabel, + ), ); }, ), - ), + // Next item + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _nextItemFocusNode, + index: 6, + icon: Symbols.skip_next_rounded, + color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl ? widget.onNext : null, + semanticLabel: t.videoControls.nextButton, + ), + ), + ], + // Finish time (hidden for live TV and when too narrow to fit) + if (widget.isLive) + const Spacer() + else + Expanded( + child: StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, posSnap) { + return StreamBuilder( + stream: widget.player.streams.duration, + initialData: widget.player.state.duration, + builder: (context, durSnap) { + return StreamBuilder( + stream: widget.player.streams.rate, + initialData: widget.player.state.rate, + builder: (context, rateSnap) { + final position = posSnap.data ?? Duration.zero; + final duration = durSnap.data ?? Duration.zero; + final remaining = duration - position; + final rate = rateSnap.data ?? 1.0; + if (remaining.inSeconds <= 0) return const SizedBox.shrink(); + + final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate)); + const style = TextStyle(color: Colors.white70, fontSize: 13); + + return LayoutBuilder( + builder: (context, constraints) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + final textWidth = tp.width + 8; + tp.dispose(); + if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(text, style: style), + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), // Volume control VolumeControl( player: widget.player, @@ -638,6 +675,7 @@ class DesktopVideoControlsState extends State { onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, canControl: widget.canControl, + isLive: widget.isLive, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, ), diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 1fbebe2f..243650ec 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -44,6 +44,12 @@ class MobileVideoControls extends StatelessWidget { /// Optional callback that returns a thumbnail URL for a given timestamp. final String Function(Duration time)? thumbnailUrlBuilder; + /// Whether this is a live TV stream + final bool isLive; + + /// Channel name for live TV display + final String? liveChannelName; + const MobileVideoControls({ super.key, required this.player, @@ -64,6 +70,8 @@ class MobileVideoControls extends StatelessWidget { this.canControl = true, this.hasFirstFrame, this.thumbnailUrlBuilder, + this.isLive = false, + this.liveChannelName, }); @override @@ -116,14 +124,16 @@ class MobileVideoControls extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Previous episode button (greyed out when unavailable) - CircularControlButton( - semanticLabel: t.videoControls.previousButton, - icon: Symbols.skip_previous_rounded, - iconSize: 48, - onPressed: onPrevious, - ), - const SizedBox(width: 24), + if (!isLive) ...[ + // Previous episode button (greyed out when unavailable) + CircularControlButton( + semanticLabel: t.videoControls.previousButton, + icon: Symbols.skip_previous_rounded, + iconSize: 48, + onPressed: onPrevious, + ), + const SizedBox(width: 24), + ], CircularControlButton( semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, @@ -138,14 +148,16 @@ class MobileVideoControls extends StatelessWidget { } }, ), - const SizedBox(width: 24), - // Next episode button (greyed out when unavailable) - CircularControlButton( - semanticLabel: t.videoControls.nextButton, - icon: Symbols.skip_next_rounded, - iconSize: 48, - onPressed: onNext, - ), + if (!isLive) ...[ + const SizedBox(width: 24), + // Next episode button (greyed out when unavailable) + CircularControlButton( + semanticLabel: t.videoControls.nextButton, + icon: Symbols.skip_next_rounded, + iconSize: 48, + onPressed: onNext, + ), + ], ], ); }, @@ -153,6 +165,27 @@ class MobileVideoControls extends StatelessWidget { } Widget _buildBottomBar(BuildContext context) { + if (isLive) { + // For live TV, show channel name instead of timeline + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + t.liveTv.live, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ), + ); + } return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildBottomBarContent(context)); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index d432ca37..a50d95a8 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -77,6 +77,9 @@ class VideoSettingsSheet extends StatefulWidget { /// Whether the user can control playback (false hides speed option in host-only mode). final bool canControl; + /// Whether this is a live TV stream (hides speed settings). + final bool isLive; + /// Optional shader service for MPV shader control final ShaderService? shaderService; @@ -89,6 +92,7 @@ class VideoSettingsSheet extends StatefulWidget { required this.audioSyncOffset, required this.subtitleSyncOffset, this.canControl = true, + this.isLive = false, this.shaderService, this.onShaderChanged, }); @@ -101,6 +105,7 @@ class VideoSettingsSheet extends StatefulWidget { VoidCallback? onOpen, VoidCallback? onClose, bool canControl = true, + bool isLive = false, ShaderService? shaderService, VoidCallback? onShaderChanged, }) { @@ -113,6 +118,7 @@ class VideoSettingsSheet extends StatefulWidget { audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset, canControl: canControl, + isLive: isLive, shaderService: shaderService, onShaderChanged: onShaderChanged, ), @@ -253,8 +259,8 @@ class _VideoSettingsSheetState extends State { return ListView( children: [ - // Playback Speed - only show if user can control playback - if (widget.canControl) + // Playback Speed - hidden for live TV and when user cannot control playback + if (widget.canControl && !widget.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 1d1c87ec..dd521dbc 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -75,6 +75,8 @@ Widget plexVideoControlsBuilder( ShaderService? shaderService, VoidCallback? onShaderChanged, String Function(Duration time)? thumbnailUrlBuilder, + bool isLive = false, + String? liveChannelName, }) { return PlexVideoControls( player: player, @@ -97,6 +99,8 @@ Widget plexVideoControlsBuilder( shaderService: shaderService, onShaderChanged: onShaderChanged, thumbnailUrlBuilder: thumbnailUrlBuilder, + isLive: isLive, + liveChannelName: liveChannelName, ); } @@ -140,6 +144,12 @@ class PlexVideoControls extends StatefulWidget { /// Optional callback that returns a thumbnail URL for a given timestamp. final String Function(Duration time)? thumbnailUrlBuilder; + /// Whether this is a live TV stream (disables seek, progress, etc.) + final bool isLive; + + /// Channel name for live TV display + final String? liveChannelName; + const PlexVideoControls({ super.key, required this.player, @@ -162,6 +172,8 @@ class PlexVideoControls extends StatefulWidget { this.shaderService, this.onShaderChanged, this.thumbnailUrlBuilder, + this.isLive = false, + this.liveChannelName, }); @override @@ -780,6 +792,9 @@ class _PlexVideoControlsState extends State with WindowListen } Future _loadPlaybackExtras() async { + // Live TV metadata uses EPG rating keys, not library items + if (widget.isLive) return; + try { appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}'); final client = _getClientForMetadata(); @@ -890,6 +905,7 @@ class _PlexVideoControlsState extends State with WindowListen onStartAutoHide: _startHideTimer, serverId: widget.metadata.serverId ?? '', canControl: widget.canControl, + isLive: widget.isLive, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, ); @@ -1155,7 +1171,7 @@ class _PlexVideoControlsState extends State with WindowListen /// Handle long-press start - activate 2x speed void _handleLongPressStart() { - if (!widget.canControl) return; // Respect Watch Together permissions + if (!widget.canControl || widget.isLive) return; setState(() { _isLongPressing = true; @@ -1752,6 +1768,8 @@ class _PlexVideoControlsState extends State with WindowListen canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + isLive: widget.isLive, + liveChannelName: widget.liveChannelName, ), ) : Listener( @@ -1809,6 +1827,8 @@ class _PlexVideoControlsState extends State with WindowListen shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + isLive: widget.isLive, + liveChannelName: widget.liveChannelName, ), ), ), diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 6e26093b..ae21434c 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -60,6 +60,9 @@ class TrackChapterControls extends StatelessWidget { /// Whether the user can control playback (false in host-only mode for non-host). final bool canControl; + /// Whether this is a live TV stream (hides speed settings). + final bool isLive; + const TrackChapterControls({ super.key, required this.player, @@ -89,6 +92,7 @@ class TrackChapterControls extends StatelessWidget { this.onFocusChange, this.onNavigateLeft, this.canControl = true, + this.isLive = false, this.shaderService, this.onShaderChanged, }); @@ -194,6 +198,7 @@ class TrackChapterControls extends StatelessWidget { onOpen: onCancelAutoHide, onClose: onStartAutoHide, canControl: canControl, + isLive: isLive, shaderService: shaderService, onShaderChanged: onShaderChanged, );