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:
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user