From 0159c184973fc61add91037312d9217585337970 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:50:10 +0100 Subject: [PATCH] feat: discontinuous buffer ranges on timeline --- lib/i18n/de.i18n.json | 4 +- lib/i18n/en.i18n.json | 4 +- lib/i18n/es.i18n.json | 4 +- lib/i18n/fr.i18n.json | 4 +- lib/i18n/it.i18n.json | 4 +- lib/i18n/ko.i18n.json | 4 +- lib/i18n/nl.i18n.json | 4 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 8 +- lib/i18n/strings_en.g.dart | 12 +- lib/i18n/strings_es.g.dart | 8 +- lib/i18n/strings_fr.g.dart | 8 +- lib/i18n/strings_it.g.dart | 8 +- lib/i18n/strings_ko.g.dart | 8 +- lib/i18n/strings_nl.g.dart | 8 +- lib/i18n/strings_sv.g.dart | 8 +- lib/i18n/strings_zh.g.dart | 8 +- lib/i18n/sv.i18n.json | 4 +- lib/i18n/zh.i18n.json | 4 +- lib/mpv/models.dart | 7 + lib/mpv/player/player_base.dart | 55 +++++++ lib/mpv/player/player_native.dart | 9 +- lib/mpv/player/player_state.dart | 6 + lib/mpv/player/player_stream_controllers.dart | 3 + lib/mpv/player/player_streams.dart | 4 + lib/providers/playback_state_provider.dart | 6 + lib/screens/video_player_screen.dart | 6 + .../desktop_video_controls.dart | 10 ++ .../video_controls/mobile_video_controls.dart | 24 +++ .../painters/buffer_range_painter.dart | 52 +++++++ .../video_controls/sheets/queue_sheet.dart | 139 ++++++++++++++++++ .../video_controls/video_controls.dart | 15 ++ .../widgets/timeline_slider.dart | 32 +++- .../widgets/track_chapter_controls.dart | 34 +++++ .../widgets/video_timeline_bar.dart | 30 ++-- 35 files changed, 501 insertions(+), 47 deletions(-) create mode 100644 lib/widgets/video_controls/painters/buffer_range_painter.dart create mode 100644 lib/widgets/video_controls/sheets/queue_sheet.dart diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index d8ebf686..c0f15a36 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -341,7 +341,9 @@ "unknown": "Ein Fehler ist aufgetreten: ${error}" }, "chapters": "Kapitel", - "noChaptersAvailable": "Keine Kapitel verfügbar" + "noChaptersAvailable": "Keine Kapitel verfügbar", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Eigentümer", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 5f010b4d..7a47f554 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -341,7 +341,9 @@ "unknown": "An error occurred: ${error}" }, "chapters": "Chapters", - "noChaptersAvailable": "No chapters available" + "noChaptersAvailable": "No chapters available", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 582ff382..63438767 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -341,7 +341,9 @@ "unknown": "Ocurrió un error: ${error}" }, "chapters": "Capítulos", - "noChaptersAvailable": "No hay capítulos disponibles" + "noChaptersAvailable": "No hay capítulos disponibles", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Administrador", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4fd1cc0b..c6201cab 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -341,7 +341,9 @@ "unknown": "Une erreur s'est produite : ${error}" }, "chapters": "Chapitres", - "noChaptersAvailable": "Aucun chapitre disponible" + "noChaptersAvailable": "Aucun chapitre disponible", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 31ab7150..7322b034 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -341,7 +341,9 @@ "unknown": "Si è verificato un errore: ${error}" }, "chapters": "Capitoli", - "noChaptersAvailable": "Nessun capitolo disponibile" + "noChaptersAvailable": "Nessun capitolo disponibile", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 794002cc..b3d6d170 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -341,7 +341,9 @@ "unknown": "오류가 발생했습니다: ${error}" }, "chapters": "챕터", - "noChaptersAvailable": "사용 가능한 챕터가 없습니다" + "noChaptersAvailable": "사용 가능한 챕터가 없습니다", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "관리자", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 65b6f5a4..978c927c 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -341,7 +341,9 @@ "unknown": "Er is een fout opgetreden: ${error}" }, "chapters": "Hoofdstukken", - "noChaptersAvailable": "Geen hoofdstukken beschikbaar" + "noChaptersAvailable": "Geen hoofdstukken beschikbaar", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Beheerder", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index c05ba857..e4b899ec 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 6687 (743 per locale) +/// Strings: 6705 (745 per locale) /// -/// Built on 2026-02-20 at 20:23 UTC +/// Built on 2026-02-20 at 23:08 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 1ac628ea..992b4993 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsDe pipErrors = _TranslationsVideoControlsPipErrorsDe._(_root); @override String get chapters => 'Kapitel'; @override String get noChaptersAvailable => 'Keine Kapitel verfügbar'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsDe { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ein Fehler ist aufgetreten: ${error}', 'videoControls.chapters' => 'Kapitel', 'videoControls.noChaptersAvailable' => 'Keine Kapitel verfügbar', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Eigentümer', 'userStatus.restricted' => 'Eingeschränkt', 'userStatus.protected' => 'Geschützt', @@ -1740,10 +1744,10 @@ extension on TranslationsDe { 'liveTv.allChannels' => 'Alle Kanäle', 'liveTv.now' => 'Jetzt', 'liveTv.today' => 'Heute', - 'liveTv.midnight' => 'Mitternacht', - 'liveTv.overnight' => 'Nacht', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Mitternacht', + 'liveTv.overnight' => 'Nacht', 'liveTv.morning' => 'Morgen', 'liveTv.daytime' => 'Tagsüber', 'liveTv.evening' => 'Abend', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index fb74a440..e0b4e381 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1071,6 +1071,12 @@ class TranslationsVideoControlsEn { /// en: 'No chapters available' String get noChaptersAvailable => 'No chapters available'; + + /// en: 'Queue' + String get queue => 'Queue'; + + /// en: 'No items in queue' + String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -3035,6 +3041,8 @@ extension on Translations { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'An error occurred: ${error}', 'videoControls.chapters' => 'Chapters', 'videoControls.noChaptersAvailable' => 'No chapters available', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Admin', 'userStatus.restricted' => 'Restricted', 'userStatus.protected' => 'Protected', @@ -3233,10 +3241,10 @@ extension on Translations { 'liveTv.allChannels' => 'All Channels', 'liveTv.now' => 'Now', 'liveTv.today' => 'Today', - 'liveTv.midnight' => 'Midnight', - 'liveTv.overnight' => 'Overnight', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Midnight', + 'liveTv.overnight' => 'Overnight', 'liveTv.morning' => 'Morning', 'liveTv.daytime' => 'Daytime', 'liveTv.evening' => 'Evening', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 5e618d15..38a79b93 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsEs pipErrors = _TranslationsVideoControlsPipErrorsEs._(_root); @override String get chapters => 'Capítulos'; @override String get noChaptersAvailable => 'No hay capítulos disponibles'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsEs { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ocurrió un error: ${error}', 'videoControls.chapters' => 'Capítulos', 'videoControls.noChaptersAvailable' => 'No hay capítulos disponibles', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Administrador', 'userStatus.restricted' => 'Restringido', 'userStatus.protected' => 'Protegido', @@ -1740,10 +1744,10 @@ extension on TranslationsEs { 'liveTv.allChannels' => 'Todos los canales', 'liveTv.now' => 'Ahora', 'liveTv.today' => 'Hoy', - 'liveTv.midnight' => 'Medianoche', - 'liveTv.overnight' => 'Madrugada', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Medianoche', + 'liveTv.overnight' => 'Madrugada', 'liveTv.morning' => 'Mañana', 'liveTv.daytime' => 'Día', 'liveTv.evening' => 'Noche', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index a77a7ef3..961f0688 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsFr pipErrors = _TranslationsVideoControlsPipErrorsFr._(_root); @override String get chapters => 'Chapitres'; @override String get noChaptersAvailable => 'Aucun chapitre disponible'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsFr { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'Une erreur s\'est produite : ${error}', 'videoControls.chapters' => 'Chapitres', 'videoControls.noChaptersAvailable' => 'Aucun chapitre disponible', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Admin', 'userStatus.restricted' => 'Restreint', 'userStatus.protected' => 'Protégé', @@ -1740,10 +1744,10 @@ extension on TranslationsFr { 'liveTv.allChannels' => 'Toutes les chaînes', 'liveTv.now' => 'Maintenant', 'liveTv.today' => 'Aujourd\'hui', - 'liveTv.midnight' => 'Minuit', - 'liveTv.overnight' => 'Nuit', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Minuit', + 'liveTv.overnight' => 'Nuit', 'liveTv.morning' => 'Matin', 'liveTv.daytime' => 'Journée', 'liveTv.evening' => 'Soirée', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 2262c77f..f97e14d0 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsIt pipErrors = _TranslationsVideoControlsPipErrorsIt._(_root); @override String get chapters => 'Capitoli'; @override String get noChaptersAvailable => 'Nessun capitolo disponibile'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsIt { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'Si è verificato un errore: ${error}', 'videoControls.chapters' => 'Capitoli', 'videoControls.noChaptersAvailable' => 'Nessun capitolo disponibile', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Admin', 'userStatus.restricted' => 'Limitato', 'userStatus.protected' => 'Protetto', @@ -1740,10 +1744,10 @@ extension on TranslationsIt { 'liveTv.allChannels' => 'Tutti i canali', 'liveTv.now' => 'Ora', 'liveTv.today' => 'Oggi', - 'liveTv.midnight' => 'Mezzanotte', - 'liveTv.overnight' => 'Notte', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Mezzanotte', + 'liveTv.overnight' => 'Notte', 'liveTv.morning' => 'Mattina', 'liveTv.daytime' => 'Giorno', 'liveTv.evening' => 'Sera', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 248c2f69..6f39ee99 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsKo pipErrors = _TranslationsVideoControlsPipErrorsKo._(_root); @override String get chapters => '챕터'; @override String get noChaptersAvailable => '사용 가능한 챕터가 없습니다'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsKo { 'videoControls.pipErrors.unknown' => ({required Object error}) => '오류가 발생했습니다: ${error}', 'videoControls.chapters' => '챕터', 'videoControls.noChaptersAvailable' => '사용 가능한 챕터가 없습니다', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => '관리자', 'userStatus.restricted' => '제한됨', 'userStatus.protected' => '보호됨', @@ -1740,10 +1744,10 @@ extension on TranslationsKo { 'liveTv.allChannels' => '전체 채널', 'liveTv.now' => '지금', 'liveTv.today' => '오늘', - 'liveTv.midnight' => '자정', - 'liveTv.overnight' => '심야', _ => null, } ?? switch (path) { + 'liveTv.midnight' => '자정', + 'liveTv.overnight' => '심야', 'liveTv.morning' => '아침', 'liveTv.daytime' => '낮', 'liveTv.evening' => '저녁', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 3c970851..fbf984de 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsNl pipErrors = _TranslationsVideoControlsPipErrorsNl._(_root); @override String get chapters => 'Hoofdstukken'; @override String get noChaptersAvailable => 'Geen hoofdstukken beschikbaar'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsNl { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'Er is een fout opgetreden: ${error}', 'videoControls.chapters' => 'Hoofdstukken', 'videoControls.noChaptersAvailable' => 'Geen hoofdstukken beschikbaar', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Beheerder', 'userStatus.restricted' => 'Beperkt', 'userStatus.protected' => 'Beschermd', @@ -1740,10 +1744,10 @@ extension on TranslationsNl { 'liveTv.allChannels' => 'Alle zenders', 'liveTv.now' => 'Nu', 'liveTv.today' => 'Vandaag', - 'liveTv.midnight' => 'Middernacht', - 'liveTv.overnight' => 'Nacht', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Middernacht', + 'liveTv.overnight' => 'Nacht', 'liveTv.morning' => 'Ochtend', 'liveTv.daytime' => 'Overdag', 'liveTv.evening' => 'Avond', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index f328733b..b8b82f8c 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsSv pipErrors = _TranslationsVideoControlsPipErrorsSv._(_root); @override String get chapters => 'Kapitel'; @override String get noChaptersAvailable => 'Inga kapitel tillgängliga'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsSv { 'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ett fel uppstod: ${error}', 'videoControls.chapters' => 'Kapitel', 'videoControls.noChaptersAvailable' => 'Inga kapitel tillgängliga', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => 'Admin', 'userStatus.restricted' => 'Begränsad', 'userStatus.protected' => 'Skyddad', @@ -1740,10 +1744,10 @@ extension on TranslationsSv { 'liveTv.allChannels' => 'Alla kanaler', 'liveTv.now' => 'Nu', 'liveTv.today' => 'Idag', - 'liveTv.midnight' => 'Midnatt', - 'liveTv.overnight' => 'Natt', _ => null, } ?? switch (path) { + 'liveTv.midnight' => 'Midnatt', + 'liveTv.overnight' => 'Natt', 'liveTv.morning' => 'Morgon', 'liveTv.daytime' => 'Dagtid', 'liveTv.evening' => 'Kväll', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 1969fc3b..c4dc843a 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -490,6 +490,8 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn { @override late final _TranslationsVideoControlsPipErrorsZh pipErrors = _TranslationsVideoControlsPipErrorsZh._(_root); @override String get chapters => '章节'; @override String get noChaptersAvailable => '没有可用的章节'; + @override String get queue => 'Queue'; + @override String get noQueueItems => 'No items in queue'; } // Path: userStatus @@ -1542,6 +1544,8 @@ extension on TranslationsZh { 'videoControls.pipErrors.unknown' => ({required Object error}) => '发生错误:${error}', 'videoControls.chapters' => '章节', 'videoControls.noChaptersAvailable' => '没有可用的章节', + 'videoControls.queue' => 'Queue', + 'videoControls.noQueueItems' => 'No items in queue', 'userStatus.admin' => '管理员', 'userStatus.restricted' => '受限', 'userStatus.protected' => '受保护', @@ -1740,10 +1744,10 @@ extension on TranslationsZh { 'liveTv.allChannels' => '所有频道', 'liveTv.now' => '现在', 'liveTv.today' => '今天', - 'liveTv.midnight' => '午夜', - 'liveTv.overnight' => '凌晨', _ => null, } ?? switch (path) { + 'liveTv.midnight' => '午夜', + 'liveTv.overnight' => '凌晨', 'liveTv.morning' => '上午', 'liveTv.daytime' => '白天', 'liveTv.evening' => '晚上', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 36ff8d09..eb765336 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -341,7 +341,9 @@ "unknown": "Ett fel uppstod: ${error}" }, "chapters": "Kapitel", - "noChaptersAvailable": "Inga kapitel tillgängliga" + "noChaptersAvailable": "Inga kapitel tillgängliga", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index cfb81c41..2c3d46aa 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -341,7 +341,9 @@ "unknown": "发生错误:${error}" }, "chapters": "章节", - "noChaptersAvailable": "没有可用的章节" + "noChaptersAvailable": "没有可用的章节", + "queue": "Queue", + "noQueueItems": "No items in queue" }, "userStatus": { "admin": "管理员", diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index a4fa8cd4..3b54c9b0 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -1,3 +1,10 @@ +/// Represents a contiguous buffered range in the demuxer cache. +class BufferRange { + final Duration start; + final Duration end; + const BufferRange({required this.start, required this.end}); +} + /// Log level for player messages. enum PlayerLogLevel { /// No logging. diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index cfca222b..97ce3263 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -176,9 +176,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final buffer = Duration(milliseconds: (value * 1000).toInt()); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); + // Synthesize a single range for players without demuxer-cache-state (ExoPlayer). + // ExoPlayer only buffers ahead of the current position, so use position as start. + final ranges = [BufferRange(start: _state.position, end: buffer)]; + _state = _state.copyWith(bufferRanges: ranges); + bufferRangesController.add(ranges); } break; + case 'demuxer-cache-state': + _handleDemuxerCacheState(value); + break; + case 'volume': if (value is num) { final volume = value.toDouble(); @@ -259,6 +268,48 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } } + /// Parse demuxer-cache-state property to extract seekable ranges and buffer end. + void _handleDemuxerCacheState(dynamic value) { + Map? cacheState; + if (value is Map) { + cacheState = value; + } else if (value is String && value.isNotEmpty) { + try { + final parsed = jsonDecode(value); + if (parsed is Map) cacheState = parsed; + } catch (_) {} + } + if (cacheState == null) return; + + // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) + final cacheEnd = cacheState['cache-end'] as num?; + if (cacheEnd != null) { + final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt()); + _state = _state.copyWith(buffer: buffer); + bufferController.add(buffer); + } + + // Extract seekable-ranges array + final seekableRanges = cacheState['seekable-ranges']; + if (seekableRanges is List) { + final ranges = []; + for (final range in seekableRanges) { + if (range is Map) { + final start = range['start'] as num?; + final end = range['end'] as num?; + if (start != null && end != null) { + ranges.add(BufferRange( + start: Duration(milliseconds: (start * 1000).toInt()), + end: Duration(milliseconds: (end * 1000).toInt()), + )); + } + } + } + _state = _state.copyWith(bufferRanges: ranges); + bufferRangesController.add(ranges); + } + } + /// Handle a player event from the platform. /// Subclasses can override this to handle platform-specific events. void handlePlayerEvent(String name, Map? data) { @@ -280,6 +331,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'playback-restart': + // Clear stale buffer ranges from before the seek; fresh ones will + // arrive shortly via the next demuxer-cache-state update. + _state = _state.copyWith(bufferRanges: const []); + bufferRangesController.add(const []); playbackRestartController.add(null); break; diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index b5e61824..2ff10169 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -30,6 +30,10 @@ class PlayerNative extends PlayerBase { @override String get playerType => 'mpv'; + /// Node properties are returned as structured maps on macOS/iOS/Linux, + /// but as JSON strings on Android/Windows. + static final String _nodeFormat = (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node'; + // ============================================ // Initialization // ============================================ @@ -58,13 +62,14 @@ class PlayerNative extends PlayerBase { await observeProperty('duration', 'double'); await observeProperty('pause', 'flag'); await observeProperty('paused-for-cache', 'flag'); - await observeProperty('track-list', (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node'); + await observeProperty('track-list', _nodeFormat); await observeProperty('eof-reached', 'flag'); await observeProperty('volume', 'double'); await observeProperty('speed', 'double'); await observeProperty('aid', 'string'); await observeProperty('sid', 'string'); - await observeProperty('audio-device-list', (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node'); + await observeProperty('demuxer-cache-state', _nodeFormat); + await observeProperty('audio-device-list', _nodeFormat); await observeProperty('audio-device', 'string'); } catch (e) { errorController.add('Initialization failed: $e'); diff --git a/lib/mpv/player/player_state.dart b/lib/mpv/player/player_state.dart index b29e834c..0a11d8c9 100644 --- a/lib/mpv/player/player_state.dart +++ b/lib/mpv/player/player_state.dart @@ -50,6 +50,9 @@ class PlayerState { /// Available audio output devices. final List audioDevices; + /// Seekable buffered ranges from the demuxer cache. + final List bufferRanges; + const PlayerState({ this.playing = false, this.completed = false, @@ -66,6 +69,7 @@ class PlayerState { this.audioPassthrough = false, this.audioDevice = AudioDevice.auto, this.audioDevices = const [], + this.bufferRanges = const [], }); /// Creates a copy with the given fields replaced. @@ -85,6 +89,7 @@ class PlayerState { bool? audioPassthrough, AudioDevice? audioDevice, List? audioDevices, + List? bufferRanges, }) { return PlayerState( playing: playing ?? this.playing, @@ -102,6 +107,7 @@ class PlayerState { audioPassthrough: audioPassthrough ?? this.audioPassthrough, audioDevice: audioDevice ?? this.audioDevice, audioDevices: audioDevices ?? this.audioDevices, + bufferRanges: bufferRanges ?? this.bufferRanges, ); } diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index f22aeb0a..f7393f87 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -23,6 +23,7 @@ mixin PlayerStreamControllersMixin { final errorController = StreamController.broadcast(); final audioDeviceController = StreamController.broadcast(); final audioDevicesController = StreamController>.broadcast(); + final bufferRangesController = StreamController>.broadcast(); final playbackRestartController = StreamController.broadcast(); final backendSwitchedController = StreamController.broadcast(); @@ -43,6 +44,7 @@ mixin PlayerStreamControllersMixin { error: errorController.stream, audioDevice: audioDeviceController.stream, audioDevices: audioDevicesController.stream, + bufferRanges: bufferRangesController.stream, playbackRestart: playbackRestartController.stream, backendSwitched: backendSwitchedController.stream, ); @@ -64,6 +66,7 @@ mixin PlayerStreamControllersMixin { await errorController.close(); await audioDeviceController.close(); await audioDevicesController.close(); + await bufferRangesController.close(); await playbackRestartController.close(); await backendSwitchedController.close(); } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index b17e5327..22deac6d 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -50,6 +50,9 @@ class PlayerStreams { /// Stream that emits when playback restarts (first frame ready after load/seek). final Stream playbackRestart; + /// Stream of seekable buffer ranges from the demuxer cache. + final Stream> bufferRanges; + /// Stream that emits when the native player backend switches (e.g., ExoPlayer to MPV). /// Only emitted on Android when ExoPlayer encounters an unsupported format. final Stream backendSwitched; @@ -69,6 +72,7 @@ class PlayerStreams { required this.error, required this.audioDevice, required this.audioDevices, + required this.bufferRanges, required this.playbackRestart, required this.backendSwitched, }); diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index dc72be79..de4ccf35 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -57,6 +57,12 @@ class PlaybackStateProvider with ChangeNotifier { /// Current play queue ID int? get playQueueId => _playQueueId; + /// The currently loaded queue items (windowed subset of full queue) + List get loadedItems => List.unmodifiable(_loadedItems); + + /// The current play queue item ID + int? get currentPlayQueueItemID => _currentPlayQueueItemID; + /// Total number of items in the play queue int get queueLength => _playQueueTotalCount; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f1c6c25b..f5631af5 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1758,6 +1758,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _navigateToEpisode(_previousEpisode!); } + /// Navigate to a specific queue item (called from QueueSheet) + Future navigateToQueueItem(PlexMetadata metadata) async { + _notifyWatchTogetherMediaChange(metadata: metadata); + await _navigateToEpisode(metadata); + } + bool _isSwitchingChannel = false; /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 20307a35..9f306562 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -95,6 +95,12 @@ class DesktopVideoControls extends StatefulWidget { /// Called to toggle ambient lighting (passed to settings sheet) final VoidCallback? onToggleAmbientLighting; + /// Whether to show the queue button + final bool showQueueButton; + + /// Callback when a queue item is selected + final Function(PlexMetadata)? onQueueItemSelected; + const DesktopVideoControls({ super.key, required this.player, @@ -143,6 +149,8 @@ class DesktopVideoControls extends StatefulWidget { this.liveChannelName, this.isAmbientLightingEnabled = false, this.onToggleAmbientLighting, + this.showQueueButton = false, + this.onQueueItemSelected, }); @override @@ -686,6 +694,8 @@ class DesktopVideoControlsState extends State { onNavigateLeft: navigateFromTrackToVolume, canControl: widget.canControl, isLive: widget.isLive, + showQueueButton: widget.showQueueButton, + onQueueItemSelected: widget.onQueueItemSelected, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 52905a15..a98e6fff 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -34,6 +34,8 @@ class MobileVideoControls extends StatelessWidget { final VoidCallback? onBack; final VoidCallback? onNext; final VoidCallback? onPrevious; + final VoidCallback? onSeekToPreviousChapter; + final VoidCallback? onSeekToNextChapter; /// Whether the user can control playback (false in host-only mode for non-host). final bool canControl; @@ -67,6 +69,8 @@ class MobileVideoControls extends StatelessWidget { this.onBack, this.onNext, this.onPrevious, + this.onSeekToPreviousChapter, + this.onSeekToNextChapter, this.canControl = true, this.hasFirstFrame, this.thumbnailUrlBuilder, @@ -118,6 +122,8 @@ class MobileVideoControls extends StatelessWidget { } Widget _buildPlaybackControlsContent(BuildContext _) { + final hasChapters = !isLive && chaptersLoaded && chapters.isNotEmpty; + return PlayPauseStreamBuilder( player: player, builder: (context, isPlaying) { @@ -125,6 +131,15 @@ class MobileVideoControls extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ if (!isLive) ...[ + if (hasChapters) ...[ + CircularControlButton( + semanticLabel: t.videoControls.previousChapterButton, + icon: Symbols.fast_rewind_rounded, + iconSize: 32, + onPressed: onSeekToPreviousChapter, + ), + const SizedBox(width: 16), + ], // Previous episode button (greyed out when unavailable) CircularControlButton( semanticLabel: t.videoControls.previousButton, @@ -157,6 +172,15 @@ class MobileVideoControls extends StatelessWidget { iconSize: 48, onPressed: onNext, ), + if (hasChapters) ...[ + const SizedBox(width: 16), + CircularControlButton( + semanticLabel: t.videoControls.nextChapterButton, + icon: Symbols.fast_forward_rounded, + iconSize: 32, + onPressed: onSeekToNextChapter, + ), + ], ], ], ); diff --git a/lib/widgets/video_controls/painters/buffer_range_painter.dart b/lib/widgets/video_controls/painters/buffer_range_painter.dart new file mode 100644 index 00000000..fc1559f0 --- /dev/null +++ b/lib/widgets/video_controls/painters/buffer_range_painter.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../../../mpv/models.dart'; + +/// Custom painter that draws a background track and buffered range bars +/// on the video timeline slider. +class BufferRangePainter extends CustomPainter { + final List ranges; + final Duration duration; + + BufferRangePainter({required this.ranges, required this.duration}); + + @override + void paint(Canvas canvas, Size size) { + final trackHeight = 4.0; + final y = (size.height - trackHeight) / 2; + + // Background track (full width) + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.3) + ..style = PaintingStyle.fill; + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(0, y, size.width, trackHeight), const Radius.circular(2)), + bgPaint, + ); + + if (duration.inMilliseconds <= 0) return; + + // Buffer range bars + final bufPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.5) + ..style = PaintingStyle.fill; + + final durationMs = duration.inMilliseconds.toDouble(); + for (final range in ranges) { + final startFraction = (range.start.inMilliseconds / durationMs).clamp(0.0, 1.0); + final endFraction = (range.end.inMilliseconds / durationMs).clamp(0.0, 1.0); + if (endFraction <= startFraction) continue; + + final left = startFraction * size.width; + final right = endFraction * size.width; + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(left, y, right - left, trackHeight), const Radius.circular(2)), + bufPaint, + ); + } + } + + @override + bool shouldRepaint(BufferRangePainter oldDelegate) { + return oldDelegate.duration != duration || oldDelegate.ranges != ranges; + } +} diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart new file mode 100644 index 00000000..6b981190 --- /dev/null +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../../i18n/strings.g.dart'; +import '../../../models/plex_metadata.dart'; +import '../../../providers/playback_state_provider.dart'; +import '../../../utils/provider_extensions.dart'; +import '../../../widgets/focusable_list_tile.dart'; +import '../../../widgets/overlay_sheet.dart'; +import 'base_video_control_sheet.dart'; +import '../../plex_optimized_image.dart'; + +/// Bottom sheet for viewing and navigating the play queue +class QueueSheet extends StatelessWidget { + final Function(PlexMetadata) onItemSelected; + + const QueueSheet({super.key, required this.onItemSelected}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, playbackState, _) { + final items = playbackState.loadedItems; + final currentItemID = playbackState.currentPlayQueueItemID; + + Widget content; + if (items.isEmpty) { + content = Center( + child: Text(t.videoControls.noQueueItems, style: const TextStyle(color: Colors.white70)), + ); + } else { + // Find current index for initial scroll + final currentIndex = items.indexWhere((item) => item.playQueueItemID == currentItemID); + + content = ListView.builder( + controller: currentIndex > 0 ? ScrollController(initialScrollOffset: currentIndex * 56.0) : null, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final isCurrent = item.playQueueItemID == currentItemID; + + return FocusableListTile( + leading: _buildThumbnail(context, item, isCurrent), + title: Text( + item.title, + style: TextStyle( + color: isCurrent ? Colors.blue : Colors.white, + fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + _buildSubtitle(item), + style: TextStyle( + color: isCurrent ? Colors.blue.withValues(alpha: 0.7) : Colors.white70, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: isCurrent + ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) + : null, + onTap: () { + onItemSelected(item); + OverlaySheetController.of(context).close(); + }, + ); + }, + ); + } + + return BaseVideoControlSheet( + title: t.videoControls.queue, + icon: Symbols.queue_music_rounded, + child: content, + ); + }, + ); + } + + Widget? _buildThumbnail(BuildContext context, PlexMetadata item, bool isCurrent) { + if (item.thumb == null) return null; + + // Try to get client for thumbnails, may fail in offline mode + final client = _tryGetClient(context, item); + + return Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: PlexOptimizedImage.thumb( + client: client, + imagePath: item.thumb, + width: 60, + height: 34, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ), + ), + if (isCurrent) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + border: const Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + ), + ), + ), + ], + ); + } + + String _buildSubtitle(PlexMetadata item) { + if (item.grandparentTitle != null && item.parentIndex != null && item.index != null) { + return '${item.grandparentTitle} \u00b7 S${item.parentIndex}E${item.index}'; + } + if (item.grandparentTitle != null) { + return item.grandparentTitle!; + } + if (item.year != null) { + return '${item.year}'; + } + return item.type; + } + + static dynamic _tryGetClient(BuildContext context, PlexMetadata item) { + if (item.serverId == null) return null; + try { + return context.getClientForServer(item.serverId!); + } catch (_) { + return null; + } + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 461b830c..c12ba842 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -51,6 +51,7 @@ import 'desktop_video_controls.dart'; import 'package:provider/provider.dart'; import '../../models/shader_preset.dart'; +import '../../providers/playback_state_provider.dart'; import '../../providers/shader_provider.dart'; import '../../services/shader_service.dart'; @@ -888,6 +889,8 @@ class _PlexVideoControlsState extends State with WindowListen } Widget _buildTrackChapterControlsWidget() { + final playbackState = context.watch(); + return TrackChapterControls( player: widget.player, chapters: _chapters, @@ -916,6 +919,8 @@ class _PlexVideoControlsState extends State with WindowListen serverId: widget.metadata.serverId ?? '', canControl: widget.canControl, isLive: widget.isLive, + showQueueButton: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, @@ -1798,6 +1803,8 @@ class _PlexVideoControlsState extends State with WindowListen onBack: widget.onBack, onNext: widget.onNext, onPrevious: widget.onPrevious, + onSeekToPreviousChapter: _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, thumbnailUrlBuilder: widget.thumbnailUrlBuilder, @@ -1862,6 +1869,7 @@ class _PlexVideoControlsState extends State with WindowListen Widget _buildDesktopControlsListener() { final pipMode = (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null; final boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null; + final playbackState = context.watch(); return Listener( behavior: HitTestBehavior.translucent, @@ -1910,6 +1918,8 @@ class _PlexVideoControlsState extends State with WindowListen onBack: widget.onBack, canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, + showQueueButton: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, thumbnailUrlBuilder: widget.thumbnailUrlBuilder, @@ -2032,6 +2042,11 @@ class _PlexVideoControlsState extends State with WindowListen } /// Switch to a different media version + void _onQueueItemSelected(PlexMetadata item) { + final videoPlayerState = context.findAncestorStateOfType(); + videoPlayerState?.navigateToQueueItem(item); + } + Future _switchMediaVersion(int newMediaIndex) async { if (newMediaIndex == widget.selectedMediaIndex) { return; // Already using this version diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 90d99b53..ec251858 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../models/plex_media_info.dart'; +import '../../../mpv/models.dart'; import '../../../i18n/strings.g.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../utils/formatters.dart'; +import '../painters/buffer_range_painter.dart'; import '../painters/chapter_marker_painter.dart'; import '../../plex_optimized_image.dart' show blurArtwork; @@ -16,6 +18,7 @@ import '../../plex_optimized_image.dart' show blurArtwork; class TimelineSlider extends StatefulWidget { final Duration position; final Duration duration; + final List bufferRanges; final List chapters; final bool chaptersLoaded; final ValueChanged onSeek; @@ -40,6 +43,7 @@ class TimelineSlider extends StatefulWidget { super.key, required this.position, required this.duration, + this.bufferRanges = const [], required this.chapters, required this.chaptersLoaded, required this.onSeek, @@ -61,7 +65,8 @@ class _TimelineSliderState extends State { bool _showKeySeekThumbnail = false; Timer? _keySeekTimer; - static const _sliderPadding = 24.0; + // Must match the slider track inset: max(overlayRadius, thumbRadius) + static const _sliderPadding = 12.0; static const _thumbWidth = 160.0; static const _thumbHeight = 90.0; @@ -201,7 +206,7 @@ class _TimelineSliderState extends State { if (widget.chaptersLoaded && widget.chapters.isNotEmpty && widget.duration.inMilliseconds > 0) Positioned.fill( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric(horizontal: _sliderPadding), child: Row( children: widget.chapters.map((chapter) { @@ -223,10 +228,26 @@ class _TimelineSliderState extends State { ), ), ), + // Buffer range + background track painter + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _sliderPadding), + child: CustomPaint( + painter: BufferRangePainter(ranges: widget.bufferRanges, duration: widget.duration), + ), + ), + ), + ), // Slider - use IgnorePointer to block interaction while preserving visual style IgnorePointer( ignoring: !widget.enabled, - child: Semantics( + child: SliderTheme( + data: SliderThemeData( + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Semantics( label: t.videoControls.timelineSlider, slider: true, child: Slider( @@ -242,16 +263,17 @@ class _TimelineSliderState extends State { widget.onSeekEnd(Duration(milliseconds: value.toInt())); }, activeColor: Colors.white, - inactiveColor: Colors.white.withValues(alpha: 0.3), + inactiveColor: Colors.transparent, ), ), + ), ), // Chapter marker indicators if (widget.chaptersLoaded && widget.chapters.isNotEmpty && widget.duration.inMilliseconds > 0) Positioned.fill( child: IgnorePointer( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric(horizontal: _sliderPadding), child: CustomPaint( painter: ChapterMarkerPainter(chapters: widget.chapters, duration: widget.duration), ), diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 4ec91d92..dfd00c65 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -12,8 +12,10 @@ import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../../../models/plex_metadata.dart'; import '../sheets/audio_track_sheet.dart'; import '../sheets/chapter_sheet.dart'; +import '../sheets/queue_sheet.dart'; import '../sheets/subtitle_track_sheet.dart'; import '../sheets/version_sheet.dart'; import '../sheets/video_settings_sheet.dart'; @@ -70,6 +72,12 @@ class TrackChapterControls extends StatelessWidget { /// Whether this is a live TV stream (hides speed settings). final bool isLive; + /// Whether to show the queue button + final bool showQueueButton; + + /// Callback when a queue item is selected + final Function(PlexMetadata)? onQueueItemSelected; + const TrackChapterControls({ super.key, required this.player, @@ -100,6 +108,8 @@ class TrackChapterControls extends StatelessWidget { this.onNavigateLeft, this.canControl = true, this.isLive = false, + this.showQueueButton = false, + this.onQueueItemSelected, this.shaderService, this.onShaderChanged, this.isAmbientLightingEnabled = false, @@ -303,6 +313,29 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } + // Queue button + if (showQueueButton && onQueueItemSelected != null) { + final currentIndex = buttonIndex; + buttons.add( + _buildTrackButton( + buttonIndex: currentIndex, + icon: Symbols.queue_music_rounded, + tooltip: t.videoControls.queue, + semanticLabel: t.videoControls.queue, + tracks: tracks, + isMobile: isMobile, + isDesktop: isDesktop, + onPressed: () { + onCancelAutoHide?.call(); + OverlaySheetController.of(context).show( + builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!), + ).whenComplete(() => onStartAutoHide?.call()); + }, + ), + ); + buttonIndex++; + } + // Versions button if (availableVersions.length > 1 && onSwitchVersion != null) { final currentIndex = buttonIndex; @@ -433,6 +466,7 @@ class TrackChapterControls extends StatelessWidget { if (_hasMultipleAudioTracks(tracks)) count++; if (_hasSubtitles(tracks)) count++; if (chapters.isNotEmpty) count++; + if (showQueueButton && onQueueItemSelected != null) count++; if (availableVersions.length > 1 && onSwitchVersion != null) count++; if (onTogglePIPMode != null) count++; if (onCycleBoxFitMode != null) count++; diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 0bf4b632..1447a189 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -65,35 +65,42 @@ class VideoTimelineBar extends StatelessWidget { stream: player.streams.duration, initialData: player.state.duration, builder: (context, durationSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final duration = durationSnapshot.data ?? Duration.zero; - final remaining = position - duration; // We want this to be negative + return StreamBuilder>( + stream: player.streams.bufferRanges, + initialData: player.state.bufferRanges, + builder: (context, bufferRangesSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + final bufferRanges = bufferRangesSnapshot.data ?? const []; + final remaining = position - duration; // We want this to be negative - return horizontalLayout - ? _buildHorizontalLayout(position, duration, remaining) - : _buildVerticalLayout(position, duration, remaining); + return horizontalLayout + ? _buildHorizontalLayout(position, duration, remaining, bufferRanges) + : _buildVerticalLayout(position, duration, remaining, bufferRanges); + }, + ); }, ); }, ); } - Widget _buildHorizontalLayout(Duration position, Duration duration, Duration remaining) { + Widget _buildHorizontalLayout(Duration position, Duration duration, Duration remaining, List bufferRanges) { return Row( children: [ _buildTimestamp(position), const SizedBox(width: 12), - Expanded(child: _buildSlider(position, duration)), + Expanded(child: _buildSlider(position, duration, bufferRanges)), const SizedBox(width: 12), _buildTimestamp(remaining), ], ); } - Widget _buildVerticalLayout(Duration position, Duration duration, Duration remaining) { + Widget _buildVerticalLayout(Duration position, Duration duration, Duration remaining, List bufferRanges) { return Column( children: [ - _buildSlider(position, duration), + _buildSlider(position, duration, bufferRanges), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -124,10 +131,11 @@ class VideoTimelineBar extends StatelessWidget { ); } - Widget _buildSlider(Duration position, Duration duration) { + Widget _buildSlider(Duration position, Duration duration, List bufferRanges) { return TimelineSlider( position: position, duration: duration, + bufferRanges: bufferRanges, chapters: chapters, chaptersLoaded: chaptersLoaded, onSeek: onSeek,