feat: live TV time-shift
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -582,7 +582,11 @@
|
||||
"whatsOn": "放送中",
|
||||
"watchChannel": "チャンネルを視聴",
|
||||
"favorites": "お気に入り",
|
||||
"reorderFavorites": "お気に入りを並べ替え"
|
||||
"reorderFavorites": "お気に入りを並べ替え",
|
||||
"joinSession": "進行中のセッションに参加",
|
||||
"watchFromStart": "最初から視聴(${minutes}分前に開始)",
|
||||
"watchLive": "ライブで視聴",
|
||||
"goToLive": "ライブに移動"
|
||||
},
|
||||
"collections": {
|
||||
"title": "コレクション",
|
||||
|
||||
@@ -582,7 +582,11 @@
|
||||
"whatsOn": "지금 방송 중",
|
||||
"watchChannel": "채널 시청",
|
||||
"favorites": "즐겨찾기",
|
||||
"reorderFavorites": "즐겨찾기 순서 변경"
|
||||
"reorderFavorites": "즐겨찾기 순서 변경",
|
||||
"joinSession": "진행 중인 세션 참여",
|
||||
"watchFromStart": "처음부터 시청 (${minutes}분 전 시작)",
|
||||
"watchLive": "실시간 시청",
|
||||
"goToLive": "실시간으로 이동"
|
||||
},
|
||||
"collections": {
|
||||
"title": "컬렉션",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -582,7 +582,11 @@
|
||||
"whatsOn": "Что идёт",
|
||||
"watchChannel": "Смотреть канал",
|
||||
"favorites": "Избранное",
|
||||
"reorderFavorites": "Изменить порядок избранного"
|
||||
"reorderFavorites": "Изменить порядок избранного",
|
||||
"joinSession": "Присоединиться к текущему сеансу",
|
||||
"watchFromStart": "Смотреть сначала (${minutes} мин. назад)",
|
||||
"watchLive": "Смотреть в прямом эфире",
|
||||
"goToLive": "К прямому эфиру"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Коллекции",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'コレクションは空です',
|
||||
|
||||
@@ -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' => '컬렉션이 비어 있습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Коллекция пуста',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '电视剧',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -582,7 +582,11 @@
|
||||
"whatsOn": "正在播出",
|
||||
"watchChannel": "观看频道",
|
||||
"favorites": "收藏",
|
||||
"reorderFavorites": "重新排序收藏"
|
||||
"reorderFavorites": "重新排序收藏",
|
||||
"joinSession": "加入正在进行的会话",
|
||||
"watchFromStart": "从头观看(${minutes}分钟前开始)",
|
||||
"watchLive": "观看直播",
|
||||
"goToLive": "跳至直播"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "下载",
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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>{
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user