Merge branch 'live-tv'
# Conflicts: # lib/i18n/strings.g.dart # lib/i18n/strings_de.g.dart # lib/i18n/strings_en.g.dart # lib/i18n/strings_es.g.dart # lib/i18n/strings_fr.g.dart # lib/i18n/strings_it.g.dart # lib/i18n/strings_ko.g.dart # lib/i18n/strings_nl.g.dart # lib/i18n/strings_sv.g.dart # lib/i18n/strings_zh.g.dart # lib/screens/main_screen.dart # lib/services/plex_client.dart # lib/widgets/video_controls/desktop_video_controls.dart
This commit is contained in:
@@ -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<String, String>?, startPositionMs: Long, autoPlay: Boolean) {
|
||||
fun open(uri: String, headers: Map<String, String>?, 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)
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
val headers = call.argument<Map<String, String>>("headers")
|
||||
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
||||
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
|
||||
val isLive = call.argument<Boolean>("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)
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
+41
-1
@@ -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": "컬렉션",
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsDe with BaseTranslations<AppLocale, Translations> 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',
|
||||
|
||||
+164
-2
@@ -68,6 +68,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||
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',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsEs with BaseTranslations<AppLocale, Translations> 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',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsFr with BaseTranslations<AppLocale, Translations> 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',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsIt with BaseTranslations<AppLocale, Translations> 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',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsKo with BaseTranslations<AppLocale, Translations> 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' => '재생 목록에서 제거 실패',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsNl with BaseTranslations<AppLocale, Translations> 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',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsSv with BaseTranslations<AppLocale, Translations> 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',
|
||||
|
||||
@@ -65,6 +65,7 @@ class TranslationsZh with BaseTranslations<AppLocale, Translations> 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' => '无法删除:未知的媒体库分区',
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
+41
-1
@@ -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": "下载",
|
||||
|
||||
@@ -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<String, dynamic> 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';
|
||||
}
|
||||
@@ -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<ChannelMapping> 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<String, dynamic> json) {
|
||||
final mappings = <ChannelMapping>[];
|
||||
if (json['ChannelMapping'] != null) {
|
||||
for (final item in json['ChannelMapping'] as List) {
|
||||
try {
|
||||
mappings.add(ChannelMapping.fromJson(item as Map<String, dynamic>));
|
||||
} 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<String, dynamic> 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'plex_metadata.dart';
|
||||
import 'livetv_program.dart';
|
||||
|
||||
/// A hub from the live TV discover endpoint, with both display and EPG data.
|
||||
class LiveTvHubResult {
|
||||
final String title;
|
||||
final String hubKey;
|
||||
final List<LiveTvHubEntry> entries;
|
||||
|
||||
LiveTvHubResult({
|
||||
required this.title,
|
||||
required this.hubKey,
|
||||
required this.entries,
|
||||
});
|
||||
}
|
||||
|
||||
/// A single item in a live TV hub, holding both display metadata and EPG timing.
|
||||
class LiveTvHubEntry {
|
||||
final PlexMetadata metadata;
|
||||
final LiveTvProgram program;
|
||||
|
||||
LiveTvHubEntry({required this.metadata, required this.program});
|
||||
}
|
||||
@@ -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<String, dynamic> json) {
|
||||
// Grid endpoint nests timing/channel info inside Media[0] and Channel[0]
|
||||
final media = (json['Media'] as List?)?.firstOrNull as Map<String, dynamic>?;
|
||||
final channel = (json['Channel'] as List?)?.firstOrNull as Map<String, dynamic>?;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SubscriptionSetting> 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<String, dynamic> json) {
|
||||
final settingsList = <SubscriptionSetting>[];
|
||||
if (json['Setting'] != null) {
|
||||
for (final item in json['Setting'] as List) {
|
||||
try {
|
||||
settingsList.add(SubscriptionSetting.fromJson(item as Map<String, dynamic>));
|
||||
} 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<SubscriptionSettingOption>? 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<String, dynamic> json) {
|
||||
List<SubscriptionSettingOption>? 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});
|
||||
}
|
||||
@@ -61,7 +61,7 @@ abstract class Player {
|
||||
///
|
||||
/// [media] - The media source to open.
|
||||
/// [play] - Whether to start playback immediately (default: true).
|
||||
Future<void> open(Media media, {bool play = true});
|
||||
Future<void> open(Media media, {bool play = true, bool isLive = false});
|
||||
|
||||
/// Start or resume playback.
|
||||
Future<void> play();
|
||||
|
||||
@@ -62,7 +62,7 @@ class PlayerAndroid extends PlayerBase {
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> open(Media media, {bool play = true}) async {
|
||||
Future<void> 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class PlayerNative extends PlayerBase {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> open(Media media, {bool play = true}) async {
|
||||
Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
|
||||
checkDisposed();
|
||||
await _ensureInitialized();
|
||||
|
||||
|
||||
@@ -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<NavigationTab> getVisibleTabs({required bool isOffline}) {
|
||||
return allNavigationTabs.where((tab) => !isOffline || !tab.onlineOnly).toList();
|
||||
/// Get tabs filtered by offline mode and feature availability
|
||||
static List<NavigationTab> 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,
|
||||
|
||||
@@ -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<LiveTvServerInfo> _liveTvServers = [];
|
||||
List<LiveTvServerInfo> 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<void> checkLiveTvAvailability() async {
|
||||
final newLiveTvServers = <LiveTvServerInfo>[];
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<DvrRecordingsScreen> createState() => _DvrRecordingsScreenState();
|
||||
}
|
||||
|
||||
class _DvrRecordingsScreenState extends State<DvrRecordingsScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
List<LiveTvSubscription> _subscriptions = [];
|
||||
List<ScheduledRecording> _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<void> _loadData() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final liveTvServers = multiServer.liveTvServers;
|
||||
|
||||
if (liveTvServers.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = t.liveTv.noDvr;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final allSubscriptions = <LiveTvSubscription>[];
|
||||
final allScheduled = <ScheduledRecording>[];
|
||||
|
||||
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<void> _deleteSubscription(LiveTvSubscription subscription) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<MultiServerProvider>();
|
||||
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<void> _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 = <String, String>{};
|
||||
for (final setting in editableSettings) {
|
||||
prefs[setting.id] = setting.value ?? setting.defaultValue ?? '';
|
||||
}
|
||||
|
||||
final result = await showDialog<Map<String, String>?>(
|
||||
context: context,
|
||||
builder: (dialogContext) =>
|
||||
_SubscriptionEditDialog(subscription: subscription, settings: editableSettings, initialPrefs: prefs),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
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<SubscriptionSetting> settings;
|
||||
final Map<String, String> initialPrefs;
|
||||
|
||||
const _SubscriptionEditDialog({required this.subscription, required this.settings, required this.initialPrefs});
|
||||
|
||||
@override
|
||||
State<_SubscriptionEditDialog> createState() => _SubscriptionEditDialogState();
|
||||
}
|
||||
|
||||
class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> {
|
||||
late Map<String, String> _prefs;
|
||||
final Map<String, TextEditingController> _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<String>(
|
||||
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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LiveTvScreen> createState() => _LiveTvScreenState();
|
||||
}
|
||||
|
||||
class _LiveTvScreenState extends State<LiveTvScreen> with SingleTickerProviderStateMixin, TabNavigationMixin {
|
||||
final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide');
|
||||
final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on');
|
||||
final _guideTabKey = GlobalKey<GuideTabState>();
|
||||
final _whatsOnTabKey = GlobalKey<WhatsOnTabState>();
|
||||
|
||||
// App bar action button focus
|
||||
final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
|
||||
final _dvrButtonFocusNode = FocusNode(debugLabel: 'DvrButton');
|
||||
bool _isRefreshFocused = false;
|
||||
bool _isDvrFocused = false;
|
||||
|
||||
List<LiveTvChannel> _channels = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
List<FocusNode> 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<void> _loadChannels() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final liveTvServers = multiServer.liveTvServers;
|
||||
|
||||
if (liveTvServers.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = t.liveTv.noDvr;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final allChannels = <LiveTvChannel>[];
|
||||
final seenChannels = <String>{};
|
||||
|
||||
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 = <String, Set<String>>{};
|
||||
final queriedServers = <String>{};
|
||||
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 = <String>{};
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LiveTvChannel> channels;
|
||||
|
||||
const LiveTvShowScheduleScreen({super.key, required this.showTitle, required this.serverId, required this.channels});
|
||||
|
||||
@override
|
||||
State<LiveTvShowScheduleScreen> createState() => _LiveTvShowScheduleScreenState();
|
||||
}
|
||||
|
||||
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
|
||||
List<LiveTvProgram> _programs = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSchedule();
|
||||
}
|
||||
|
||||
Future<void> _loadSchedule() async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(widget.serverId);
|
||||
if (client == null) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
// Fetch a generous window: 1h ago (to catch currently airing) + 48h ahead
|
||||
final beginsAt = now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000;
|
||||
final endsAt = now.add(const Duration(hours: 48)).millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final programs = await client.getEpgGrid(beginsAt: beginsAt, endsAt: endsAt);
|
||||
|
||||
// Filter for this show
|
||||
final filtered = programs.where((p) {
|
||||
if (p.grandparentTitle == widget.showTitle) return true;
|
||||
if (p.grandparentTitle == null && p.title == widget.showTitle) return true;
|
||||
return false;
|
||||
}).toList();
|
||||
|
||||
// Sort by start time
|
||||
filtered.sort((a, b) => (a.beginsAt ?? 0).compareTo(b.beginsAt ?? 0));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_programs = filtered;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
LiveTvChannel? _findChannel(String? channelIdentifier) {
|
||||
if (channelIdentifier == null) return null;
|
||||
return widget.channels.where((ch) {
|
||||
return ch.identifier == channelIdentifier || ch.key == channelIdentifier;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
Future<void> _tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: widget.channels,
|
||||
);
|
||||
}
|
||||
|
||||
void _showProgramDetails(LiveTvProgram program, LiveTvChannel? channel) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(widget.serverId);
|
||||
String? posterUrl;
|
||||
if (program.thumb != null && client != null) {
|
||||
posterUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: program.thumb,
|
||||
maxWidth: 80,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
|
||||
imageType: ImageType.poster,
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<FocusNode> _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 = <Widget>[];
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<LiveTvChannel> channels;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const WhatsOnTab({super.key, required this.channels, this.onNavigateUp, this.onBack});
|
||||
|
||||
@override
|
||||
State<WhatsOnTab> createState() => WhatsOnTabState();
|
||||
}
|
||||
|
||||
class WhatsOnTabState extends State<WhatsOnTab> {
|
||||
List<LiveTvHubResult> _hubs = [];
|
||||
bool _isLoading = true;
|
||||
Timer? _refreshTimer;
|
||||
List<GlobalKey<_LiveTvHubSectionState>> _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<void> _loadHubs() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = _hubs.isEmpty);
|
||||
|
||||
try {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final liveTvServers = multiServer.liveTvServers;
|
||||
final allHubs = <LiveTvHubResult>[];
|
||||
final queriedServers = <String>{};
|
||||
|
||||
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<void> _tuneChannel(LiveTvChannel channel) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
await navigateToLiveTv(
|
||||
context,
|
||||
client: client,
|
||||
dvrKey: serverInfo.dvrKey,
|
||||
channel: channel,
|
||||
channels: widget.channels,
|
||||
);
|
||||
}
|
||||
|
||||
void _onItemTap(LiveTvHubEntry entry) {
|
||||
final channel = _findChannel(entry.program.channelIdentifier);
|
||||
|
||||
if (entry.program.isCurrentlyAiring && channel != null) {
|
||||
// Live → play directly
|
||||
_tuneChannel(channel);
|
||||
} else if (entry.metadata.type.toLowerCase() == 'show') {
|
||||
// Show with upcoming episodes → show full schedule
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LiveTvShowScheduleScreen(
|
||||
showTitle: entry.metadata.title,
|
||||
serverId: entry.metadata.serverId ?? '',
|
||||
channels: widget.channels,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Individual program (episode, movie, etc.) → bottom sheet
|
||||
_showProgramDetails(entry, channel);
|
||||
}
|
||||
}
|
||||
|
||||
void _showProgramDetails(LiveTvHubEntry entry, LiveTvChannel? channel) {
|
||||
final program = entry.program;
|
||||
final metadata = entry.metadata;
|
||||
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(metadata.serverId ?? '');
|
||||
final posterImage = metadata.grandparentThumb ?? metadata.thumb;
|
||||
String? posterUrl;
|
||||
if (posterImage != null && client != null) {
|
||||
posterUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: posterImage,
|
||||
maxWidth: 80,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context),
|
||||
imageType: ImageType.poster,
|
||||
);
|
||||
}
|
||||
|
||||
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<SettingsProvider>();
|
||||
final densityScale = switch (settings.libraryDensity) {
|
||||
LibraryDensity.compact => 0.8,
|
||||
LibraryDensity.normal => 1.0,
|
||||
LibraryDensity.comfortable => 1.15,
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Hub header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(Symbols.live_tv_rounded, fill: 1),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MainScreen> 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<MainScreen> with RouteAware, WindowListener
|
||||
late List<Widget> _screens;
|
||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
||||
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
||||
final GlobalKey<State<LiveTvScreen>> _liveTvKey = GlobalKey();
|
||||
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
||||
final GlobalKey<State<DownloadsScreen>> _downloadsKey = GlobalKey();
|
||||
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
|
||||
@@ -388,6 +392,14 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
_offlineModeProvider!.addListener(_handleOfflineStatusChanged);
|
||||
}
|
||||
|
||||
// Listen for Live TV / DVR availability changes
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
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<MainScreen> 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<MainScreen> with RouteAware, WindowListener
|
||||
windowManager.setPreventClose(false);
|
||||
}
|
||||
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
||||
_multiServerProvider?.removeListener(_handleLiveTvChanged);
|
||||
_sidebarFocusScope.dispose();
|
||||
_contentFocusScope.dispose();
|
||||
|
||||
@@ -511,14 +525,16 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
|
||||
List<Widget> _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<MultiServerProvider>().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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Live TV tab is currently visible
|
||||
bool get _hasLiveTv {
|
||||
try {
|
||||
return context.read<MultiServerProvider>().hasLiveTv;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get navigation tabs filtered by offline mode
|
||||
List<NavigationTab> _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.
|
||||
|
||||
@@ -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<LiveTvChannel>? 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
Future<void> _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<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _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<OfflineWatchSyncService>() : null;
|
||||
|
||||
@@ -1205,10 +1306,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Store provider reference for use in dispose and notify remote
|
||||
try {
|
||||
_companionRemoteProvider = context.read<CompanionRemoteProvider>();
|
||||
_companionRemoteProvider!.sendCommand(
|
||||
RemoteCommandType.syncState,
|
||||
data: {'playerActive': true},
|
||||
);
|
||||
_companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -1229,10 +1327,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<void> _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<void> _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<void> _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<MultiServerProvider>();
|
||||
final serverInfo =
|
||||
multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ??
|
||||
multiServer.liveTvServers.firstOrNull;
|
||||
|
||||
if (serverInfo == null) return;
|
||||
|
||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
||||
if (client == null) return;
|
||||
|
||||
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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<void> 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<void> removeFromOnDeck(String ratingKey) async {
|
||||
@@ -1941,6 +1976,526 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live TV / DVR Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get all DVR devices configured on this server
|
||||
Future<List<LiveTvDvr>> getDvrs() async {
|
||||
return _wrapListApiCall<LiveTvDvr>(
|
||||
() => _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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get DVRs',
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this server has at least one DVR configured
|
||||
Future<bool> hasDvr() async {
|
||||
final dvrs = await getDvrs();
|
||||
return dvrs.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get EPG channels for a specific lineup
|
||||
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (lineup != null) queryParams['lineup'] = lineup;
|
||||
|
||||
return _wrapListApiCall<LiveTvChannel>(
|
||||
() => _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<String, dynamic>)
|
||||
.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<String, dynamic>)
|
||||
.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<List<({String identifier, String gridEndpoint})>> _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<List<LiveTvProgram>> getEpgGrid({
|
||||
int? beginsAt,
|
||||
int? endsAt,
|
||||
}) async {
|
||||
final providers = await _discoverEpgProviders();
|
||||
if (providers.isEmpty) return [];
|
||||
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (beginsAt != null) queryParams['beginsAt>'] = beginsAt;
|
||||
if (endsAt != null) queryParams['endsAt<'] = endsAt;
|
||||
|
||||
final allPrograms = <LiveTvProgram>[];
|
||||
|
||||
for (final provider in providers) {
|
||||
try {
|
||||
final programs = await _wrapListApiCall<LiveTvProgram>(
|
||||
() => _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 = <LiveTvProgram>[];
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
for (final item in container['Metadata'] as List) {
|
||||
try {
|
||||
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
||||
} 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<String, dynamic>));
|
||||
} 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<List<LiveTvHubResult>> getLiveTvHubs({int count = 12}) async {
|
||||
final providers = await _discoverEpgProviders();
|
||||
if (providers.isEmpty) return [];
|
||||
|
||||
final allHubs = <LiveTvHubResult>[];
|
||||
|
||||
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 = <LiveTvHubEntry>[];
|
||||
for (final itemJson in metadataList) {
|
||||
if (itemJson is! Map<String, dynamic>) continue;
|
||||
|
||||
// Extract poster/art from Image array before parsing
|
||||
_extractLiveTvImages(itemJson);
|
||||
|
||||
try {
|
||||
final metadata = PlexMetadata.fromJson(itemJson)
|
||||
.copyWith(serverId: serverId, serverName: serverName);
|
||||
final program = LiveTvProgram.fromJson(itemJson);
|
||||
entries.add(LiveTvHubEntry(metadata: metadata, program: program));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (entries.isNotEmpty) {
|
||||
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<String, dynamic>? metadataJson;
|
||||
final subscriptions = container['MediaSubscription'] as List?;
|
||||
if (subscriptions != null && subscriptions.isNotEmpty) {
|
||||
final sub = subscriptions[0] as Map<String, dynamic>;
|
||||
final ops = sub['MediaGrabOperation'] as List?;
|
||||
if (ops != null && ops.isNotEmpty) {
|
||||
final op = ops[0] as Map<String, dynamic>;
|
||||
final nested = op['Metadata'];
|
||||
if (nested is Map<String, dynamic>) {
|
||||
metadataJson = nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map<String, dynamic>?;
|
||||
|
||||
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 = <String, String>{
|
||||
'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<String, String>.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<bool> reloadGuide(String dvrKey) async {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.post('/livetv/dvrs/$dvrKey/reloadGuide'),
|
||||
'Failed to reload guide',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get active live TV sessions
|
||||
Future<List<PlexMetadata>> getLiveTvSessions() async {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/livetv/sessions'),
|
||||
_extractMetadataList,
|
||||
'Failed to get live TV sessions',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all DVR recording subscriptions
|
||||
Future<List<LiveTvSubscription>> getSubscriptions() async {
|
||||
return _wrapListApiCall<LiveTvSubscription>(
|
||||
() => _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<String, dynamic>);
|
||||
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<LiveTvSubscription?> createSubscription({
|
||||
required String type,
|
||||
required int targetSectionID,
|
||||
required int targetLibrarySectionID,
|
||||
Map<String, String>? prefs,
|
||||
String? hint,
|
||||
String? uri,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'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<String, dynamic>);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create subscription', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a DVR recording subscription
|
||||
Future<bool> deleteSubscription(String subscriptionId) async {
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/media/subscriptions/$subscriptionId'),
|
||||
'Failed to delete subscription',
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit a DVR recording subscription's preferences
|
||||
Future<bool> editSubscription(String subscriptionId, Map<String, String> prefs) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
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<List<ScheduledRecording>> getScheduledRecordings() async {
|
||||
return _wrapListApiCall<ScheduledRecording>(
|
||||
() => _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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
'Failed to get scheduled recordings',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get subscription template for a program (used for recording setup)
|
||||
Future<Map<String, dynamic>?> 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<void> _handleEndpointSwitch(String newBaseUrl) async {
|
||||
if (config.baseUrl == newBaseUrl) {
|
||||
return;
|
||||
|
||||
@@ -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<void> navigateToLiveTv(
|
||||
BuildContext context, {
|
||||
required PlexClient client,
|
||||
required String dvrKey,
|
||||
required LiveTvChannel channel,
|
||||
List<LiveTvChannel>? 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<bool>(
|
||||
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<bool>(route);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<SideNavigationRail> {
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Live TV (only if DVR available)
|
||||
if (context.watch<MultiServerProvider>().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<MultiServerProvider>().hasLiveTv,
|
||||
),
|
||||
isFocused: _focusTracker.isFocused(_kSearch),
|
||||
onTap: () => widget.onDestinationSelected(2),
|
||||
onTap: () => widget.onDestinationSelected(
|
||||
NavigationTab.indexFor(
|
||||
NavigationTabId.search,
|
||||
isOffline: widget.isOfflineMode,
|
||||
hasLiveTv: context.read<MultiServerProvider>().hasLiveTv,
|
||||
),
|
||||
),
|
||||
focusNode: _focusTracker.get(_kSearch),
|
||||
isCollapsed: isCollapsed,
|
||||
),
|
||||
@@ -367,42 +402,50 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
],
|
||||
|
||||
// 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<MultiServerProvider>().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<MultiServerProvider>().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,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<DesktopVideoControls> {
|
||||
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<DesktopVideoControls> {
|
||||
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<Duration>(
|
||||
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<Duration>(
|
||||
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<DesktopVideoControls> {
|
||||
},
|
||||
),
|
||||
),
|
||||
// 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<Duration>(
|
||||
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<Duration>(
|
||||
// Next chapter
|
||||
StreamBuilder<Duration>(
|
||||
stream: widget.player.streams.position,
|
||||
initialData: widget.player.state.position,
|
||||
builder: (context, posSnap) {
|
||||
return StreamBuilder<Duration>(
|
||||
stream: widget.player.streams.duration,
|
||||
initialData: widget.player.state.duration,
|
||||
builder: (context, durSnap) {
|
||||
return StreamBuilder<double>(
|
||||
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<Duration>(
|
||||
stream: widget.player.streams.position,
|
||||
initialData: widget.player.state.position,
|
||||
builder: (context, posSnap) {
|
||||
return StreamBuilder<Duration>(
|
||||
stream: widget.player.streams.duration,
|
||||
initialData: widget.player.state.duration,
|
||||
builder: (context, durSnap) {
|
||||
return StreamBuilder<double>(
|
||||
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<DesktopVideoControls> {
|
||||
onFocusChange: _onFocusChange,
|
||||
onNavigateLeft: navigateFromTrackToVolume,
|
||||
canControl: widget.canControl,
|
||||
isLive: widget.isLive,
|
||||
shaderService: widget.shaderService,
|
||||
onShaderChanged: widget.onShaderChanged,
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<VideoSettingsSheet> {
|
||||
|
||||
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<double>(
|
||||
stream: widget.player.streams.rate,
|
||||
initialData: widget.player.state.rate,
|
||||
|
||||
@@ -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<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
Future<void> _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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> with WindowListen
|
||||
shaderService: widget.shaderService,
|
||||
onShaderChanged: widget.onShaderChanged,
|
||||
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
|
||||
isLive: widget.isLive,
|
||||
liveChannelName: widget.liveChannelName,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user