Initial commit
This commit is contained in:
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user