feat: discontinuous buffer ranges on timeline
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -341,7 +341,9 @@
|
||||
"unknown": "오류가 발생했습니다: ${error}"
|
||||
},
|
||||
"chapters": "챕터",
|
||||
"noChaptersAvailable": "사용 가능한 챕터가 없습니다"
|
||||
"noChaptersAvailable": "사용 가능한 챕터가 없습니다",
|
||||
"queue": "Queue",
|
||||
"noQueueItems": "No items in queue"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "관리자",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '저녁',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '晚上',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -341,7 +341,9 @@
|
||||
"unknown": "发生错误:${error}"
|
||||
},
|
||||
"chapters": "章节",
|
||||
"noChaptersAvailable": "没有可用的章节"
|
||||
"noChaptersAvailable": "没有可用的章节",
|
||||
"queue": "Queue",
|
||||
"noQueueItems": "No items in queue"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "管理员",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = <BufferRange>[];
|
||||
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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -50,6 +50,9 @@ class PlayerState {
|
||||
/// Available audio output devices.
|
||||
final List<AudioDevice> audioDevices;
|
||||
|
||||
/// Seekable buffered ranges from the demuxer cache.
|
||||
final List<BufferRange> 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<AudioDevice>? audioDevices,
|
||||
List<BufferRange>? 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ mixin PlayerStreamControllersMixin {
|
||||
final errorController = StreamController<String>.broadcast();
|
||||
final audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
final bufferRangesController = StreamController<List<BufferRange>>.broadcast();
|
||||
final playbackRestartController = StreamController<void>.broadcast();
|
||||
final backendSwitchedController = StreamController<void>.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();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ class PlayerStreams {
|
||||
/// Stream that emits when playback restarts (first frame ready after load/seek).
|
||||
final Stream<void> playbackRestart;
|
||||
|
||||
/// Stream of seekable buffer ranges from the demuxer cache.
|
||||
final Stream<List<BufferRange>> 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<void> 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,
|
||||
});
|
||||
|
||||
@@ -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<PlexMetadata> 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;
|
||||
|
||||
|
||||
@@ -1758,6 +1758,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await _navigateToEpisode(_previousEpisode!);
|
||||
}
|
||||
|
||||
/// Navigate to a specific queue item (called from QueueSheet)
|
||||
Future<void> 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)
|
||||
|
||||
@@ -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<DesktopVideoControls> {
|
||||
onNavigateLeft: navigateFromTrackToVolume,
|
||||
canControl: widget.canControl,
|
||||
isLive: widget.isLive,
|
||||
showQueueButton: widget.showQueueButton,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
shaderService: widget.shaderService,
|
||||
onShaderChanged: widget.onShaderChanged,
|
||||
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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<BufferRange> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<PlaybackStateProvider>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
Widget _buildTrackChapterControlsWidget() {
|
||||
final playbackState = context.watch<PlaybackStateProvider>();
|
||||
|
||||
return TrackChapterControls(
|
||||
player: widget.player,
|
||||
chapters: _chapters,
|
||||
@@ -916,6 +919,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlaybackStateProvider>();
|
||||
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
@@ -1910,6 +1918,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
/// Switch to a different media version
|
||||
void _onQueueItemSelected(PlexMetadata item) {
|
||||
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
videoPlayerState?.navigateToQueueItem(item);
|
||||
}
|
||||
|
||||
Future<void> _switchMediaVersion(int newMediaIndex) async {
|
||||
if (newMediaIndex == widget.selectedMediaIndex) {
|
||||
return; // Already using this version
|
||||
|
||||
@@ -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<BufferRange> bufferRanges;
|
||||
final List<PlexChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
final ValueChanged<Duration> 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<TimelineSlider> {
|
||||
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<TimelineSlider> {
|
||||
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<TimelineSlider> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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<TimelineSlider> {
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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<List<BufferRange>>(
|
||||
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<BufferRange> 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<BufferRange> 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<BufferRange> bufferRanges) {
|
||||
return TimelineSlider(
|
||||
position: position,
|
||||
duration: duration,
|
||||
bufferRanges: bufferRanges,
|
||||
chapters: chapters,
|
||||
chaptersLoaded: chaptersLoaded,
|
||||
onSeek: onSeek,
|
||||
|
||||
Reference in New Issue
Block a user