feat: live TV time-shift

This commit is contained in:
edde746
2026-04-01 12:20:07 +02:00
parent 45525a4f14
commit 3f1da5f289
44 changed files with 982 additions and 90 deletions
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "Hvad der kører",
"watchChannel": "Se kanal",
"favorites": "Favoritter",
"reorderFavorites": "Omarranger favoritter"
"reorderFavorites": "Omarranger favoritter",
"joinSession": "Deltag i igangværende session",
"watchFromStart": "Se fra start (${minutes} min siden)",
"watchLive": "Se live",
"goToLive": "Gå til live"
},
"collections": {
"title": "Samlinger",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "Jetzt im TV",
"watchChannel": "Kanal ansehen",
"favorites": "Favoriten",
"reorderFavorites": "Favoriten sortieren"
"reorderFavorites": "Favoriten sortieren",
"joinSession": "Laufender Sitzung beitreten",
"watchFromStart": "Von Anfang an ansehen (vor ${minutes} Min.)",
"watchLive": "Live ansehen",
"goToLive": "Zum Live-Bild"
},
"downloads": {
"title": "Downloads",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "What's On",
"watchChannel": "Watch Channel",
"favorites": "Favorites",
"reorderFavorites": "Reorder Favorites"
"reorderFavorites": "Reorder Favorites",
"joinSession": "Join Session in Progress",
"watchFromStart": "Watch from start (${minutes} min ago)",
"watchLive": "Watch Live",
"goToLive": "Go to Live"
},
"collections": {
"title": "Collections",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "En emisión",
"watchChannel": "Ver canal",
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos"
"reorderFavorites": "Reordenar favoritos",
"joinSession": "Unirse a sesión en curso",
"watchFromStart": "Ver desde el inicio (hace ${minutes} min)",
"watchLive": "Ver en vivo",
"goToLive": "Ir a en vivo"
},
"collections": {
"title": "Colecciones",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "En ce moment",
"watchChannel": "Regarder la chaîne",
"favorites": "Favoris",
"reorderFavorites": "Réorganiser les favoris"
"reorderFavorites": "Réorganiser les favoris",
"joinSession": "Rejoindre la session en cours",
"watchFromStart": "Regarder depuis le début (il y a ${minutes} min)",
"watchLive": "Regarder en direct",
"goToLive": "Aller au direct"
},
"collections": {
"title": "Collections",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "In onda ora",
"watchChannel": "Guarda canale",
"favorites": "Preferiti",
"reorderFavorites": "Riordina preferiti"
"reorderFavorites": "Riordina preferiti",
"joinSession": "Partecipa alla sessione in corso",
"watchFromStart": "Guarda dall'inizio (${minutes} min fa)",
"watchLive": "Guarda in diretta",
"goToLive": "Vai al live"
},
"downloads": {
"title": "Download",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "放送中",
"watchChannel": "チャンネルを視聴",
"favorites": "お気に入り",
"reorderFavorites": "お気に入りを並べ替え"
"reorderFavorites": "お気に入りを並べ替え",
"joinSession": "進行中のセッションに参加",
"watchFromStart": "最初から視聴(${minutes}分前に開始)",
"watchLive": "ライブで視聴",
"goToLive": "ライブに移動"
},
"collections": {
"title": "コレクション",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "지금 방송 중",
"watchChannel": "채널 시청",
"favorites": "즐겨찾기",
"reorderFavorites": "즐겨찾기 순서 변경"
"reorderFavorites": "즐겨찾기 순서 변경",
"joinSession": "진행 중인 세션 참여",
"watchFromStart": "처음부터 시청 (${minutes}분 전 시작)",
"watchLive": "실시간 시청",
"goToLive": "실시간으로 이동"
},
"collections": {
"title": "컬렉션",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "Hva går nå",
"watchChannel": "Se kanal",
"favorites": "Favoritter",
"reorderFavorites": "Endre rekkefølge på favoritter"
"reorderFavorites": "Endre rekkefølge på favoritter",
"joinSession": "Bli med i pågående økt",
"watchFromStart": "Se fra starten (${minutes} min siden)",
"watchLive": "Se direkte",
"goToLive": "Gå til direkte"
},
"collections": {
"title": "Samlinger",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "Nu op TV",
"watchChannel": "Kanaal bekijken",
"favorites": "Favorieten",
"reorderFavorites": "Favorieten herordenen"
"reorderFavorites": "Favorieten herordenen",
"joinSession": "Deelnemen aan lopende sessie",
"watchFromStart": "Kijk vanaf het begin (${minutes} min geleden)",
"watchLive": "Live kijken",
"goToLive": "Ga naar live"
},
"downloads": {
"title": "Downloads",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "Co leci",
"watchChannel": "Oglądaj kanał",
"favorites": "Ulubione",
"reorderFavorites": "Zmień kolejność ulubionych"
"reorderFavorites": "Zmień kolejność ulubionych",
"joinSession": "Dołącz do trwającej sesji",
"watchFromStart": "Oglądaj od początku (${minutes} min temu)",
"watchLive": "Oglądaj na żywo",
"goToLive": "Przejdź do na żywo"
},
"collections": {
"title": "Kolekcje",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "O que Está Passando",
"watchChannel": "Assistir Canal",
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos"
"reorderFavorites": "Reordenar favoritos",
"joinSession": "Entrar na sessão em andamento",
"watchFromStart": "Assistir do início (${minutes} min atrás)",
"watchLive": "Assistir ao vivo",
"goToLive": "Ir para o ao vivo"
},
"collections": {
"title": "Coleções",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "Что идёт",
"watchChannel": "Смотреть канал",
"favorites": "Избранное",
"reorderFavorites": "Изменить порядок избранного"
"reorderFavorites": "Изменить порядок избранного",
"joinSession": "Присоединиться к текущему сеансу",
"watchFromStart": "Смотреть сначала (${minutes} мин. назад)",
"watchLive": "Смотреть в прямом эфире",
"goToLive": "К прямому эфиру"
},
"collections": {
"title": "Коллекции",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 12315 (821 per locale)
/// Strings: 12375 (825 per locale)
///
/// Built on 2026-04-01 at 03:58 UTC
/// Built on 2026-04-01 at 09:43 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvDa implements TranslationsLiveTvEn {
@override String get watchChannel => 'Se kanal';
@override String get favorites => 'Favoritter';
@override String get reorderFavorites => 'Omarranger favoritter';
@override String get joinSession => 'Deltag i igangværende session';
@override String watchFromStart({required Object minutes}) => 'Se fra start (${minutes} min siden)';
@override String get watchLive => 'Se live';
@override String get goToLive => 'Gå til live';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsDa {
'liveTv.watchChannel' => 'Se kanal',
'liveTv.favorites' => 'Favoritter',
'liveTv.reorderFavorites' => 'Omarranger favoritter',
'liveTv.joinSession' => 'Deltag i igangværende session',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra start (${minutes} min siden)',
'liveTv.watchLive' => 'Se live',
'liveTv.goToLive' => 'Gå til live',
'collections.title' => 'Samlinger',
'collections.collection' => 'Samling',
'collections.empty' => 'Samlingen er tom',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvDe implements TranslationsLiveTvEn {
@override String get watchChannel => 'Kanal ansehen';
@override String get favorites => 'Favoriten';
@override String get reorderFavorites => 'Favoriten sortieren';
@override String get joinSession => 'Laufender Sitzung beitreten';
@override String watchFromStart({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)';
@override String get watchLive => 'Live ansehen';
@override String get goToLive => 'Zum Live-Bild';
}
// Path: downloads
@@ -1841,6 +1845,10 @@ extension on TranslationsDe {
'liveTv.watchChannel' => 'Kanal ansehen',
'liveTv.favorites' => 'Favoriten',
'liveTv.reorderFavorites' => 'Favoriten sortieren',
'liveTv.joinSession' => 'Laufender Sitzung beitreten',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)',
'liveTv.watchLive' => 'Live ansehen',
'liveTv.goToLive' => 'Zum Live-Bild',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Verwalten',
'downloads.tvShows' => 'Serien',
+16
View File
@@ -1789,6 +1789,18 @@ class TranslationsLiveTvEn {
/// en: 'Reorder Favorites'
String get reorderFavorites => 'Reorder Favorites';
/// en: 'Join Session in Progress'
String get joinSession => 'Join Session in Progress';
/// en: 'Watch from start (${minutes} min ago)'
String watchFromStart({required Object minutes}) => 'Watch from start (${minutes} min ago)';
/// en: 'Watch Live'
String get watchLive => 'Watch Live';
/// en: 'Go to Live'
String get goToLive => 'Go to Live';
}
// Path: collections
@@ -3490,6 +3502,10 @@ extension on Translations {
'liveTv.watchChannel' => 'Watch Channel',
'liveTv.favorites' => 'Favorites',
'liveTv.reorderFavorites' => 'Reorder Favorites',
'liveTv.joinSession' => 'Join Session in Progress',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Watch from start (${minutes} min ago)',
'liveTv.watchLive' => 'Watch Live',
'liveTv.goToLive' => 'Go to Live',
'collections.title' => 'Collections',
'collections.collection' => 'Collection',
'collections.empty' => 'Collection is empty',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvEs implements TranslationsLiveTvEn {
@override String get watchChannel => 'Ver canal';
@override String get favorites => 'Favoritos';
@override String get reorderFavorites => 'Reordenar favoritos';
@override String get joinSession => 'Unirse a sesión en curso';
@override String watchFromStart({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)';
@override String get watchLive => 'Ver en vivo';
@override String get goToLive => 'Ir a en vivo';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsEs {
'liveTv.watchChannel' => 'Ver canal',
'liveTv.favorites' => 'Favoritos',
'liveTv.reorderFavorites' => 'Reordenar favoritos',
'liveTv.joinSession' => 'Unirse a sesión en curso',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)',
'liveTv.watchLive' => 'Ver en vivo',
'liveTv.goToLive' => 'Ir a en vivo',
'collections.title' => 'Colecciones',
'collections.collection' => 'Colección',
'collections.empty' => 'La colección está vacía',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvFr implements TranslationsLiveTvEn {
@override String get watchChannel => 'Regarder la chaîne';
@override String get favorites => 'Favoris';
@override String get reorderFavorites => 'Réorganiser les favoris';
@override String get joinSession => 'Rejoindre la session en cours';
@override String watchFromStart({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)';
@override String get watchLive => 'Regarder en direct';
@override String get goToLive => 'Aller au direct';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsFr {
'liveTv.watchChannel' => 'Regarder la chaîne',
'liveTv.favorites' => 'Favoris',
'liveTv.reorderFavorites' => 'Réorganiser les favoris',
'liveTv.joinSession' => 'Rejoindre la session en cours',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)',
'liveTv.watchLive' => 'Regarder en direct',
'liveTv.goToLive' => 'Aller au direct',
'collections.title' => 'Collections',
'collections.collection' => 'Collection',
'collections.empty' => 'La collection est vide',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvIt implements TranslationsLiveTvEn {
@override String get watchChannel => 'Guarda canale';
@override String get favorites => 'Preferiti';
@override String get reorderFavorites => 'Riordina preferiti';
@override String get joinSession => 'Partecipa alla sessione in corso';
@override String watchFromStart({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)';
@override String get watchLive => 'Guarda in diretta';
@override String get goToLive => 'Vai al live';
}
// Path: downloads
@@ -1841,6 +1845,10 @@ extension on TranslationsIt {
'liveTv.watchChannel' => 'Guarda canale',
'liveTv.favorites' => 'Preferiti',
'liveTv.reorderFavorites' => 'Riordina preferiti',
'liveTv.joinSession' => 'Partecipa alla sessione in corso',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)',
'liveTv.watchLive' => 'Guarda in diretta',
'liveTv.goToLive' => 'Vai al live',
'downloads.title' => 'Download',
'downloads.manage' => 'Gestisci',
'downloads.tvShows' => 'Serie TV',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvJa implements TranslationsLiveTvEn {
@override String get watchChannel => 'チャンネルを視聴';
@override String get favorites => 'お気に入り';
@override String get reorderFavorites => 'お気に入りを並べ替え';
@override String get joinSession => '進行中のセッションに参加';
@override String watchFromStart({required Object minutes}) => '最初から視聴(${minutes}分前に開始)';
@override String get watchLive => 'ライブで視聴';
@override String get goToLive => 'ライブに移動';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsJa {
'liveTv.watchChannel' => 'チャンネルを視聴',
'liveTv.favorites' => 'お気に入り',
'liveTv.reorderFavorites' => 'お気に入りを並べ替え',
'liveTv.joinSession' => '進行中のセッションに参加',
'liveTv.watchFromStart' => ({required Object minutes}) => '最初から視聴(${minutes}分前に開始)',
'liveTv.watchLive' => 'ライブで視聴',
'liveTv.goToLive' => 'ライブに移動',
'collections.title' => 'コレクション',
'collections.collection' => 'コレクション',
'collections.empty' => 'コレクションは空です',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvKo implements TranslationsLiveTvEn {
@override String get watchChannel => '채널 시청';
@override String get favorites => '즐겨찾기';
@override String get reorderFavorites => '즐겨찾기 순서 변경';
@override String get joinSession => '진행 중인 세션 참여';
@override String watchFromStart({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)';
@override String get watchLive => '실시간 시청';
@override String get goToLive => '실시간으로 이동';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsKo {
'liveTv.watchChannel' => '채널 시청',
'liveTv.favorites' => '즐겨찾기',
'liveTv.reorderFavorites' => '즐겨찾기 순서 변경',
'liveTv.joinSession' => '진행 중인 세션 참여',
'liveTv.watchFromStart' => ({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)',
'liveTv.watchLive' => '실시간 시청',
'liveTv.goToLive' => '실시간으로 이동',
'collections.title' => '컬렉션',
'collections.collection' => '컬렉션',
'collections.empty' => '컬렉션이 비어 있습니다',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvNb implements TranslationsLiveTvEn {
@override String get watchChannel => 'Se kanal';
@override String get favorites => 'Favoritter';
@override String get reorderFavorites => 'Endre rekkefølge på favoritter';
@override String get joinSession => 'Bli med i pågående økt';
@override String watchFromStart({required Object minutes}) => 'Se fra starten (${minutes} min siden)';
@override String get watchLive => 'Se direkte';
@override String get goToLive => 'Gå til direkte';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsNb {
'liveTv.watchChannel' => 'Se kanal',
'liveTv.favorites' => 'Favoritter',
'liveTv.reorderFavorites' => 'Endre rekkefølge på favoritter',
'liveTv.joinSession' => 'Bli med i pågående økt',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra starten (${minutes} min siden)',
'liveTv.watchLive' => 'Se direkte',
'liveTv.goToLive' => 'Gå til direkte',
'collections.title' => 'Samlinger',
'collections.collection' => 'Samling',
'collections.empty' => 'Samlingen er tom',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvNl implements TranslationsLiveTvEn {
@override String get watchChannel => 'Kanaal bekijken';
@override String get favorites => 'Favorieten';
@override String get reorderFavorites => 'Favorieten herordenen';
@override String get joinSession => 'Deelnemen aan lopende sessie';
@override String watchFromStart({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)';
@override String get watchLive => 'Live kijken';
@override String get goToLive => 'Ga naar live';
}
// Path: downloads
@@ -1841,6 +1845,10 @@ extension on TranslationsNl {
'liveTv.watchChannel' => 'Kanaal bekijken',
'liveTv.favorites' => 'Favorieten',
'liveTv.reorderFavorites' => 'Favorieten herordenen',
'liveTv.joinSession' => 'Deelnemen aan lopende sessie',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)',
'liveTv.watchLive' => 'Live kijken',
'liveTv.goToLive' => 'Ga naar live',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Beheren',
'downloads.tvShows' => 'Series',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvPl implements TranslationsLiveTvEn {
@override String get watchChannel => 'Oglądaj kanał';
@override String get favorites => 'Ulubione';
@override String get reorderFavorites => 'Zmień kolejność ulubionych';
@override String get joinSession => 'Dołącz do trwającej sesji';
@override String watchFromStart({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)';
@override String get watchLive => 'Oglądaj na żywo';
@override String get goToLive => 'Przejdź do na żywo';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsPl {
'liveTv.watchChannel' => 'Oglądaj kanał',
'liveTv.favorites' => 'Ulubione',
'liveTv.reorderFavorites' => 'Zmień kolejność ulubionych',
'liveTv.joinSession' => 'Dołącz do trwającej sesji',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)',
'liveTv.watchLive' => 'Oglądaj na żywo',
'liveTv.goToLive' => 'Przejdź do na żywo',
'collections.title' => 'Kolekcje',
'collections.collection' => 'Kolekcja',
'collections.empty' => 'Kolekcja jest pusta',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvPt implements TranslationsLiveTvEn {
@override String get watchChannel => 'Assistir Canal';
@override String get favorites => 'Favoritos';
@override String get reorderFavorites => 'Reordenar favoritos';
@override String get joinSession => 'Entrar na sessão em andamento';
@override String watchFromStart({required Object minutes}) => 'Assistir do início (${minutes} min atrás)';
@override String get watchLive => 'Assistir ao vivo';
@override String get goToLive => 'Ir para o ao vivo';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsPt {
'liveTv.watchChannel' => 'Assistir Canal',
'liveTv.favorites' => 'Favoritos',
'liveTv.reorderFavorites' => 'Reordenar favoritos',
'liveTv.joinSession' => 'Entrar na sessão em andamento',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Assistir do início (${minutes} min atrás)',
'liveTv.watchLive' => 'Assistir ao vivo',
'liveTv.goToLive' => 'Ir para o ao vivo',
'collections.title' => 'Coleções',
'collections.collection' => 'Coleção',
'collections.empty' => 'A coleção está vazia',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvRu implements TranslationsLiveTvEn {
@override String get watchChannel => 'Смотреть канал';
@override String get favorites => 'Избранное';
@override String get reorderFavorites => 'Изменить порядок избранного';
@override String get joinSession => 'Присоединиться к текущему сеансу';
@override String watchFromStart({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)';
@override String get watchLive => 'Смотреть в прямом эфире';
@override String get goToLive => 'К прямому эфиру';
}
// Path: collections
@@ -1841,6 +1845,10 @@ extension on TranslationsRu {
'liveTv.watchChannel' => 'Смотреть канал',
'liveTv.favorites' => 'Избранное',
'liveTv.reorderFavorites' => 'Изменить порядок избранного',
'liveTv.joinSession' => 'Присоединиться к текущему сеансу',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)',
'liveTv.watchLive' => 'Смотреть в прямом эфире',
'liveTv.goToLive' => 'К прямому эфиру',
'collections.title' => 'Коллекции',
'collections.collection' => 'Коллекция',
'collections.empty' => 'Коллекция пуста',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvSv implements TranslationsLiveTvEn {
@override String get watchChannel => 'Titta på kanal';
@override String get favorites => 'Favoriter';
@override String get reorderFavorites => 'Ordna om favoriter';
@override String get joinSession => 'Gå med i pågående session';
@override String watchFromStart({required Object minutes}) => 'Titta från början (${minutes} min sedan)';
@override String get watchLive => 'Titta live';
@override String get goToLive => 'Gå till live';
}
// Path: downloads
@@ -1841,6 +1845,10 @@ extension on TranslationsSv {
'liveTv.watchChannel' => 'Titta på kanal',
'liveTv.favorites' => 'Favoriter',
'liveTv.reorderFavorites' => 'Ordna om favoriter',
'liveTv.joinSession' => 'Gå med i pågående session',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Titta från början (${minutes} min sedan)',
'liveTv.watchLive' => 'Titta live',
'liveTv.goToLive' => 'Gå till live',
'downloads.title' => 'Nedladdningar',
'downloads.manage' => 'Hantera',
'downloads.tvShows' => 'TV-serier',
+8
View File
@@ -815,6 +815,10 @@ class _TranslationsLiveTvZh implements TranslationsLiveTvEn {
@override String get watchChannel => '观看频道';
@override String get favorites => '收藏';
@override String get reorderFavorites => '重新排序收藏';
@override String get joinSession => '加入正在进行的会话';
@override String watchFromStart({required Object minutes}) => '从头观看(${minutes}分钟前开始)';
@override String get watchLive => '观看直播';
@override String get goToLive => '跳至直播';
}
// Path: downloads
@@ -1841,6 +1845,10 @@ extension on TranslationsZh {
'liveTv.watchChannel' => '观看频道',
'liveTv.favorites' => '收藏',
'liveTv.reorderFavorites' => '重新排序收藏',
'liveTv.joinSession' => '加入正在进行的会话',
'liveTv.watchFromStart' => ({required Object minutes}) => '从头观看(${minutes}分钟前开始)',
'liveTv.watchLive' => '观看直播',
'liveTv.goToLive' => '跳至直播',
'downloads.title' => '下载',
'downloads.manage' => '管理',
'downloads.tvShows' => '电视剧',
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "På TV nu",
"watchChannel": "Titta på kanal",
"favorites": "Favoriter",
"reorderFavorites": "Ordna om favoriter"
"reorderFavorites": "Ordna om favoriter",
"joinSession": "Gå med i pågående session",
"watchFromStart": "Titta från början (${minutes} min sedan)",
"watchLive": "Titta live",
"goToLive": "Gå till live"
},
"downloads": {
"title": "Nedladdningar",
+5 -1
View File
@@ -582,7 +582,11 @@
"whatsOn": "正在播出",
"watchChannel": "观看频道",
"favorites": "收藏",
"reorderFavorites": "重新排序收藏"
"reorderFavorites": "重新排序收藏",
"joinSession": "加入正在进行的会话",
"watchFromStart": "从头观看(${minutes}分钟前开始)",
"watchLive": "观看直播",
"goToLive": "跳至直播"
},
"downloads": {
"title": "下载",
+51
View File
@@ -0,0 +1,51 @@
/// Represents the seekable capture buffer for a live TV transcode session.
///
/// Extracted from the `TranscodeSession` element in the tune response:
/// - [startedAt] = `timeStamp` (epoch seconds, the buffer origin)
/// - [seekStartSeconds] = `minOffsetAvailable` (earliest seekable offset)
/// - [seekEndSeconds] = `maxOffsetAvailable` (latest seekable offset / live edge)
class CaptureBuffer {
final double startedAt;
final double seekStartSeconds;
final double seekEndSeconds;
const CaptureBuffer({
required this.startedAt,
required this.seekStartSeconds,
required this.seekEndSeconds,
});
/// Absolute epoch second of the earliest seekable point.
int get seekableStartEpoch => (startedAt + seekStartSeconds).round();
/// Absolute epoch second of the latest seekable point (≈ live edge).
int get seekableEndEpoch => (startedAt + seekEndSeconds).round();
/// Duration of the seekable range in seconds.
int get seekableDurationSeconds => (seekEndSeconds - seekStartSeconds).round();
/// Parse from a TranscodeSession JSON map. Returns null if required fields are missing.
/// Values may be num or String depending on whether the server returned JSON or XML.
static CaptureBuffer? fromTranscodeSession(Map<String, dynamic> session) {
final timeStamp = _parseDouble(session['timeStamp']);
final minOffset = _parseDouble(session['minOffsetAvailable']);
final maxOffset = _parseDouble(session['maxOffsetAvailable']);
if (timeStamp == null || minOffset == null || maxOffset == null) return null;
return CaptureBuffer(
startedAt: timeStamp,
seekStartSeconds: minOffset,
seekEndSeconds: maxOffset,
);
}
static double? _parseDouble(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
@override
String toString() =>
'CaptureBuffer(startedAt: $startedAt, seek: $seekStartSeconds..$seekEndSeconds, '
'range: $seekableStartEpoch..$seekableEndEpoch)';
}
+10 -8
View File
@@ -1,5 +1,6 @@
import '../utils/formatters.dart';
import '../utils/codec_utils.dart';
import '../utils/json_utils.dart';
class PlexMediaVersion {
final int id;
@@ -22,20 +23,21 @@ class PlexMediaVersion {
required this.partKey,
});
/// Creates a PlexMediaVersion from Plex API Media object
/// Creates a PlexMediaVersion from Plex API Media object.
/// Values may be String or int depending on the response format (XML vs JSON).
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) {
// Get the first Part key for playback
final parts = json['Part'] as List<dynamic>?;
final partKey = parts != null && parts.isNotEmpty ? parts.first['key'] as String? ?? '' : '';
return PlexMediaVersion(
id: json['id'] as int? ?? 0,
videoResolution: json['videoResolution'] as String?,
videoCodec: json['videoCodec'] as String?,
bitrate: json['bitrate'] as int?,
width: json['width'] as int?,
height: json['height'] as int?,
container: json['container'] as String?,
id: flexibleInt(json['id']) ?? 0,
videoResolution: json['videoResolution']?.toString(),
videoCodec: json['videoCodec']?.toString(),
bitrate: flexibleInt(json['bitrate']),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
container: json['container']?.toString(),
partKey: partKey,
);
}
+3 -8
View File
@@ -7,6 +7,7 @@ import 'mixins/multi_server_fields.dart';
import 'plex_media_version.dart';
import 'plex_role.dart';
import '../utils/global_key_utils.dart';
import '../utils/json_utils.dart';
part 'plex_metadata.g.dart';
@@ -16,12 +17,6 @@ Object? _readRatingKey(Map json, String key) =>
List<String>? _tagsFromJson(List? json) =>
json?.cast<Map<String, dynamic>>().map((e) => e['tag'] as String).toList();
int? _flexibleInt(Object? v) => switch (v) {
num n => n.toInt(),
String s => int.tryParse(s),
_ => null,
};
/// Media type enum for type-safe media type handling
enum PlexMediaType {
movie,
@@ -99,7 +94,7 @@ class PlexMetadata with MultiServerFields {
final int? viewCount;
final int? leafCount; // Total number of episodes in a series/season
final int? viewedLeafCount; // Number of watched episodes in a series/season
@JsonKey(fromJson: _flexibleInt)
@JsonKey(fromJson: flexibleInt)
final int? childCount; // Number of items in a collection or playlist
@JsonKey(name: 'Role')
final List<PlexRole>? role; // Cast members
@@ -125,7 +120,7 @@ class PlexMetadata with MultiServerFields {
final List<String>? mood;
final String? audioLanguage; // Per-media preferred audio language
final String? subtitleLanguage; // Per-media preferred subtitle language
@JsonKey(fromJson: _flexibleInt)
@JsonKey(fromJson: flexibleInt)
final int? subtitleMode; // Per-media subtitle mode (0=manual, 1=foreign audio, 2=always, -1=account default)
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
+2 -2
View File
@@ -41,7 +41,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
viewCount: (json['viewCount'] as num?)?.toInt(),
leafCount: (json['leafCount'] as num?)?.toInt(),
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
childCount: _flexibleInt(json['childCount']),
childCount: flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -59,7 +59,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
mood: _tagsFromJson(json['Mood'] as List?),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: _flexibleInt(json['subtitleMode']),
subtitleMode: flexibleInt(json['subtitleMode']),
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
+4 -8
View File
@@ -1,23 +1,19 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
part 'plex_role.g.dart';
int? _flexibleInt(Object? v) => switch (v) {
num n => n.toInt(),
String s => int.tryParse(s),
_ => null,
};
@JsonSerializable()
class PlexRole {
@JsonKey(fromJson: _flexibleInt)
@JsonKey(fromJson: flexibleInt)
final int? id;
final String? filter;
final String tag;
final String? tagKey;
final String? role;
final String? thumb;
@JsonKey(fromJson: _flexibleInt)
@JsonKey(fromJson: flexibleInt)
final int? count;
PlexRole({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count});
+2 -2
View File
@@ -7,13 +7,13 @@ part of 'plex_role.dart';
// **************************************************************************
PlexRole _$PlexRoleFromJson(Map<String, dynamic> json) => PlexRole(
id: _flexibleInt(json['id']),
id: flexibleInt(json['id']),
filter: json['filter'] as String?,
tag: json['tag'] as String,
tagKey: json['tagKey'] as String?,
role: json['role'] as String?,
thumb: json['thumb'] as String?,
count: _flexibleInt(json['count']),
count: flexibleInt(json['count']),
);
Map<String, dynamic> _$PlexRoleToJson(PlexRole instance) => <String, dynamic>{
+154 -15
View File
@@ -19,6 +19,7 @@ import '../mpv/player/platform/player_android.dart';
import '../../services/bif_thumbnail_service.dart';
import '../../services/plex_client.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../services/plex_api_cache.dart';
import '../models/plex_media_version.dart';
@@ -174,6 +175,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
String? _liveRatingKey;
int? _liveDurationMs;
// Live TV time-shift
CaptureBuffer? _captureBuffer;
int? _programBeginsAt;
int? _programEndsAt;
double _streamStartEpoch = 0;
bool _isAtLiveEdge = true;
String? _transcodeSessionId;
// Auto-play next episode
Timer? _autoPlayTimer;
int _autoPlayCountdown = 5;
@@ -1028,15 +1037,53 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
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');
final tuneResult = await client.tuneChannel(widget.liveDvrKey!, channel.key);
if (tuneResult == null) throw Exception('Failed to tune channel');
streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
_liveRatingKey = tuneResult.metadata.ratingKey;
_liveDurationMs = tuneResult.metadata.duration;
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_programEndsAt = tuneResult.beginsAt != null && tuneResult.metadata.duration != null
? tuneResult.beginsAt! + tuneResult.metadata.duration! ~/ 1000
: null;
_transcodeSessionId = PlexClient.generateSessionIdentifier();
_liveSessionIdentifier = result.sessionIdentifier;
_liveSessionPath = result.sessionPath;
_liveRatingKey = result.metadata.ratingKey;
_liveDurationMs = result.metadata.duration;
// Show "Watch from Start" dialog when program has been on for >60s
int? offsetSeconds;
if (_captureBuffer != null && _programBeginsAt != null) {
final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final effectiveStart = max(_captureBuffer!.seekableStartEpoch, _programBeginsAt!);
if (nowEpoch - effectiveStart > 60) {
final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch);
if (!mounted) return;
if (watchFromStart == true) {
offsetSeconds = max(_programBeginsAt! - _captureBuffer!.startedAt.round(), 0);
}
}
}
// Build the stream URL (with optional offset for time-shift)
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
offsetSeconds: offsetSeconds,
);
if (streamPath == null || !mounted) throw Exception('Failed to build stream path');
streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token);
// Track stream start epoch for position calculations
if (offsetSeconds != null) {
_streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds;
_isAtLiveEdge = false;
} else {
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
}
}
_livePlaybackStartTime = DateTime.now();
@@ -2143,7 +2190,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final time = playbackTime;
final duration = _liveDurationMs ?? 0;
await client.updateLiveTimeline(
final updatedBuffer = await client.updateLiveTimeline(
ratingKey: ratingKey,
sessionPath: sessionPath,
sessionIdentifier: sessionId,
@@ -2152,6 +2199,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
duration: duration,
playbackTime: playbackTime,
);
if (updatedBuffer != null && mounted) {
setState(() => _captureBuffer = updatedBuffer);
}
} catch (e) {
appLogger.d('Live timeline update failed', error: e);
}
@@ -2182,6 +2232,70 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await p.setProperty('force-seekable', 'no');
}
/// The current playback position as an absolute epoch second (for live TV time-shift).
int get _currentPositionEpoch =>
(_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
/// Show "Watch from Start" / "Watch Live" dialog.
/// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed.
Future<bool?> _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) {
final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round();
return showOptionPickerDialog<bool>(
context,
title: t.liveTv.joinSession,
options: [
(icon: Symbols.replay_rounded, label: t.liveTv.watchFromStart(minutes: minutesAgo), value: true),
(icon: Symbols.live_tv_rounded, label: t.liveTv.watchLive, value: false),
],
);
}
/// Seek the live TV stream to an absolute epoch second.
/// Creates a new transcode session at the target offset.
Future<void> _seekLivePosition(int targetEpochSeconds) async {
if (_captureBuffer == null || _liveSessionPath == null || _liveSessionIdentifier == null || _transcodeSessionId == null) return;
final clamped = targetEpochSeconds.clamp(
_captureBuffer!.seekableStartEpoch,
_captureBuffer!.seekableEndEpoch,
);
final offsetSeconds = clamped - _captureBuffer!.startedAt.round();
final client = widget.liveClient;
if (client == null) return;
if (mounted) setState(() => _hasFirstFrame.value = false);
final streamPath = await client.buildLiveStreamPath(
sessionPath: _liveSessionPath!,
sessionIdentifier: _liveSessionIdentifier!,
transcodeSessionId: _transcodeSessionId!,
offsetSeconds: offsetSeconds,
);
if (streamPath == null || !mounted) return;
final streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token);
_streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds;
_isAtLiveEdge = (clamped >= _captureBuffer!.seekableEndEpoch - 5);
_livePlaybackStartTime = DateTime.now();
await _setLiveStreamOptions();
await player!.open(
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
play: true,
isLive: true,
);
if (mounted) setState(() {});
}
/// Jump to the live edge of the capture buffer.
Future<void> _jumpToLiveEdge() async {
if (_captureBuffer == null) return;
await _seekLivePosition(_captureBuffer!.seekableEndEpoch);
}
Future<void> _switchLiveChannel(int delta) async {
final channels = widget.liveChannels;
if (channels == null || channels.isEmpty) return;
@@ -2214,24 +2328,42 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
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 tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key);
if (tuneResult == null || !mounted) return;
final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token);
_transcodeSessionId = PlexClient.generateSessionIdentifier();
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
);
if (streamPath == null || !mounted) return;
final streamUrl = '${client.config.baseUrl}$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;
_liveRatingKey = tuneResult.metadata.ratingKey;
_liveDurationMs = tuneResult.metadata.duration;
// Reset time-shift state for new channel
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_programEndsAt = tuneResult.beginsAt != null && tuneResult.metadata.duration != null
? tuneResult.beginsAt! + tuneResult.metadata.duration! ~/ 1000
: null;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
if (!mounted) return;
setState(() {
_liveChannelIndex = newIndex;
_liveChannelName = channel.displayName;
_liveSessionIdentifier = result.sessionIdentifier;
_liveSessionPath = result.sessionPath;
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
});
// Restart timeline heartbeats for the new session
@@ -2684,6 +2816,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
thumbnailDataBuilder: _bifService?.isAvailable == true ? _getThumbnailData : null,
isLive: widget.isLive,
liveChannelName: _liveChannelName,
captureBuffer: _captureBuffer,
isAtLiveEdge: _isAtLiveEdge,
programBeginsAt: _programBeginsAt,
programEndsAt: _programEndsAt,
currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null,
onLiveSeek: _captureBuffer != null ? _seekLivePosition : null,
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
onToggleAmbientLighting: _toggleAmbientLighting,
),
+176 -25
View File
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_hub_result.dart';
@@ -1293,7 +1294,11 @@ class PlexClient {
}
/// Send a live TV timeline heartbeat to keep the transcode session alive.
Future<void> updateLiveTimeline({
///
/// Returns an updated [CaptureBuffer] if the response contains a
/// `TranscodeSession` with seek-range data (used to expand the seekable
/// window over time).
Future<CaptureBuffer?> updateLiveTimeline({
required String ratingKey,
required String sessionPath,
required String sessionIdentifier,
@@ -1317,7 +1322,42 @@ class PlexClient {
);
if (response.statusCode != null && response.statusCode != 200) {
appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}');
return null;
}
// Parse updated capture buffer from TranscodeSession in the response
try {
final data = response.data;
if (data is! Map<String, dynamic>) return null;
final container = data['MediaContainer'] as Map<String, dynamic>? ?? data;
// Try CaptureBuffer wrapper first, then TranscodeSession directly
final captureBufferWrapper = container['CaptureBuffer'];
if (captureBufferWrapper != null) {
final cbMap = captureBufferWrapper is List
? captureBufferWrapper.firstOrNull as Map<String, dynamic>?
: captureBufferWrapper as Map<String, dynamic>?;
if (cbMap != null) {
final ts = cbMap['TranscodeSession'];
final tsMap = ts is List
? ts.firstOrNull as Map<String, dynamic>?
: ts as Map<String, dynamic>?;
if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap);
}
}
final transcodeSessions = container['TranscodeSession'];
if (transcodeSessions is List && transcodeSessions.isNotEmpty) {
return CaptureBuffer.fromTranscodeSession(
transcodeSessions.first as Map<String, dynamic>,
);
} else if (transcodeSessions is Map<String, dynamic>) {
return CaptureBuffer.fromTranscodeSession(transcodeSessions);
}
} catch (e) {
// Parsing failure is non-fatal — just no updated seek range
}
return null;
}
/// Remove item from Continue Watching (On Deck) without affecting watch status or progress
@@ -2424,21 +2464,46 @@ class PlexClient {
}
/// Generate 24-char random alphanumeric string (matching official client format)
static String _generateSessionIdentifier() {
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.
/// Coerce String values to num for fields that json_serializable expects as num.
/// Plex tune responses use XML-to-JSON conversion where all values are strings.
static void _coerceNumericFields(Map<String, dynamic> json) {
const numericKeys = [
'duration', 'year', 'addedAt', 'updatedAt', 'lastViewedAt',
'parentIndex', 'index', 'viewOffset', 'viewCount', 'leafCount',
'viewedLeafCount', 'childCount', 'rating', 'audienceRating',
'userRating', 'ratingCount', 'skipCount', 'lastRatedAt',
];
for (final key in numericKeys) {
final val = json[key];
if (val is String) {
json[key] = num.tryParse(val);
}
}
}
/// Tune to a live TV channel.
///
/// Flow: tune → decision → return /start path (MKV-over-HTTP).
Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(
/// POSTs to the tune endpoint and extracts metadata, session info, and
/// capture buffer data from the response. Call [buildLiveStreamPath] after
/// to build the actual stream URL (with optional offset for time-shift).
Future<({
PlexMetadata metadata,
String sessionPath,
String sessionIdentifier,
CaptureBuffer? captureBuffer,
int? beginsAt,
})?> tuneChannel(
String dvrKey,
String channelIdentifier,
) async {
try {
final sessionIdentifier = _generateSessionIdentifier();
final sessionIdentifier = generateSessionIdentifier();
final response = await _dio.post(
'/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune',
@@ -2453,8 +2518,16 @@ class PlexClient {
final container = _getMediaContainer(response);
if (container == null) return null;
// Log the full container keys and types for debugging
appLogger.d('Tune response keys: ${container.keys.toList()}');
for (final key in container.keys) {
final val = container[key];
appLogger.d(' $key: ${val.runtimeType} = ${val is List ? '[${val.length} items]' : val is Map ? '{${val.keys.toList()}}' : val}');
}
final containerStatus = container['status'];
if (containerStatus != null && containerStatus != 0 && containerStatus != 200) {
final statusInt = containerStatus is num ? containerStatus.toInt() : containerStatus is String ? int.tryParse(containerStatus) : null;
if (statusInt != null && statusInt != 0 && statusInt != 200) {
final msg = container['message'] ?? 'Unknown error';
appLogger.w('Tune channel error: $msg (status: $containerStatus)');
throw Exception(msg);
@@ -2462,13 +2535,24 @@ class PlexClient {
// 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.first as Map<String, dynamic>;
final ops = sub['MediaGrabOperation'] as List?;
if (ops != null && ops.isNotEmpty) {
final op = ops.first as Map<String, dynamic>;
int? beginsAt;
final subscriptions = container['MediaSubscription'];
appLogger.d('MediaSubscription type: ${subscriptions.runtimeType}');
final subList = subscriptions is List ? subscriptions : subscriptions is Map ? [subscriptions] : null;
if (subList != null && subList.isNotEmpty) {
final sub = subList.first as Map<String, dynamic>;
final ops = sub['MediaGrabOperation'];
appLogger.d('MediaGrabOperation type: ${ops.runtimeType}');
final opList = ops is List ? ops : ops is Map ? [ops] : null;
if (opList != null && opList.isNotEmpty) {
final op = opList.first as Map<String, dynamic>;
appLogger.d('GrabOperation keys: ${op.keys.toList()}');
final rawBeginsAt = op['beginsAt'];
beginsAt = rawBeginsAt is num
? rawBeginsAt.toInt()
: rawBeginsAt is String ? int.tryParse(rawBeginsAt) : null;
final nested = op['Metadata'];
appLogger.d('Metadata type: ${nested.runtimeType}');
if (nested is Map<String, dynamic>) {
metadataJson = nested;
} else if (nested is List && nested.isNotEmpty) {
@@ -2476,14 +2560,36 @@ class PlexClient {
}
}
}
metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map<String, dynamic>?;
if (metadataJson == null) {
final fallback = container['Metadata'];
appLogger.d('Fallback Metadata type: ${fallback.runtimeType}');
if (fallback is List && fallback.isNotEmpty) {
metadataJson = fallback.first as Map<String, dynamic>;
} else if (fallback is Map<String, dynamic>) {
metadataJson = fallback;
}
}
if (metadataJson == null) {
appLogger.w('Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})');
return null;
}
final metadata = _createTaggedMetadata(metadataJson);
// Tune response may return XML-style string values where fromJson expects nums.
// Coerce known numeric fields so the generated deserializer doesn't choke.
_coerceNumericFields(metadataJson);
PlexMetadata metadata;
try {
metadata = _createTaggedMetadata(metadataJson);
} catch (e, st) {
appLogger.e('Tune metadata parse failed. Keys: ${metadataJson.keys.toList()}');
for (final entry in metadataJson.entries) {
appLogger.d(' ${entry.key}: ${entry.value.runtimeType} = ${entry.value}');
}
appLogger.e('Stack: $st');
rethrow;
}
final sessionPath = metadataJson['key'] as String?;
if (sessionPath == null) {
@@ -2491,8 +2597,57 @@ class PlexClient {
return null;
}
// All identity goes in query params; the only HTTP header is Accept-Language
// (matching the official Plex client behaviour).
// Extract capture buffer from TranscodeSession.
// May be at the container level OR inside the Metadata object.
CaptureBuffer? captureBuffer;
final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession'];
if (tsSource is List && tsSource.isNotEmpty) {
captureBuffer = CaptureBuffer.fromTranscodeSession(
tsSource.first as Map<String, dynamic>,
);
} else if (tsSource is Map<String, dynamic>) {
captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource);
}
// beginsAt may also be on the Media items (not just the GrabOperation)
if (beginsAt == null) {
final media = metadataJson['Media'];
if (media is List && media.isNotEmpty) {
final firstMedia = media.first;
if (firstMedia is Map<String, dynamic>) {
final raw = firstMedia['beginsAt'];
beginsAt = raw is num ? raw.toInt() : raw is String ? int.tryParse(raw) : null;
}
}
}
return (
metadata: metadata,
sessionPath: sessionPath,
sessionIdentifier: sessionIdentifier,
captureBuffer: captureBuffer,
beginsAt: beginsAt,
);
} catch (e, st) {
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
return null;
}
}
/// Build a live TV stream URL (decision + start path).
///
/// [sessionPath] and [sessionIdentifier] come from [tuneChannel].
/// [transcodeSessionId] should be reused across seeks within the same
/// viewing session so the server reuses its capture buffer.
/// [offsetSeconds] positions the stream at that many seconds from the
/// capture buffer origin (for time-shift / watch-from-start).
Future<String?> buildLiveStreamPath({
required String sessionPath,
required String sessionIdentifier,
required String transcodeSessionId,
int? offsetSeconds,
}) async {
try {
final allParams = <String, String>{
'hasMDE': '1',
'path': sessionPath,
@@ -2510,7 +2665,7 @@ class PlexClient {
'directStreamAudio': '1',
'advancedSubtitles': 'text',
'mediaBufferSize': '157286',
'session': _generateSessionIdentifier(),
'session': transcodeSessionId,
'subtitles': 'auto',
'copyts': '0',
'Accept-Language': 'en',
@@ -2522,6 +2677,7 @@ class PlexClient {
'X-Plex-Client-Identifier': config.clientIdentifier,
'X-Plex-Platform': config.platform,
'X-Plex-Client-Profile-Name': 'Plex Desktop',
if (offsetSeconds != null) 'offset': offsetSeconds.toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
@@ -2552,14 +2708,9 @@ class PlexClient {
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return (
metadata: metadata,
streamPath: '/video/:/transcode/universal/start?$startQuery',
sessionIdentifier: sessionIdentifier,
sessionPath: sessionPath,
);
return '/video/:/transcode/universal/start?$startQuery';
} catch (e, st) {
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
appLogger.e('Failed to build live stream path', error: e, stackTrace: st);
return null;
}
}
+9
View File
@@ -0,0 +1,9 @@
/// Parse a value that may be [int], [num], or [String] to [int].
/// Used as `@JsonKey(fromJson: flexibleInt)` and in manual `fromJson` factories
/// to handle Plex API responses where numeric fields may arrive as strings
/// (XML-to-JSON conversion).
int? flexibleInt(Object? v) => switch (v) {
num n => n.toInt(),
String s => int.tryParse(s),
_ => null,
};
@@ -15,8 +15,10 @@ import '../../utils/platform_detector.dart';
import '../../utils/formatters.dart';
import '../../i18n/strings.g.dart';
import '../../focus/focusable_wrapper.dart';
import '../../models/livetv_capture_buffer.dart';
import 'models/track_controls_state.dart';
import 'widgets/content_strip.dart';
import 'widgets/live_timeline_bar.dart';
import 'widgets/first_frame_guard.dart';
import 'widgets/play_pause_stream_builder.dart';
import 'widgets/video_controls_header.dart';
@@ -63,6 +65,15 @@ class DesktopVideoControls extends StatefulWidget {
/// Channel name for live TV display
final String? liveChannelName;
// Live TV time-shift
final CaptureBuffer? captureBuffer;
final bool isAtLiveEdge;
final int? programBeginsAt;
final int? programEndsAt;
final int? currentPositionEpoch;
final ValueChanged<int>? onLiveSeek;
final VoidCallback? onJumpToLive;
/// Whether to use dpad navigation for content strip (TV or keyboard nav mode)
final bool useDpadNavigation;
@@ -112,6 +123,13 @@ class DesktopVideoControls extends StatefulWidget {
this.hasFirstFrame,
this.thumbnailDataBuilder,
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.programBeginsAt,
this.programEndsAt,
this.currentPositionEpoch,
this.onLiveSeek,
this.onJumpToLive,
this.useDpadNavigation = false,
this.serverId,
this.showQueueTab = false,
@@ -604,7 +622,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onBack: widget.onBack,
),
),
if (_isLive) ...[
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -614,6 +632,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
),
),
] else if (_isLive && widget.captureBuffer != null && !widget.isAtLiveEdge) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: widget.onJumpToLive,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: const BorderRadius.all(Radius.circular(4)),
border: Border.all(color: Colors.white24),
),
child: Text(
t.liveTv.goToLive,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12),
),
),
),
],
],
),
@@ -628,8 +663,24 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
// Row 1: Timeline with time indicators (hidden for live TV)
if (!_isLive) ...[
// Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD)
if (_isLive && widget.captureBuffer != null && widget.currentPositionEpoch != null) ...[
LiveTimelineBar(
captureBuffer: widget.captureBuffer!,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch!,
isAtLiveEdge: widget.isAtLiveEdge,
onSeekEnd: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
horizontalLayout: true,
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: canInteract,
),
const SizedBox(height: 4),
] else if (!_isLive) ...[
VideoTimelineBar(
player: widget.player,
chapters: widget.chapters,
@@ -684,6 +735,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
);
},
),
],
if (!_isLive || widget.captureBuffer != null) ...[
// Skip backward
Opacity(
opacity: _canControl ? 1.0 : 0.5,
@@ -721,7 +774,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
},
),
),
if (!_isLive) ...[
if (!_isLive || widget.captureBuffer != null) ...[
// Skip forward
Opacity(
opacity: _canControl ? 1.0 : 0.5,
@@ -733,6 +786,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall),
),
),
],
if (!_isLive) ...[
// Next chapter
StreamBuilder<Duration>(
stream: widget.player.streams.position,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../mpv/mpv.dart';
import '../../models/livetv_capture_buffer.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_metadata.dart';
import '../../utils/desktop_window_padding.dart';
@@ -12,6 +13,7 @@ import 'widgets/circular_control_button.dart';
import 'widgets/content_strip.dart';
import 'widgets/first_frame_guard.dart';
import 'widgets/play_pause_stream_builder.dart';
import 'widgets/live_timeline_bar.dart';
import 'widgets/video_controls_header.dart';
import 'widgets/video_timeline_bar.dart';
@@ -56,6 +58,15 @@ class MobileVideoControls extends StatefulWidget {
/// Channel name for live TV display
final String? liveChannelName;
// Live TV time-shift
final CaptureBuffer? captureBuffer;
final bool isAtLiveEdge;
final int? programBeginsAt;
final int? programEndsAt;
final int? currentPositionEpoch;
final ValueChanged<int>? onLiveSeek;
final VoidCallback? onJumpToLive;
/// Server ID for chapter thumbnails in the content strip
final String? serverId;
@@ -93,6 +104,13 @@ class MobileVideoControls extends StatefulWidget {
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.programBeginsAt,
this.programEndsAt,
this.currentPositionEpoch,
this.onLiveSeek,
this.onJumpToLive,
this.serverId,
this.showQueueTab = false,
this.onQueueItemSelected,
@@ -365,7 +383,24 @@ class _MobileVideoControlsState extends State<MobileVideoControls>
Widget _buildBottomBar(BuildContext _) {
if (widget.isLive) {
// For live TV, show channel name instead of timeline
if (widget.captureBuffer != null && widget.currentPositionEpoch != null) {
// Live TV with time-shift: show seekable timeline
return FirstFrameGuard(
hasFirstFrame: widget.hasFirstFrame,
builder: (context) => LiveTimelineBar(
captureBuffer: widget.captureBuffer!,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch!,
isAtLiveEdge: widget.isAtLiveEdge,
onSeekEnd: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
horizontalLayout: false,
enabled: widget.canControl,
),
);
}
// Fallback: static LIVE badge (no capture buffer)
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -27,6 +27,7 @@ import '../overlay_sheet.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focusable_wrapper.dart';
import '../../models/livetv_capture_buffer.dart';
import '../../services/plex_client.dart';
import '../../services/plex_api_cache.dart';
import '../../models/plex_media_info.dart';
@@ -85,6 +86,13 @@ Widget plexVideoControlsBuilder(
Uint8List? Function(Duration time)? thumbnailDataBuilder,
bool isLive = false,
String? liveChannelName,
CaptureBuffer? captureBuffer,
bool isAtLiveEdge = true,
int? programBeginsAt,
int? programEndsAt,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
VoidCallback? onJumpToLive,
bool isAmbientLightingEnabled = false,
VoidCallback? onToggleAmbientLighting,
}) {
@@ -114,6 +122,13 @@ Widget plexVideoControlsBuilder(
thumbnailDataBuilder: thumbnailDataBuilder,
isLive: isLive,
liveChannelName: liveChannelName,
captureBuffer: captureBuffer,
isAtLiveEdge: isAtLiveEdge,
programBeginsAt: programBeginsAt,
programEndsAt: programEndsAt,
currentPositionEpoch: currentPositionEpoch,
onLiveSeek: onLiveSeek,
onJumpToLive: onJumpToLive,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
);
@@ -168,6 +183,27 @@ class PlexVideoControls extends StatefulWidget {
/// Channel name for live TV display
final String? liveChannelName;
/// Capture buffer for live TV time-shift (null = no time-shift support)
final CaptureBuffer? captureBuffer;
/// Whether playback is at the live edge
final bool isAtLiveEdge;
/// Program start epoch seconds (from EPG)
final int? programBeginsAt;
/// Program end epoch seconds (from EPG)
final int? programEndsAt;
/// Current playback position as absolute epoch seconds (for live TV)
final int? currentPositionEpoch;
/// Seek callback for live TV time-shift (epoch seconds)
final ValueChanged<int>? onLiveSeek;
/// Jump to live edge callback
final VoidCallback? onJumpToLive;
/// Whether ambient lighting is enabled (passed to settings sheet)
final bool isAmbientLightingEnabled;
@@ -201,6 +237,13 @@ class PlexVideoControls extends StatefulWidget {
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.programBeginsAt,
this.programEndsAt,
this.currentPositionEpoch,
this.onLiveSeek,
this.onJumpToLive,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
@@ -1199,6 +1242,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
Future<void> _seekByOffset(Duration delta, {bool notifyCompletion = true}) async {
// Route through live seek callback for time-shifted live TV
if (widget.isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) {
widget.onLiveSeek!(widget.currentPositionEpoch! + delta.inSeconds);
return;
}
final target = widget.player.state.position + delta;
final clamped = clampSeekPosition(widget.player, target);
await widget.player.seek(clamped);
@@ -2082,6 +2130,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
thumbnailDataBuilder: widget.thumbnailDataBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
serverId: widget.metadata.serverId,
showQueueTab: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive
@@ -2228,6 +2283,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
hasFirstFrame: widget.hasFirstFrame,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
programBeginsAt: widget.programBeginsAt,
programEndsAt: widget.programEndsAt,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onJumpToLive: widget.onJumpToLive,
useDpadNavigation: useDpad,
serverId: widget.metadata.serverId,
showQueueTab: playbackState.isQueueActive,
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../models/livetv_capture_buffer.dart';
import '../../../focus/focusable_wrapper.dart';
/// Timeline bar for live TV time-shift.
///
/// Shows the full program range (beginsAt..endsAt) with the seekable capture
/// buffer highlighted. The user can drag within the buffer region to seek.
class LiveTimelineBar extends StatefulWidget {
final CaptureBuffer captureBuffer;
final int? programBeginsAt;
final int? programEndsAt;
final int currentPositionEpoch;
final bool isAtLiveEdge;
final ValueChanged<int>? onSeekEnd;
final VoidCallback? onJumpToLive;
final bool horizontalLayout;
final FocusNode? focusNode;
final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent;
final ValueChanged<bool>? onFocusChange;
final bool enabled;
const LiveTimelineBar({
super.key,
required this.captureBuffer,
this.programBeginsAt,
this.programEndsAt,
required this.currentPositionEpoch,
this.isAtLiveEdge = true,
this.onSeekEnd,
this.onJumpToLive,
this.horizontalLayout = true,
this.focusNode,
this.onKeyEvent,
this.onFocusChange,
this.enabled = true,
});
@override
State<LiveTimelineBar> createState() => _LiveTimelineBarState();
}
class _LiveTimelineBarState extends State<LiveTimelineBar> {
bool _isDragging = false;
int _dragPositionEpoch = 0;
int get _rangeStart => widget.captureBuffer.seekableStartEpoch;
int get _rangeEnd => widget.captureBuffer.seekableEndEpoch;
int get _displayPosition => _isDragging ? _dragPositionEpoch : widget.currentPositionEpoch;
String _formatEpochTime(int epochSeconds) {
final dt = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000);
return DateFormat.jm().format(dt);
}
double _epochToFraction(int epoch) {
final range = _rangeEnd - _rangeStart;
if (range <= 0) return 0;
return ((epoch - _rangeStart) / range).clamp(0.0, 1.0);
}
int _fractionToEpoch(double fraction) {
final range = _rangeEnd - _rangeStart;
return (_rangeStart + (fraction * range).round()).clamp(_rangeStart, _rangeEnd);
}
@override
Widget build(BuildContext context) {
if (widget.horizontalLayout) {
return _buildHorizontalLayout();
}
return _buildVerticalLayout();
}
Widget _buildHorizontalLayout() {
return Row(
children: [
Text(
_formatEpochTime(_displayPosition),
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(width: 8),
Expanded(child: _buildSlider()),
],
);
}
Widget _buildVerticalLayout() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildSlider(),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
_formatEpochTime(_displayPosition),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
);
}
Widget _buildSlider() {
final positionFraction = _epochToFraction(_displayPosition);
return FocusableWrapper(
focusNode: widget.focusNode,
onKeyEvent: widget.onKeyEvent,
onFocusChange: widget.onFocusChange,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return GestureDetector(
onHorizontalDragStart: widget.enabled ? _onDragStart : null,
onHorizontalDragUpdate: widget.enabled
? (details) => _onDragUpdate(details, width)
: null,
onHorizontalDragEnd: widget.enabled ? _onDragEnd : null,
onTapUp: widget.enabled ? (details) => _onTap(details, width) : null,
child: SizedBox(
height: 24,
child: CustomPaint(
size: Size(width, 24),
painter: _LiveTimelinePainter(
positionFraction: positionFraction,
),
),
),
);
},
),
);
}
void _onDragStart(DragStartDetails details) {
setState(() {
_isDragging = true;
_dragPositionEpoch = widget.currentPositionEpoch;
});
}
void _onDragUpdate(DragUpdateDetails details, double width) {
if (width <= 0) return;
final fraction = (details.localPosition.dx / width).clamp(0.0, 1.0);
setState(() {
_dragPositionEpoch = _fractionToEpoch(fraction);
});
}
void _onDragEnd(DragEndDetails details) {
final target = _dragPositionEpoch;
setState(() => _isDragging = false);
widget.onSeekEnd?.call(target);
}
void _onTap(TapUpDetails details, double width) {
if (width <= 0) return;
final fraction = (details.localPosition.dx / width).clamp(0.0, 1.0);
final target = _fractionToEpoch(fraction);
widget.onSeekEnd?.call(target);
}
}
class _LiveTimelinePainter extends CustomPainter {
final double positionFraction;
_LiveTimelinePainter({required this.positionFraction});
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final trackY = size.height / 2;
const trackHeight = 4.0;
const thumbRadius = 6.0;
final posX = positionFraction * w;
// Background track (unplayed)
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(w / 2, trackY), width: w, height: trackHeight),
const Radius.circular(2),
),
Paint()..color = Colors.white.withValues(alpha: 0.15),
);
// Played region
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(0, trackY - trackHeight / 2, posX, trackY + trackHeight / 2),
const Radius.circular(2),
),
Paint()..color = Colors.red,
);
// Thumb
canvas.drawCircle(Offset(posX, trackY), thumbRadius, Paint()..color = Colors.red);
}
@override
bool shouldRepaint(covariant _LiveTimelinePainter oldDelegate) =>
positionFraction != oldDelegate.positionFraction;
}