refactor: abstract player & deduplicate
This commit is contained in:
@@ -8,6 +8,8 @@ import 'player_streams.dart';
|
||||
import 'platform/player_linux.dart';
|
||||
import 'platform/player_windows.dart';
|
||||
|
||||
export 'player_base.dart';
|
||||
|
||||
/// Abstract interface for the video player.
|
||||
///
|
||||
/// This interface defines all playback control methods, state access,
|
||||
|
||||
@@ -1,363 +1,75 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models.dart';
|
||||
import 'player.dart';
|
||||
import 'player_state.dart';
|
||||
import 'player_streams.dart';
|
||||
import 'player_base.dart';
|
||||
|
||||
/// Android implementation of [Player] using ExoPlayer.
|
||||
/// Provides hardware-accelerated playback with ASS subtitle support via libass-android.
|
||||
class PlayerAndroid implements Player {
|
||||
class PlayerAndroid extends PlayerBase {
|
||||
static const _methodChannel = MethodChannel('com.plezy/exo_player');
|
||||
static const _eventChannel = EventChannel('com.plezy/exo_player/events');
|
||||
|
||||
PlayerState _state = const PlayerState();
|
||||
@override
|
||||
MethodChannel get methodChannel => _methodChannel;
|
||||
|
||||
@override
|
||||
PlayerState get state => _state;
|
||||
|
||||
late final PlayerStreams _streams;
|
||||
EventChannel get eventChannel => _eventChannel;
|
||||
|
||||
@override
|
||||
PlayerStreams get streams => _streams;
|
||||
|
||||
@override
|
||||
int? get textureId => null; // Uses SurfaceView, not Flutter texture
|
||||
String get logPrefix => 'ExoPlayer';
|
||||
|
||||
@override
|
||||
String get playerType => 'exoplayer';
|
||||
|
||||
// Stream controllers
|
||||
final _playingController = StreamController<bool>.broadcast();
|
||||
final _completedController = StreamController<bool>.broadcast();
|
||||
final _bufferingController = StreamController<bool>.broadcast();
|
||||
final _positionController = StreamController<Duration>.broadcast();
|
||||
final _durationController = StreamController<Duration>.broadcast();
|
||||
final _bufferController = StreamController<Duration>.broadcast();
|
||||
final _volumeController = StreamController<double>.broadcast();
|
||||
final _rateController = StreamController<double>.broadcast();
|
||||
final _tracksController = StreamController<Tracks>.broadcast();
|
||||
final _trackController = StreamController<TrackSelection>.broadcast();
|
||||
final _logController = StreamController<PlayerLog>.broadcast();
|
||||
final _errorController = StreamController<String>.broadcast();
|
||||
final _audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
final _playbackRestartController = StreamController<void>.broadcast();
|
||||
final _backendSwitchedController = StreamController<void>.broadcast();
|
||||
// ============================================
|
||||
// Platform-Specific Event Handling
|
||||
// ============================================
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
bool _disposed = false;
|
||||
bool _initialized = false;
|
||||
|
||||
PlayerAndroid() {
|
||||
_streams = PlayerStreams(
|
||||
playing: _playingController.stream,
|
||||
completed: _completedController.stream,
|
||||
buffering: _bufferingController.stream,
|
||||
position: _positionController.stream,
|
||||
duration: _durationController.stream,
|
||||
buffer: _bufferController.stream,
|
||||
volume: _volumeController.stream,
|
||||
rate: _rateController.stream,
|
||||
tracks: _tracksController.stream,
|
||||
track: _trackController.stream,
|
||||
log: _logController.stream,
|
||||
error: _errorController.stream,
|
||||
audioDevice: _audioDeviceController.stream,
|
||||
audioDevices: _audioDevicesController.stream,
|
||||
playbackRestart: _playbackRestartController.stream,
|
||||
backendSwitched: _backendSwitchedController.stream,
|
||||
);
|
||||
|
||||
_setupEventListener();
|
||||
|
||||
// Forward logs to app logger
|
||||
_logController.stream.listen(_forwardToAppLogger);
|
||||
}
|
||||
|
||||
void _forwardToAppLogger(PlayerLog log) {
|
||||
final message = '[ExoPlayer:${log.prefix}] ${log.text}'.trimRight();
|
||||
switch (log.level) {
|
||||
case PlayerLogLevel.fatal:
|
||||
case PlayerLogLevel.error:
|
||||
appLogger.e(message);
|
||||
case PlayerLogLevel.warn:
|
||||
appLogger.w(message);
|
||||
case PlayerLogLevel.info:
|
||||
case PlayerLogLevel.verbose:
|
||||
appLogger.i(message);
|
||||
case PlayerLogLevel.debug:
|
||||
case PlayerLogLevel.trace:
|
||||
appLogger.d(message);
|
||||
case PlayerLogLevel.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _setupEventListener() {
|
||||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
_handleEvent,
|
||||
onError: (error) {
|
||||
_errorController.add(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEvent(dynamic event) {
|
||||
if (event is! Map) return;
|
||||
|
||||
final type = event['type'] as String?;
|
||||
final name = event['name'] as String?;
|
||||
|
||||
if (type == 'property' && name != null) {
|
||||
_handlePropertyChange(name, event['value']);
|
||||
} else if (type == 'event' && name != null) {
|
||||
_handlePlayerEvent(name, event['data'] as Map?);
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePropertyChange(String name, dynamic value) {
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
final playing = value == false;
|
||||
_state = _state.copyWith(playing: playing);
|
||||
_playingController.add(playing);
|
||||
break;
|
||||
|
||||
case 'eof-reached':
|
||||
final completed = value == true;
|
||||
_state = _state.copyWith(completed: completed);
|
||||
_completedController.add(completed);
|
||||
break;
|
||||
|
||||
case 'paused-for-cache':
|
||||
final buffering = value == true;
|
||||
_state = _state.copyWith(buffering: buffering);
|
||||
_bufferingController.add(buffering);
|
||||
break;
|
||||
|
||||
case 'time-pos':
|
||||
if (value is num) {
|
||||
final position = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(position: position);
|
||||
_positionController.add(position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'duration':
|
||||
if (value is num) {
|
||||
final duration = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(duration: duration);
|
||||
_durationController.add(duration);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'demuxer-cache-time':
|
||||
if (value is num) {
|
||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
_bufferController.add(buffer);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
if (value is num) {
|
||||
final volume = value.toDouble();
|
||||
_state = _state.copyWith(volume: volume);
|
||||
_volumeController.add(volume);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speed':
|
||||
if (value is num) {
|
||||
final rate = value.toDouble();
|
||||
_state = _state.copyWith(rate: rate);
|
||||
_rateController.add(rate);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'track-list':
|
||||
List? trackList;
|
||||
if (value is List) {
|
||||
trackList = value;
|
||||
} else if (value is String && value.isNotEmpty) {
|
||||
// MPV sends track-list as JSON string after fallback
|
||||
try {
|
||||
final parsed = jsonDecode(value);
|
||||
if (parsed is List) trackList = parsed;
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
if (trackList != null) {
|
||||
final tracks = _parseTrackList(trackList);
|
||||
_state = _state.copyWith(tracks: tracks);
|
||||
_tracksController.add(tracks);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'aid':
|
||||
_updateSelectedAudioTrack(value);
|
||||
break;
|
||||
|
||||
case 'sid':
|
||||
_updateSelectedSubtitleTrack(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePlayerEvent(String name, Map? data) {
|
||||
switch (name) {
|
||||
case 'end-file':
|
||||
final reason = data?['reason'] as String?;
|
||||
if (reason == 'eof') {
|
||||
_state = _state.copyWith(completed: true);
|
||||
_completedController.add(true);
|
||||
} else if (reason == 'error') {
|
||||
_errorController.add(data?['message'] as String? ?? 'Playback error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-loaded':
|
||||
_state = _state.copyWith(completed: false);
|
||||
_completedController.add(false);
|
||||
break;
|
||||
|
||||
case 'playback-restart':
|
||||
_playbackRestartController.add(null);
|
||||
break;
|
||||
|
||||
case 'backend-switched':
|
||||
// Native player switched from ExoPlayer to MPV due to unsupported format
|
||||
_backendSwitchedController.add(null);
|
||||
break;
|
||||
|
||||
case 'log-message':
|
||||
final prefix = data?['prefix'] as String? ?? '';
|
||||
final levelStr = data?['level'] as String? ?? 'info';
|
||||
final text = data?['text'] as String? ?? '';
|
||||
final level = _parseLogLevel(levelStr);
|
||||
_logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerLogLevel _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'fatal' => PlayerLogLevel.fatal,
|
||||
'error' => PlayerLogLevel.error,
|
||||
'warn' => PlayerLogLevel.warn,
|
||||
'info' => PlayerLogLevel.info,
|
||||
'v' || 'verbose' => PlayerLogLevel.verbose,
|
||||
'debug' => PlayerLogLevel.debug,
|
||||
'trace' => PlayerLogLevel.trace,
|
||||
_ => PlayerLogLevel.info,
|
||||
};
|
||||
}
|
||||
|
||||
Tracks _parseTrackList(List trackList) {
|
||||
final audioTracks = <AudioTrack>[];
|
||||
final subtitleTracks = <SubtitleTrack>[];
|
||||
|
||||
for (final track in trackList) {
|
||||
if (track is! Map) continue;
|
||||
|
||||
final type = track['type'] as String?;
|
||||
final id = track['id']?.toString() ?? '';
|
||||
|
||||
if (type == 'audio') {
|
||||
audioTracks.add(
|
||||
AudioTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
||||
isDefault: track['default'] as bool? ?? false,
|
||||
),
|
||||
);
|
||||
} else if (type == 'sub') {
|
||||
subtitleTracks.add(
|
||||
SubtitleTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
isExternal: track['external'] as bool? ?? false,
|
||||
uri: track['external-filename'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
@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;
|
||||
}
|
||||
|
||||
return Tracks(audio: audioTracks, subtitle: subtitleTracks);
|
||||
// Delegate to base class for common events
|
||||
super.handlePlayerEvent(name, data);
|
||||
}
|
||||
|
||||
void _updateSelectedAudioTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
AudioTrack? selectedTrack;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = _state.tracks.audio.cast<AudioTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
|
||||
void _updateSelectedSubtitleTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
SubtitleTrack? selectedTrack;
|
||||
|
||||
if (id == null || id == 'no') {
|
||||
selectedTrack = SubtitleTrack.off;
|
||||
} else {
|
||||
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
if (initialized) return;
|
||||
|
||||
try {
|
||||
final result = await _methodChannel.invokeMethod<bool>('initialize');
|
||||
_initialized = result == true;
|
||||
if (!_initialized) {
|
||||
final result = await methodChannel.invokeMethod<bool>('initialize');
|
||||
initialized = result == true;
|
||||
if (!initialized) {
|
||||
throw Exception('Failed to initialize ExoPlayer');
|
||||
}
|
||||
} catch (e) {
|
||||
_errorController.add('Initialization failed: $e');
|
||||
errorController.add('Initialization failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _checkDisposed() {
|
||||
if (_disposed) {
|
||||
throw StateError('Player has been disposed');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback Control
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> open(Media media, {bool play = true}) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await _ensureInitialized();
|
||||
|
||||
// Show the video layer
|
||||
await setVisible(true);
|
||||
|
||||
await _methodChannel.invokeMethod('open', {
|
||||
await methodChannel.invokeMethod('open', {
|
||||
'uri': media.uri,
|
||||
'headers': media.headers,
|
||||
'startPositionMs': media.start?.inMilliseconds ?? 0,
|
||||
@@ -367,37 +79,27 @@ class PlayerAndroid implements Player {
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('play');
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('play');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('pause');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playOrPause() async {
|
||||
_checkDisposed();
|
||||
if (_state.playing) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('pause');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('stop');
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('stop');
|
||||
await setVisible(false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('seek', {'positionMs': position.inMilliseconds});
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('seek', {'positionMs': position.inMilliseconds});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -406,20 +108,20 @@ class PlayerAndroid implements Player {
|
||||
|
||||
@override
|
||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id});
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id});
|
||||
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', {
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('addSubtitleTrack', {
|
||||
'uri': uri,
|
||||
'title': title,
|
||||
'language': language,
|
||||
@@ -433,20 +135,14 @@ class PlayerAndroid implements Player {
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('setVolume', {'volume': volume});
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('setVolume', {'volume': volume});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRate(double rate) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('setRate', {'rate': rate});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAudioDevice(AudioDevice device) async {
|
||||
// ExoPlayer doesn't support audio device selection on Android
|
||||
// This is a no-op
|
||||
checkDisposed();
|
||||
await methodChannel.invokeMethod('setRate', {'rate': rate});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -455,7 +151,7 @@ class PlayerAndroid implements Player {
|
||||
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
// ExoPlayer doesn't use MPV properties, but we handle common ones
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
@@ -472,27 +168,24 @@ class PlayerAndroid implements Player {
|
||||
await setRate(double.tryParse(value) ?? 1.0);
|
||||
break;
|
||||
// Other properties are no-ops for ExoPlayer
|
||||
default:
|
||||
// No-op for MPV-specific properties
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getProperty(String name) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
// Return state-based values for common properties
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
return _state.playing ? 'no' : 'yes';
|
||||
return state.playing ? 'no' : 'yes';
|
||||
case 'volume':
|
||||
return _state.volume.toString();
|
||||
return state.volume.toString();
|
||||
case 'speed':
|
||||
return _state.rate.toString();
|
||||
return state.rate.toString();
|
||||
case 'time-pos':
|
||||
return (_state.position.inMilliseconds / 1000.0).toString();
|
||||
return (state.position.inMilliseconds / 1000.0).toString();
|
||||
case 'duration':
|
||||
return (_state.duration.inMilliseconds / 1000.0).toString();
|
||||
return (state.duration.inMilliseconds / 1000.0).toString();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -501,9 +194,9 @@ class PlayerAndroid implements Player {
|
||||
/// 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();
|
||||
checkDisposed();
|
||||
try {
|
||||
final result = await _methodChannel.invokeMethod<Map>('getStats');
|
||||
final result = await methodChannel.invokeMethod<Map>('getStats');
|
||||
return Map<String, dynamic>.from(result ?? {});
|
||||
} catch (e) {
|
||||
return {};
|
||||
@@ -512,9 +205,9 @@ class PlayerAndroid implements Player {
|
||||
|
||||
/// Get the current player type ('exoplayer' or 'mpv' if fallback is active).
|
||||
Future<String> getPlayerType() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
try {
|
||||
final result = await _methodChannel.invokeMethod<String>('getPlayerType');
|
||||
final result = await methodChannel.invokeMethod<String>('getPlayerType');
|
||||
return result ?? 'unknown';
|
||||
} catch (e) {
|
||||
return 'unknown';
|
||||
@@ -523,7 +216,7 @@ class PlayerAndroid implements Player {
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> args) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
// Handle MPV commands by translating to ExoPlayer equivalents
|
||||
if (args.isEmpty) return;
|
||||
|
||||
@@ -540,7 +233,7 @@ class PlayerAndroid implements Player {
|
||||
if (mode == 'absolute') {
|
||||
await seek(Duration(milliseconds: (seconds * 1000).toInt()));
|
||||
} else {
|
||||
final newPos = _state.position + Duration(milliseconds: (seconds * 1000).toInt());
|
||||
final newPos = state.position + Duration(milliseconds: (seconds * 1000).toInt());
|
||||
await seek(newPos);
|
||||
}
|
||||
}
|
||||
@@ -554,60 +247,27 @@ class PlayerAndroid implements Player {
|
||||
await addSubtitleTrack(uri: args[1], select: select);
|
||||
}
|
||||
break;
|
||||
// Other commands are no-ops
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Passthrough (Not supported by ExoPlayer)
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
// ExoPlayer doesn't support direct audio passthrough configuration
|
||||
// This is handled by the device's audio settings
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Visibility
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> setVisible(bool visible) async {
|
||||
_checkDisposed();
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod('setVisible', {'visible': visible});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorController.add('Failed to set visibility: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
// Not needed for ExoPlayer on Android
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Frame Rate Matching
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
checkDisposed();
|
||||
if (!initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
await methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearVideoFrameRate() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
checkDisposed();
|
||||
if (!initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('clearVideoFrameRate');
|
||||
await methodChannel.invokeMethod('clearVideoFrameRate');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -616,48 +276,18 @@ class PlayerAndroid implements Player {
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioFocus() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return false;
|
||||
checkDisposed();
|
||||
if (!initialized) return false;
|
||||
|
||||
final result = await _methodChannel.invokeMethod<bool>('requestAudioFocus');
|
||||
final result = await methodChannel.invokeMethod<bool>('requestAudioFocus');
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abandonAudioFocus() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
checkDisposed();
|
||||
if (!initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('abandonAudioFocus');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await _eventSubscription?.cancel();
|
||||
await _methodChannel.invokeMethod('dispose');
|
||||
|
||||
await _playingController.close();
|
||||
await _completedController.close();
|
||||
await _bufferingController.close();
|
||||
await _positionController.close();
|
||||
await _durationController.close();
|
||||
await _bufferController.close();
|
||||
await _volumeController.close();
|
||||
await _rateController.close();
|
||||
await _tracksController.close();
|
||||
await _trackController.close();
|
||||
await _logController.close();
|
||||
await _errorController.close();
|
||||
await _audioDeviceController.close();
|
||||
await _audioDevicesController.close();
|
||||
await _playbackRestartController.close();
|
||||
await _backendSwitchedController.close();
|
||||
await methodChannel.invokeMethod('abandonAudioFocus');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models.dart';
|
||||
import 'player.dart';
|
||||
import 'player_state.dart';
|
||||
import 'player_stream_controllers.dart';
|
||||
import 'player_streams.dart';
|
||||
|
||||
/// Abstract base class for player implementations.
|
||||
///
|
||||
/// This class contains shared logic for both [PlayerAndroid] (ExoPlayer)
|
||||
/// and [PlayerNative] (MPV) implementations, including:
|
||||
/// - State management
|
||||
/// - Stream controller setup
|
||||
/// - Event handling infrastructure
|
||||
/// - Property change handlers
|
||||
/// - Track parsing and selection
|
||||
/// - Common lifecycle methods
|
||||
abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
PlayerState _state = const PlayerState();
|
||||
|
||||
@override
|
||||
PlayerState get state => _state;
|
||||
|
||||
late final PlayerStreams _streams;
|
||||
|
||||
@override
|
||||
PlayerStreams get streams => _streams;
|
||||
|
||||
@override
|
||||
int? get textureId => null;
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Whether the player has been initialized.
|
||||
/// Subclasses should set this to true after initialization.
|
||||
@protected
|
||||
bool initialized = false;
|
||||
|
||||
/// Whether the player has been disposed.
|
||||
bool get disposed => _disposed;
|
||||
|
||||
/// The method channel for platform communication.
|
||||
MethodChannel get methodChannel;
|
||||
|
||||
/// The event channel for receiving platform events.
|
||||
EventChannel get eventChannel;
|
||||
|
||||
/// The log prefix for this player (e.g., 'MPV', 'ExoPlayer').
|
||||
String get logPrefix;
|
||||
|
||||
PlayerBase() {
|
||||
_streams = createStreams();
|
||||
_setupEventListener();
|
||||
logController.stream.listen(_forwardToAppLogger);
|
||||
}
|
||||
|
||||
void _forwardToAppLogger(PlayerLog log) {
|
||||
final message = '[$logPrefix:${log.prefix}] ${log.text}'.trimRight();
|
||||
switch (log.level) {
|
||||
case PlayerLogLevel.fatal:
|
||||
case PlayerLogLevel.error:
|
||||
appLogger.e(message);
|
||||
case PlayerLogLevel.warn:
|
||||
appLogger.w(message);
|
||||
case PlayerLogLevel.info:
|
||||
case PlayerLogLevel.verbose:
|
||||
appLogger.i(message);
|
||||
case PlayerLogLevel.debug:
|
||||
case PlayerLogLevel.trace:
|
||||
appLogger.d(message);
|
||||
case PlayerLogLevel.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _setupEventListener() {
|
||||
_eventSubscription = eventChannel.receiveBroadcastStream().listen(
|
||||
_handleEvent,
|
||||
onError: (error) {
|
||||
errorController.add(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEvent(dynamic event) {
|
||||
if (event is! Map) return;
|
||||
|
||||
final type = event['type'] as String?;
|
||||
final name = event['name'] as String?;
|
||||
|
||||
if (type == 'property' && name != null) {
|
||||
handlePropertyChange(name, event['value']);
|
||||
} else if (type == 'event' && name != null) {
|
||||
handlePlayerEvent(name, event['data'] as Map?);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a property change event from the platform.
|
||||
/// Subclasses can override this to handle platform-specific properties.
|
||||
void handlePropertyChange(String name, dynamic value) {
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
final playing = value == false;
|
||||
_state = _state.copyWith(playing: playing);
|
||||
playingController.add(playing);
|
||||
break;
|
||||
|
||||
case 'eof-reached':
|
||||
final completed = value == true;
|
||||
_state = _state.copyWith(completed: completed);
|
||||
completedController.add(completed);
|
||||
break;
|
||||
|
||||
case 'paused-for-cache':
|
||||
final buffering = value == true;
|
||||
_state = _state.copyWith(buffering: buffering);
|
||||
bufferingController.add(buffering);
|
||||
break;
|
||||
|
||||
case 'time-pos':
|
||||
if (value is num) {
|
||||
final position = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(position: position);
|
||||
positionController.add(position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'duration':
|
||||
if (value is num) {
|
||||
final duration = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(duration: duration);
|
||||
durationController.add(duration);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'demuxer-cache-time':
|
||||
if (value is num) {
|
||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
bufferController.add(buffer);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
if (value is num) {
|
||||
final volume = value.toDouble();
|
||||
_state = _state.copyWith(volume: volume);
|
||||
volumeController.add(volume);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speed':
|
||||
if (value is num) {
|
||||
final rate = value.toDouble();
|
||||
_state = _state.copyWith(rate: rate);
|
||||
rateController.add(rate);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'track-list':
|
||||
List? trackList;
|
||||
if (value is List) {
|
||||
trackList = value;
|
||||
} else if (value is String && value.isNotEmpty) {
|
||||
try {
|
||||
final parsed = jsonDecode(value);
|
||||
if (parsed is List) trackList = parsed;
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
if (trackList != null) {
|
||||
final tracks = parseTrackList(trackList);
|
||||
_state = _state.copyWith(tracks: tracks);
|
||||
tracksController.add(tracks);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'aid':
|
||||
updateSelectedAudioTrack(value);
|
||||
break;
|
||||
|
||||
case 'sid':
|
||||
updateSelectedSubtitleTrack(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a player event from the platform.
|
||||
/// Subclasses can override this to handle platform-specific events.
|
||||
void handlePlayerEvent(String name, Map? data) {
|
||||
switch (name) {
|
||||
case 'end-file':
|
||||
final reason = data?['reason'] as String?;
|
||||
if (reason == 'eof') {
|
||||
_state = _state.copyWith(completed: true);
|
||||
completedController.add(true);
|
||||
} else if (reason == 'error') {
|
||||
errorController.add(data?['message'] as String? ?? 'Playback error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-loaded':
|
||||
_state = _state.copyWith(completed: false);
|
||||
completedController.add(false);
|
||||
break;
|
||||
|
||||
case 'playback-restart':
|
||||
playbackRestartController.add(null);
|
||||
break;
|
||||
|
||||
case 'log-message':
|
||||
final prefix = data?['prefix'] as String? ?? '';
|
||||
final levelStr = data?['level'] as String? ?? 'info';
|
||||
final text = data?['text'] as String? ?? '';
|
||||
final level = parseLogLevel(levelStr);
|
||||
logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a log level string to [PlayerLogLevel].
|
||||
PlayerLogLevel parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'fatal' => PlayerLogLevel.fatal,
|
||||
'error' => PlayerLogLevel.error,
|
||||
'warn' => PlayerLogLevel.warn,
|
||||
'info' => PlayerLogLevel.info,
|
||||
'v' || 'verbose' => PlayerLogLevel.verbose,
|
||||
'debug' => PlayerLogLevel.debug,
|
||||
'trace' => PlayerLogLevel.trace,
|
||||
_ => PlayerLogLevel.info,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse a track list from the platform into [Tracks].
|
||||
Tracks parseTrackList(List trackList) {
|
||||
final audioTracks = <AudioTrack>[];
|
||||
final subtitleTracks = <SubtitleTrack>[];
|
||||
|
||||
for (final track in trackList) {
|
||||
if (track is! Map) continue;
|
||||
|
||||
final type = track['type'] as String?;
|
||||
final id = track['id']?.toString() ?? '';
|
||||
|
||||
if (type == 'audio') {
|
||||
audioTracks.add(
|
||||
AudioTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
||||
isDefault: track['default'] as bool? ?? false,
|
||||
),
|
||||
);
|
||||
} else if (type == 'sub') {
|
||||
subtitleTracks.add(
|
||||
SubtitleTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
isExternal: track['external'] as bool? ?? false,
|
||||
uri: track['external-filename'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Tracks(audio: audioTracks, subtitle: subtitleTracks);
|
||||
}
|
||||
|
||||
/// Update the selected audio track.
|
||||
void updateSelectedAudioTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
AudioTrack? selectedTrack;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = _state.tracks.audio.cast<AudioTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
|
||||
trackController.add(_state.track);
|
||||
}
|
||||
|
||||
/// Update the selected subtitle track.
|
||||
void updateSelectedSubtitleTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
SubtitleTrack? selectedTrack;
|
||||
|
||||
if (id == null || id == 'no') {
|
||||
selectedTrack = SubtitleTrack.off;
|
||||
} else {
|
||||
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||
trackController.add(_state.track);
|
||||
}
|
||||
|
||||
/// Update the internal state.
|
||||
void updateState(PlayerState Function(PlayerState) update) {
|
||||
_state = update(_state);
|
||||
}
|
||||
|
||||
/// Throws if the player has been disposed.
|
||||
void checkDisposed() {
|
||||
if (_disposed) {
|
||||
throw StateError('Player has been disposed');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Default Implementations
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> playOrPause() async {
|
||||
checkDisposed();
|
||||
if (_state.playing) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> setVisible(bool visible) async {
|
||||
checkDisposed();
|
||||
try {
|
||||
await methodChannel.invokeMethod('setVisible', {'visible': visible});
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorController.add('Failed to set visibility: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
// Default no-op, overridden by platforms that need it
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearVideoFrameRate() async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioFocus() async {
|
||||
// Default returns true, overridden by Android
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abandonAudioFocus() async {
|
||||
// Default no-op, overridden by Android
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAudioDevice(AudioDevice device) async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
// Default no-op, overridden by platforms that support it
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await _eventSubscription?.cancel();
|
||||
await methodChannel.invokeMethod('dispose');
|
||||
await closeStreamControllers();
|
||||
}
|
||||
}
|
||||
@@ -1,358 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../font_loader.dart';
|
||||
import '../models.dart';
|
||||
import 'player.dart';
|
||||
import 'player_state.dart';
|
||||
import 'player_streams.dart';
|
||||
import 'player_base.dart';
|
||||
|
||||
/// Shared native implementation of [Player] for iOS and macOS.
|
||||
/// Uses MPVKit via platform channels with Metal rendering.
|
||||
class PlayerNative implements Player {
|
||||
/// Shared native implementation of [Player] for iOS, macOS, and Android (MPV fallback).
|
||||
/// Uses MPVKit via platform channels with Metal rendering (Apple) or native window (Android).
|
||||
class PlayerNative extends PlayerBase {
|
||||
static const _methodChannel = MethodChannel('com.plezy/mpv_player');
|
||||
static const _eventChannel = EventChannel('com.plezy/mpv_player/events');
|
||||
|
||||
PlayerState _state = const PlayerState();
|
||||
@override
|
||||
MethodChannel get methodChannel => _methodChannel;
|
||||
|
||||
@override
|
||||
PlayerState get state => _state;
|
||||
|
||||
late final PlayerStreams _streams;
|
||||
EventChannel get eventChannel => _eventChannel;
|
||||
|
||||
@override
|
||||
PlayerStreams get streams => _streams;
|
||||
|
||||
@override
|
||||
int? get textureId => null; // Uses direct Metal layer, not Flutter texture
|
||||
String get logPrefix => 'MPV';
|
||||
|
||||
@override
|
||||
String get playerType => 'mpv';
|
||||
|
||||
// Stream controllers
|
||||
final _playingController = StreamController<bool>.broadcast();
|
||||
final _completedController = StreamController<bool>.broadcast();
|
||||
final _bufferingController = StreamController<bool>.broadcast();
|
||||
final _positionController = StreamController<Duration>.broadcast();
|
||||
final _durationController = StreamController<Duration>.broadcast();
|
||||
final _bufferController = StreamController<Duration>.broadcast();
|
||||
final _volumeController = StreamController<double>.broadcast();
|
||||
final _rateController = StreamController<double>.broadcast();
|
||||
final _tracksController = StreamController<Tracks>.broadcast();
|
||||
final _trackController = StreamController<TrackSelection>.broadcast();
|
||||
final _logController = StreamController<PlayerLog>.broadcast();
|
||||
final _errorController = StreamController<String>.broadcast();
|
||||
final _audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
final _playbackRestartController = StreamController<void>.broadcast();
|
||||
// MPV handles all formats, so this stream never emits (only ExoPlayer needs fallback)
|
||||
final _backendSwitchedController = StreamController<void>.broadcast();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
bool _disposed = false;
|
||||
bool _initialized = false;
|
||||
|
||||
PlayerNative() {
|
||||
_streams = PlayerStreams(
|
||||
playing: _playingController.stream,
|
||||
completed: _completedController.stream,
|
||||
buffering: _bufferingController.stream,
|
||||
position: _positionController.stream,
|
||||
duration: _durationController.stream,
|
||||
buffer: _bufferController.stream,
|
||||
volume: _volumeController.stream,
|
||||
rate: _rateController.stream,
|
||||
tracks: _tracksController.stream,
|
||||
track: _trackController.stream,
|
||||
log: _logController.stream,
|
||||
error: _errorController.stream,
|
||||
audioDevice: _audioDeviceController.stream,
|
||||
audioDevices: _audioDevicesController.stream,
|
||||
playbackRestart: _playbackRestartController.stream,
|
||||
backendSwitched: _backendSwitchedController.stream,
|
||||
);
|
||||
|
||||
_setupEventListener();
|
||||
|
||||
// Forward MPV logs to app logger
|
||||
_logController.stream.listen(_forwardToAppLogger);
|
||||
}
|
||||
|
||||
void _forwardToAppLogger(PlayerLog log) {
|
||||
final message = '[MPV:${log.prefix}] ${log.text}'.trimRight();
|
||||
switch (log.level) {
|
||||
case PlayerLogLevel.fatal:
|
||||
case PlayerLogLevel.error:
|
||||
appLogger.e(message);
|
||||
case PlayerLogLevel.warn:
|
||||
appLogger.w(message);
|
||||
case PlayerLogLevel.info:
|
||||
case PlayerLogLevel.verbose:
|
||||
appLogger.i(message);
|
||||
case PlayerLogLevel.debug:
|
||||
case PlayerLogLevel.trace:
|
||||
appLogger.d(message);
|
||||
case PlayerLogLevel.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerLogLevel _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'fatal' => PlayerLogLevel.fatal,
|
||||
'error' => PlayerLogLevel.error,
|
||||
'warn' => PlayerLogLevel.warn,
|
||||
'info' => PlayerLogLevel.info,
|
||||
'v' || 'verbose' => PlayerLogLevel.verbose,
|
||||
'debug' => PlayerLogLevel.debug,
|
||||
'trace' => PlayerLogLevel.trace,
|
||||
_ => PlayerLogLevel.info,
|
||||
};
|
||||
}
|
||||
|
||||
void _setupEventListener() {
|
||||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
_handleEvent,
|
||||
onError: (error) {
|
||||
_errorController.add(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEvent(dynamic event) {
|
||||
if (event is! Map) return;
|
||||
|
||||
final type = event['type'] as String?;
|
||||
final name = event['name'] as String?;
|
||||
|
||||
if (type == 'property' && name != null) {
|
||||
_handlePropertyChange(name, event['value']);
|
||||
} else if (type == 'event' && name != null) {
|
||||
_handleMpvEvent(name, event['data'] as Map?);
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePropertyChange(String name, dynamic value) {
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
final playing = value == false;
|
||||
_state = _state.copyWith(playing: playing);
|
||||
_playingController.add(playing);
|
||||
break;
|
||||
|
||||
case 'eof-reached':
|
||||
final completed = value == true;
|
||||
_state = _state.copyWith(completed: completed);
|
||||
_completedController.add(completed);
|
||||
break;
|
||||
|
||||
case 'paused-for-cache':
|
||||
final buffering = value == true;
|
||||
_state = _state.copyWith(buffering: buffering);
|
||||
_bufferingController.add(buffering);
|
||||
break;
|
||||
|
||||
case 'time-pos':
|
||||
if (value is num) {
|
||||
final position = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(position: position);
|
||||
_positionController.add(position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'duration':
|
||||
if (value is num) {
|
||||
final duration = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(duration: duration);
|
||||
_durationController.add(duration);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'demuxer-cache-time':
|
||||
if (value is num) {
|
||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
_bufferController.add(buffer);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
if (value is num) {
|
||||
final volume = value.toDouble();
|
||||
_state = _state.copyWith(volume: volume);
|
||||
_volumeController.add(volume);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speed':
|
||||
if (value is num) {
|
||||
final rate = value.toDouble();
|
||||
_state = _state.copyWith(rate: rate);
|
||||
_rateController.add(rate);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'track-list':
|
||||
List? trackList;
|
||||
if (value is List) {
|
||||
trackList = value;
|
||||
} else if (value is String) {
|
||||
// Android - JSON string that needs parsing
|
||||
try {
|
||||
final decoded = jsonDecode(value);
|
||||
if (decoded is List) {
|
||||
trackList = decoded;
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid JSON, ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackList != null) {
|
||||
final tracks = _parseTrackList(trackList);
|
||||
_state = _state.copyWith(tracks: tracks);
|
||||
_tracksController.add(tracks);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'aid':
|
||||
_updateSelectedAudioTrack(value);
|
||||
break;
|
||||
|
||||
case 'sid':
|
||||
_updateSelectedSubtitleTrack(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMpvEvent(String name, Map? data) {
|
||||
switch (name) {
|
||||
case 'end-file':
|
||||
final reason = data?['reason'] as String?;
|
||||
if (reason == 'eof') {
|
||||
_state = _state.copyWith(completed: true);
|
||||
_completedController.add(true);
|
||||
} else if (reason == 'error') {
|
||||
_errorController.add('Playback error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-loaded':
|
||||
// Reset completed state when new file is loaded
|
||||
_state = _state.copyWith(completed: false);
|
||||
_completedController.add(false);
|
||||
break;
|
||||
|
||||
case 'playback-restart':
|
||||
// Playback started/restarted - first frame is ready
|
||||
_playbackRestartController.add(null);
|
||||
break;
|
||||
|
||||
case 'log-message':
|
||||
final prefix = data?['prefix'] as String? ?? '';
|
||||
final levelStr = data?['level'] as String? ?? 'info';
|
||||
final text = data?['text'] as String? ?? '';
|
||||
final level = _parseLogLevel(levelStr);
|
||||
_logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Tracks _parseTrackList(List trackList) {
|
||||
final audioTracks = <AudioTrack>[];
|
||||
final subtitleTracks = <SubtitleTrack>[];
|
||||
|
||||
for (final track in trackList) {
|
||||
if (track is! Map) continue;
|
||||
|
||||
final type = track['type'] as String?;
|
||||
final id = track['id']?.toString() ?? '';
|
||||
|
||||
if (type == 'audio') {
|
||||
audioTracks.add(
|
||||
AudioTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
||||
isDefault: track['default'] as bool? ?? false,
|
||||
),
|
||||
);
|
||||
} else if (type == 'sub') {
|
||||
subtitleTracks.add(
|
||||
SubtitleTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
isExternal: track['external'] as bool? ?? false,
|
||||
uri: track['external-filename'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Tracks(audio: audioTracks, subtitle: subtitleTracks);
|
||||
}
|
||||
|
||||
void _updateSelectedAudioTrack(dynamic trackId) {
|
||||
_updateSelectedTrack<AudioTrack>(
|
||||
trackId,
|
||||
_state.tracks.audio.cast<AudioTrack?>().toList(),
|
||||
(selection, track) => selection.copyWith(audio: track),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSelectedSubtitleTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
SubtitleTrack? selectedTrack;
|
||||
|
||||
if (id == null || id == 'no') {
|
||||
// Explicitly set SubtitleTrack.off so episode navigation can detect "subtitles off"
|
||||
selectedTrack = SubtitleTrack.off;
|
||||
} else {
|
||||
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
|
||||
void _updateSelectedTrack<T>(
|
||||
dynamic trackId,
|
||||
List<T?> tracks,
|
||||
TrackSelection Function(TrackSelection, T?) selectionSetter,
|
||||
) {
|
||||
final id = trackId?.toString();
|
||||
T? selectedTrack;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = tracks.firstWhere((track) => _getTrackId(track) == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: selectionSetter(_state.track, selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
|
||||
String? _getTrackId<T>(T? track) {
|
||||
final dynamic t = track;
|
||||
return t?.id?.toString();
|
||||
}
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
if (initialized) return;
|
||||
|
||||
try {
|
||||
final result = await _methodChannel.invokeMethod<bool>('initialize');
|
||||
_initialized = result == true;
|
||||
if (!_initialized) {
|
||||
final result = await methodChannel.invokeMethod<bool>('initialize');
|
||||
initialized = result == true;
|
||||
if (!initialized) {
|
||||
throw Exception('Failed to initialize player');
|
||||
}
|
||||
|
||||
@@ -371,13 +53,13 @@ class PlayerNative implements Player {
|
||||
await _observeProperty('aid', 'string');
|
||||
await _observeProperty('sid', 'string');
|
||||
} catch (e) {
|
||||
_errorController.add('Initialization failed: $e');
|
||||
errorController.add('Initialization failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _observeProperty(String name, String format) async {
|
||||
await _methodChannel.invokeMethod('observeProperty', {'name': name, 'format': format});
|
||||
await methodChannel.invokeMethod('observeProperty', {'name': name, 'format': format});
|
||||
}
|
||||
|
||||
/// Configures subtitle fonts for libass support.
|
||||
@@ -394,13 +76,7 @@ class PlayerNative implements Player {
|
||||
}
|
||||
} catch (e) {
|
||||
// Font configuration is not critical - continue without it
|
||||
_errorController.add('Failed to configure subtitle fonts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _checkDisposed() {
|
||||
if (_disposed) {
|
||||
throw StateError('Player has been disposed');
|
||||
errorController.add('Failed to configure subtitle fonts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,10 +86,10 @@ class PlayerNative implements Player {
|
||||
|
||||
@override
|
||||
Future<void> open(Media media, {bool play = true}) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await _ensureInitialized();
|
||||
|
||||
// Show the video layer (use error-handled method)
|
||||
// Show the video layer
|
||||
await setVisible(true);
|
||||
|
||||
// Set HTTP headers for Plex authentication and profile
|
||||
@@ -442,36 +118,26 @@ class PlayerNative implements Player {
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('pause', 'no');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('pause', 'yes');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playOrPause() async {
|
||||
_checkDisposed();
|
||||
if (_state.playing) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await command(['stop']);
|
||||
await _methodChannel.invokeMethod('setVisible', {'visible': false});
|
||||
await methodChannel.invokeMethod('setVisible', {'visible': false});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']);
|
||||
}
|
||||
|
||||
@@ -481,19 +147,19 @@ class PlayerNative implements Player {
|
||||
|
||||
@override
|
||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('aid', track.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('sid', track.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
final args = ['sub-add', uri, select ? 'select' : 'auto'];
|
||||
if (title != null) args.add('title=$title');
|
||||
if (language != null) args.add('lang=$language');
|
||||
@@ -506,19 +172,19 @@ class PlayerNative implements Player {
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('volume', volume.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRate(double rate) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('speed', rate.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAudioDevice(AudioDevice device) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await setProperty('audio-device', device.name);
|
||||
}
|
||||
|
||||
@@ -528,23 +194,23 @@ class PlayerNative implements Player {
|
||||
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await _ensureInitialized();
|
||||
await _methodChannel.invokeMethod('setProperty', {'name': name, 'value': value});
|
||||
await methodChannel.invokeMethod('setProperty', {'name': name, 'value': value});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getProperty(String name) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await _ensureInitialized();
|
||||
return await _methodChannel.invokeMethod<String>('getProperty', {'name': name});
|
||||
return await methodChannel.invokeMethod<String>('getProperty', {'name': name});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> args) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
await _ensureInitialized();
|
||||
await _methodChannel.invokeMethod('command', {'args': args});
|
||||
await methodChannel.invokeMethod('command', {'args': args});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -553,7 +219,7 @@ class PlayerNative implements Player {
|
||||
|
||||
@override
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
if (enabled) {
|
||||
await setProperty('audio-spdif', 'ac3,eac3,dts,dts-hd,truehd');
|
||||
await setProperty('audio-exclusive', 'yes');
|
||||
@@ -564,104 +230,53 @@ class PlayerNative implements Player {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Visibility
|
||||
// Platform-Specific Overrides
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> setVisible(bool visible) async {
|
||||
_checkDisposed();
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod('setVisible', {'visible': visible});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorController.add('Failed to set visibility: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
checkDisposed();
|
||||
if (!initialized) return;
|
||||
// Only iOS and macOS use Metal layer that needs frame updates
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
await _methodChannel.invokeMethod('updateFrame');
|
||||
await methodChannel.invokeMethod('updateFrame');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Frame Rate Matching (Android)
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_initialized) return;
|
||||
if (!initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
await methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearVideoFrameRate() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_initialized) return;
|
||||
if (!initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('clearVideoFrameRate');
|
||||
await methodChannel.invokeMethod('clearVideoFrameRate');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Audio Focus (Android)
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioFocus() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
if (!Platform.isAndroid) return true;
|
||||
if (!_initialized) return false;
|
||||
if (!initialized) return false;
|
||||
|
||||
final result = await _methodChannel.invokeMethod<bool>('requestAudioFocus');
|
||||
final result = await methodChannel.invokeMethod<bool>('requestAudioFocus');
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abandonAudioFocus() async {
|
||||
_checkDisposed();
|
||||
checkDisposed();
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_initialized) return;
|
||||
if (!initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('abandonAudioFocus');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await _eventSubscription?.cancel();
|
||||
await _methodChannel.invokeMethod('dispose');
|
||||
|
||||
await _playingController.close();
|
||||
await _completedController.close();
|
||||
await _bufferingController.close();
|
||||
await _positionController.close();
|
||||
await _durationController.close();
|
||||
await _bufferController.close();
|
||||
await _volumeController.close();
|
||||
await _rateController.close();
|
||||
await _tracksController.close();
|
||||
await _trackController.close();
|
||||
await _logController.close();
|
||||
await _errorController.close();
|
||||
await _audioDeviceController.close();
|
||||
await _audioDevicesController.close();
|
||||
await _playbackRestartController.close();
|
||||
await _backendSwitchedController.close();
|
||||
await methodChannel.invokeMethod('abandonAudioFocus');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../models.dart';
|
||||
import 'player_streams.dart';
|
||||
|
||||
/// Mixin providing stream controllers for player state changes.
|
||||
///
|
||||
/// This mixin contains the 16 stream controllers used by both
|
||||
/// [PlayerAndroid] and [PlayerNative] implementations.
|
||||
mixin PlayerStreamControllersMixin {
|
||||
// Stream controllers
|
||||
final playingController = StreamController<bool>.broadcast();
|
||||
final completedController = StreamController<bool>.broadcast();
|
||||
final bufferingController = StreamController<bool>.broadcast();
|
||||
final positionController = StreamController<Duration>.broadcast();
|
||||
final durationController = StreamController<Duration>.broadcast();
|
||||
final bufferController = StreamController<Duration>.broadcast();
|
||||
final volumeController = StreamController<double>.broadcast();
|
||||
final rateController = StreamController<double>.broadcast();
|
||||
final tracksController = StreamController<Tracks>.broadcast();
|
||||
final trackController = StreamController<TrackSelection>.broadcast();
|
||||
final logController = StreamController<PlayerLog>.broadcast();
|
||||
final errorController = StreamController<String>.broadcast();
|
||||
final audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
final playbackRestartController = StreamController<void>.broadcast();
|
||||
final backendSwitchedController = StreamController<void>.broadcast();
|
||||
|
||||
/// Creates a [PlayerStreams] instance from the stream controllers.
|
||||
PlayerStreams createStreams() {
|
||||
return PlayerStreams(
|
||||
playing: playingController.stream,
|
||||
completed: completedController.stream,
|
||||
buffering: bufferingController.stream,
|
||||
position: positionController.stream,
|
||||
duration: durationController.stream,
|
||||
buffer: bufferController.stream,
|
||||
volume: volumeController.stream,
|
||||
rate: rateController.stream,
|
||||
tracks: tracksController.stream,
|
||||
track: trackController.stream,
|
||||
log: logController.stream,
|
||||
error: errorController.stream,
|
||||
audioDevice: audioDeviceController.stream,
|
||||
audioDevices: audioDevicesController.stream,
|
||||
playbackRestart: playbackRestartController.stream,
|
||||
backendSwitched: backendSwitchedController.stream,
|
||||
);
|
||||
}
|
||||
|
||||
/// Closes all stream controllers.
|
||||
Future<void> closeStreamControllers() async {
|
||||
await playingController.close();
|
||||
await completedController.close();
|
||||
await bufferingController.close();
|
||||
await positionController.close();
|
||||
await durationController.close();
|
||||
await bufferController.close();
|
||||
await volumeController.close();
|
||||
await rateController.close();
|
||||
await tracksController.close();
|
||||
await trackController.close();
|
||||
await logController.close();
|
||||
await errorController.close();
|
||||
await audioDeviceController.close();
|
||||
await audioDevicesController.close();
|
||||
await playbackRestartController.close();
|
||||
await backendSwitchedController.close();
|
||||
}
|
||||
}
|
||||
@@ -167,31 +167,43 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
return episodes.first;
|
||||
}
|
||||
|
||||
/// Mark an item as watched while offline.
|
||||
///
|
||||
/// This queues the action for sync when online and emits a [WatchStateEvent].
|
||||
Future<void> markAsWatched({required String serverId, required String ratingKey}) async {
|
||||
await _syncService.queueMarkWatched(serverId: serverId, ratingKey: ratingKey);
|
||||
|
||||
// Emit event for immediate UI update
|
||||
/// Emit a watch state change event for immediate UI update.
|
||||
void _emitWatchStateChange({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required bool isNowWatched,
|
||||
required WatchStateChangeType changeType,
|
||||
}) {
|
||||
final globalKey = '$serverId:$ratingKey';
|
||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||
if (metadata != null) {
|
||||
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true);
|
||||
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: isNowWatched);
|
||||
} else {
|
||||
// Fallback: emit minimal event without parent chain
|
||||
WatchStateNotifier().notify(
|
||||
WatchStateEvent(
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
changeType: WatchStateChangeType.watched,
|
||||
changeType: changeType,
|
||||
parentChain: [],
|
||||
mediaType: 'unknown',
|
||||
isNowWatched: true,
|
||||
isNowWatched: isNowWatched,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark an item as watched while offline.
|
||||
///
|
||||
/// This queues the action for sync when online and emits a [WatchStateEvent].
|
||||
Future<void> markAsWatched({required String serverId, required String ratingKey}) async {
|
||||
await _syncService.queueMarkWatched(serverId: serverId, ratingKey: ratingKey);
|
||||
_emitWatchStateChange(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
isNowWatched: true,
|
||||
changeType: WatchStateChangeType.watched,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -200,26 +212,12 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
/// This queues the action for sync when online and emits a [WatchStateEvent].
|
||||
Future<void> markAsUnwatched({required String serverId, required String ratingKey}) async {
|
||||
await _syncService.queueMarkUnwatched(serverId: serverId, ratingKey: ratingKey);
|
||||
|
||||
// Emit event for immediate UI update
|
||||
final globalKey = '$serverId:$ratingKey';
|
||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||
if (metadata != null) {
|
||||
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false);
|
||||
} else {
|
||||
// Fallback: emit minimal event without parent chain
|
||||
WatchStateNotifier().notify(
|
||||
WatchStateEvent(
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
changeType: WatchStateChangeType.unwatched,
|
||||
parentChain: [],
|
||||
mediaType: 'unknown',
|
||||
isNowWatched: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_emitWatchStateChange(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
isNowWatched: false,
|
||||
changeType: WatchStateChangeType.unwatched,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -130,12 +130,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
}
|
||||
}
|
||||
|
||||
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16;
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
@@ -163,7 +157,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
Widget _buildFocusableGrid() {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final columnCount = _getGridColumnCount(context, settingsProvider);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(context, settingsProvider.libraryDensity);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
sliver: SliverGrid.builder(
|
||||
@@ -171,7 +165,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final inFirstRow = isFirstRow(index, columnCount);
|
||||
final inFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Configuration for app bar buttons
|
||||
class AppBarButtonConfig {
|
||||
@@ -238,11 +236,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of a grid with given column count
|
||||
bool isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
|
||||
/// Track focus on a grid item. Call from onFocusChange of grid items.
|
||||
void trackGridItemFocus(int index, bool hasFocus) {
|
||||
if (hasFocus) {
|
||||
|
||||
@@ -506,18 +506,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
_groupingChipFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Calculate the number of columns in the current grid based on screen width
|
||||
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16; // Subtract padding
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of the grid
|
||||
bool _isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
@@ -544,16 +532,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
return Stack(
|
||||
children: [
|
||||
// Grid fills the entire area, with top padding for chips bar
|
||||
Positioned.fill(
|
||||
child: _buildScrollableContent(),
|
||||
),
|
||||
Positioned.fill(child: _buildScrollableContent()),
|
||||
// Chips bar on top with solid background
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildChipsBar(),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: _buildChipsBar()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -602,8 +583,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
onNavigateRight: _isFiltersChipVisible
|
||||
? () => _filtersChipFocusNode.requestFocus()
|
||||
: _isSortChipVisible
|
||||
? () => _sortChipFocusNode.requestFocus()
|
||||
: null,
|
||||
? () => _sortChipFocusNode.requestFocus()
|
||||
: null,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -697,7 +678,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
);
|
||||
} else {
|
||||
// In grid view, calculate columns and pass to item builder
|
||||
final columnCount = _getGridColumnCount(context, settingsProvider);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(context, settingsProvider.libraryDensity);
|
||||
// Use 16:9 aspect ratio when browsing episodes with episode thumbnail mode
|
||||
final useWideRatio =
|
||||
_selectedGrouping == 'episodes' && settingsProvider.episodePosterMode == EpisodePosterMode.episodeThumbnail;
|
||||
@@ -710,7 +691,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
useWideAspectRatio: useWideRatio,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => _buildMediaCardItem(index, isFirstRow: _isFirstRow(index, columnCount)),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildMediaCardItem(index, isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../../models/plex_playlist.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../widgets/media_grid_sliver.dart';
|
||||
import '../../widgets/focusable_media_card.dart';
|
||||
import '../../widgets/media_grid_delegate.dart';
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
@@ -623,18 +622,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the number of columns in the current grid
|
||||
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16;
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of the grid
|
||||
bool _isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
|
||||
/// Build focusable app bar actions
|
||||
List<Widget> _buildFocusableAppBarActions() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -763,7 +750,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
Widget _buildSmartPlaylistGrid(bool isKeyboardMode) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final columnCount = _getGridColumnCount(context, settingsProvider);
|
||||
final columnCount = GridSizeCalculator.getColumnCount(context, settingsProvider.libraryDensity);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
sliver: SliverGrid.builder(
|
||||
@@ -771,7 +758,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isFirstRow = _isFirstRow(index, columnCount);
|
||||
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
||||
final focusNode = index == 0 ? _firstItemFocusNode : _getGridItemFocusNode(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
|
||||
@@ -146,8 +146,6 @@ class DataAggregationService {
|
||||
}) async {
|
||||
appLogger.d('Fetching global hubs from ${clients.length} servers');
|
||||
|
||||
final allHubs = <PlexHub>[];
|
||||
|
||||
// Fetch global hubs from all servers in parallel
|
||||
final hubFutures = clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
@@ -196,14 +194,7 @@ class DataAggregationService {
|
||||
});
|
||||
|
||||
final results = await Future.wait(hubFutures);
|
||||
|
||||
// Flatten results
|
||||
for (final hubs in results) {
|
||||
allHubs.addAll(hubs);
|
||||
}
|
||||
|
||||
// Apply limit if specified (applied after merging from all servers)
|
||||
final result = limit != null && limit < allHubs.length ? allHubs.sublist(0, limit) : allHubs;
|
||||
final result = _collectAndLimitResults(results, limit);
|
||||
|
||||
appLogger.i('Fetched ${result.length} global hubs from all servers');
|
||||
|
||||
@@ -222,8 +213,6 @@ class DataAggregationService {
|
||||
|
||||
appLogger.d('Fetching per-library hubs from ${clients.length} servers');
|
||||
|
||||
final allHubs = <PlexHub>[];
|
||||
|
||||
// Fetch from all servers in parallel using cached libraries
|
||||
final hubFutures = clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
@@ -282,14 +271,7 @@ class DataAggregationService {
|
||||
});
|
||||
|
||||
final results = await Future.wait(hubFutures);
|
||||
|
||||
// Flatten results
|
||||
for (final hubs in results) {
|
||||
allHubs.addAll(hubs);
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allHubs.length ? allHubs.sublist(0, limit) : allHubs;
|
||||
final result = _collectAndLimitResults(results, limit);
|
||||
|
||||
appLogger.i('Fetched ${result.length} library hubs from all servers');
|
||||
|
||||
@@ -353,6 +335,15 @@ class DataAggregationService {
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/// Collect results from multiple lists and optionally limit the total count.
|
||||
List<T> _collectAndLimitResults<T>(List<List<T>> results, int? limit) {
|
||||
final all = <T>[];
|
||||
for (final items in results) {
|
||||
all.addAll(items);
|
||||
}
|
||||
return limit != null && limit < all.length ? all.sublist(0, limit) : all;
|
||||
}
|
||||
|
||||
/// Base helper for per-server fan-out operations
|
||||
///
|
||||
/// Returns raw results as (serverId, result) tuples.
|
||||
|
||||
@@ -121,4 +121,18 @@ class GridSizeCalculator {
|
||||
static bool isMobile(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width <= tabletBreakpoint;
|
||||
}
|
||||
|
||||
/// Calculates the number of columns in a grid based on screen width and density.
|
||||
///
|
||||
/// Accounts for standard horizontal padding (16px total).
|
||||
static int getColumnCount(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16;
|
||||
final maxCrossAxisExtent = getMaxCrossAxisExtent(context, density);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of a grid with given column count.
|
||||
static bool isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user