From 6228eb26906e734198dfe2c1359be126ca10c714 Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Sat, 8 Nov 2025 10:03:52 +0100
Subject: [PATCH] refactor: rewrite os media controls
# Conflicts:
# lib/screens/video_player_screen.dart
# lib/services/media_service_manager.dart
# pubspec.yaml
---
android/app/src/main/AndroidManifest.xml | 20 +-
.../flutter_application_1/MainActivity.kt | 4 +-
lib/main.dart | 4 -
lib/screens/video_player_screen.dart | 105 ++--
lib/services/audio_service_manager.dart | 186 +++++++
lib/services/media_kit_audio_handler.dart | 456 ++++++++++--------
lib/services/media_service_manager.dart | 123 -----
pubspec.lock | 12 +-
pubspec.yaml | 13 +-
9 files changed, 507 insertions(+), 416 deletions(-)
create mode 100644 lib/services/audio_service_manager.dart
delete mode 100644 lib/services/media_service_manager.dart
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: