feat: discontinuous buffer ranges on timeline

This commit is contained in:
edde746
2026-02-21 04:50:10 +01:00
parent 0b1b18fd35
commit 0159c18497
35 changed files with 501 additions and 47 deletions
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -341,7 +341,9 @@
"unknown": "오류가 발생했습니다: ${error}"
},
"chapters": "챕터",
"noChaptersAvailable": "사용 가능한 챕터가 없습니다"
"noChaptersAvailable": "사용 가능한 챕터가 없습니다",
"queue": "Queue",
"noQueueItems": "No items in queue"
},
"userStatus": {
"admin": "관리자",
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+6 -2
View File
@@ -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',
+10 -2
View File
@@ -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',
+6 -2
View File
@@ -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',
+6 -2
View File
@@ -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',
+6 -2
View File
@@ -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',
+6 -2
View File
@@ -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' => '저녁',
+6 -2
View File
@@ -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',
+6 -2
View File
@@ -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',
+6 -2
View File
@@ -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' => '晚上',
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -341,7 +341,9 @@
"unknown": "发生错误:${error}"
},
"chapters": "章节",
"noChaptersAvailable": "没有可用的章节"
"noChaptersAvailable": "没有可用的章节",
"queue": "Queue",
"noQueueItems": "No items in queue"
},
"userStatus": {
"admin": "管理员",
+7
View File
@@ -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.
+55
View File
@@ -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;
+7 -2
View File
@@ -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');
+6
View File
@@ -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();
}
+4
View File
@@ -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;
+6
View File
@@ -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,