fix(native): bound cross-platform lifecycle ownership

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 09656fa4d3
commit 9f2e050797
98 changed files with 12908 additions and 1599 deletions
+110 -3
View File
@@ -1,7 +1,15 @@
import 'dart:convert';
/// Decodes an mpv node delivered either as a platform-channel value or JSON.
///
/// Native payloads are bounded before traversal so a malformed backend cannot
/// turn a property update into unbounded allocation or recursion on the UI
/// isolate.
abstract final class MpvNodeDecoder {
static const _maximumDepth = 32;
static const _maximumEntries = 16384;
static const _maximumStringBytes = 16 * 1024 * 1024;
static List<Object?>? decodeList(Object? value) {
final decoded = _decode(value);
return decoded is List<Object?> ? decoded : null;
@@ -13,13 +21,112 @@ abstract final class MpvNodeDecoder {
}
static Object? _decode(Object? value) {
if (value is List || value is Map) return value;
if (value is! String || value.isEmpty) return null;
if (value is List || value is Map) {
return _isBoundedStructure(value) ? value : null;
}
if (value is! String || value.isEmpty || !_isPlausiblyBoundedJson(value)) return null;
try {
return jsonDecode(value);
final decoded = jsonDecode(value);
return _isBoundedStructure(decoded) ? decoded : null;
} on FormatException {
return null;
}
}
static bool _isPlausiblyBoundedJson(String value) {
if (value.length > _maximumStringBytes) return false;
var depth = 0;
var separators = 0;
var inString = false;
var escaped = false;
for (var i = 0; i < value.length; i++) {
final codeUnit = value.codeUnitAt(i);
if (inString) {
if (escaped) {
escaped = false;
} else if (codeUnit == 0x5c) {
escaped = true;
} else if (codeUnit == 0x22) {
inString = false;
}
continue;
}
if (codeUnit == 0x22) {
inString = true;
} else if (codeUnit == 0x5b || codeUnit == 0x7b) {
depth++;
if (depth > _maximumDepth) return false;
} else if (codeUnit == 0x5d || codeUnit == 0x7d) {
depth--;
if (depth < 0) return false;
} else if (codeUnit == 0x2c) {
separators++;
if (separators >= _maximumEntries) return false;
}
}
return !inString && depth == 0;
}
static bool _isBoundedStructure(Object? root) {
var remainingEntries = _maximumEntries;
var remainingStringBytes = _maximumStringBytes;
final pending = <(Object?, int)>[(root, 0)];
while (pending.isNotEmpty) {
final (value, depth) = pending.removeLast();
if (remainingEntries == 0 || depth >= _maximumDepth) return false;
remainingEntries--;
if (value is String) {
final byteLength = _utf8LengthAtMost(value, remainingStringBytes);
if (byteLength == null) return false;
remainingStringBytes -= byteLength;
} else if (value is num) {
if (value is double && !value.isFinite) return false;
} else if (value is List) {
if (value.length > remainingEntries) return false;
for (var i = value.length - 1; i >= 0; i--) {
pending.add((value[i], depth + 1));
}
} else if (value is Map) {
if (value.length > remainingEntries) return false;
for (final entry in value.entries) {
final key = entry.key;
if (key is! String) return false;
final byteLength = _utf8LengthAtMost(key, remainingStringBytes);
if (byteLength == null) return false;
remainingStringBytes -= byteLength;
pending.add((entry.value, depth + 1));
}
} else if (value != null && value is! bool) {
return false;
}
}
return true;
}
static int? _utf8LengthAtMost(String value, int limit) {
var length = 0;
for (var i = 0; i < value.length; i++) {
final codeUnit = value.codeUnitAt(i);
if (codeUnit <= 0x7f) {
length++;
} else if (codeUnit <= 0x7ff) {
length += 2;
} else if (codeUnit >= 0xd800 &&
codeUnit <= 0xdbff &&
i + 1 < value.length &&
value.codeUnitAt(i + 1) >= 0xdc00 &&
value.codeUnitAt(i + 1) <= 0xdfff) {
length += 4;
i++;
} else {
length += 3;
}
if (length > limit) return null;
}
return length;
}
}
+14 -1
View File
@@ -114,6 +114,7 @@ class PlayerAndroid extends PlayerBase {
.read(SettingsService.subtitleRenderResolution)
.androidRenderScale,
});
if (disposed) throw StateError('Player was disposed during initialization');
if (result != true) {
throw Exception('Failed to initialize ExoPlayer');
}
@@ -123,11 +124,23 @@ class PlayerAndroid extends PlayerBase {
// future would falsely treat as ready.
await observeCoreProperties(trackListFormat: 'string');
await observeProperty('demuxer-cache-time', 'double');
if (disposed) throw StateError('Player was disposed during initialization');
// These settings can be queued before any operation initializes the
// native core. Apply the latest requested values now so ExoPlayer and
// the already-queued mpv fallback properties start in the same state.
await invoke('setAudioNormalization', {'enabled': _audioNormalizationEnabled});
await invoke('setAudioDownmix', {
'enabled': _downmixEnabled,
'centerBoostDb': _downmixCenterBoostDb,
'normalize': _downmixNormalize,
});
if (disposed) throw StateError('Player was disposed during initialization');
initialized = true;
} catch (e) {
_initFuture = null;
errorController.add(PlayerError('Initialization failed: $e'));
if (!disposed) errorController.add(PlayerError('Initialization failed: $e'));
rethrow;
}
}
+183 -71
View File
@@ -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();
}
}
+322 -92
View File
@@ -10,6 +10,17 @@ import '../../utils/app_logger.dart';
import '../models.dart';
import 'player_base.dart';
typedef _AudioStateRequest = ({
bool passthrough,
bool normalization,
bool downmix,
int downmixCenterBoostDb,
bool downmixNormalize,
double rate,
});
typedef _AudioStateGenerations = ({int passthrough, int normalization, int downmix, int rate});
/// MPV-backed player for platforms where AetherEngine is not the native route.
class PlayerNative extends PlayerBase {
/// Video player on the default mpv channels/core.
@@ -27,7 +38,6 @@ class PlayerNative extends PlayerBase {
eventChannel = const EventChannel('com.plezy/mpv_audio_player/events'),
audioOnly = true;
int? _textureIdValue;
String _dvConversionMode = 'auto';
String _dvConversionLog = 'no';
@@ -45,13 +55,14 @@ class PlayerNative extends PlayerBase {
@visibleForTesting
static bool debugForceContentFdConversion = false;
/// Overrides the Linux-only video readiness handshake in host tests.
@visibleForTesting
static bool? debugUseLinuxVideoBootstrap;
// Set by open() and consumed by that load's file-loaded event, so it is
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
bool _expectOpenFileLoad = false;
@override
int? get textureId => _textureIdValue;
/// Whether this instance drives the audio-only core.
final bool audioOnly;
@@ -168,13 +179,29 @@ class PlayerNative extends PlayerBase {
);
}
/// Whether the UI must mount the provisional texture before initialization
/// can complete its first render/bootstrap handshake.
bool get requiresProvisionalTextureSurface => !audioOnly && (debugUseLinuxVideoBootstrap ?? Platform.isLinux);
// Memoizes the in-flight init Future so concurrent callers (e.g. the
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
// Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize
// to dispose-and-recreate the in-flight core, hanging playback (#930).
Future<void>? _initFuture;
Future<void> _rateChangeTail = Future<void>.value();
Future<void> _audioStateTail = Future<void>.value();
Future<void>? _disposeFuture;
bool _disposing = false;
bool get _nativeCoreUnavailable => disposed || _disposing;
@override
Future<T?> invoke<T>(String method, [dynamic args]) {
if (_nativeCoreUnavailable) return Future<T?>.value();
return super.invoke<T>(method, args);
}
double _requestedRate = 1.0;
Future<void> _ensureInitialized() async {
if (initialized) return;
@@ -186,8 +213,13 @@ class PlayerNative extends PlayerBase {
final result = await invoke<Object>('initialize');
final bool ok;
if (result is int) {
// Linux: initialize returns the texture ID
_textureIdValue = result;
// Linux publishes a provisional texture so Flutter can invoke
// FlTextureGL::populate. Playback stays gated until native GPU
// bootstrap reports that the texture is usable.
setTextureId(result);
if (debugUseLinuxVideoBootstrap ?? Platform.isLinux) {
await invoke('waitForVideoReady');
}
ok = true;
} else {
ok = result == true;
@@ -195,6 +227,7 @@ class PlayerNative extends PlayerBase {
if (!ok) {
throw Exception('Failed to initialize player');
}
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
// Subscribe to MPV properties before flipping `initialized` so partial
// failures don't leave us in a half-initialized state that the memoized
@@ -217,10 +250,14 @@ class PlayerNative extends PlayerBase {
await invoke('setProperty', {'name': 'gapless-audio', 'value': 'weak'});
}
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
initialized = true;
} catch (e) {
setTextureId(null);
_initFuture = null;
errorController.add(PlayerError('Initialization failed: $e'));
if (!_nativeCoreUnavailable) {
errorController.add(PlayerError('Initialization failed: $e'));
}
rethrow;
}
}
@@ -235,9 +272,13 @@ class PlayerNative extends PlayerBase {
/// Closes a detached content fd that mpv will never consume. Fire-and-forget
/// safe: a failure only leaks one fd.
Future<void> _closeContentFd(int fd) async {
Future<void> _closeContentFd(int fd, {bool duringDispose = false}) async {
try {
await invoke('closeContentFd', {'fd': fd});
if (duringDispose) {
await super.invoke('closeContentFd', {'fd': fd});
} else {
await invoke('closeContentFd', {'fd': fd});
}
} catch (e) {
appLogger.d('$logPrefix: closeContentFd($fd) failed', error: e);
}
@@ -269,8 +310,9 @@ class PlayerNative extends PlayerBase {
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
if (disposed) return;
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
if (_nativeCoreUnavailable) return;
// `loadfile replace` (below) clears the native playlist, dropping any
// gapless entry armed via setNext — settle its content-fd claim first.
// No transition is surfaced: the caller is replacing playback anyway.
@@ -338,16 +380,19 @@ class PlayerNative extends PlayerBase {
@override
Future<void> play() async {
if (_nativeCoreUnavailable) return;
await setProperty('pause', 'no');
}
@override
Future<void> pause() async {
if (_nativeCoreUnavailable) return;
await setProperty('pause', 'yes');
}
@override
Future<void> stop() async {
if (_nativeCoreUnavailable) return;
// `stop` tears down the playlist without mpv opening the armed entry —
// settle its content-fd claim first. No transition: playback is ending.
await _clearArmedNext(adoptIfRolledIn: false);
@@ -358,12 +403,13 @@ class PlayerNative extends PlayerBase {
@override
Future<void> seek(Duration position) async {
if (_nativeCoreUnavailable) return;
await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
}
@override
Future<void> setNext(Media? media) async {
if (!audioOnly || disposed || !initialized) return;
if (_nativeCoreUnavailable || !audioOnly || !initialized) return;
await _clearArmedNext();
if (media == null) return;
@@ -409,7 +455,7 @@ class PlayerNative extends PlayerBase {
/// exactly at the gapless boundary desyncs the music service from the
/// audio for the whole next track. Callers that replace or stop playback
/// pass false: no one is listening for that entry anymore.
Future<void> _clearArmedNext({bool adoptIfRolledIn = true}) async {
Future<void> _clearArmedNext({bool adoptIfRolledIn = true, bool duringDispose = false}) async {
if (!_hasArmedNext) return;
final uri = _armedNextUri;
final fd = _armedNextFd;
@@ -419,7 +465,9 @@ class PlayerNative extends PlayerBase {
String? pos;
try {
pos = await getProperty('playlist-pos');
pos = duringDispose
? await super.invoke<String>('getProperty', {'name': 'playlist-pos'})
: await getProperty('playlist-pos');
} catch (_) {
// Unknown state — fall through to the remove, never close the fd.
}
@@ -431,7 +479,13 @@ class PlayerNative extends PlayerBase {
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
try {
await command(['playlist-remove', '1']);
if (duringDispose) {
await super.invoke('command', {
'args': ['playlist-remove', '1'],
});
} else {
await command(['playlist-remove', '1']);
}
} on PlatformException {
// Entry 1 vanished in the arm/advance race — mpv rolled into it and
// the file-loaded handler already rebased. The fd (if any) is mpv's.
@@ -440,10 +494,12 @@ class PlayerNative extends PlayerBase {
if (fd == null) return;
String? postPos;
try {
postPos = await getProperty('playlist-pos');
postPos = duringDispose
? await super.invoke<String>('getProperty', {'name': 'playlist-pos'})
: await getProperty('playlist-pos');
} catch (_) {}
if (pos == '0' && postPos == '0') {
unawaited(_closeContentFd(fd));
unawaited(_closeContentFd(fd, duringDispose: duringDispose));
}
// Any other combination is ambiguous (mpv advanced mid-clear, idle
// playlist, property error): leak on doubt.
@@ -516,38 +572,52 @@ class PlayerNative extends PlayerBase {
}
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
Future<void> dispose({bool preserveDisplayMode = false}) {
final existing = _disposeFuture;
if (existing != null) return existing;
_disposing = true;
final disposal = _disposeNative(preserveDisplayMode: preserveDisplayMode);
_disposeFuture = disposal;
return disposal;
}
Future<void> _disposeNative({required bool preserveDisplayMode}) async {
if (disposed) return;
// Settle an armed-but-unconsumed content fd before the base teardown
// disables invoke() — the playlist is torn down without mpv ever opening
// the entry.
if (_hasArmedNext) {
try {
await _clearArmedNext(adoptIfRolledIn: false);
await _clearArmedNext(adoptIfRolledIn: false, duringDispose: true);
} catch (_) {
// Leak on doubt.
}
}
await _audioStateTail;
await super.dispose(preserveDisplayMode: preserveDisplayMode);
}
@override
Future<void> selectAudioTrack(AudioTrack track) async {
if (_nativeCoreUnavailable) return;
await setProperty('aid', track.id);
}
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
if (_nativeCoreUnavailable) return;
await setProperty('sid', track.id);
}
@override
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
if (_nativeCoreUnavailable) return;
await setProperty('secondary-sid', track.id);
}
@override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
if (_nativeCoreUnavailable) return;
final args = ['sub-add', uri, select ? 'select' : 'auto'];
if (title != null) args.add('title=$title');
if (language != null) args.add('lang=$language');
@@ -556,53 +626,64 @@ class PlayerNative extends PlayerBase {
@override
Future<void> setVolume(double volume) async {
if (_nativeCoreUnavailable) return;
await setProperty('volume', volume.toString());
if (!disposed) setVolumeState(volume);
if (!_nativeCoreUnavailable) setVolumeState(volume);
}
@override
Future<void> setRate(double rate) {
_currentRate = rate;
final operation = _rateChangeTail.then((_) => _applyRateChange(rate));
_rateChangeTail = operation.catchError((Object _, StackTrace _) {});
return operation;
}
Future<void> _applyRateChange(double rate) async {
// mpv cannot scaletempo compressed (spdif) audio and silently keeps
// playing at 1x, so serialize passthrough and speed transitions.
if (_passthroughActive && rate != 1.0) {
await _applyPassthrough(false);
}
await setProperty('speed', rate.toString());
if (_passthroughRequested && !_passthroughActive && rate == 1.0 && !_downmixEnabled) {
await _applyPassthrough(true);
}
if (_nativeCoreUnavailable) return Future<void>.value();
_requestedRate = rate;
return _enqueueAudioStateReconciliation(_rateAudioField);
}
@override
Future<void> setAudioDevice(AudioDevice device) async {
if (_nativeCoreUnavailable) return;
await setProperty('audio-device', device.name);
}
@override
Future<void> setProperty(String name, String value) async {
if (disposed) return;
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') {
value = _normalizeDvConversionMode(value);
_dvConversionMode = value;
}
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log') {
value = _normalizeBoolProperty(value);
_dvConversionLog = value;
}
Future<void> setProperty(String name, String value) => _setProperty(name, value, synchronizeRate: true);
Future<void> _setProperty(String name, String value, {required bool synchronizeRate}) async {
if (_nativeCoreUnavailable) return;
final updatesDvMode = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode';
final updatesDvLog = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log';
if (updatesDvMode) value = _normalizeDvConversionMode(value);
if (updatesDvLog) value = _normalizeBoolProperty(value);
await _ensureInitialized();
await invoke('setProperty', {'name': name, 'value': value});
if (_nativeCoreUnavailable) return;
if (updatesDvMode) _dvConversionMode = value;
if (updatesDvLog) _dvConversionLog = value;
if (synchronizeRate && name == 'speed') {
final rate = double.tryParse(value);
if (rate != null && rate.isFinite) {
_currentRate = rate;
_requestedRate = rate;
final accepted = _acceptedAudioState;
_acceptedAudioState = (
passthrough: accepted.passthrough,
normalization: accepted.normalization,
downmix: accepted.downmix,
downmixCenterBoostDb: accepted.downmixCenterBoostDb,
downmixNormalize: accepted.downmixNormalize,
rate: rate,
);
} else {
// The native bridge may accept custom mpv speed syntax. Its numeric
// value is unknown, so the next typed setRate must write explicitly.
_currentRate = double.nan;
}
}
}
@override
Future<String?> getProperty(String name) async {
if (disposed) return null;
if (_nativeCoreUnavailable) return null;
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') {
return _dvConversionMode;
}
@@ -615,7 +696,7 @@ class PlayerNative extends PlayerBase {
@override
Future<Map<String, dynamic>> getStats() async {
if (disposed || !Platform.isAndroid) return super.getStats();
if (_nativeCoreUnavailable || !Platform.isAndroid) return super.getStats();
await _ensureInitialized();
final result = await invoke<Map>('getStats');
return Map<String, dynamic>.from(result ?? const {});
@@ -623,7 +704,7 @@ class PlayerNative extends PlayerBase {
@override
Future<void> command(List<String> args) async {
if (disposed) return;
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
await invoke('command', {'args': args});
}
@@ -633,7 +714,7 @@ class PlayerNative extends PlayerBase {
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
if (disposed || audioOnly || !Platform.isIOS) return;
if (_nativeCoreUnavailable || audioOnly || !Platform.isIOS) return;
await _ensureInitialized();
await invoke('setDisplayCriteria', {
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
@@ -643,16 +724,46 @@ class PlayerNative extends PlayerBase {
@override
Future<void> setLogLevel(String level) async {
if (disposed) return;
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
await invoke('setLogLevel', {'level': level});
}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
if (_nativeCoreUnavailable) return false;
final changed = await super.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible);
return changed && !_nativeCoreUnavailable;
}
static const int _passthroughAudioField = 1 << 0;
static const int _normalizationAudioField = 1 << 1;
static const int _downmixAudioField = 1 << 2;
static const int _rateAudioField = 1 << 3;
bool _passthroughRequested = false;
bool _passthroughActive = false;
bool _normalizationRequested = false;
bool _downmixEnabled = false;
bool _normalizationActive = false;
bool _downmixRequested = false;
bool _downmixActive = false;
int _downmixCenterBoostDb = 0;
int _activeDownmixCenterBoostDb = 0;
bool _downmixNormalize = false;
bool _activeDownmixNormalize = false;
double _currentRate = 1.0;
int _passthroughGeneration = 0;
int _normalizationGeneration = 0;
int _downmixGeneration = 0;
int _rateGeneration = 0;
_AudioStateRequest _acceptedAudioState = const (
passthrough: false,
normalization: false,
downmix: false,
downmixCenterBoostDb: 0,
downmixNormalize: false,
rate: 1.0,
);
@override
bool get audioPassthroughActive => _passthroughActive;
@@ -662,58 +773,177 @@ class PlayerNative extends PlayerBase {
/// Digital (Plus); desktop does real device passthrough for the full list.
static final String _passthroughCodecs = Platform.isIOS ? 'ac3,eac3' : 'ac3,eac3,dts,dts-hd,truehd';
@override
Future<void> setAudioPassthrough(bool enabled) async {
_passthroughRequested = enabled;
// Deferred until the rate returns to 1.0 (see setRate) and the stereo
// downmix ends (see setAudioDownmix).
if (enabled && (_currentRate != 1.0 || _downmixEnabled)) return;
await _applyPassthrough(enabled);
}
_AudioStateRequest get _requestedAudioState => (
passthrough: _passthroughRequested,
normalization: _normalizationRequested,
downmix: _downmixRequested,
downmixCenterBoostDb: _downmixCenterBoostDb,
downmixNormalize: _downmixNormalize,
rate: _requestedRate,
);
Future<void> _applyPassthrough(bool enabled) async {
_passthroughActive = enabled;
// loudnorm decodes to PCM, which defeats bitstream passthrough; the
// filter yields while passthrough is active and returns when it ends.
if (enabled && _normalizationRequested) {
await super.setAudioNormalization(false);
_AudioStateRequest _rebaseAudioState(_AudioStateRequest accepted, _AudioStateRequest requested, int fields) => (
passthrough: fields & _passthroughAudioField != 0 ? requested.passthrough : accepted.passthrough,
normalization: fields & _normalizationAudioField != 0 ? requested.normalization : accepted.normalization,
downmix: fields & _downmixAudioField != 0 ? requested.downmix : accepted.downmix,
downmixCenterBoostDb: fields & _downmixAudioField != 0
? requested.downmixCenterBoostDb
: accepted.downmixCenterBoostDb,
downmixNormalize: fields & _downmixAudioField != 0 ? requested.downmixNormalize : accepted.downmixNormalize,
rate: fields & _rateAudioField != 0 ? requested.rate : accepted.rate,
);
void _restoreFailedRequestedFields(_AudioStateRequest previous, int fields, _AudioStateGenerations generations) {
if (fields & _passthroughAudioField != 0 && generations.passthrough == _passthroughGeneration) {
_passthroughRequested = previous.passthrough;
}
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
// audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS
// (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at
// playback start and must not be clobbered here.
if (!Platform.isIOS) {
await setProperty('audio-exclusive', enabled ? 'yes' : 'no');
if (fields & _normalizationAudioField != 0 && generations.normalization == _normalizationGeneration) {
_normalizationRequested = previous.normalization;
}
if (!enabled && _normalizationRequested) {
await super.setAudioNormalization(true);
if (fields & _downmixAudioField != 0 && generations.downmix == _downmixGeneration) {
_downmixRequested = previous.downmix;
_downmixCenterBoostDb = previous.downmixCenterBoostDb;
_downmixNormalize = previous.downmixNormalize;
}
if (fields & _rateAudioField != 0 && generations.rate == _rateGeneration) {
_requestedRate = previous.rate;
}
}
@override
Future<void> setAudioNormalization(bool enabled) async {
_normalizationRequested = enabled;
if (enabled && _passthroughActive) return; // deferred until passthrough ends
await super.setAudioNormalization(enabled);
Future<void> _enqueueAudioStateReconciliation(int fields) {
final requested = _requestedAudioState;
if (fields & _passthroughAudioField != 0) ++_passthroughGeneration;
if (fields & _normalizationAudioField != 0) ++_normalizationGeneration;
if (fields & _downmixAudioField != 0) ++_downmixGeneration;
if (fields & _rateAudioField != 0) ++_rateGeneration;
final generations = (
passthrough: _passthroughGeneration,
normalization: _normalizationGeneration,
downmix: _downmixGeneration,
rate: _rateGeneration,
);
final operation = _audioStateTail.then((_) => _reconcileAudioState(requested, fields, generations));
_audioStateTail = operation.catchError((Object _, StackTrace _) {});
return operation;
}
@override
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {
_downmixEnabled = enabled;
// spdif bypasses the filter chain entirely; passthrough yields while a
// stereo downmix is forced and returns when it is disabled.
if (enabled && _passthroughActive) {
Future<void> _reconcileAudioState(
_AudioStateRequest requested,
int fields,
_AudioStateGenerations generations,
) async {
if (_nativeCoreUnavailable) return;
final previous = _acceptedAudioState;
final target = _rebaseAudioState(previous, requested, fields);
try {
await _applyAudioState(target);
if (_nativeCoreUnavailable) return;
_acceptedAudioState = target;
} catch (error, stackTrace) {
try {
await _applyAudioState(
previous,
forceDownmix: fields & _downmixAudioField != 0,
forceNormalization: fields & _downmixAudioField != 0,
);
} catch (rollbackError, rollbackStackTrace) {
appLogger.e(
'MPV: failed to restore accepted audio state',
error: rollbackError,
stackTrace: rollbackStackTrace,
);
}
_restoreFailedRequestedFields(previous, fields, generations);
Error.throwWithStackTrace(error, stackTrace);
}
}
Future<void> _applyAudioState(
_AudioStateRequest target, {
bool forceDownmix = false,
bool forceNormalization = false,
}) async {
if (_nativeCoreUnavailable) return;
final passthroughShouldBeActive = target.passthrough && target.rate == 1.0 && !target.downmix;
// mpv cannot scaletempo compressed audio and filters cannot process a
// bitstream. Always leave passthrough before applying either state.
if (_passthroughActive && !passthroughShouldBeActive) {
await _applyPassthrough(false);
}
await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize);
if (!enabled && _passthroughRequested && !_passthroughActive && _currentRate == 1.0) {
if (_currentRate != target.rate) {
await _setProperty('speed', target.rate.toString(), synchronizeRate: false);
_currentRate = target.rate;
}
if (forceDownmix ||
_downmixActive != target.downmix ||
(target.downmix &&
(_activeDownmixCenterBoostDb != target.downmixCenterBoostDb ||
_activeDownmixNormalize != target.downmixNormalize))) {
await super.setAudioDownmix(
enabled: target.downmix,
centerBoostDb: target.downmixCenterBoostDb,
normalize: target.downmixNormalize,
);
_downmixActive = target.downmix;
_activeDownmixCenterBoostDb = target.downmixCenterBoostDb;
_activeDownmixNormalize = target.downmixNormalize;
}
final normalizationShouldBeActive = target.normalization && !passthroughShouldBeActive;
if (forceNormalization || _normalizationActive != normalizationShouldBeActive) {
await super.setAudioNormalization(normalizationShouldBeActive);
_normalizationActive = normalizationShouldBeActive;
}
if (passthroughShouldBeActive && !_passthroughActive) {
await _applyPassthrough(true);
}
}
@override
Future<void> setAudioPassthrough(bool enabled) {
if (_nativeCoreUnavailable) return Future<void>.value();
_passthroughRequested = enabled;
return _enqueueAudioStateReconciliation(_passthroughAudioField);
}
Future<void> _applyPassthrough(bool enabled) async {
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
if (_nativeCoreUnavailable) return;
// audio-spdif is the authoritative transition. Publish only after mpv
// accepts it; audio-exclusive below is an independent device-mode hint.
_passthroughActive = enabled;
// audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS
// (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at
// playback start and must not be clobbered here.
if (!Platform.isIOS) {
try {
await setProperty('audio-exclusive', enabled ? 'yes' : 'no');
} catch (error, stackTrace) {
appLogger.w('MPV: failed to update exclusive-audio hint', error: error, stackTrace: stackTrace);
}
}
}
@override
Future<void> setAudioNormalization(bool enabled) {
if (_nativeCoreUnavailable) return Future<void>.value();
_normalizationRequested = enabled;
return _enqueueAudioStateReconciliation(_normalizationAudioField);
}
@override
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) {
if (_nativeCoreUnavailable) return Future<void>.value();
_downmixRequested = enabled;
_downmixCenterBoostDb = centerBoostDb;
_downmixNormalize = normalize;
return _enqueueAudioStateReconciliation(_downmixAudioField);
}
@override
Future<void> updateFrame() async {
if (disposed || !initialized) return;
if (_nativeCoreUnavailable || !initialized) return;
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
await invoke('updateFrame');
}
@@ -727,7 +957,7 @@ class PlayerNative extends PlayerBase {
int videoWidth = 0,
int videoHeight = 0,
}) async {
if (!Platform.isAndroid || disposed || !initialized) return false;
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return false;
final result = await invoke<bool>('setVideoFrameRate', {
'fps': fps,
'duration': durationMs,
@@ -740,13 +970,13 @@ class PlayerNative extends PlayerBase {
@override
Future<void> clearVideoFrameRate() async {
if (!Platform.isAndroid || disposed || !initialized) return;
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
await invoke('clearVideoFrameRate');
}
@override
Future<bool> requestAudioFocus() async {
if (disposed) return false;
if (_nativeCoreUnavailable) return false;
if (!Platform.isAndroid) return true;
await _ensureInitialized();
return await invoke<bool>('requestAudioFocus') ?? false;
@@ -754,7 +984,7 @@ class PlayerNative extends PlayerBase {
@override
Future<void> abandonAudioFocus() async {
if (!Platform.isAndroid || disposed || !initialized) return;
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
await invoke('abandonAudioFocus');
}
}
+11 -1
View File
@@ -108,7 +108,17 @@ class _VideoState extends State<Video> {
}
Widget _buildVideoSurface() {
final textureId = widget.player.textureId;
final player = widget.player;
if (player is PlayerBase) {
return ValueListenableBuilder<int?>(
valueListenable: player.textureIdListenable,
builder: (context, textureId, _) => _buildVideoSurfaceForId(textureId),
);
}
return _buildVideoSurfaceForId(player.textureId);
}
Widget _buildVideoSurfaceForId(int? textureId) {
if (textureId != null) {
return Texture(textureId: textureId);
}
+17
View File
@@ -88,6 +88,23 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
);
}
Widget _buildPlayerInitializationSurface() {
final bootstrapPlayer = _bootstrapPlayer;
if (bootstrapPlayer == null) return _buildLoadingSpinner();
// Linux creates the texture before its EGL/mpv render bootstrap can be
// proven. Mount the provisional surface so Flutter drives one texture
// copy, while retaining the black loading cover until playback itself
// reports its first frame.
return Stack(
fit: StackFit.expand,
children: [
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
const Center(child: CircularProgressIndicator(color: Colors.white)),
],
);
}
Widget _buildInitializationError(String message) {
return Scaffold(
backgroundColor: Colors.black,
+22 -2
View File
@@ -13,6 +13,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import '../mpv/mpv.dart';
import '../mpv/player/platform/player_android.dart';
import '../mpv/player/player_native.dart';
import '../services/scrub_preview_source.dart';
import '../media/media_backend.dart';
@@ -122,6 +123,16 @@ part 'video_player/parts/watch_together.dart';
final WakelockController _wakelockController = WakelockController();
/// Whether an in-place source reload may start the replacement media.
///
/// Reloading a paused player must not manufacture a new play intent. Watch
/// Together and explicit paused starts keep owning the eventual resume.
bool shouldAutoStartReloadedMedia({
required bool wasPlayingBeforeReload,
required bool watchTogetherOwnsStart,
required bool startPaused,
}) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused;
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
/// They are mutually exclusive by construction — entry points bail while a
/// transition is in flight.
@@ -279,6 +290,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity);
Player? player;
Player? _bootstrapPlayer;
VideoVolumeController? _volumeController;
bool _isPlayerInitialized = false;
String? _playerInitializationError;
@@ -884,6 +896,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (identical(player, attemptPlayer)) {
player = null;
}
if (identical(_bootstrapPlayer, attemptPlayer)) {
_bootstrapPlayer = null;
}
try {
await _tearDownFailedPlayerAttempt(attemptPlayer);
} catch (e, st) {
@@ -955,6 +970,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final currentPlayer = Player(useExoPlayer: useExoPlayer);
attemptPlayer = currentPlayer;
if (!mounted || generation != _playerInitializationGeneration) return;
if (currentPlayer is PlayerNative && currentPlayer.requiresProvisionalTextureSurface) {
setState(() => _bootstrapPlayer = currentPlayer);
}
if (Platform.isAndroid && useExoPlayer) {
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
if (!mounted || generation != _playerInitializationGeneration) return;
@@ -1194,6 +1212,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (mounted) {
setState(() {
_isPlayerInitialized = true;
_bootstrapPlayer = null;
});
// Restart sleep timer if we're starting a new playback session
@@ -1548,8 +1567,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final volumeController = _volumeController;
_volumeController = null;
volumeController?.dispose();
final playerToDispose = player;
final playerToDispose = player ?? _bootstrapPlayer;
player = null;
_bootstrapPlayer = null;
if (playerToDispose != null) {
// Keep the native display mode (tvOS HDMI criteria) across a
// player→player handoff; the replacement screen primes its own.
@@ -1866,7 +1886,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
? _buildVideoPlayer(sheetContext)
: (_playerInitializationError != null
? _buildInitializationError(_playerInitializationError!)
: _buildLoadingSpinner()),
: _buildPlayerInitializationSurface()),
),
),
);
@@ -12,6 +12,8 @@ import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
/// 3. Optionally override onInit() for post-initialization setup
abstract class BaseSharedPreferencesService {
static final Map<Type, BaseSharedPreferencesService> _instances = {};
static final Map<Type, Future<BaseSharedPreferencesService>> _initializations = {};
static int _resetGeneration = 0;
// Single shared cache across all subclasses so writes from one service are
// visible to reads from another without per-instance cache divergence.
static Future<SharedPreferencesWithCache>? _cacheFuture;
@@ -29,14 +31,30 @@ abstract class BaseSharedPreferencesService {
/// - One-time migration from the legacy SharedPreferences API to the
/// SharedPreferencesAsync-backed cache (idempotent across launches)
/// - Calling onInit() hook for subclass-specific setup
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) async {
if (_instances[T] == null) {
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) {
final initialized = _instances[T];
if (initialized != null) return Future<T>.value(initialized as T);
final inFlight = _initializations[T];
if (inFlight != null) return inFlight.then((instance) => instance as T);
final generation = _resetGeneration;
final initialization = () async {
final instance = constructor();
_instances[T] = instance;
instance._cache = await sharedCache();
await instance.onInit();
}
return _instances[T] as T;
if (generation != _resetGeneration) {
return initializeInstance<T>(constructor);
}
_instances[T] = instance;
return instance;
}();
_initializations[T] = initialization;
return initialization.whenComplete(() {
if (identical(_initializations[T], initialization)) {
_initializations.remove(T);
}
});
}
/// Shared preferences cache used app-wide. Runs the legacy → async
@@ -59,6 +77,8 @@ abstract class BaseSharedPreferencesService {
/// `SharedPreferences.setMockInitialValues(...)`. Test-only.
@visibleForTesting
static void resetForTesting() {
_resetGeneration++;
_initializations.clear();
_instances.clear();
_cacheFuture = null;
}
+26 -5
View File
@@ -20,6 +20,9 @@ class DevicePerformance {
DevicePerformance._();
static DevicePerformance? _instance;
static Future<void>? _initialization;
@visibleForTesting
static Future<void>? debugDetectionGate;
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
@@ -37,15 +40,31 @@ class DevicePerformance {
/// Get the singleton, detecting hardware signals on first call.
/// [override] is the persisted SettingsService.visualEffects value.
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async {
if (_instance == null) {
_instance = DevicePerformance._();
_instance!._override = override;
await _instance!._detect();
final existing = _instance;
if (existing != null) {
final initialization = _initialization;
if (initialization != null) await initialization;
return existing;
}
return _instance!;
final instance = DevicePerformance._().._override = override;
_instance = instance;
final initialization = instance._detect();
_initialization = initialization;
try {
await initialization;
} catch (_) {
if (identical(_instance, instance)) _instance = null;
rethrow;
} finally {
if (identical(_initialization, initialization)) _initialization = null;
}
return instance;
}
Future<void> _detect() async {
final gate = debugDetectionGate;
if (gate != null) await gate;
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
try {
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
@@ -139,6 +158,8 @@ class DevicePerformance {
@visibleForTesting
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
_initialization = null;
debugDetectionGate = null;
if (autoReduced == null && override == null) {
_instance = null;
return;
+32 -11
View File
@@ -10,10 +10,12 @@ import 'settings_service.dart';
/// Orchestrates Windows display mode matching (refresh rate, HDR) during video playback.
/// Uses the same platform channel as the mpv player (com.plezy/mpv_player).
class DisplayModeService {
static const _channel = MethodChannel('com.plezy/mpv_player');
static const _defaultChannel = MethodChannel('com.plezy/mpv_player');
final SettingsService _settings;
final FullscreenStateManager _fullscreen;
final MethodChannel _channel;
final bool? _isWindowsOverride;
bool _displayModeChanged = false;
bool _hdrStateChanged = false;
@@ -21,7 +23,18 @@ class DisplayModeService {
bool get hdrStateChanged => _hdrStateChanged;
bool get anyChangeApplied => _displayModeChanged || _hdrStateChanged;
DisplayModeService(this._settings, this._fullscreen);
DisplayModeService(this._settings, this._fullscreen) : _channel = _defaultChannel, _isWindowsOverride = null;
factory DisplayModeService.forTesting(
SettingsService settings,
FullscreenStateManager fullscreen, {
required MethodChannel channel,
bool isWindows = true,
}) => DisplayModeService._(settings, fullscreen, channel, isWindows);
DisplayModeService._(this._settings, this._fullscreen, this._channel, this._isWindowsOverride);
bool get _isWindows => _isWindowsOverride ?? Platform.isWindows;
/// Apply display matching based on video properties. Returns the delay
/// duration to wait before starting playback.
@@ -30,7 +43,7 @@ class DisplayModeService {
required double? fallbackFps,
required double? fallbackSigPeak,
}) async {
if (!Platform.isWindows) return Duration.zero;
if (!_isWindows) return Duration.zero;
if (!_fullscreen.isFullscreen) {
appLogger.d('Display matching skipped: not in fullscreen');
return Duration.zero;
@@ -68,13 +81,17 @@ class DisplayModeService {
}
Future<void> restoreAll() async {
if (!Platform.isWindows) return;
if (!_isWindows) return;
if (_hdrStateChanged) {
try {
await _channel.invokeMethod('restoreSystemHDR');
_hdrStateChanged = false;
appLogger.d('Restored system HDR state');
final restored = await _channel.invokeMethod<bool>('restoreSystemHDR');
if (restored == true) {
_hdrStateChanged = false;
appLogger.d('Restored system HDR state');
} else {
appLogger.w('Native system HDR restore was not accepted; retaining retry state');
}
} catch (e) {
appLogger.w('Failed to restore system HDR', error: e);
}
@@ -82,9 +99,13 @@ class DisplayModeService {
if (_displayModeChanged) {
try {
await _channel.invokeMethod('restoreDisplayMode');
_displayModeChanged = false;
appLogger.d('Restored display mode');
final restored = await _channel.invokeMethod<bool>('restoreDisplayMode');
if (restored == true) {
_displayModeChanged = false;
appLogger.d('Restored display mode');
} else {
appLogger.w('Native display mode restore was not accepted; retaining retry state');
}
} catch (e) {
appLogger.w('Failed to restore display mode', error: e);
}
@@ -177,7 +198,7 @@ class DisplayModeService {
}
Future<void> syncWithNative() async {
if (!Platform.isWindows) return;
if (!_isWindows) return;
try {
final modeChanged = await _channel.invokeMethod<bool>('isModeChanged');
_displayModeChanged = modeChanged ?? false;
+32 -5
View File
@@ -15,11 +15,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
static KeyboardShortcutsService? _instance;
static Future<void>? _initialization;
late final SettingsBindingOwner _settingsBinding;
Map<String, HotKey?> _hotkeys = {};
Future<void> _shortcutMutationTail = Future.value();
int _seekTimeSmall = 10; // Default, loaded from settings
int _seekTimeLarge = 30; // Default, loaded from settings
bool _disposed = false;
bool _settingsInitialized = false;
KeyboardShortcutsService._() {
@@ -32,11 +34,31 @@ class KeyboardShortcutsService extends ChangeNotifier {
SettingsService get _settingsService => _settingsBinding.settings!;
static Future<KeyboardShortcutsService> getInstance() async {
if (_instance == null) {
_instance = KeyboardShortcutsService._();
await _instance!._init();
var instance = _instance;
if (instance == null) {
instance = KeyboardShortcutsService._();
_instance = instance;
final initialization = instance._init();
_initialization = initialization;
}
return _instance!;
final initialization = _initialization;
if (initialization != null) {
try {
await initialization;
} catch (_) {
if (identical(_instance, instance)) {
instance._settingsBinding.dispose();
instance._disposed = true;
_instance = null;
}
rethrow;
} finally {
if (identical(_initialization, initialization)) _initialization = null;
}
}
if (instance._disposed) throw StateError('KeyboardShortcutsService was disposed during initialization');
return instance;
}
/// Keyboard shortcut customization is only supported on desktop platforms.
@@ -112,8 +134,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_settingsBinding.dispose();
if (identical(_instance, this)) _instance = null;
if (identical(_instance, this)) {
_instance = null;
_initialization = null;
}
super.dispose();
}
-1
View File
@@ -205,7 +205,6 @@ class SystemShelfService {
final result = await _enqueueMutation<bool>(() async {
if (!_owns(profileId, generation)) return false;
try {
if (!_owns(profileId, generation)) return false;
return await channel.invokeMethod<bool>('sync', {
'schemaVersion': schemaVersion,
'ownerId': profileId,
+32 -4
View File
@@ -31,6 +31,9 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> fea
/// Service for detecting if the app is running on Android TV or Apple TV.
class TvDetectionService {
static TvDetectionService? _instance;
static Future<void>? _initialization;
@visibleForTesting
static Future<void>? debugDetectionGate;
static bool? _debugAppleTVOverride;
bool _detected = false;
bool _forceTv = false;
@@ -44,17 +47,34 @@ class TvDetectionService {
/// Get the singleton instance, initializing if needed.
/// Pass [forceTv] to combine a user override with the system-feature check.
static Future<TvDetectionService> getInstance({bool forceTv = false}) async {
if (_instance == null) {
_instance = TvDetectionService._();
await _instance!._detect(forceTv);
final existing = _instance;
if (existing != null) {
final initialization = _initialization;
if (initialization != null) await initialization;
return existing;
}
return _instance!;
final instance = TvDetectionService._();
_instance = instance;
final initialization = instance._detect(forceTv);
_initialization = initialization;
try {
await initialization;
} catch (_) {
if (identical(_instance, instance)) _instance = null;
rethrow;
} finally {
if (identical(_initialization, initialization)) _initialization = null;
}
return instance;
}
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
Future<void> _detect(bool forceTv) async {
final gate = debugDetectionGate;
if (gate != null) await gate;
if (_initialized) return;
final deviceInfo = DeviceInfoPlugin();
@@ -147,6 +167,14 @@ class TvDetectionService {
_debugAppleTVOverride = value;
}
@visibleForTesting
static void debugReset() {
_instance = null;
_initialization = null;
debugDetectionGate = null;
_debugAppleTVOverride = null;
}
static List<String> tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const [];
/// Convenience setter that forwards to the singleton if available.