diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 20294c8f..0dfda6f0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,10 +3,10 @@ - + + - @@ -46,22 +46,18 @@ android:name="flutterEmbedding" android:value="2" /> - - + + android:exported="true" tools:ignore="Instantiatable"> - - + + diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt index 699f962e..923422cc 100644 --- a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -1,5 +1,5 @@ package com.edde746.plezy -import com.ryanheise.audioservice.AudioServiceFragmentActivity +import com.ryanheise.audioservice.AudioServiceActivity -class MainActivity : AudioServiceFragmentActivity() +class MainActivity : AudioServiceActivity() diff --git a/lib/main.dart b/lib/main.dart index ac908e2f..8aab1ae3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,6 @@ import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/update_service.dart'; import 'services/settings_service.dart'; -import 'services/media_service_manager.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; @@ -43,9 +42,6 @@ void main() async { // Initialize MediaKit MediaKit.ensureInitialized(); - // Initialize OS media controls - await MediaServiceManager.instance.initialize(); - // Note: Orientation will be set dynamically based on device type in MainApp await StorageService.getInstance(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index c480c1db..a1d3de42 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -14,7 +14,7 @@ import '../widgets/video_controls/video_controls.dart'; import '../utils/language_codes.dart'; import '../utils/app_logger.dart'; import '../services/settings_service.dart'; -import '../services/media_service_manager.dart'; +import '../services/audio_service_manager.dart'; import '../utils/orientation_helper.dart'; import '../utils/video_player_navigation.dart'; import '../utils/platform_detector.dart'; @@ -166,9 +166,6 @@ class VideoPlayerScreenState extends State { final savedVolume = settingsService.getVolume(); player!.setVolume(savedVolume); - // Update media service manager with new player - await _updateMediaService(); - // Notify that player is ready if (mounted) { setState(() { @@ -223,6 +220,9 @@ class VideoPlayerScreenState extends State { // Load next/previous episodes _loadAdjacentEpisodes(); + + // Initialize audio service for OS media controls + _initializeAudioService(); } catch (e) { appLogger.e('Failed to initialize player', error: e); if (mounted) { @@ -233,39 +233,6 @@ class VideoPlayerScreenState extends State { } } - Future _updateMediaService() async { - if (!mounted) return; - - try { - final mediaService = MediaServiceManager.instance; - - // Build thumbnail URL if available - String? thumbnailUrl; - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (widget.metadata.thumb != null && client != null) { - final baseUrl = client.config.baseUrl; - final token = client.config.token; - if (token != null) { - thumbnailUrl = '$baseUrl${widget.metadata.thumb}?X-Plex-Token=$token'; - } - } - - // CRITICAL: Set mediaItem FIRST before updating player - // Android requires mediaItem to exist before playbackState broadcasts - mediaService.updateMediaItem(widget.metadata, thumbnailUrl); - - if (!mounted) return; - - await mediaService.updatePlayer( - player: player, - onNext: _playNext, - onPrevious: _playPrevious, - ); - } catch (e) { - appLogger.w('Failed to update media service', error: e); - } - } Future _loadAdjacentEpisodes() async { if (widget.metadata.type.toLowerCase() != 'episode') { @@ -295,9 +262,7 @@ class VideoPlayerScreenState extends State { widget.metadata.ratingKey, loopQueue: loopQueue, ); - previous = playbackState.getPreviousEpisode( - widget.metadata.ratingKey, - ); + previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); } else { // Use chronological order even in shuffle mode next = await client.findAdjacentEpisode(widget.metadata, 1); @@ -315,17 +280,51 @@ class VideoPlayerScreenState extends State { _previousEpisode = previous; }); - // Update media service navigation controls - MediaServiceManager.instance.updateNavigationActions( - hasNext: _nextEpisode != null, - hasPrevious: _previousEpisode != null, - ); + // Update audio handler navigation availability + _updateAudioHandlerNavigation(); } } catch (e) { // Silently handle errors } } + Future _initializeAudioService() async { + try { + if (player == null) return; + + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final audioManager = AudioServiceManager.instance; + + // Initialize audio service (will only happen once) + await audioManager.initialize( + player: player!, + plexServerUrl: client.config.baseUrl, + authToken: client.config.token ?? '', + onSkipToNext: _nextEpisode != null ? _playNext : null, + onSkipToPrevious: _previousEpisode != null ? _playPrevious : null, + ); + + // Update media item with current content + audioManager.updateMediaItem(widget.metadata); + + appLogger.d('Audio service initialized/updated successfully'); + } catch (e) { + appLogger.e('Failed to initialize audio service', error: e); + // Continue playback even if audio service fails + } + } + + void _updateAudioHandlerNavigation() { + final audioManager = AudioServiceManager.instance; + audioManager.updateNavigation( + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null ? _playPrevious : null, + ); + } + Future _startPlayback() async { try { final clientProvider = context.plexClient; @@ -446,10 +445,6 @@ class VideoPlayerScreenState extends State { // Start playback after seeking await player!.play(); - // Force media service state update to trigger Android notification - // This ensures the notification appears even if streams don't fire reliably - MediaServiceManager.instance.forceStateUpdate(); - // Wait for tracks to be loaded, then apply preferred tracks _waitForTracksAndApply(); } else { @@ -525,8 +520,16 @@ class VideoPlayerScreenState extends State { _logSubscription?.cancel(); _errorSubscription?.cancel(); - // Stop media service and clear OS controls - MediaServiceManager.instance.stop(); + // Handle audio service based on navigation intent + if (_isReplacingWithVideo) { + // Switching to another episode: pause but keep notification visible + // The new VideoPlayerScreen will update the notification + AudioServiceManager.instance.pause(); + } else { + // Truly exiting player: clear notification but keep singleton alive + // This allows controls to reappear when playing again + AudioServiceManager.instance.clearNotification(); + } // Send final stopped state _sendProgress('stopped'); diff --git a/lib/services/audio_service_manager.dart b/lib/services/audio_service_manager.dart new file mode 100644 index 00000000..778d7688 --- /dev/null +++ b/lib/services/audio_service_manager.dart @@ -0,0 +1,186 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:media_kit/media_kit.dart'; +import '../models/plex_metadata.dart'; +import '../utils/app_logger.dart'; +import 'media_kit_audio_handler.dart'; + +/// Global singleton manager for audio service +/// Ensures AudioService.init() is only called once and the handler is reused +class AudioServiceManager { + static AudioServiceManager? _instance; + MediaKitAudioHandler? _handler; + bool _isInitialized = false; + bool _isInitializing = false; + + AudioServiceManager._(); + + static AudioServiceManager get instance { + _instance ??= AudioServiceManager._(); + return _instance!; + } + + /// Initialize the audio service with the given player and configuration + /// This should only be called once - subsequent calls will reuse the existing handler + Future initialize({ + required Player player, + required String plexServerUrl, + required String authToken, + Future Function()? onSkipToNext, + Future Function()? onSkipToPrevious, + }) async { + // If already initialized, update the player reference and callbacks + if (_isInitialized && _handler != null) { + appLogger.d('Audio service already initialized, updating player reference and callbacks'); + + // Update the player reference (critical for episode switching) + await _handler!.updatePlayer(player); + + // Update navigation callbacks + _handler!.onSkipToNext = onSkipToNext; + _handler!.onSkipToPrevious = onSkipToPrevious; + + return; + } + + // Prevent concurrent initialization + if (_isInitializing) { + appLogger.d('Audio service initialization already in progress, waiting...'); + // Wait for initialization to complete + while (_isInitializing) { + await Future.delayed(const Duration(milliseconds: 100)); + } + return; + } + + try { + _isInitializing = true; + appLogger.d('Initializing audio service for the first time'); + + final handler = await AudioService.init( + builder: () => MediaKitAudioHandler( + player: player, + plexServerUrl: plexServerUrl, + authToken: authToken, + onSkipToNext: onSkipToNext, + onSkipToPrevious: onSkipToPrevious, + ), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.edde746.plezy.audio', + androidNotificationChannelName: 'Plezy Playback', + androidNotificationOngoing: true, + androidShowNotificationBadge: true, + ), + ); + + _handler = handler; + _isInitialized = true; + appLogger.d('Audio service initialized successfully'); + } catch (e) { + appLogger.e('Failed to initialize audio service', error: e); + rethrow; + } finally { + _isInitializing = false; + } + } + + /// Update the current media item being played + void updateMediaItem(PlexMetadata metadata) { + if (_handler == null) { + appLogger.w('Cannot update media item: audio handler not initialized'); + return; + } + _handler!.updateCurrentMediaItem(metadata); + } + + /// Update navigation callbacks for next/previous episode + void updateNavigation({ + Future Function()? onNext, + Future Function()? onPrevious, + }) { + if (_handler == null) { + appLogger.w('Cannot update navigation: audio handler not initialized'); + return; + } + _handler!.updateNavigationCallbacks( + onNext: onNext, + onPrevious: onPrevious, + ); + } + + /// Pause playback but keep the media session active + Future pause() async { + if (_handler == null) { + appLogger.w('Cannot pause: audio handler not initialized'); + return; + } + await _handler!.pause(); + } + + /// Clear the notification but keep the audio service singleton alive + /// This is used when exiting the video player but not closing the app + Future clearNotification() async { + if (_handler == null) { + appLogger.w('Cannot clear notification: audio handler not initialized'); + return; + } + + appLogger.d('Clearing notification while keeping audio service alive'); + + // Broadcast idle state to remove notification + _handler!.playbackState.add(PlaybackState( + processingState: AudioProcessingState.idle, + playing: false, + controls: [], + systemActions: const {}, + updatePosition: Duration.zero, + bufferedPosition: Duration.zero, + speed: 1.0, + )); + + // Clear media item + _handler!.mediaItem.add(null); + + // IMPORTANT: DON'T reset _isInitialized or _handler + // Keep the singleton alive for next playback + + appLogger.d('Notification cleared, audio service remains initialized'); + } + + /// Stop playback completely and remove the notification + /// This should only be called when the app is closing or user explicitly stops + Future shutdown() async { + if (_handler == null) { + appLogger.w('Cannot shutdown: audio handler not initialized'); + return; + } + + appLogger.d('Shutting down audio service and removing notification'); + + // Broadcast idle state to remove notification + _handler!.playbackState.add(PlaybackState( + processingState: AudioProcessingState.idle, + playing: false, + controls: [], + systemActions: const {}, + updatePosition: Duration.zero, + bufferedPosition: Duration.zero, + speed: 1.0, + )); + + // Clear media item + _handler!.mediaItem.add(null); + + // Dispose the handler + await _handler!.dispose(); + + _handler = null; + _isInitialized = false; + appLogger.d('Audio service shutdown complete'); + } + + /// Check if the audio service is initialized + bool get isInitialized => _isInitialized; + + /// Get the current handler (for advanced use cases) + MediaKitAudioHandler? get handler => _handler; +} diff --git a/lib/services/media_kit_audio_handler.dart b/lib/services/media_kit_audio_handler.dart index e77c26f4..80b624b2 100644 --- a/lib/services/media_kit_audio_handler.dart +++ b/lib/services/media_kit_audio_handler.dart @@ -1,117 +1,195 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:audio_service/audio_service.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:rxdart/rxdart.dart'; import '../models/plex_metadata.dart'; import '../utils/app_logger.dart'; -/// AudioHandler that bridges media_kit Player with OS media controls +/// Audio handler that bridges media_kit player with OS media controls class MediaKitAudioHandler extends BaseAudioHandler with SeekHandler { - Player? _player; - VoidCallback? _onNext; - VoidCallback? _onPrevious; + Player _player; + final String plexServerUrl; + final String authToken; + // Getter for player + Player get player => _player; + + // Callback functions for episode navigation + Future Function()? onSkipToNext; + Future Function()? onSkipToPrevious; + + // Stream subscriptions StreamSubscription? _playingSubscription; StreamSubscription? _positionSubscription; - StreamSubscription? _completedSubscription; + StreamSubscription? _durationSubscription; + StreamSubscription? _bufferSubscription; + + // Queue management + final BehaviorSubject> _queueSubject = BehaviorSubject.seeded([]); MediaKitAudioHandler({ - required Player? player, - VoidCallback? onNext, - VoidCallback? onPrevious, - }) : _player = player, - _onNext = onNext, - _onPrevious = onPrevious { - if (_player != null) { - _initializeListeners(); + required Player player, + required this.plexServerUrl, + required this.authToken, + this.onSkipToNext, + this.onSkipToPrevious, + }) : _player = player { + _init(); + } + + void _init() { + // Map player state to playback state + _playingSubscription = _player.stream.playing.listen((isPlaying) { + _updatePlaybackState(); + }); + + _positionSubscription = _player.stream.position.listen((_) { + _updatePlaybackState(); + }); + + _durationSubscription = _player.stream.duration.listen((_) { + _updatePlaybackState(); + }); + + _bufferSubscription = _player.stream.buffer.listen((_) { + _updatePlaybackState(); + }); + + // Set initial state + _updatePlaybackState(); + } + + /// Update media item with current playing content + void updateCurrentMediaItem(PlexMetadata metadata) { + final artworkUrl = _getArtworkUrl(metadata); + + final newMediaItem = MediaItem( + id: metadata.ratingKey, + title: metadata.title, + album: _getAlbumName(metadata), + artist: _getArtistName(metadata), + duration: metadata.duration != null + ? Duration(milliseconds: metadata.duration!) + : null, + artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null, + extras: { + 'ratingKey': metadata.ratingKey, + 'type': metadata.type, + }, + ); + + mediaItem.add(newMediaItem); + appLogger.d('Updated media item: ${metadata.title}'); + } + + /// Update navigation callbacks + void updateNavigationCallbacks({ + Future Function()? onNext, + Future Function()? onPrevious, + }) { + onSkipToNext = onNext; + onSkipToPrevious = onPrevious; + _updatePlaybackState(); + } + + /// Update the queue with episodes + void updateEpisodeQueue(List episodes, int currentIndex) { + final items = episodes.map((episode) { + final artworkUrl = _getArtworkUrl(episode); + return MediaItem( + id: episode.ratingKey, + title: episode.title, + album: _getAlbumName(episode), + artist: _getArtistName(episode), + duration: episode.duration != null + ? Duration(milliseconds: episode.duration!) + : null, + artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null, + extras: { + 'ratingKey': episode.ratingKey, + 'type': episode.type, + }, + ); + }).toList(); + + _queueSubject.add(items); + queue.add(items); + + if (currentIndex >= 0 && currentIndex < items.length) { + mediaItem.add(items[currentIndex]); } + + appLogger.d('Updated queue with ${items.length} episodes, current index: $currentIndex'); } - void _initializeListeners() { - if (_player == null) return; + String? _getArtworkUrl(PlexMetadata metadata) { + String? thumbPath; - _playingSubscription = _player!.stream.playing.listen((_) { - _broadcastState(); - }); - - _positionSubscription = _player!.stream.position.listen((_) { - _broadcastState(); - }); - - _completedSubscription = _player!.stream.completed.listen((completed) { - if (completed) { - _broadcastState(); - } - }); - - _broadcastState(); - } - - /// Update the player reference and callbacks (for video changes) - Future updatePlayer({ - required Player? player, - VoidCallback? onNext, - VoidCallback? onPrevious, - }) async { - await _cleanupSubscriptions(); - - // Update player and callbacks - _player = player; - _onNext = onNext; - _onPrevious = onPrevious; - - // Set up new subscriptions if player is not null - if (_player != null) { - _initializeListeners(); + // For episodes, prefer show poster over episode thumbnail + if (metadata.type.toLowerCase() == 'episode') { + thumbPath = metadata.grandparentThumb ?? metadata.thumb; } else { - // No player, broadcast idle state - _broadcastIdleState(); + thumbPath = metadata.thumb; } + + if (thumbPath == null) return null; + + // Build full URL with authentication + return '$plexServerUrl$thumbPath?X-Plex-Token=$authToken'; } - /// Clean up current subscriptions - Future _cleanupSubscriptions() async { - await _playingSubscription?.cancel(); - await _positionSubscription?.cancel(); - await _completedSubscription?.cancel(); - _playingSubscription = null; - _positionSubscription = null; - _completedSubscription = null; - } - - void _broadcastState() { - if (_player == null || mediaItem.value == null) { - if (_player == null) { - _broadcastIdleState(); + String _getAlbumName(PlexMetadata metadata) { + // For episodes: "Show Name - Season X" + if (metadata.type.toLowerCase() == 'episode') { + final showName = metadata.grandparentTitle ?? ''; + final seasonNum = metadata.parentIndex; + if (seasonNum != null) { + return '$showName - Season $seasonNum'; } - return; + return showName; } - final playing = _player!.state.playing; - final position = _player!.state.position; - final duration = _player!.state.duration; - final rate = _player!.state.rate; + // For movies: studio or year + return metadata.studio ?? metadata.year?.toString() ?? ''; + } + + String _getArtistName(PlexMetadata metadata) { + // For episodes: show episode info + if (metadata.type.toLowerCase() == 'episode') { + final seasonNum = metadata.parentIndex; + final episodeNum = metadata.index; + if (seasonNum != null && episodeNum != null) { + return 'S${seasonNum.toString().padLeft(2, '0')}E${episodeNum.toString().padLeft(2, '0')}'; + } + } + + return ''; + } + + void _updatePlaybackState() { + final isPlaying = _player.state.playing; + final position = _player.state.position; + final bufferedPosition = _player.state.buffer; // Determine processing state AudioProcessingState processingState; - if (_player!.state.completed) { + if (_player.state.buffering) { + processingState = AudioProcessingState.buffering; + } else if (_player.state.completed) { processingState = AudioProcessingState.completed; - } else if (duration.inMilliseconds > 0) { - processingState = AudioProcessingState.ready; } else { - processingState = AudioProcessingState.loading; + processingState = AudioProcessingState.ready; } - // Build control list + // Build controls based on state and available navigation final controls = [ - if (_onPrevious != null) MediaControl.skipToPrevious, - playing ? MediaControl.pause : MediaControl.play, - MediaControl.stop, - if (_onNext != null) MediaControl.skipToNext, + if (onSkipToPrevious != null) MediaControl.skipToPrevious, + MediaControl.rewind, + if (isPlaying) MediaControl.pause else MediaControl.play, + MediaControl.fastForward, + if (onSkipToNext != null) MediaControl.skipToNext, ]; - final compactIndices = _calculateCompactActionIndices(controls); - playbackState.add(PlaybackState( controls: controls, systemActions: const { @@ -119,164 +197,118 @@ class MediaKitAudioHandler extends BaseAudioHandler with SeekHandler { MediaAction.seekForward, MediaAction.seekBackward, }, - androidCompactActionIndices: compactIndices, - playing: playing, - updatePosition: position, - speed: rate, + androidCompactActionIndices: [ + if (onSkipToPrevious != null) 0, + onSkipToPrevious != null ? 2 : 1, // Play/Pause button + if (onSkipToNext != null) + onSkipToPrevious != null ? 4 : 3, + ].where((i) => i < controls.length).toList(), processingState: processingState, + playing: isPlaying, + updatePosition: position, + bufferedPosition: bufferedPosition, + speed: 1.0, + queueIndex: 0, // TODO: Update based on actual queue position )); } - void _broadcastIdleState() { - playbackState.add(PlaybackState( - controls: [], - systemActions: const {}, - playing: false, - processingState: AudioProcessingState.idle, - )); - } - - /// Calculate compact action indices for Android notification - /// Returns indices for the most important controls to show in compact view - List _calculateCompactActionIndices(List controls) { - final indices = []; - - // Find indices of key controls - int? previousIndex; - int? playPauseIndex; - int? nextIndex; - - for (int i = 0; i < controls.length; i++) { - if (controls[i] == MediaControl.skipToPrevious) { - previousIndex = i; - } else if (controls[i] == MediaControl.play || - controls[i] == MediaControl.pause) { - playPauseIndex = i; - } else if (controls[i] == MediaControl.skipToNext) { - nextIndex = i; - } - } - - // Build compact indices: Previous (if exists), Play/Pause, Next (if exists) - if (previousIndex != null) indices.add(previousIndex); - if (playPauseIndex != null) indices.add(playPauseIndex); - if (nextIndex != null) indices.add(nextIndex); - - // Ensure we have at least the play/pause button - if (indices.isEmpty && playPauseIndex != null) { - indices.add(playPauseIndex); - } - - return indices; - } - - /// Update the media item shown in OS controls - void setMediaItemFromMetadata(PlexMetadata metadata, String? thumbnailUrl) { - final title = metadata.type.toLowerCase() == 'episode' - ? metadata.title - : metadata.title; - - final artist = metadata.type.toLowerCase() == 'episode' - ? metadata.grandparentTitle ?? metadata.year?.toString() - : metadata.year?.toString(); - - final album = metadata.type.toLowerCase() == 'episode' - ? 'S${metadata.parentIndex} · E${metadata.index} · ${metadata.parentTitle ?? ""}' - : metadata.studio; - - final duration = metadata.duration != null - ? Duration(milliseconds: metadata.duration!) - : Duration.zero; - - mediaItem.add(MediaItem( - id: metadata.ratingKey, - title: title, - artist: artist, - album: album, - duration: duration, - artUri: thumbnailUrl != null ? Uri.parse(thumbnailUrl) : null, - extras: { - 'ratingKey': metadata.ratingKey, - 'type': metadata.type, - }, - )); - - appLogger.i('Media item updated: $title${artist != null ? " - $artist" : ""}'); - } - - /// Update whether next/previous actions are available - void updateNavigationActions({bool? hasNext, bool? hasPrevious}) { - _broadcastState(); - } - - /// Force an immediate state update - /// Use this after playback starts to ensure notification appears - void forceStateUpdate() { - _broadcastState(); - } - - // BaseAudioHandler implementations @override Future play() async { - if (_player == null) return; - await _player!.play(); + appLogger.d('Audio handler: play'); + await _player.play(); } @override Future pause() async { - if (_player == null) return; - await _player!.pause(); - } - - @override - Future stop() async { - if (_player != null) { - await _player!.pause(); - } - - playbackState.add(PlaybackState( - controls: [], - systemActions: const {}, - playing: false, - processingState: AudioProcessingState.idle, - )); - - await super.stop(); + appLogger.d('Audio handler: pause'); + await _player.pause(); } @override Future seek(Duration position) async { - if (_player == null) return; - await _player!.seek(position); + appLogger.d('Audio handler: seek to ${position.inSeconds}s'); + await _player.seek(position); } @override - Future skipToNext() async { - _onNext?.call(); - } - - @override - Future skipToPrevious() async { - _onPrevious?.call(); + Future stop() async { + appLogger.d('Audio handler: stop'); + await _player.stop(); } @override Future fastForward() async { - if (_player == null) return; - final newPosition = _player!.state.position + const Duration(seconds: 15); - await _player!.seek(newPosition); + appLogger.d('Audio handler: fast forward'); + final newPosition = _player.state.position + const Duration(seconds: 10); + await _player.seek(newPosition); } @override Future rewind() async { - if (_player == null) return; - final newPosition = _player!.state.position - const Duration(seconds: 15); - await _player!.seek(newPosition > Duration.zero ? newPosition : Duration.zero); + appLogger.d('Audio handler: rewind'); + final newPosition = _player.state.position - const Duration(seconds: 10); + await _player.seek(newPosition > Duration.zero ? newPosition : Duration.zero); + } + + @override + Future skipToNext() async { + appLogger.d('Audio handler: skip to next'); + if (onSkipToNext != null) { + await onSkipToNext!(); + } + } + + @override + Future skipToPrevious() async { + appLogger.d('Audio handler: skip to previous'); + if (onSkipToPrevious != null) { + await onSkipToPrevious!(); + } + } + + /// Update the player reference when switching episodes + /// This ensures the handler stays in sync with the current player instance + Future updatePlayer(Player newPlayer) async { + appLogger.d('Audio handler: updating player reference'); + + // Cancel old subscriptions + await _playingSubscription?.cancel(); + await _positionSubscription?.cancel(); + await _durationSubscription?.cancel(); + await _bufferSubscription?.cancel(); + + // Update player reference + _player = newPlayer; + + // Create new subscriptions with the new player + _playingSubscription = _player.stream.playing.listen((isPlaying) { + _updatePlaybackState(); + }); + + _positionSubscription = _player.stream.position.listen((_) { + _updatePlaybackState(); + }); + + _durationSubscription = _player.stream.duration.listen((_) { + _updatePlaybackState(); + }); + + _bufferSubscription = _player.stream.buffer.listen((_) { + _updatePlaybackState(); + }); + + // Update playback state immediately + _updatePlaybackState(); + + appLogger.d('Audio handler: player reference updated successfully'); } Future dispose() async { - appLogger.d('Disposing MediaKitAudioHandler'); - await _cleanupSubscriptions(); - await stop(); + // Cancel subscriptions + await _playingSubscription?.cancel(); + await _positionSubscription?.cancel(); + await _durationSubscription?.cancel(); + await _bufferSubscription?.cancel(); + await _queueSubject.close(); } } diff --git a/lib/services/media_service_manager.dart b/lib/services/media_service_manager.dart deleted file mode 100644 index 15c1e259..00000000 --- a/lib/services/media_service_manager.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:audio_service/audio_service.dart'; -import 'package:media_kit/media_kit.dart'; -import 'media_kit_audio_handler.dart'; -import '../utils/app_logger.dart'; - -/// Singleton manager for OS media controls integration -/// Manages a single AudioHandler instance for the entire app lifecycle -class MediaServiceManager { - static MediaServiceManager? _instance; - static MediaKitAudioHandler? _audioHandler; - static bool _isInitialized = false; - - MediaServiceManager._(); - - static MediaServiceManager get instance { - _instance ??= MediaServiceManager._(); - return _instance!; - } - - /// Initialize the audio service once at app startup - Future initialize() async { - if (_isInitialized) { - appLogger.w('MediaServiceManager already initialized'); - return; - } - - try { - appLogger.i('Initializing MediaServiceManager'); - - _audioHandler = await AudioService.init( - builder: () => MediaKitAudioHandler( - player: null, // Will be set when first video plays - onNext: null, - onPrevious: null, - ), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.plezy.app.channel.audio', - androidNotificationChannelName: 'Plezy Playback', - androidNotificationOngoing: false, - androidStopForegroundOnPause: true, - androidNotificationIcon: 'drawable/ic_stat_notification', - // Configure audio session for proper media playback - preloadArtwork: true, - ), - ); - - _isInitialized = true; - appLogger.i('MediaServiceManager initialized successfully'); - } catch (e, stackTrace) { - appLogger.e( - '❌ Failed to initialize MediaServiceManager', - error: e, - stackTrace: stackTrace, - ); - // Non-fatal, app can continue without OS media controls - } - } - - /// Update the audio handler with a new player and callbacks - Future updatePlayer({ - required Player? player, - VoidCallback? onNext, - VoidCallback? onPrevious, - }) async { - if (!_isInitialized || _audioHandler == null) { - appLogger.w('MediaServiceManager not initialized, cannot update player'); - return; - } - - try { - await _audioHandler!.updatePlayer( - player: player, - onNext: onNext, - onPrevious: onPrevious, - ); - } catch (e) { - appLogger.e('Failed to update player', error: e); - } - } - - /// Stop playback and clear OS controls - Future stop() async { - if (_audioHandler == null) return; - - try { - await _audioHandler!.stop(); - } catch (e) { - appLogger.e('Failed to stop audio service', error: e); - } - } - - /// Update the media item shown in OS controls - void updateMediaItem(dynamic metadata, String? thumbnailUrl) { - if (_audioHandler == null) return; - - try { - _audioHandler!.setMediaItemFromMetadata(metadata, thumbnailUrl); - } catch (e) { - appLogger.e('Failed to update media item', error: e); - } - } - - /// Update navigation actions availability - void updateNavigationActions({bool? hasNext, bool? hasPrevious}) { - _audioHandler?.updateNavigationActions( - hasNext: hasNext, - hasPrevious: hasPrevious, - ); - } - - /// Force an immediate state update to trigger notification - /// Call this after playback starts to ensure Android shows the notification - void forceStateUpdate() { - _audioHandler?.forceStateUpdate(); - } - - /// Check if the service is initialized - bool get isInitialized => _isInitialized; - - /// Get the audio handler (for advanced usage) - MediaKitAudioHandler? get audioHandler => _audioHandler; -} diff --git a/pubspec.lock b/pubspec.lock index 3dbbbdf2..9d457614 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -67,13 +67,13 @@ packages: source: hosted version: "0.1.4" audio_session: - dependency: transitive + dependency: "direct main" description: name: audio_session - sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.1.25" boolean_selector: dependency: transitive description: @@ -793,13 +793,13 @@ packages: source: hosted version: "4.1.0" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted - version: "0.28.0" + version: "0.27.7" safe_local_storage: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9712efb5..e4b46358 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,8 +25,14 @@ dependencies: provider: ^6.1.2 hotkey_manager: ^0.2.3 flex_color_picker: ^3.6.0 - audio_service: ^0.18.18 qr_flutter: ^4.1.0 + audio_session: ^0.1.25 + rxdart: ^0.27.2 + audio_service: + git: + url: https://github.com/marckornberger/audio_service.git + ref: 4ca42f391183854095188a5a10fa6b59cd641e68 + path: audio_service dependency_overrides: media_kit: @@ -39,11 +45,6 @@ dependency_overrides: url: https://github.com/edde746/media-kit ref: b6de7e44148043bd26f2569e8062dc8bce4f11b9 path: libs/universal/media_kit_libs_video - audio_service: - git: - url: https://github.com/marckornberger/audio_service.git - ref: 4ca42f391183854095188a5a10fa6b59cd641e68 - path: audio_service dev_dependencies: flutter_test: