feat: discontinuous buffer ranges on timeline
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user