refactor: rewrite os media controls

# Conflicts:
#	lib/screens/video_player_screen.dart
#	lib/services/media_service_manager.dart
#	pubspec.yaml
This commit is contained in:
edde746
2025-11-08 10:04:51 +01:00
parent 0d4f7a9e9f
commit 6228eb2690
9 changed files with 507 additions and 416 deletions
+8 -12
View File
@@ -3,10 +3,10 @@
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Permissions for audio_service (OS media controls) -->
<!-- Audio service permissions -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Explicitly remove media permissions that media_kit may add -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove" />
@@ -46,22 +46,18 @@
android:name="flutterEmbedding"
android:value="2" />
<!-- Audio service for OS media controls -->
<service
android:name="com.ryanheise.audioservice.AudioService"
<!-- Audio service -->
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- Media button receiver for headset controls -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<!-- Media button receiver -->
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
@@ -1,5 +1,5 @@
package com.edde746.plezy
import com.ryanheise.audioservice.AudioServiceFragmentActivity
import com.ryanheise.audioservice.AudioServiceActivity
class MainActivity : AudioServiceFragmentActivity()
class MainActivity : AudioServiceActivity()
-4
View File
@@ -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();
+54 -51
View File
@@ -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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
}
}
Future<void> _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<void> _loadAdjacentEpisodes() async {
if (widget.metadata.type.toLowerCase() != 'episode') {
@@ -295,9 +262,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
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<VideoPlayerScreen> {
_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<void> _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<void> _startPlayback() async {
try {
final clientProvider = context.plexClient;
@@ -446,10 +445,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
_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');
+186
View File
@@ -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<void> initialize({
required Player player,
required String plexServerUrl,
required String authToken,
Future<void> Function()? onSkipToNext,
Future<void> 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<void> Function()? onNext,
Future<void> 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<void> 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<void> 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<void> 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;
}
+244 -212
View File
@@ -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<void> Function()? onSkipToNext;
Future<void> Function()? onSkipToPrevious;
// Stream subscriptions
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<bool>? _completedSubscription;
StreamSubscription<Duration>? _durationSubscription;
StreamSubscription<Duration>? _bufferSubscription;
// Queue management
final BehaviorSubject<List<MediaItem>> _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<void> Function()? onNext,
Future<void> Function()? onPrevious,
}) {
onSkipToNext = onNext;
onSkipToPrevious = onPrevious;
_updatePlaybackState();
}
/// Update the queue with episodes
void updateEpisodeQueue(List<PlexMetadata> 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<void> 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<void> _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 = <MediaControl>[
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<int> _calculateCompactActionIndices(List<MediaControl> controls) {
final indices = <int>[];
// 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<void> play() async {
if (_player == null) return;
await _player!.play();
appLogger.d('Audio handler: play');
await _player.play();
}
@override
Future<void> pause() async {
if (_player == null) return;
await _player!.pause();
}
@override
Future<void> 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<void> 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<void> skipToNext() async {
_onNext?.call();
}
@override
Future<void> skipToPrevious() async {
_onPrevious?.call();
Future<void> stop() async {
appLogger.d('Audio handler: stop');
await _player.stop();
}
@override
Future<void> 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<void> 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<void> skipToNext() async {
appLogger.d('Audio handler: skip to next');
if (onSkipToNext != null) {
await onSkipToNext!();
}
}
@override
Future<void> 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<void> 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<void> 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();
}
}
-123
View File
@@ -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<void> 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<void> 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<void> 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;
}
+6 -6
View File
@@ -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:
+7 -6
View File
@@ -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: