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:
edde746
2026-02-14 22:29:16 +01:00
49 changed files with 6343 additions and 266 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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
+88 -2
View File
@@ -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
View File
@@ -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',
+88 -2
View File
@@ -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',
+88 -2
View File
@@ -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',
+88 -2
View File
@@ -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',
+88 -2
View File
@@ -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' => '재생 목록에서 제거 실패',
+88 -2
View File
@@ -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',
+88 -2
View File
@@ -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',
+88 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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": "下载",
+71
View File
@@ -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';
}
+86
View File
@@ -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?,
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'plex_metadata.dart';
import 'livetv_program.dart';
/// A hub from the live TV discover endpoint, with both display and EPG data.
class LiveTvHubResult {
final String title;
final String hubKey;
final List<LiveTvHubEntry> entries;
LiveTvHubResult({
required this.title,
required this.hubKey,
required this.entries,
});
}
/// A single item in a live TV hub, holding both display metadata and EPG timing.
class LiveTvHubEntry {
final PlexMetadata metadata;
final LiveTvProgram program;
LiveTvHubEntry({required this.metadata, required this.program});
}
+112
View File
@@ -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;
}
}
+123
View File
@@ -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});
}
+1 -1
View File
@@ -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();
+2 -1
View File
@@ -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,
});
}
+1 -1
View File
@@ -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();
+14 -8
View File
@@ -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,
+55
View File
@@ -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;
},
),
);
}
}
+421
View File
@@ -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
+641
View File
@@ -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),
),
],
),
),
),
);
}
}
+53 -14
View File
@@ -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.
+257 -13
View File
@@ -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),
),
],
),
),
+555
View File
@@ -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;
+54
View File
@@ -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);
}
+13 -2
View File
@@ -143,9 +143,20 @@ class PlexImageHelper {
final basePath = thumbPath;
// If we can't/shouldn't transcode (already a full URL), just return it.
// External URLs (e.g. EPG provider images) — proxy through the server's
// photo transcoder so the Plex server fetches them on our behalf.
if (basePath.startsWith('http://') || basePath.startsWith('https://')) {
return basePath;
if (client == null) return basePath;
final (width, height) = calculateOptimalDimensions(
maxWidth: maxWidth,
maxHeight: maxHeight,
devicePixelRatio: devicePixelRatio,
imageType: imageType,
);
// Don't append Plex token to the inner URL — only on the outer request
final encodedUrl = Uri.encodeComponent(basePath);
final token = client.config.token;
return '${client.config.baseUrl}/photo/:/transcode?width=$width&height=$height&minSize=1&upscale=1&url=$encodedUrl&X-Plex-Token=$token';
}
// If no client (offline mode), we can't build URLs for relative paths
+77 -34
View File
@@ -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,
+21 -1
View File
@@ -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,
);