refactor: abstract player & deduplicate

This commit is contained in:
edde746
2026-01-24 04:06:58 +01:00
parent 9777344edc
commit 54e8e0c621
12 changed files with 659 additions and 985 deletions
+2
View File
@@ -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,
+71 -441
View File
@@ -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');
}
}
+398
View File
@@ -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();
}
}
+54 -439
View File
@@ -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();
}
}
+28 -30
View File
@@ -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();
}
+2 -8
View File
@@ -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(
+11 -20
View File
@@ -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.
+14
View File
@@ -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;
}
}