From 3f1da5f289a84799653ad351f7fb0a10a3bbba63 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:19:37 +0200 Subject: [PATCH] feat: live TV time-shift --- lib/i18n/da.i18n.json | 6 +- lib/i18n/de.i18n.json | 6 +- lib/i18n/en.i18n.json | 6 +- lib/i18n/es.i18n.json | 6 +- lib/i18n/fr.i18n.json | 6 +- lib/i18n/it.i18n.json | 6 +- lib/i18n/ja.i18n.json | 6 +- lib/i18n/ko.i18n.json | 6 +- lib/i18n/nb.i18n.json | 6 +- lib/i18n/nl.i18n.json | 6 +- lib/i18n/pl.i18n.json | 6 +- lib/i18n/pt.i18n.json | 6 +- lib/i18n/ru.i18n.json | 6 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_da.g.dart | 8 + lib/i18n/strings_de.g.dart | 8 + lib/i18n/strings_en.g.dart | 16 ++ lib/i18n/strings_es.g.dart | 8 + lib/i18n/strings_fr.g.dart | 8 + lib/i18n/strings_it.g.dart | 8 + lib/i18n/strings_ja.g.dart | 8 + lib/i18n/strings_ko.g.dart | 8 + lib/i18n/strings_nb.g.dart | 8 + lib/i18n/strings_nl.g.dart | 8 + lib/i18n/strings_pl.g.dart | 8 + lib/i18n/strings_pt.g.dart | 8 + lib/i18n/strings_ru.g.dart | 8 + lib/i18n/strings_sv.g.dart | 8 + lib/i18n/strings_zh.g.dart | 8 + lib/i18n/sv.i18n.json | 6 +- lib/i18n/zh.i18n.json | 6 +- lib/models/livetv_capture_buffer.dart | 51 +++++ lib/models/plex_media_version.dart | 18 +- lib/models/plex_metadata.dart | 11 +- lib/models/plex_metadata.g.dart | 4 +- lib/models/plex_role.dart | 12 +- lib/models/plex_role.g.dart | 4 +- lib/screens/video_player_screen.dart | 169 ++++++++++++-- lib/services/plex_client.dart | 201 ++++++++++++++--- lib/utils/json_utils.dart | 9 + .../desktop_video_controls.dart | 63 +++++- .../video_controls/mobile_video_controls.dart | 37 +++- .../video_controls/video_controls.dart | 62 ++++++ .../widgets/live_timeline_bar.dart | 209 ++++++++++++++++++ 44 files changed, 982 insertions(+), 90 deletions(-) create mode 100644 lib/models/livetv_capture_buffer.dart create mode 100644 lib/utils/json_utils.dart create mode 100644 lib/widgets/video_controls/widgets/live_timeline_bar.dart diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 70ffe2ad..dc3aa721 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index e5d3f89f..19aecaca 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 6116793c..19839e49 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 77990b06..1bde66ed 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index be842091..5e1a8e83 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index d06e7dcb..193a4804 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 769ddd0d..f1674ad7 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -582,7 +582,11 @@ "whatsOn": "放送中", "watchChannel": "チャンネルを視聴", "favorites": "お気に入り", - "reorderFavorites": "お気に入りを並べ替え" + "reorderFavorites": "お気に入りを並べ替え", + "joinSession": "進行中のセッションに参加", + "watchFromStart": "最初から視聴(${minutes}分前に開始)", + "watchLive": "ライブで視聴", + "goToLive": "ライブに移動" }, "collections": { "title": "コレクション", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 2e422322..bc446387 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -582,7 +582,11 @@ "whatsOn": "지금 방송 중", "watchChannel": "채널 시청", "favorites": "즐겨찾기", - "reorderFavorites": "즐겨찾기 순서 변경" + "reorderFavorites": "즐겨찾기 순서 변경", + "joinSession": "진행 중인 세션 참여", + "watchFromStart": "처음부터 시청 (${minutes}분 전 시작)", + "watchLive": "실시간 시청", + "goToLive": "실시간으로 이동" }, "collections": { "title": "컬렉션", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index eebc7bcf..78432219 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 2a507a27..47bdc65a 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index d9ebd639..d8af82f3 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 888de0e7..cbf47fdc 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index cbca5378..089ef962 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -582,7 +582,11 @@ "whatsOn": "Что идёт", "watchChannel": "Смотреть канал", "favorites": "Избранное", - "reorderFavorites": "Изменить порядок избранного" + "reorderFavorites": "Изменить порядок избранного", + "joinSession": "Присоединиться к текущему сеансу", + "watchFromStart": "Смотреть сначала (${minutes} мин. назад)", + "watchLive": "Смотреть в прямом эфире", + "goToLive": "К прямому эфиру" }, "collections": { "title": "Коллекции", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index d5ad5c48..740a01d8 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 56d1e938..951e9e62 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 0f5b4e98..fa025b78 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 15f9b835..9266e565 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index d0d66aea..b5230110 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 482f72a2..248ec2dd 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 59edb959..db40ee81 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 00941dc3..ef571522 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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' => 'コレクションは空です', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index f94607fc..2e4e7bed 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '컬렉션이 비어 있습니다', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index beb25dd6..8ec02718 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 998d7fe8..67355bb6 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index d821c609..6acc3b17 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index e3b899ea..4282b871 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index fb1991bf..1ffe69e8 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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' => 'Коллекция пуста', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 9536f070..d47d7fb5 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index aebb1e7d..a62e9cc8 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '电视剧', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index bd74ce30..4461433b 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 65c07442..d3194ee1 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -582,7 +582,11 @@ "whatsOn": "正在播出", "watchChannel": "观看频道", "favorites": "收藏", - "reorderFavorites": "重新排序收藏" + "reorderFavorites": "重新排序收藏", + "joinSession": "加入正在进行的会话", + "watchFromStart": "从头观看(${minutes}分钟前开始)", + "watchLive": "观看直播", + "goToLive": "跳至直播" }, "downloads": { "title": "下载", diff --git a/lib/models/livetv_capture_buffer.dart b/lib/models/livetv_capture_buffer.dart new file mode 100644 index 00000000..45b16068 --- /dev/null +++ b/lib/models/livetv_capture_buffer.dart @@ -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 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)'; +} diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart index 78e23cb9..a139355c 100644 --- a/lib/models/plex_media_version.dart +++ b/lib/models/plex_media_version.dart @@ -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 json) { // Get the first Part key for playback final parts = json['Part'] as List?; 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, ); } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 94ff8038..a58e733f 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -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? _tagsFromJson(List? json) => json?.cast>().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? role; // Cast members @@ -125,7 +120,7 @@ class PlexMetadata with MultiServerFields { final List? 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) diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 9f16a540..43d0be10 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -41,7 +41,7 @@ PlexMetadata _$PlexMetadataFromJson(Map 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?) ?.map((e) => PlexRole.fromJson(e as Map)) .toList(), @@ -59,7 +59,7 @@ PlexMetadata _$PlexMetadataFromJson(Map 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(), diff --git a/lib/models/plex_role.dart b/lib/models/plex_role.dart index f0aff798..14a76d6e 100644 --- a/lib/models/plex_role.dart +++ b/lib/models/plex_role.dart @@ -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}); diff --git a/lib/models/plex_role.g.dart b/lib/models/plex_role.g.dart index 94294b9c..4e2f5cfd 100644 --- a/lib/models/plex_role.g.dart +++ b/lib/models/plex_role.g.dart @@ -7,13 +7,13 @@ part of 'plex_role.dart'; // ************************************************************************** PlexRole _$PlexRoleFromJson(Map 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 _$PlexRoleToJson(PlexRole instance) => { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 17ad79ab..a3743803 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 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 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 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 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 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 _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) { + final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round(); + return showOptionPickerDialog( + 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 _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 _jumpToLiveEdge() async { + if (_captureBuffer == null) return; + await _seekLivePosition(_captureBuffer!.seekableEndEpoch); + } + Future _switchLiveChannel(int delta) async { final channels = widget.liveChannels; if (channels == null || channels.isEmpty) return; @@ -2214,24 +2328,42 @@ class VideoPlayerScreenState extends State 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 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, ), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8ce7450c..7bdd3ab4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 updateLiveTimeline({ + /// + /// Returns an updated [CaptureBuffer] if the response contains a + /// `TranscodeSession` with seek-range data (used to expand the seekable + /// window over time). + Future 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) return null; + final container = data['MediaContainer'] as Map? ?? data; + + // Try CaptureBuffer wrapper first, then TranscodeSession directly + final captureBufferWrapper = container['CaptureBuffer']; + if (captureBufferWrapper != null) { + final cbMap = captureBufferWrapper is List + ? captureBufferWrapper.firstOrNull as Map? + : captureBufferWrapper as Map?; + if (cbMap != null) { + final ts = cbMap['TranscodeSession']; + final tsMap = ts is List + ? ts.firstOrNull as Map? + : ts as Map?; + if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap); + } + } + + final transcodeSessions = container['TranscodeSession']; + if (transcodeSessions is List && transcodeSessions.isNotEmpty) { + return CaptureBuffer.fromTranscodeSession( + transcodeSessions.first as Map, + ); + } else if (transcodeSessions is Map) { + 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 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? metadataJson; - final subscriptions = container['MediaSubscription'] as List?; - if (subscriptions != null && subscriptions.isNotEmpty) { - final sub = subscriptions.first as Map; - final ops = sub['MediaGrabOperation'] as List?; - if (ops != null && ops.isNotEmpty) { - final op = ops.first as Map; + 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; + 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; + 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) { metadataJson = nested; } else if (nested is List && nested.isNotEmpty) { @@ -2476,14 +2560,36 @@ class PlexClient { } } } - metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map?; + 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; + } else if (fallback is Map) { + 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, + ); + } else if (tsSource is Map) { + 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) { + 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 buildLiveStreamPath({ + required String sessionPath, + required String sessionIdentifier, + required String transcodeSessionId, + int? offsetSeconds, + }) async { + try { final allParams = { '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; } } diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart new file mode 100644 index 00000000..a3381b33 --- /dev/null +++ b/lib/utils/json_utils.dart @@ -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, + }; diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index a2b3d53e..81c8559a 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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? 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 { 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 { 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 { 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 { ); }, ), + ], + if (!_isLive || widget.captureBuffer != null) ...[ // Skip backward Opacity( opacity: _canControl ? 1.0 : 0.5, @@ -721,7 +774,7 @@ class DesktopVideoControlsState extends State { }, ), ), - if (!_isLive) ...[ + if (!_isLive || widget.captureBuffer != null) ...[ // Skip forward Opacity( opacity: _canControl ? 1.0 : 0.5, @@ -733,6 +786,8 @@ class DesktopVideoControlsState extends State { semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), ), ), + ], + if (!_isLive) ...[ // Next chapter StreamBuilder( stream: widget.player.streams.position, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 99ed1f83..bcadc2ff 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -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? 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 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( diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index c36ccc56..4782b33e 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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? 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? 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 with WindowListen } Future _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 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 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, diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart new file mode 100644 index 00000000..6af70d86 --- /dev/null +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -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? onSeekEnd; + final VoidCallback? onJumpToLive; + final bool horizontalLayout; + final FocusNode? focusNode; + final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent; + final ValueChanged? 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 createState() => _LiveTimelineBarState(); +} + +class _LiveTimelineBarState extends State { + 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; +}