409 lines
12 KiB
Dart
409 lines
12 KiB
Dart
import 'package:flutter/services.dart';
|
|
|
|
import '../../models.dart';
|
|
import '../player_base.dart';
|
|
|
|
/// Android implementation of [Player] using ExoPlayer.
|
|
/// Provides hardware-accelerated playback with ASS subtitle support via libass-android.
|
|
class PlayerAndroid extends PlayerBase {
|
|
static const _methodChannel = MethodChannel('com.plezy/exo_player');
|
|
static const _eventChannel = EventChannel('com.plezy/exo_player/events');
|
|
|
|
int? _bufferSizeBytes;
|
|
bool _tunnelingEnabled = true;
|
|
|
|
/// Stored subtitle track ID when subtitles are hidden via sub-visibility.
|
|
String? _hiddenSubtitleTrackId;
|
|
|
|
@override
|
|
MethodChannel get methodChannel => _methodChannel;
|
|
|
|
@override
|
|
EventChannel get eventChannel => _eventChannel;
|
|
|
|
@override
|
|
String get logPrefix => 'ExoPlayer';
|
|
|
|
@override
|
|
String get playerType => 'exoplayer';
|
|
|
|
// ============================================
|
|
// Platform-Specific Event Handling
|
|
// ============================================
|
|
|
|
@override
|
|
void handlePlayerEvent(String name, Map? data) {
|
|
// Handle Android-specific events
|
|
if (name == 'backend-switched') {
|
|
// Native player switched from ExoPlayer to MPV due to unsupported format
|
|
backendSwitchedController.add(null);
|
|
return;
|
|
}
|
|
|
|
// Delegate to base class for common events
|
|
super.handlePlayerEvent(name, data);
|
|
}
|
|
|
|
// ============================================
|
|
// Initialization
|
|
// ============================================
|
|
|
|
Future<void> _ensureInitialized() async {
|
|
if (initialized) return;
|
|
|
|
try {
|
|
final result = await methodChannel.invokeMethod<bool>('initialize', {
|
|
'bufferSizeBytes': _bufferSizeBytes,
|
|
'tunnelingEnabled': _tunnelingEnabled,
|
|
});
|
|
initialized = result == true;
|
|
if (!initialized) {
|
|
throw Exception('Failed to initialize ExoPlayer');
|
|
}
|
|
|
|
// Register property observers so the plugin knows propId mappings
|
|
await observeProperty('time-pos', 'double');
|
|
await observeProperty('duration', 'double');
|
|
await observeProperty('pause', 'flag');
|
|
await observeProperty('paused-for-cache', 'flag');
|
|
await observeProperty('track-list', 'string');
|
|
await observeProperty('eof-reached', 'flag');
|
|
await observeProperty('volume', 'double');
|
|
await observeProperty('speed', 'double');
|
|
await observeProperty('aid', 'string');
|
|
await observeProperty('sid', 'string');
|
|
await observeProperty('demuxer-cache-time', 'double');
|
|
} catch (e) {
|
|
errorController.add('Initialization failed: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Playback Control
|
|
// ============================================
|
|
|
|
@override
|
|
Future<void> open(Media media, {bool play = true, bool isLive = false}) async {
|
|
checkDisposed();
|
|
await _ensureInitialized();
|
|
|
|
// Show the video layer
|
|
await setVisible(true);
|
|
|
|
await methodChannel.invokeMethod('open', {
|
|
'uri': media.uri,
|
|
'headers': media.headers,
|
|
'startPositionMs': media.start?.inMilliseconds ?? 0,
|
|
'autoPlay': play,
|
|
'isLive': isLive,
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('play');
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('pause');
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('stop');
|
|
await setVisible(false);
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration position) async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('seek', {'positionMs': position.inMilliseconds});
|
|
}
|
|
|
|
// ============================================
|
|
// Track Selection
|
|
// ============================================
|
|
|
|
@override
|
|
Future<void> selectAudioTrack(AudioTrack track) async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id});
|
|
}
|
|
|
|
@override
|
|
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id});
|
|
}
|
|
|
|
@override
|
|
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('addSubtitleTrack', {
|
|
'uri': uri,
|
|
'title': title,
|
|
'language': language,
|
|
'select': select,
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Volume and Rate
|
|
// ============================================
|
|
|
|
@override
|
|
Future<void> setVolume(double volume) async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('setVolume', {'volume': volume});
|
|
}
|
|
|
|
@override
|
|
Future<void> setRate(double rate) async {
|
|
checkDisposed();
|
|
await methodChannel.invokeMethod('setRate', {'rate': rate});
|
|
}
|
|
|
|
// ============================================
|
|
// MPV Properties (Compatibility Layer)
|
|
// ============================================
|
|
|
|
@override
|
|
Future<void> setProperty(String name, String value) async {
|
|
checkDisposed();
|
|
// ExoPlayer doesn't use MPV properties, but we handle common ones
|
|
switch (name) {
|
|
case 'pause':
|
|
if (value == 'yes') {
|
|
await pause();
|
|
} else {
|
|
await play();
|
|
}
|
|
break;
|
|
case 'volume':
|
|
await setVolume(double.tryParse(value) ?? 100);
|
|
break;
|
|
case 'speed':
|
|
await setRate(double.tryParse(value) ?? 1.0);
|
|
break;
|
|
case 'demuxer-max-bytes':
|
|
_bufferSizeBytes = int.tryParse(value);
|
|
break;
|
|
case 'tunneled-playback':
|
|
_tunnelingEnabled = value != 'no';
|
|
break;
|
|
case 'sub-visibility':
|
|
if (value == 'no') {
|
|
// Store current subtitle track and disable
|
|
final current = state.track.subtitle;
|
|
if (current != null && current.id != 'no') {
|
|
_hiddenSubtitleTrackId = current.id;
|
|
await selectSubtitleTrack(SubtitleTrack.off);
|
|
}
|
|
} else {
|
|
// Restore previously hidden subtitle track
|
|
final storedId = _hiddenSubtitleTrackId;
|
|
if (storedId != null) {
|
|
_hiddenSubtitleTrackId = null;
|
|
final track = state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere(
|
|
(t) => t?.id == storedId,
|
|
orElse: () => null,
|
|
);
|
|
if (track != null) {
|
|
await selectSubtitleTrack(track);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
// Other properties are no-ops for ExoPlayer
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<String?> getProperty(String name) async {
|
|
checkDisposed();
|
|
// Return state-based values for common properties
|
|
switch (name) {
|
|
case 'pause':
|
|
return state.playing ? 'no' : 'yes';
|
|
case 'volume':
|
|
return state.volume.toString();
|
|
case 'speed':
|
|
return state.rate.toString();
|
|
case 'time-pos':
|
|
return (state.position.inMilliseconds / 1000.0).toString();
|
|
case 'duration':
|
|
return (state.duration.inMilliseconds / 1000.0).toString();
|
|
// Video dimensions - query from ExoPlayer stats
|
|
case 'width':
|
|
case 'dwidth':
|
|
final stats = await getStats();
|
|
final width = stats['videoWidth'];
|
|
return width?.toString();
|
|
case 'height':
|
|
case 'dheight':
|
|
final stats = await getStats();
|
|
final height = stats['videoHeight'];
|
|
return height?.toString();
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Get all playback stats from ExoPlayer.
|
|
/// Returns a map with video/audio codec info, buffer state, and performance metrics.
|
|
Future<Map<String, dynamic>> getStats() async {
|
|
checkDisposed();
|
|
try {
|
|
final result = await methodChannel.invokeMethod<Map>('getStats');
|
|
return Map<String, dynamic>.from(result ?? {});
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Get the device's large heap size in MB (Android only).
|
|
/// Returns 0 if unavailable.
|
|
static Future<int> getHeapSize() async {
|
|
try {
|
|
final result = await _methodChannel.invokeMethod<int>('getHeapSize');
|
|
return result ?? 0;
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// Get the current player type ('exoplayer' or 'mpv' if fallback is active).
|
|
Future<String> getPlayerType() async {
|
|
checkDisposed();
|
|
try {
|
|
final result = await methodChannel.invokeMethod<String>('getPlayerType');
|
|
return result ?? 'unknown';
|
|
} catch (e) {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> command(List<String> args) async {
|
|
checkDisposed();
|
|
// Handle MPV commands by translating to ExoPlayer equivalents
|
|
if (args.isEmpty) return;
|
|
|
|
switch (args.first) {
|
|
case 'loadfile':
|
|
if (args.length > 1) {
|
|
await open(Media(args[1]));
|
|
}
|
|
break;
|
|
case 'seek':
|
|
if (args.length > 1) {
|
|
final seconds = double.tryParse(args[1]) ?? 0;
|
|
final mode = args.length > 2 ? args[2] : 'relative';
|
|
if (mode == 'absolute') {
|
|
await seek(Duration(milliseconds: (seconds * 1000).toInt()));
|
|
} else {
|
|
final newPos = state.position + Duration(milliseconds: (seconds * 1000).toInt());
|
|
await seek(newPos);
|
|
}
|
|
}
|
|
break;
|
|
case 'stop':
|
|
await stop();
|
|
break;
|
|
case 'sub-add':
|
|
if (args.length > 1) {
|
|
final select = args.length > 2 && args[2] == 'select';
|
|
await addSubtitleTrack(uri: args[1], select: select);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Subtitle Styling (ExoPlayer Native)
|
|
// ============================================
|
|
|
|
/// Apply subtitle styling to the native ExoPlayer layer.
|
|
///
|
|
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
|
|
/// For ASS subtitles, applies font scale via libass setFontScale().
|
|
Future<void> setSubtitleStyle({
|
|
required double fontSize,
|
|
required String textColor,
|
|
required double borderSize,
|
|
required String borderColor,
|
|
required String bgColor,
|
|
required int bgOpacity,
|
|
int subtitlePosition = 100,
|
|
}) async {
|
|
checkDisposed();
|
|
if (!initialized) return;
|
|
await methodChannel.invokeMethod('setSubtitleStyle', {
|
|
'fontSize': fontSize,
|
|
'textColor': textColor,
|
|
'borderSize': borderSize,
|
|
'borderColor': borderColor,
|
|
'bgColor': bgColor,
|
|
'bgOpacity': bgOpacity,
|
|
'subtitlePosition': subtitlePosition,
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Frame Rate Matching
|
|
// ============================================
|
|
|
|
@override
|
|
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
|
checkDisposed();
|
|
if (!initialized) return;
|
|
|
|
await methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
|
}
|
|
|
|
@override
|
|
Future<void> clearVideoFrameRate() async {
|
|
checkDisposed();
|
|
if (!initialized) return;
|
|
|
|
await methodChannel.invokeMethod('clearVideoFrameRate');
|
|
}
|
|
|
|
// ============================================
|
|
// Audio Focus
|
|
// ============================================
|
|
|
|
@override
|
|
Future<bool> requestAudioFocus() async {
|
|
checkDisposed();
|
|
if (!initialized) return false;
|
|
|
|
final result = await methodChannel.invokeMethod<bool>('requestAudioFocus');
|
|
return result ?? false;
|
|
}
|
|
|
|
@override
|
|
Future<void> abandonAudioFocus() async {
|
|
checkDisposed();
|
|
if (!initialized) return;
|
|
|
|
await methodChannel.invokeMethod('abandonAudioFocus');
|
|
}
|
|
|
|
// ============================================
|
|
// Log Level
|
|
// ============================================
|
|
|
|
@override
|
|
Future<void> setLogLevel(String level) async {
|
|
checkDisposed();
|
|
if (!initialized) return;
|
|
await methodChannel.invokeMethod('setLogLevel', {'level': level});
|
|
}
|
|
}
|