fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
+183
-71
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../media/media_display_criteria.dart';
|
||||
@@ -48,12 +48,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
@override
|
||||
PlayerStreams get streams => _streams;
|
||||
|
||||
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
|
||||
|
||||
@override
|
||||
int? get textureId => null;
|
||||
int? get textureId => _textureId.value;
|
||||
|
||||
ValueListenable<int?> get textureIdListenable => _textureId;
|
||||
|
||||
@protected
|
||||
void setTextureId(int? value) {
|
||||
if (!_disposed) _textureId.value = value;
|
||||
}
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _logSubscription;
|
||||
bool _disposed = false;
|
||||
late final Future<void>? _nativeOwnershipReady;
|
||||
final Completer<void> _nativeRelease = Completer<void>();
|
||||
final _throttleSw = Stopwatch()..start();
|
||||
int _lastEmitMs = 0;
|
||||
int _lastCacheStateMs = 0;
|
||||
@@ -65,6 +76,36 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
bool _primaryMediaLoadStarted = false;
|
||||
bool _primaryMediaReadyEmitted = false;
|
||||
|
||||
@visibleForTesting
|
||||
static Duration debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3);
|
||||
|
||||
static const _maximumDurationMilliseconds = 9223372036854775;
|
||||
|
||||
static double? _finiteDouble(Object? value) {
|
||||
if (value is! num) return null;
|
||||
final result = value.toDouble();
|
||||
return result.isFinite ? result : null;
|
||||
}
|
||||
|
||||
static int? _millisecondsFromSeconds(Object? value, {bool round = false}) {
|
||||
final seconds = _finiteDouble(value);
|
||||
if (seconds == null) return null;
|
||||
final milliseconds = seconds * Duration.millisecondsPerSecond;
|
||||
if (!milliseconds.isFinite ||
|
||||
milliseconds < -_maximumDurationMilliseconds ||
|
||||
milliseconds > _maximumDurationMilliseconds) {
|
||||
return null;
|
||||
}
|
||||
return round ? milliseconds.round() : milliseconds.toInt();
|
||||
}
|
||||
|
||||
static int? _finiteInt(Object? value) {
|
||||
if (value is int) return value;
|
||||
final result = _finiteDouble(value);
|
||||
if (result == null || result < -9007199254740991 || result > 9007199254740991) return null;
|
||||
return result.toInt();
|
||||
}
|
||||
|
||||
@protected
|
||||
bool initialized = false;
|
||||
|
||||
@@ -78,6 +119,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
String get logPrefix;
|
||||
|
||||
PlayerBase() {
|
||||
_nativeOwnershipReady = _eventChannelOwners[eventChannel.name]?._nativeRelease.future;
|
||||
_streams = createStreams();
|
||||
_setupEventListener();
|
||||
_logSubscription = logController.stream.listen(_forwardToAppLogger);
|
||||
@@ -161,15 +203,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
void _handleEvent(dynamic event) {
|
||||
if (_disposed) return;
|
||||
if (event is List && event.length == 2) {
|
||||
final name = _propIdToName[event.first as int];
|
||||
final propertyId = event.first;
|
||||
if (propertyId is! int) return;
|
||||
final name = _propIdToName[propertyId];
|
||||
if (name != null) {
|
||||
handlePropertyChange(name, event[1]);
|
||||
}
|
||||
} else if (event is Map) {
|
||||
final type = event['type'] as String?;
|
||||
final name = event['name'] as String?;
|
||||
if (type == 'event' && name != null) {
|
||||
handlePlayerEvent(name, event['data'] as Map?);
|
||||
final type = event['type'];
|
||||
final name = event['name'];
|
||||
if (type == 'event' && name is String) {
|
||||
final rawData = event['data'];
|
||||
handlePlayerEvent(name, rawData is Map ? rawData : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,11 +241,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
break;
|
||||
|
||||
case 'time-pos':
|
||||
if (value is num) {
|
||||
final pos = Duration(milliseconds: (value * 1000).round());
|
||||
_positionMs = pos.inMilliseconds;
|
||||
// Only allocate Duration + copyWith + emit at ~4Hz (250ms).
|
||||
// Raw int is stored every tick so synchronous reads via _positionMs stay current.
|
||||
final positionMs = _millisecondsFromSeconds(value, round: true);
|
||||
if (positionMs != null) {
|
||||
final pos = Duration(milliseconds: positionMs);
|
||||
_positionMs = positionMs;
|
||||
// Only allocate PlayerState + emit at ~4Hz (250ms). The raw integer
|
||||
// remains current for synchronous position reads on every tick.
|
||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||
if (nowMs - _lastEmitMs >= 250) {
|
||||
_lastEmitMs = nowMs;
|
||||
@@ -211,8 +257,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
break;
|
||||
|
||||
case 'duration':
|
||||
if (value is num) {
|
||||
final duration = _timelineDuration ?? Duration(milliseconds: (value * 1000).toInt());
|
||||
final durationMs = _millisecondsFromSeconds(value);
|
||||
if (durationMs != null) {
|
||||
final duration = _timelineDuration ?? Duration(milliseconds: durationMs);
|
||||
_state = _state.copyWith(duration: duration);
|
||||
durationController.add(duration);
|
||||
}
|
||||
@@ -225,11 +272,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
break;
|
||||
|
||||
case 'demuxer-cache-time':
|
||||
if (value is num) {
|
||||
final bufferMs = _millisecondsFromSeconds(value);
|
||||
if (bufferMs != null) {
|
||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||
if (nowMs - _lastCacheStateMs < 250) break;
|
||||
_lastCacheStateMs = nowMs;
|
||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
||||
final buffer = Duration(milliseconds: bufferMs);
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
bufferController.add(buffer);
|
||||
// Synthesize a single range for players without demuxer-cache-state (ExoPlayer).
|
||||
@@ -245,14 +293,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
if (value is num) {
|
||||
setVolumeState(value.toDouble());
|
||||
final volume = _finiteDouble(value);
|
||||
if (volume != null) {
|
||||
setVolumeState(volume);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speed':
|
||||
if (value is num) {
|
||||
final rate = value.toDouble();
|
||||
final rate = _finiteDouble(value);
|
||||
if (rate != null) {
|
||||
_state = _state.copyWith(rate: rate);
|
||||
rateController.add(rate);
|
||||
}
|
||||
@@ -295,10 +344,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
case 'audio-device-list':
|
||||
final deviceList = MpvNodeDecoder.decodeList(value);
|
||||
if (deviceList != null) {
|
||||
final devices = deviceList
|
||||
.whereType<Map>()
|
||||
.map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? ''))
|
||||
.toList();
|
||||
final devices = <AudioDevice>[];
|
||||
for (final entry in deviceList) {
|
||||
if (entry is! Map) continue;
|
||||
final name = entry['name'];
|
||||
final description = entry['description'];
|
||||
if (name is! String) continue;
|
||||
devices.add(AudioDevice(name: name, description: description is String ? description : ''));
|
||||
}
|
||||
_state = _state.copyWith(audioDevices: devices);
|
||||
audioDevicesController.add(devices);
|
||||
}
|
||||
@@ -326,9 +379,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
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());
|
||||
final cacheEndMs = _millisecondsFromSeconds(cacheState['cache-end']);
|
||||
if (cacheEndMs != null) {
|
||||
final buffer = Duration(milliseconds: cacheEndMs);
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
bufferController.add(buffer);
|
||||
}
|
||||
@@ -338,17 +391,16 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (range is! Map) continue;
|
||||
final startMs = _millisecondsFromSeconds(range['start']);
|
||||
final endMs = _millisecondsFromSeconds(range['end']);
|
||||
if (startMs != null && endMs != null) {
|
||||
ranges.add(
|
||||
BufferRange(
|
||||
start: Duration(milliseconds: startMs),
|
||||
end: Duration(milliseconds: endMs),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_state = _state.copyWith(bufferRanges: ranges);
|
||||
@@ -383,8 +435,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
completedController.add(true);
|
||||
} else if (reason == 'error') {
|
||||
fileLoadFailedController.add(null);
|
||||
final rawMessage = data?['message'];
|
||||
final rawCause = data?['cause'];
|
||||
errorController.add(
|
||||
PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?),
|
||||
PlayerError(
|
||||
rawMessage is String ? rawMessage : 'Playback error',
|
||||
cause: rawCause is String ? rawCause : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -400,10 +457,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
break;
|
||||
|
||||
case 'log-message':
|
||||
final prefix = data?['prefix'] as String? ?? '';
|
||||
final levelStr = data?['level'] as String? ?? 'info';
|
||||
final text = data?['text'] as String? ?? '';
|
||||
final level = parseLogLevel(levelStr);
|
||||
final rawPrefix = data?['prefix'];
|
||||
final rawLevel = data?['level'];
|
||||
final rawText = data?['text'];
|
||||
final prefix = rawPrefix is String ? rawPrefix : '';
|
||||
final level = parseLogLevel(rawLevel is String ? rawLevel : 'info');
|
||||
final text = rawText is String ? rawText : '';
|
||||
logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
||||
break;
|
||||
}
|
||||
@@ -444,37 +503,44 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
for (final track in trackList) {
|
||||
if (track is! Map) continue;
|
||||
|
||||
final type = track['type'] as String?;
|
||||
final id = track['id']?.toString() ?? '';
|
||||
final selected = track['selected'] as bool? ?? false;
|
||||
final rawType = track['type'];
|
||||
if (rawType is! String) continue;
|
||||
final type = rawType;
|
||||
final rawId = track['id'];
|
||||
final id = rawId is String || rawId is num ? rawId.toString() : '';
|
||||
final selected = track['selected'] == true;
|
||||
|
||||
if (type == 'audio') {
|
||||
if (selected) selectedAudioId = id;
|
||||
audioTracks.add(
|
||||
AudioTrack(
|
||||
id: id,
|
||||
title: cleanTrackMetadataValue(track['title'] as String?),
|
||||
language: cleanTrackMetadataValue(track['lang'] as String?),
|
||||
codec: track['codec'] as String?,
|
||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
||||
isDefault: track['default'] as bool? ?? false,
|
||||
title: cleanTrackMetadataValue(track['title'] is String ? track['title'] as String : null),
|
||||
language: cleanTrackMetadataValue(track['lang'] is String ? track['lang'] as String : null),
|
||||
codec: track['codec'] is String ? track['codec'] as String : null,
|
||||
channels: _finiteInt(track['demux-channel-count']),
|
||||
sampleRate: _finiteInt(track['demux-samplerate']),
|
||||
isDefault: track['default'] == true,
|
||||
),
|
||||
);
|
||||
} else if (type == 'sub') {
|
||||
if (selected) selectedSubtitleId = id;
|
||||
final codec = track['codec'] as String?;
|
||||
final externalFilename = track['external-filename'] as String?;
|
||||
final rawCodec = track['codec'];
|
||||
final codec = rawCodec is String ? rawCodec : null;
|
||||
final rawExternalFilename = track['external-filename'];
|
||||
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
|
||||
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
|
||||
final rawTitle = track['title'];
|
||||
final rawLanguage = track['lang'];
|
||||
subtitleTracks.add(
|
||||
SubtitleTrack(
|
||||
id: id,
|
||||
title: externalMetadata?.title ?? cleanSubtitleTitle(track['title'] as String?, codec: codec),
|
||||
language: externalMetadata?.language ?? cleanTrackMetadataValue(track['lang'] as String?),
|
||||
title: externalMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec),
|
||||
language: externalMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null),
|
||||
codec: externalMetadata?.codec ?? codec,
|
||||
isDefault: externalMetadata?.isDefault ?? (track['default'] as bool? ?? false),
|
||||
isForced: externalMetadata?.isForced ?? (track['forced'] as bool? ?? false),
|
||||
isExternal: track['external'] as bool? ?? false,
|
||||
isDefault: externalMetadata?.isDefault ?? (track['default'] == true),
|
||||
isForced: externalMetadata?.isForced ?? (track['forced'] == true),
|
||||
isExternal: track['external'] == true,
|
||||
uri: externalFilename,
|
||||
),
|
||||
);
|
||||
@@ -490,13 +556,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
|
||||
void updateSelectedAudioTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
AudioTrack? selectedTrack;
|
||||
final selectedTrack = (id == null || id == 'no')
|
||||
? null
|
||||
: _state.tracks.audio.firstWhereOrNull((track) => track.id == id);
|
||||
if (id != null && id != 'no' && selectedTrack == null) return;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = _state.tracks.audio.firstWhereOrNull((t) => t.id == id);
|
||||
}
|
||||
|
||||
if (selectedTrack == null) return;
|
||||
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
|
||||
trackController.add(_state.track);
|
||||
}
|
||||
@@ -505,7 +569,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
final id = trackId?.toString();
|
||||
final selectedTrack = (id == null || id == 'no')
|
||||
? SubtitleTrack.off
|
||||
: _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id);
|
||||
: _state.tracks.subtitle.firstWhereOrNull((track) => track.id == id);
|
||||
|
||||
if (selectedTrack == null) return;
|
||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||
@@ -584,6 +648,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
|
||||
@protected
|
||||
Future<T?> invoke<T>(String method, [dynamic args]) async {
|
||||
if (_disposed) return null;
|
||||
if (_nativeOwnershipReady case final ready?) {
|
||||
try {
|
||||
await ready.timeout(debugNativeOwnershipDisposeTimeout);
|
||||
} on TimeoutException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (_disposed) return null;
|
||||
return methodChannel.invokeMethod<T>(method, args);
|
||||
}
|
||||
@@ -800,13 +872,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
|
||||
}
|
||||
|
||||
Future<bool> _waitForNativeOwnershipForDispose() async {
|
||||
final ready = _nativeOwnershipReady;
|
||||
if (ready == null) return true;
|
||||
try {
|
||||
await ready.timeout(debugNativeOwnershipDisposeTimeout);
|
||||
return true;
|
||||
} on TimeoutException catch (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Timed out waiting for the previous player to release the native channel; skipping native dispose',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (!_nativeRelease.isCompleted) _nativeRelease.complete(ready);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_textureId.value = null;
|
||||
|
||||
if (identical(_eventChannelOwners[eventChannel.name], this)) {
|
||||
_eventChannelOwners.remove(eventChannel.name);
|
||||
final channelName = eventChannel.name;
|
||||
if (identical(_eventChannelOwners[channelName], this)) {
|
||||
// Keep this owner registered while its native release is pending so a
|
||||
// player created during disposal inherits the complete release chain.
|
||||
// The newer listen cannot interleave before cancel() is invoked on this
|
||||
// isolate; after the first await, ownership is checked again at removal.
|
||||
try {
|
||||
await _eventSubscription?.cancel();
|
||||
} on PlatformException catch (e, st) {
|
||||
@@ -822,15 +916,33 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
_eventSubscription = null;
|
||||
await _logSubscription?.cancel();
|
||||
final ownsNativeChannel = await _waitForNativeOwnershipForDispose();
|
||||
try {
|
||||
await methodChannel.invokeMethod('dispose', {
|
||||
'preserveDisplayMode': preserveDisplayMode,
|
||||
}); // Direct call — already guarded by _disposed check above
|
||||
if (ownsNativeChannel) {
|
||||
await methodChannel.invokeMethod('dispose', {
|
||||
'preserveDisplayMode': preserveDisplayMode,
|
||||
}); // Direct call — invoke() is disabled once _disposed is set.
|
||||
}
|
||||
} on PlatformException catch (e, st) {
|
||||
appLogger.w('Player native dispose failed during teardown', error: e, stackTrace: st);
|
||||
} on MissingPluginException catch (e, st) {
|
||||
appLogger.w('Player native dispose plugin missing during teardown', error: e, stackTrace: st);
|
||||
} finally {
|
||||
if (ownsNativeChannel && !_nativeRelease.isCompleted) _nativeRelease.complete();
|
||||
}
|
||||
|
||||
// A timed-out predecessor is still represented by this release future.
|
||||
// Do not expose an empty ownership slot until that chained release settles.
|
||||
if (_nativeRelease.isCompleted) {
|
||||
unawaited(
|
||||
_nativeRelease.future.whenComplete(() {
|
||||
if (identical(_eventChannelOwners[channelName], this)) {
|
||||
_eventChannelOwners.remove(channelName);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
await closeStreamControllers();
|
||||
_textureId.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user