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
+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,
});