fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user