From 54e8e0c6217530aa8dd8803a98d506ca3f0e92cf Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 24 Jan 2026 04:06:58 +0100 Subject: [PATCH] refactor: abstract player & deduplicate --- lib/mpv/player/player.dart | 2 + lib/mpv/player/player_android.dart | 512 +++--------------- lib/mpv/player/player_base.dart | 398 ++++++++++++++ lib/mpv/player/player_native.dart | 493 ++--------------- lib/mpv/player/player_stream_controllers.dart | 70 +++ lib/providers/offline_watch_provider.dart | 58 +- lib/screens/collection_detail_screen.dart | 10 +- .../focusable_detail_screen_mixin.dart | 7 - .../libraries/tabs/library_browse_tab.dart | 32 +- .../playlist/playlist_detail_screen.dart | 17 +- lib/services/data_aggregation_service.dart | 31 +- lib/utils/grid_size_calculator.dart | 14 + 12 files changed, 659 insertions(+), 985 deletions(-) create mode 100644 lib/mpv/player/player_base.dart create mode 100644 lib/mpv/player/player_stream_controllers.dart diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 235f23da..9786a1a8 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -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, diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index c8ee6e3f..b4e9f7fd 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -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.broadcast(); - final _completedController = StreamController.broadcast(); - final _bufferingController = StreamController.broadcast(); - final _positionController = StreamController.broadcast(); - final _durationController = StreamController.broadcast(); - final _bufferController = StreamController.broadcast(); - final _volumeController = StreamController.broadcast(); - final _rateController = StreamController.broadcast(); - final _tracksController = StreamController.broadcast(); - final _trackController = StreamController.broadcast(); - final _logController = StreamController.broadcast(); - final _errorController = StreamController.broadcast(); - final _audioDeviceController = StreamController.broadcast(); - final _audioDevicesController = StreamController>.broadcast(); - final _playbackRestartController = StreamController.broadcast(); - final _backendSwitchedController = StreamController.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 = []; - final subtitleTracks = []; - - 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().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().firstWhere((t) => t?.id == id, orElse: () => null); - } - - _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); - _trackController.add(_state.track); - } + // ============================================ + // Initialization + // ============================================ Future _ensureInitialized() async { - if (_initialized) return; + if (initialized) return; try { - final result = await _methodChannel.invokeMethod('initialize'); - _initialized = result == true; - if (!_initialized) { + final result = await methodChannel.invokeMethod('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 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 play() async { - _checkDisposed(); - await _methodChannel.invokeMethod('play'); + checkDisposed(); + await methodChannel.invokeMethod('play'); } @override Future pause() async { - _checkDisposed(); - await _methodChannel.invokeMethod('pause'); - } - - @override - Future playOrPause() async { - _checkDisposed(); - if (_state.playing) { - await pause(); - } else { - await play(); - } + checkDisposed(); + await methodChannel.invokeMethod('pause'); } @override Future stop() async { - _checkDisposed(); - await _methodChannel.invokeMethod('stop'); + checkDisposed(); + await methodChannel.invokeMethod('stop'); await setVisible(false); } @override Future 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 selectAudioTrack(AudioTrack track) async { - _checkDisposed(); - await _methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id}); + checkDisposed(); + await methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id}); } @override Future selectSubtitleTrack(SubtitleTrack track) async { - _checkDisposed(); - await _methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id}); + checkDisposed(); + await methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id}); } @override Future 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 setVolume(double volume) async { - _checkDisposed(); - await _methodChannel.invokeMethod('setVolume', {'volume': volume}); + checkDisposed(); + await methodChannel.invokeMethod('setVolume', {'volume': volume}); } @override Future setRate(double rate) async { - _checkDisposed(); - await _methodChannel.invokeMethod('setRate', {'rate': rate}); - } - - @override - Future 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 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 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> getStats() async { - _checkDisposed(); + checkDisposed(); try { - final result = await _methodChannel.invokeMethod('getStats'); + final result = await methodChannel.invokeMethod('getStats'); return Map.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 getPlayerType() async { - _checkDisposed(); + checkDisposed(); try { - final result = await _methodChannel.invokeMethod('getPlayerType'); + final result = await methodChannel.invokeMethod('getPlayerType'); return result ?? 'unknown'; } catch (e) { return 'unknown'; @@ -523,7 +216,7 @@ class PlayerAndroid implements Player { @override Future command(List 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 setAudioPassthrough(bool enabled) async { - // ExoPlayer doesn't support direct audio passthrough configuration - // This is handled by the device's audio settings - } - - // ============================================ - // Visibility - // ============================================ - - @override - Future 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 updateFrame() async { - // Not needed for ExoPlayer on Android - } - // ============================================ // Frame Rate Matching // ============================================ @override Future 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 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 requestAudioFocus() async { - _checkDisposed(); - if (!_initialized) return false; + checkDisposed(); + if (!initialized) return false; - final result = await _methodChannel.invokeMethod('requestAudioFocus'); + final result = await methodChannel.invokeMethod('requestAudioFocus'); return result ?? false; } @override Future abandonAudioFocus() async { - _checkDisposed(); - if (!_initialized) return; + checkDisposed(); + if (!initialized) return; - await _methodChannel.invokeMethod('abandonAudioFocus'); - } - - // ============================================ - // Lifecycle - // ============================================ - - @override - Future 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'); } } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart new file mode 100644 index 00000000..e47098c3 --- /dev/null +++ b/lib/mpv/player/player_base.dart @@ -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 = []; + final subtitleTracks = []; + + 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().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().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 playOrPause() async { + checkDisposed(); + if (_state.playing) { + await pause(); + } else { + await play(); + } + } + + @override + Future 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 updateFrame() async { + // Default no-op, overridden by platforms that need it + } + + @override + Future setVideoFrameRate(double fps, int durationMs) async { + // Default no-op, overridden by platforms that support it + } + + @override + Future clearVideoFrameRate() async { + // Default no-op, overridden by platforms that support it + } + + @override + Future requestAudioFocus() async { + // Default returns true, overridden by Android + return true; + } + + @override + Future abandonAudioFocus() async { + // Default no-op, overridden by Android + } + + @override + Future setAudioDevice(AudioDevice device) async { + // Default no-op, overridden by platforms that support it + } + + @override + Future setAudioPassthrough(bool enabled) async { + // Default no-op, overridden by platforms that support it + } + + // ============================================ + // Lifecycle + // ============================================ + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + + await _eventSubscription?.cancel(); + await methodChannel.invokeMethod('dispose'); + await closeStreamControllers(); + } +} diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index e4993499..5260c404 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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.broadcast(); - final _completedController = StreamController.broadcast(); - final _bufferingController = StreamController.broadcast(); - final _positionController = StreamController.broadcast(); - final _durationController = StreamController.broadcast(); - final _bufferController = StreamController.broadcast(); - final _volumeController = StreamController.broadcast(); - final _rateController = StreamController.broadcast(); - final _tracksController = StreamController.broadcast(); - final _trackController = StreamController.broadcast(); - final _logController = StreamController.broadcast(); - final _errorController = StreamController.broadcast(); - final _audioDeviceController = StreamController.broadcast(); - final _audioDevicesController = StreamController>.broadcast(); - final _playbackRestartController = StreamController.broadcast(); - // MPV handles all formats, so this stream never emits (only ExoPlayer needs fallback) - final _backendSwitchedController = StreamController.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 = []; - final subtitleTracks = []; - - 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( - trackId, - _state.tracks.audio.cast().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().firstWhere((t) => t?.id == id, orElse: () => null); - } - - _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); - _trackController.add(_state.track); - } - - void _updateSelectedTrack( - dynamic trackId, - List 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? track) { - final dynamic t = track; - return t?.id?.toString(); - } + // ============================================ + // Initialization + // ============================================ Future _ensureInitialized() async { - if (_initialized) return; + if (initialized) return; try { - final result = await _methodChannel.invokeMethod('initialize'); - _initialized = result == true; - if (!_initialized) { + final result = await methodChannel.invokeMethod('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 _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 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 play() async { - _checkDisposed(); + checkDisposed(); await setProperty('pause', 'no'); } @override Future pause() async { - _checkDisposed(); + checkDisposed(); await setProperty('pause', 'yes'); } - @override - Future playOrPause() async { - _checkDisposed(); - if (_state.playing) { - await pause(); - } else { - await play(); - } - } - @override Future stop() async { - _checkDisposed(); + checkDisposed(); await command(['stop']); - await _methodChannel.invokeMethod('setVisible', {'visible': false}); + await methodChannel.invokeMethod('setVisible', {'visible': false}); } @override Future 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 selectAudioTrack(AudioTrack track) async { - _checkDisposed(); + checkDisposed(); await setProperty('aid', track.id); } @override Future selectSubtitleTrack(SubtitleTrack track) async { - _checkDisposed(); + checkDisposed(); await setProperty('sid', track.id); } @override Future 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 setVolume(double volume) async { - _checkDisposed(); + checkDisposed(); await setProperty('volume', volume.toString()); } @override Future setRate(double rate) async { - _checkDisposed(); + checkDisposed(); await setProperty('speed', rate.toString()); } @override Future setAudioDevice(AudioDevice device) async { - _checkDisposed(); + checkDisposed(); await setProperty('audio-device', device.name); } @@ -528,23 +194,23 @@ class PlayerNative implements Player { @override Future 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 getProperty(String name) async { - _checkDisposed(); + checkDisposed(); await _ensureInitialized(); - return await _methodChannel.invokeMethod('getProperty', {'name': name}); + return await methodChannel.invokeMethod('getProperty', {'name': name}); } @override Future command(List 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 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 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 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 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 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 requestAudioFocus() async { - _checkDisposed(); + checkDisposed(); if (!Platform.isAndroid) return true; - if (!_initialized) return false; + if (!initialized) return false; - final result = await _methodChannel.invokeMethod('requestAudioFocus'); + final result = await methodChannel.invokeMethod('requestAudioFocus'); return result ?? false; } @override Future abandonAudioFocus() async { - _checkDisposed(); + checkDisposed(); if (!Platform.isAndroid) return; - if (!_initialized) return; + if (!initialized) return; - await _methodChannel.invokeMethod('abandonAudioFocus'); - } - - // ============================================ - // Lifecycle - // ============================================ - - @override - Future 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'); } } diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart new file mode 100644 index 00000000..f22aeb0a --- /dev/null +++ b/lib/mpv/player/player_stream_controllers.dart @@ -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.broadcast(); + final completedController = StreamController.broadcast(); + final bufferingController = StreamController.broadcast(); + final positionController = StreamController.broadcast(); + final durationController = StreamController.broadcast(); + final bufferController = StreamController.broadcast(); + final volumeController = StreamController.broadcast(); + final rateController = StreamController.broadcast(); + final tracksController = StreamController.broadcast(); + final trackController = StreamController.broadcast(); + final logController = StreamController.broadcast(); + final errorController = StreamController.broadcast(); + final audioDeviceController = StreamController.broadcast(); + final audioDevicesController = StreamController>.broadcast(); + final playbackRestartController = StreamController.broadcast(); + final backendSwitchedController = StreamController.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 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(); + } +} diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index a755040a..0b056e74 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -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 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 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 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(); } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 84110e2f..2d966ba7 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -130,12 +130,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen( 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 on State { } } - /// 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) { diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 62dc7fa5..5b49b537 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -506,18 +506,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filtersChipFocusNode.requestFocus() : _isSortChipVisible - ? () => _sortChipFocusNode.requestFocus() - : null, + ? () => _sortChipFocusNode.requestFocus() + : null, onBack: widget.onBack, ), const SizedBox(width: 8), @@ -697,7 +678,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem(index, isFirstRow: _isFirstRow(index, columnCount)), + itemBuilder: (context, index) => + _buildMediaCardItem(index, isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount)), ), ); } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index b833b6c6..ea8c4243 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -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 _buildFocusableAppBarActions() { final colorScheme = Theme.of(context).colorScheme; @@ -763,7 +750,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen( 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[]; - // 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 = []; - // 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 _collectAndLimitResults(List> results, int? limit) { + final all = []; + 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. diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index 852fd676..40b33601 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -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; + } }