Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../contracts/cancellation.dart';
import '../../../shared/utils/app_logger.dart';
import 'player_engine.dart';
const _tag = 'Media3Player';
@visibleForTesting
bool isMedia3NetworkError(String code) => code.startsWith('ERROR_CODE_IO_');
class Media3PlayerEngine implements PlayerEngine {
Media3PlayerEngine({bool? isAndroid}) {
final onAndroid = isAndroid ?? Platform.isAndroid;
if (!onAndroid) {
throw UnsupportedError('Media3PlayerEngine is Android-only');
}
AppLogger.debug(_tag, 'construct');
}
static const MethodChannel _mc = MethodChannel('smplayer/media3_engine');
static const EventChannel _events = EventChannel(
'smplayer/media3_engine/events',
);
@override
final PlayerStreams stream = PlayerStreams();
@override
final PlayerState state = PlayerState();
@override
final ValueNotifier<int?> textureId = ValueNotifier<int?>(null);
@override
final ValueNotifier<int> textureVersion = ValueNotifier<int>(0);
@override
final ValueNotifier<ui.Size?> videoSize = ValueNotifier<ui.Size?>(null);
StreamSubscription<dynamic>? _eventSub;
final ValueNotifier<int?> _playerIdNotifier = ValueNotifier<int?>(null);
int? get _playerId => _playerIdNotifier.value;
set _playerId(int? v) => _playerIdNotifier.value = v;
int _generation = 0;
bool _disposed = false;
bool _completedEmitted = false;
double _lastNonZeroVolume = 100.0;
@override
String get displayName => 'media3';
@override
bool get usesPlatformSurface => true;
@override
Future<int?> totalRxBytes() async {
if (_disposed || _playerId == null) return null;
final value = await _mc.invokeMethod<dynamic>(
'getProperty',
<String, dynamic>{'playerId': _playerId, 'name': 'totalRxBytes'},
);
return (value as num?)?.toInt();
}
@override
bool isNetworkStreamError(String error) => isMedia3NetworkError(error);
@override
void configureHardwareDecoding(String mode) {}
@override
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
}) async {
_ensureNotDisposed();
cancel?.throwIfCancelled();
await _ensureCreated();
if (_disposed) return;
cancel?.throwIfCancelled();
final generation = ++_generation;
final openFuture = _mc.invokeMethod<void>('open', <String, dynamic>{
'playerId': _playerId,
'url': url,
'headers': headers ?? const <String, String>{},
'startMs': start.inMilliseconds,
'play': play,
'generation': generation,
});
await _runCancellable(openFuture, cancel, generation);
if (_disposed) return;
cancel?.throwIfCancelled();
_completedEmitted = false;
videoSize.value = null;
state
..position = start
..buffer = start
..duration = Duration.zero
..playing = false
..completed = false
..tracks = const PlayerTracks()
..track = const PlayerTrack()
..subtitle = const []
..error = null;
_emitStateSnapshot();
await _invoke('replayKeyState');
}
Future<void> _runCancellable(
Future<void> future,
CancellationToken? cancel,
int generation,
) {
if (cancel == null) return future;
return Future.any<void>([
future,
cancel.whenCancelled.then<void>((_) async {
if (!_disposed && _playerId != null) {
++_generation;
try {
await _mc.invokeMethod<void>('stop', <String, dynamic>{
'playerId': _playerId,
'generation': _generation,
});
} catch (e) {
AppLogger.debug(_tag, 'stop on cancel failed', e);
}
}
throw CancelledException(cancel.reason);
}),
]);
}
@override
Future<void> play() async {
if (_disposed || _playerId == null) return;
await _invoke('play');
}
@override
Future<void> pause() async {
if (_disposed || _playerId == null) return;
await _invoke('pause');
}
@override
Future<void> stop() async {
if (_disposed || _playerId == null) return;
++_generation;
await _invoke('stop', <String, dynamic>{'generation': _generation});
}
@override
Future<void> seek(Duration position) async {
if (_disposed || _playerId == null) return;
final durationMs = state.duration.inMilliseconds;
final maxMs = durationMs <= 0 ? (1 << 62) : durationMs;
final target = Duration(
milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(),
);
state.position = target;
stream.addPosition(target);
await _invoke('seek', <String, dynamic>{
'positionMs': target.inMilliseconds,
});
}
@override
Future<void> setRate(double rate) async {
if (_disposed || _playerId == null) return;
state.rate = rate;
await _invoke('setRate', <String, dynamic>{'rate': rate});
}
@override
Future<void> setVolume(double volume) async {
if (_disposed) return;
final next = volume.clamp(0.0, 200.0).toDouble();
state.volume = next;
if (next > 0) _lastNonZeroVolume = next;
stream.addVolume(next);
if (_playerId == null) return;
await _invoke('setVolume', <String, dynamic>{'volume': next});
}
@override
Future<void> toggleMute() async {
if (state.volume <= 0) {
await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume);
} else {
await setVolume(0);
}
}
@override
Future<void> setAudioTrack(AudioTrack track) async {
if (_disposed || _playerId == null) return;
await _invoke('setAudioTrack', <String, dynamic>{'id': track.id});
state.track = state.track.copyWith(audio: track);
stream.addTrack(state.track);
}
@override
Future<void> setSubtitleTrack(SubtitleTrack track) async {
if (_disposed || _playerId == null) return;
await _invoke('setSubtitleTrack', <String, dynamic>{'id': track.id});
state.track = state.track.copyWith(subtitle: track);
stream.addTrack(state.track);
}
@override
void setNativeSubtitleRendering(bool enabled) {
}
@override
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
}) {
if (_disposed || _playerId == null) return;
_invoke('setSubtitleStyle', <String, dynamic>{
'fontSize': fontSize,
'bgOpacity': bgOpacity,
'position': position,
});
}
@override
ValueListenable<PlaybackStats?> get debugStats => kEmptyPlaybackStats;
@override
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit) {
return _Media3PlatformVideoView(
playerId: _playerIdNotifier,
fit: fit,
onResizeMode: _setResizeMode,
);
}
Future<void> _setResizeMode(String mode) async {
if (_disposed || _playerId == null) return;
try {
await _mc.invokeMethod<void>('setResizeMode', <String, dynamic>{
'playerId': _playerId,
'mode': mode,
});
} catch (e) {
AppLogger.debug(_tag, 'setResizeMode($mode) failed', e);
}
}
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
++_generation;
await _eventSub?.cancel();
_eventSub = null;
final id = _playerId;
_playerId = null;
if (id != null) {
try {
await _mc.invokeMethod<void>('dispose', <String, dynamic>{
'playerId': id,
});
} catch (e) {
AppLogger.warn(_tag, 'native dispose failed', e);
}
}
textureId.dispose();
textureVersion.dispose();
videoSize.dispose();
_playerIdNotifier.dispose();
stream.dispose();
}
Future<void> prewarmNative() async {
if (_disposed) return;
await _ensureCreated();
}
Future<void>? _createFuture;
Future<void> _ensureCreated() {
if (_playerId != null) return Future<void>.value();
return _createFuture ??= _doCreateNative();
}
Future<void> _doCreateNative() async {
try {
final result = await _mc.invokeMapMethod<String, dynamic>('create');
if (result == null) {
throw StateError('Media3 create returned null');
}
final createdPlayerId = (result['playerId'] as num).toInt();
if (_disposed) {
AppLogger.debug(
_tag,
'disposed during create, releasing orphan playerId=$createdPlayerId',
);
try {
await _mc.invokeMethod<void>('dispose', <String, dynamic>{
'playerId': createdPlayerId,
});
} catch (e) {
AppLogger.warn(_tag, 'orphan dispose failed', e);
}
return;
}
_playerId = createdPlayerId;
AppLogger.debug(
_tag,
'created playerId=$_playerId '
'ffmpeg=${result['ffmpegAvailable']} v=${result['ffmpegVersion']}',
);
_eventSub = _events.receiveBroadcastStream().listen(
_handleEvent,
onError: (Object e) {
if (_disposed) return;
AppLogger.warn(_tag, 'event stream error', e);
},
);
try {
await _mc.invokeMethod<void>('setVolume', <String, dynamic>{
'playerId': _playerId,
'volume': state.volume,
});
} catch (e) {
AppLogger.debug(_tag, 'initial setVolume replay failed', e);
}
} catch (e) {
_createFuture = null;
rethrow;
}
}
void _handleEvent(dynamic event) {
if (_disposed) return;
if (event is! Map) return;
if (event['playerId'] != _playerId) return;
final gen = (event['generation'] as num?)?.toInt();
if (gen == null || gen != _generation) return;
final type = event['type'] as String?;
switch (type) {
case 'position':
final ms = (event['positionMs'] as num?)?.toInt() ?? 0;
final pos = Duration(milliseconds: ms);
state.position = pos;
stream.addPosition(pos);
final bufMs = (event['bufferMs'] as num?)?.toInt();
if (bufMs != null) {
final buf = Duration(milliseconds: bufMs);
state.buffer = buf;
stream.addBuffer(buf);
}
case 'duration':
final ms = (event['durationMs'] as num?)?.toInt() ?? 0;
final dur = Duration(milliseconds: ms);
if (dur == state.duration) return;
state.duration = dur;
stream.addDuration(dur);
case 'playing':
final playing = event['playing'] == true;
if (state.playing == playing) return;
state.playing = playing;
stream.addPlaying(playing);
case 'completed':
if (_completedEmitted) return;
_completedEmitted = true;
state.completed = true;
stream.addCompleted(true);
case 'videoSize':
final w = (event['width'] as num?)?.toDouble() ?? 0;
final h = (event['height'] as num?)?.toDouble() ?? 0;
if (w > 0 && h > 0) {
videoSize.value = ui.Size(w, h);
}
case 'tracks':
final tracks = _decodeTracks(event);
if (tracks == state.tracks) return;
state.tracks = tracks;
stream.addTracks(tracks);
case 'subtitle':
final lines =
(event['lines'] as List?)?.cast<String>() ?? const <String>[];
state.subtitle = lines;
stream.addSubtitle(lines);
case 'volume':
final vol = (event['volume'] as num?)?.toDouble();
if (vol != null) {
state.volume = vol;
if (vol > 0) _lastNonZeroVolume = vol;
stream.addVolume(vol);
}
case 'error':
final code = event['code'] as String? ?? 'unknown';
state.error = code;
AppLogger.warn(_tag, 'media3 error: $code ${event['message'] ?? ''}');
stream.addError(code);
default:
AppLogger.debug(_tag, 'unknown event type: $type');
}
}
PlayerTracks _decodeTracks(Map<dynamic, dynamic> event) {
final audio = <AudioTrack>[];
for (final raw in (event['audio'] as List? ?? const [])) {
final m = raw as Map;
audio.add(
AudioTrack(
id: m['id'] as String,
index: (m['index'] as num?)?.toInt(),
title: (m['title'] as String?)?.isNotEmpty == true
? m['title'] as String
: null,
language: (m['language'] as String?)?.isNotEmpty == true
? m['language'] as String
: null,
),
);
}
final subtitle = <SubtitleTrack>[];
for (final raw in (event['subtitle'] as List? ?? const [])) {
final m = raw as Map;
subtitle.add(
SubtitleTrack(
id: m['id'] as String,
index: (m['index'] as num?)?.toInt(),
title: (m['title'] as String?)?.isNotEmpty == true
? m['title'] as String
: null,
language: (m['language'] as String?)?.isNotEmpty == true
? m['language'] as String
: null,
),
);
}
return PlayerTracks(audio: audio, subtitle: subtitle);
}
Future<void> _invoke(String method, [Map<String, dynamic>? args]) {
return _mc.invokeMethod<void>(method, <String, dynamic>{
'playerId': _playerId,
...?args,
});
}
void _ensureNotDisposed() {
if (_disposed) throw StateError('Media3PlayerEngine has been disposed');
}
void _emitStateSnapshot() {
stream.addPosition(state.position);
stream.addDuration(state.duration);
stream.addBuffer(state.buffer);
stream.addPlaying(state.playing);
stream.addCompleted(state.completed);
stream.addTracks(state.tracks);
stream.addTrack(state.track);
stream.addSubtitle(state.subtitle);
stream.addVolume(state.volume);
}
}
class _Media3PlatformVideoView extends StatefulWidget {
const _Media3PlatformVideoView({
required this.playerId,
required this.fit,
required this.onResizeMode,
});
static const String _viewType = 'smplayer/media3_surface';
final ValueListenable<int?> playerId;
final ValueListenable<BoxFit> fit;
final Future<void> Function(String mode) onResizeMode;
@override
State<_Media3PlatformVideoView> createState() =>
_Media3PlatformVideoViewState();
}
class _Media3PlatformVideoViewState extends State<_Media3PlatformVideoView> {
int? _lastSentPlayerId;
String? _lastSentMode;
@override
void initState() {
super.initState();
widget.playerId.addListener(_syncResizeMode);
widget.fit.addListener(_syncResizeMode);
_syncResizeMode();
}
@override
void didUpdateWidget(covariant _Media3PlatformVideoView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.playerId != widget.playerId) {
oldWidget.playerId.removeListener(_syncResizeMode);
widget.playerId.addListener(_syncResizeMode);
}
if (oldWidget.fit != widget.fit) {
oldWidget.fit.removeListener(_syncResizeMode);
widget.fit.addListener(_syncResizeMode);
}
_syncResizeMode();
}
@override
void dispose() {
widget.playerId.removeListener(_syncResizeMode);
widget.fit.removeListener(_syncResizeMode);
super.dispose();
}
void _syncResizeMode() {
final id = widget.playerId.value;
if (id == null) return;
final mode = _resizeModeForFit(widget.fit.value);
if (_lastSentPlayerId == id && _lastSentMode == mode) return;
_lastSentPlayerId = id;
_lastSentMode = mode;
unawaited(widget.onResizeMode(mode));
}
String _resizeModeForFit(BoxFit fit) {
return switch (fit) {
BoxFit.fill => 'fill',
BoxFit.cover => 'zoom',
_ => 'fit',
};
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int?>(
valueListenable: widget.playerId,
builder: (context, id, _) {
if (id == null) {
return const SizedBox.expand();
}
return PlatformViewLink(
key: ValueKey<int>(id),
viewType: _Media3PlatformVideoView._viewType,
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
gestureRecognizers:
const <Factory<OneSequenceGestureRecognizer>>{},
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
);
},
onCreatePlatformView: (params) {
final controller =
PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: _Media3PlatformVideoView._viewType,
layoutDirection: TextDirection.ltr,
creationParams: <String, dynamic>{'playerId': id},
creationParamsCodec: const StandardMessageCodec(),
onFocus: () => params.onFocusChanged(true),
)
..addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
)
..create();
return controller;
},
);
},
);
}
}
@@ -0,0 +1,615 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart' as mk;
import 'package:media_kit_video/media_kit_video.dart' as mkv;
import '../../contracts/cancellation.dart';
import '../../../shared/utils/app_logger.dart';
import 'player_engine.dart';
const _tag = 'MKPlayer';
@visibleForTesting
bool isMpvNetworkError(String error) {
final lower = error.toLowerCase();
if (lower.startsWith('tcp:') || lower.startsWith('tls:')) return true;
const patterns = [
'failed to open http',
'loading failed',
'connection refused',
'connection reset',
'connection timed out',
'network is unreachable',
'host not found',
'name resolution',
'http error 4',
'http error 5',
'tls handshake',
];
for (final p in patterns) {
if (lower.contains(p)) return true;
}
return false;
}
const int _mpvNetworkTimeoutSeconds = 60;
double clampMediaKitVolume(double volume) =>
volume.clamp(0.0, 200.0).toDouble();
PlayerTracks mapMkTracksToPlayerTracks(mk.Tracks mkTracks) {
final audio = <AudioTrack>[];
for (var i = 0; i < mkTracks.audio.length; i++) {
final t = mkTracks.audio[i];
if (t.id == 'auto' || t.id == 'no') continue;
audio.add(mapMkAudioTrack(t, index: i));
}
final subtitle = <SubtitleTrack>[];
for (var i = 0; i < mkTracks.subtitle.length; i++) {
final t = mkTracks.subtitle[i];
if (t.id == 'auto' || t.id == 'no') continue;
subtitle.add(mapMkSubtitleTrack(t, index: i));
}
return PlayerTracks(audio: audio, subtitle: subtitle);
}
AudioTrack mapMkAudioTrack(mk.AudioTrack t, {int? index}) {
if (t.id == 'auto') return const AudioTrack.auto();
if (t.id == 'no') return const AudioTrack.no();
return AudioTrack(
id: t.id,
index: index,
title: t.title?.isNotEmpty == true ? t.title : null,
language: t.language?.isNotEmpty == true ? t.language : null,
native: t,
);
}
SubtitleTrack mapMkSubtitleTrack(mk.SubtitleTrack t, {int? index}) {
if (t.id == 'auto') return const SubtitleTrack.no();
if (t.id == 'no') return const SubtitleTrack.no();
return SubtitleTrack(
id: t.id,
index: index,
title: t.title?.isNotEmpty == true ? t.title : null,
language: t.language?.isNotEmpty == true ? t.language : null,
native: t,
);
}
class MediaKitPlayerEngine implements PlayerEngine {
MediaKitPlayerEngine() {
AppLogger.debug(_tag, 'construct start');
_subscribeToStreams();
_configureNetworkTimeout();
_configureNetworkReconnect();
AppLogger.debug(_tag, 'construct done');
}
final mk.Player _player = mk.Player(
configuration: const mk.PlayerConfiguration(
title: 'smPlayer mpv',
libass: true,
logLevel: mk.MPVLogLevel.warn,
),
);
mkv.VideoController? _controller;
@override
final PlayerStreams stream = PlayerStreams();
@override
final PlayerState state = PlayerState();
@override
final ValueNotifier<int?> textureId = ValueNotifier<int?>(null);
@override
final ValueNotifier<int> textureVersion = ValueNotifier<int>(0);
@override
bool get usesPlatformSurface => false;
@override
bool isNetworkStreamError(String error) => isMpvNetworkError(error);
@override
Future<int?> totalRxBytes() async {
final native = _player.platform;
if (_disposed || native is! mk.NativePlayer) return null;
try {
final raw = await native.getProperty('cache-speed');
final speed = double.tryParse(raw);
if (speed == null || speed < 0) return _rxBytesAccum;
final dtMs = _rxStopwatch.elapsedMilliseconds;
_rxStopwatch.reset();
_rxStopwatch.start();
final clampedDtMs = dtMs.clamp(0, 5000);
_rxBytesAccum += (speed * clampedDtMs) ~/ 1000;
return _rxBytesAccum;
} catch (_) {
return null;
}
}
@override
final ValueNotifier<ui.Size?> videoSize = ValueNotifier<ui.Size?>(null);
bool _disposed = false;
bool _completedEmitted = false;
bool _playerReady = false;
double _lastNonZeroVolume = 100.0;
final List<StreamSubscription<dynamic>> _subs = [];
double? _pendingInitialVolume;
int _rxBytesAccum = 0;
final Stopwatch _rxStopwatch = Stopwatch();
void _subscribeToStreams() {
_subs.addAll([
_player.stream.playing.listen(_handlePlaying),
_player.stream.completed.listen(_handleCompleted),
_player.stream.position.listen(_handlePosition),
_player.stream.duration.listen(_handleDuration),
_player.stream.buffer.listen(_handleBuffer),
_player.stream.volume.listen(_handleVolume),
_player.stream.tracks.listen(_handleTracks),
_player.stream.track.listen(_handleTrack),
_player.stream.width.listen(_handleWidth),
_player.stream.height.listen(_handleHeight),
_player.stream.error.listen(_handleError),
_player.stream.log.listen(_handleMpvLog),
]);
}
int? _lastHttpErrorStatus;
static final RegExp _httpErrorStatusPattern = RegExp(r'HTTP error (\d{3})');
void _handleMpvLog(mk.PlayerLog log) {
final text = log.text;
if (text.contains('HTTP error')) {
final match = _httpErrorStatusPattern.firstMatch(text);
if (match != null) {
_lastHttpErrorStatus = int.tryParse(match.group(1)!);
}
}
if (!kDebugMode) return;
if (text.contains('request:') ||
text.contains('User-Agent') ||
text.contains('HTTP error') ||
text.contains('Location:')) {
AppLogger.debug(_tag, 'mpv[${log.prefix}] ${text.trim()}');
}
}
void _configureVolumeBoostLimit() {
_setMpvPropertyWhenReady('volume-max', '200');
}
void _configureNetworkTimeout() {
_setMpvPropertyWhenReady('network-timeout', '$_mpvNetworkTimeoutSeconds');
}
void _configureNetworkReconnect() {
const reconnectOptions =
'reconnect=1,'
'reconnect_streamed=1,'
'reconnect_on_network_error=1,'
'reconnect_on_http_error=4xx+5xx,'
'reconnect_delay_max=5';
_setMpvPropertyWhenReady('stream-lavf-o', reconnectOptions);
}
void _setMpvPropertyWhenReady(String propertyName, String propertyValue) {
final native = _player.platform;
if (native is! mk.NativePlayer) return;
AppLogger.debug(_tag, 'set $propertyName=$propertyValue queued');
unawaited(
native
.setProperty(propertyName, propertyValue)
.then(
(_) => AppLogger.debug(_tag, 'set $propertyName done'),
onError: (Object e) =>
AppLogger.debug(_tag, 'set $propertyName failed', e),
),
);
}
void _handlePlaying(bool playing) {
if (_disposed) return;
if (state.playing == playing) return;
state.playing = playing;
stream.addPlaying(playing);
}
void _handleCompleted(bool completed) {
if (_disposed) return;
if (!completed) return;
if (_completedEmitted) return;
_completedEmitted = true;
state.completed = true;
stream.addCompleted(true);
}
void _handlePosition(Duration pos) {
if (_disposed) return;
state.position = pos;
stream.addPosition(pos);
}
void _handleDuration(Duration dur) {
if (_disposed) return;
if (dur == state.duration) return;
state.duration = dur;
stream.addDuration(dur);
}
void _handleBuffer(Duration buf) {
if (_disposed) return;
state.buffer = buf;
stream.addBuffer(buf);
}
void _handleVolume(double vol) {
if (_disposed) return;
state.volume = vol;
if (vol > 0) _lastNonZeroVolume = vol;
stream.addVolume(vol);
}
void _handleTracks(mk.Tracks mkTracks) {
if (_disposed) return;
final tracks = mapMkTracksToPlayerTracks(mkTracks);
if (tracks == state.tracks) return;
state.tracks = tracks;
stream.addTracks(tracks);
}
void _handleTrack(mk.Track mkTrack) {
if (_disposed) return;
final next = PlayerTrack(
audio: mapMkAudioTrack(mkTrack.audio),
subtitle: mapMkSubtitleTrack(mkTrack.subtitle),
);
if (next == state.track) return;
state.track = next;
stream.addTrack(next);
}
void _handleWidth(int? w) {
if (_disposed) return;
_updateVideoSize(w, null);
}
void _handleHeight(int? h) {
if (_disposed) return;
_updateVideoSize(null, h);
}
void _handleError(String error) {
if (_disposed) return;
final status = _lastHttpErrorStatus;
final enriched = (status != null && !error.contains('(http '))
? '$error (http $status)'
: error;
state.error = enriched;
AppLogger.warn(_tag, 'mpv error: $enriched');
stream.addError(enriched);
}
void _updateVideoSize(int? newW, int? newH) {
final current = videoSize.value;
final w = newW?.toDouble() ?? current?.width ?? 0;
final h = newH?.toDouble() ?? current?.height ?? 0;
if (w > 0 && h > 0) {
videoSize.value = ui.Size(w, h);
}
}
@override
String get displayName => 'mpv';
@override
void configureHardwareDecoding(String mode) {
final native = _player.platform;
if (native is! mk.NativePlayer) return;
final mpvHwdec = mode == 'no' ? 'no' : mode;
native.setProperty('hwdec', mpvHwdec).catchError((e) {
AppLogger.debug(_tag, 'setProperty(hwdec, $mpvHwdec) failed', e);
});
AppLogger.debug(_tag, 'hwdec=$mpvHwdec');
}
@override
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
}) async {
_ensureNotDisposed();
cancel?.throwIfCancelled();
AppLogger.debug(
_tag,
'open start start=${start.inMilliseconds}ms play=$play '
'headers=${headers?.keys.join(',') ?? '<none>'}',
);
_rxBytesAccum = 0;
_rxStopwatch.reset();
_rxStopwatch.start();
_completedEmitted = false;
_lastHttpErrorStatus = null;
videoSize.value = null;
state
..position = start
..buffer = start
..duration = Duration.zero
..playing = false
..completed = false
..tracks = const PlayerTracks()
..track = const PlayerTrack()
..subtitle = const []
..error = null;
_emitStateSnapshot();
final media = mk.Media(
url,
httpHeaders: headers,
start: start == Duration.zero ? null : start,
);
AppLogger.debug(_tag, 'player.open start');
await _runCancellable(_player.open(media, play: false), cancel);
AppLogger.debug(_tag, 'player.open done');
if (_disposed) return;
cancel?.throwIfCancelled();
_playerReady = true;
_configureVolumeBoostLimit();
final pendingVolume = _pendingInitialVolume;
if (pendingVolume != null) {
_pendingInitialVolume = null;
await setVolume(pendingVolume);
if (_disposed) return;
cancel?.throwIfCancelled();
}
if (start > Duration.zero) {
try {
await _runCancellable(_player.seek(start), cancel);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.debug(_tag, 'seek-on-open failed, continuing', e);
}
if (_disposed) return;
cancel?.throwIfCancelled();
}
if (play) {
AppLogger.debug(_tag, 'player.play start');
await _player.play();
AppLogger.debug(_tag, 'player.play done');
}
}
Future<T> _runCancellable<T>(Future<T> future, CancellationToken? cancel) {
if (cancel == null) return future;
return Future.any<T>([
future,
cancel.whenCancelled.then<T>((_) async {
try {
if (!_disposed) await _player.stop();
} catch (e) {
AppLogger.debug(_tag, 'stop on cancel failed', e);
}
throw CancelledException(cancel.reason);
}),
]);
}
@override
Future<void> play() async {
if (_disposed) return;
await _player.play();
}
@override
Future<void> pause() async {
if (_disposed) return;
await _player.pause();
}
@override
Future<void> stop() async {
if (_disposed) return;
await _player.stop();
}
@override
Future<void> seek(Duration position) async {
if (_disposed) return;
final durationMs = state.duration.inMilliseconds;
final maxMs = durationMs <= 0 ? (1 << 62) : durationMs;
final target = Duration(
milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(),
);
state.position = target;
stream.addPosition(target);
await _player.seek(target);
}
@override
Future<void> setRate(double rate) async {
if (_disposed) return;
state.rate = rate;
await _player.setRate(rate);
}
@override
Future<void> setVolume(double volume) async {
if (_disposed) return;
final next = clampMediaKitVolume(volume);
state.volume = next;
if (next > 0) _lastNonZeroVolume = next;
if (!_playerReady) {
_pendingInitialVolume = next;
} else {
await _player.setVolume(next);
}
stream.addVolume(next);
}
@override
Future<void> toggleMute() async {
if (state.volume <= 0) {
await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume);
} else {
await setVolume(0);
}
}
@override
Future<void> setAudioTrack(AudioTrack track) async {
if (_disposed) return;
if (track.no) {
await _player.setAudioTrack(mk.AudioTrack.no());
} else if (track.native is mk.AudioTrack) {
await _player.setAudioTrack(track.native as mk.AudioTrack);
} else {
AppLogger.warn(
_tag,
'setAudioTrack: no native mk.AudioTrack on ${track.id}',
);
}
state.track = state.track.copyWith(audio: track);
stream.addTrack(state.track);
}
@override
Future<void> setSubtitleTrack(SubtitleTrack track) async {
if (_disposed) return;
if (track.no) {
await _player.setSubtitleTrack(mk.SubtitleTrack.no());
} else if (track.native is mk.SubtitleTrack) {
await _player.setSubtitleTrack(track.native as mk.SubtitleTrack);
} else {
AppLogger.warn(
_tag,
'setSubtitleTrack: no native mk.SubtitleTrack on ${track.id} '
'(track globalIdx=${track.index} title=${track.title})',
);
}
state.track = state.track.copyWith(subtitle: track);
stream.addTrack(state.track);
}
@override
void setNativeSubtitleRendering(bool enabled) {
}
@override
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
}) {}
@override
ValueListenable<PlaybackStats?> get debugStats => kEmptyPlaybackStats;
@override
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit) {
final controller = _ensureVideoController();
return ValueListenableBuilder<BoxFit>(
valueListenable: fit,
builder: (context, fitValue, _) => mkv.Video(
controller: controller,
fit: fitValue,
controls: null,
wakelock: false,
pauseUponEnteringBackgroundMode: false,
),
);
}
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
for (final sub in _subs) {
await sub.cancel();
}
_subs.clear();
await _player.dispose();
textureId.dispose();
textureVersion.dispose();
videoSize.dispose();
stream.dispose();
}
mkv.VideoController _ensureVideoController() {
final existing = _controller;
if (existing != null) return existing;
AppLogger.debug(
_tag,
'video controller create start os=${Platform.operatingSystem}',
);
final controller = mkv.VideoController(
_player,
configuration: const mkv.VideoControllerConfiguration(
enableHardwareAcceleration: true,
),
);
AppLogger.debug(_tag, 'video controller create done');
_controller = controller;
return controller;
}
void _ensureNotDisposed() {
if (_disposed) throw StateError('MediaKitPlayerEngine has been disposed');
}
void _emitStateSnapshot() {
stream.addPosition(state.position);
stream.addDuration(state.duration);
stream.addBuffer(state.buffer);
stream.addPlaying(state.playing);
stream.addCompleted(state.completed);
stream.addTracks(state.tracks);
stream.addTrack(state.track);
stream.addSubtitle(state.subtitle);
stream.addVolume(state.volume);
}
}
@@ -0,0 +1,327 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../contracts/cancellation.dart';
int? streamOpenErrorHttpStatus(String error) {
final match = RegExp(r'\(http (\d{3})\)').firstMatch(error.toLowerCase());
if (match == null) return null;
return int.tryParse(match.group(1)!);
}
bool isTerminalStreamOpenError(String error) {
final status = streamOpenErrorHttpStatus(error);
if (status == null) return false;
if (status == 408 || status == 429) return false;
return status >= 400 && status < 500;
}
abstract class PlayerEngine {
PlayerStreams get stream;
PlayerState get state;
ValueListenable<int?> get textureId;
ValueListenable<int> get textureVersion;
ValueNotifier<ui.Size?> get videoSize;
String get displayName;
bool get usesPlatformSurface => false;
Future<int?> totalRxBytes() async => null;
bool isNetworkStreamError(String error) => false;
void configureHardwareDecoding(String mode);
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
});
Future<void> play();
Future<void> pause();
Future<void> stop();
Future<void> seek(Duration position);
Future<void> setRate(double rate);
Future<void> setVolume(double volume);
Future<void> toggleMute();
Future<void> setAudioTrack(AudioTrack track);
Future<void> setSubtitleTrack(SubtitleTrack track);
void setNativeSubtitleRendering(bool enabled) {}
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
});
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit);
ValueListenable<PlaybackStats?> get debugStats => kEmptyPlaybackStats;
Future<void> dispose();
}
final ValueNotifier<PlaybackStats?> kEmptyPlaybackStats =
ValueNotifier<PlaybackStats?>(null);
@immutable
class PlaybackStats {
final Duration position;
final int bufferLeadMs;
final double bitrateMbps;
final double rate;
final String decoder;
final bool isDolbyVision;
final String hwdec;
const PlaybackStats({
required this.position,
required this.bufferLeadMs,
required this.bitrateMbps,
required this.rate,
required this.decoder,
required this.isDolbyVision,
required this.hwdec,
});
}
class PlayerStreams {
final StreamController<Duration> _position =
StreamController<Duration>.broadcast();
final StreamController<Duration> _duration =
StreamController<Duration>.broadcast();
final StreamController<Duration> _buffer =
StreamController<Duration>.broadcast();
final StreamController<bool> _playing = StreamController<bool>.broadcast();
final StreamController<bool> _completed = StreamController<bool>.broadcast();
final StreamController<PlayerTracks> _tracks =
StreamController<PlayerTracks>.broadcast();
final StreamController<PlayerTrack> _track =
StreamController<PlayerTrack>.broadcast();
final StreamController<List<String>> _subtitle =
StreamController<List<String>>.broadcast();
final StreamController<double> _volume = StreamController<double>.broadcast();
final StreamController<String> _error = StreamController<String>.broadcast();
Stream<Duration> get position => _position.stream;
Stream<Duration> get duration => _duration.stream;
Stream<Duration> get buffer => _buffer.stream;
Stream<bool> get playing => _playing.stream;
Stream<bool> get completed => _completed.stream;
Stream<PlayerTracks> get tracks => _tracks.stream;
Stream<PlayerTrack> get track => _track.stream;
Stream<List<String>> get subtitle => _subtitle.stream;
Stream<double> get volume => _volume.stream;
Stream<String> get error => _error.stream;
void addPosition(Duration value) => _position.add(value);
void addDuration(Duration value) => _duration.add(value);
void addBuffer(Duration value) => _buffer.add(value);
void addPlaying(bool value) => _playing.add(value);
void addCompleted(bool value) => _completed.add(value);
void addTracks(PlayerTracks value) => _tracks.add(value);
void addTrack(PlayerTrack value) => _track.add(value);
void addSubtitle(List<String> value) => _subtitle.add(value);
void addVolume(double value) => _volume.add(value);
void addError(String value) => _error.add(value);
void dispose() {
_position.close();
_duration.close();
_buffer.close();
_playing.close();
_completed.close();
_tracks.close();
_track.close();
_subtitle.close();
_volume.close();
_error.close();
}
}
class PlayerState {
Duration position = Duration.zero;
Duration duration = Duration.zero;
Duration buffer = Duration.zero;
bool playing = false;
bool completed = false;
double volume = 100.0;
double rate = 1.0;
PlayerTracks tracks = const PlayerTracks();
PlayerTrack track = const PlayerTrack();
List<String> subtitle = const [];
String? error;
}
@immutable
class PlayerTracks {
final List<AudioTrack> audio;
final List<SubtitleTrack> subtitle;
const PlayerTracks({
this.audio = const <AudioTrack>[],
this.subtitle = const <SubtitleTrack>[],
});
List<SubtitleTrack> get embeddedSubtitles =>
subtitle.where((t) => t.id != 'auto' && t.id != 'no').toList();
List<AudioTrack> get embeddedAudio =>
audio.where((t) => t.id != 'auto' && t.id != 'no').toList();
@override
bool operator ==(Object other) {
return other is PlayerTracks &&
listEquals(other.audio, audio) &&
listEquals(other.subtitle, subtitle);
}
@override
int get hashCode =>
Object.hash(Object.hashAll(audio), Object.hashAll(subtitle));
}
@immutable
class PlayerTrack {
final AudioTrack audio;
final SubtitleTrack subtitle;
const PlayerTrack({
this.audio = const AudioTrack.auto(),
this.subtitle = const SubtitleTrack.no(),
});
PlayerTrack copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) {
return PlayerTrack(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
);
}
@override
bool operator ==(Object other) {
return other is PlayerTrack &&
other.audio == audio &&
other.subtitle == subtitle;
}
@override
int get hashCode => Object.hash(audio, subtitle);
}
@immutable
class AudioTrack {
final String id;
final int? index;
final String? title;
final String? language;
final bool no;
final bool auto;
final Object? native;
const AudioTrack({
required this.id,
this.index,
this.title,
this.language,
this.no = false,
this.auto = false,
this.native,
});
const AudioTrack.no()
: id = 'no',
index = null,
title = null,
language = null,
no = true,
auto = false,
native = null;
const AudioTrack.auto()
: id = 'auto',
index = null,
title = null,
language = null,
no = false,
auto = true,
native = null;
@override
bool operator ==(Object other) {
return other is AudioTrack &&
other.id == id &&
other.index == index &&
other.title == title &&
other.language == language &&
other.no == no &&
other.auto == auto;
}
@override
int get hashCode => Object.hash(id, index, title, language, no, auto);
}
@immutable
class SubtitleTrack {
final String id;
final int? index;
final String? title;
final String? language;
final bool no;
final Object? native;
const SubtitleTrack({
required this.id,
this.index,
this.title,
this.language,
this.no = false,
this.native,
});
const SubtitleTrack.no()
: id = 'no',
index = null,
title = null,
language = null,
no = true,
native = null;
@override
bool operator ==(Object other) {
return other is SubtitleTrack &&
other.id == id &&
other.index == index &&
other.title == title &&
other.language == language &&
other.no == no;
}
@override
int get hashCode => Object.hash(id, index, title, language, no);
}
@@ -0,0 +1,36 @@
import '../../../shared/utils/app_logger.dart';
const _tag = 'SeekGuard';
class SeekGuard {
SeekGuard({this.tolerance = const Duration(seconds: 2)});
final Duration tolerance;
Duration? _target;
void arm(Duration target) {
_target = target > Duration.zero ? target : null;
}
void disarm() {
_target = null;
}
bool shouldSuppress(Duration reported) {
final guard = _target;
if (guard == null) return false;
if (reported < guard - tolerance) {
AppLogger.debug(
_tag,
'Suppressing reported position $reported below target $guard',
);
return true;
}
disarm();
return false;
}
}
@@ -0,0 +1,63 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TextureVideoView extends StatelessWidget {
const TextureVideoView({
super.key,
required this.textureId,
required this.textureVersion,
required this.videoSize,
required this.fit,
});
final ValueListenable<int?> textureId;
final ValueListenable<int> textureVersion;
final ValueListenable<ui.Size?> videoSize;
final ValueListenable<BoxFit> fit;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int?>(
valueListenable: textureId,
builder: (context, id, _) {
if (id == null || id < 0) {
return const SizedBox.expand();
}
return ValueListenableBuilder<int>(
valueListenable: textureVersion,
builder: (context, version, _) {
final texture = Texture(
key: ValueKey<int>(Object.hash(id, version)),
textureId: id,
);
return ValueListenableBuilder<ui.Size?>(
valueListenable: videoSize,
builder: (context, size, _) {
if (size == null || size.width <= 0 || size.height <= 0) {
return texture;
}
return ValueListenableBuilder<BoxFit>(
valueListenable: fit,
builder: (context, fitValue, _) => SizedBox.expand(
child: FittedBox(
fit: fitValue,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: size.width,
height: size.height,
child: texture,
),
),
),
);
},
);
},
);
},
);
}
}