feat: os media controls
@@ -3,6 +3,11 @@
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Permissions for audio_service (OS media controls) -->
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
|
||||
@@ -40,6 +45,27 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Audio service for OS media controls -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import com.ryanheise.audioservice.AudioServiceFragmentActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : AudioServiceFragmentActivity()
|
||||
|
||||
|
Before Width: | Height: | Size: 582 B |
|
After Width: | Height: | Size: 952 B |
|
Before Width: | Height: | Size: 354 B |
|
After Width: | Height: | Size: 688 B |
|
Before Width: | Height: | Size: 724 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,9 @@
|
||||
PODS:
|
||||
- audio_service (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- media_kit_libs_ios_video (1.0.4):
|
||||
- Flutter
|
||||
@@ -23,6 +28,8 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `.symlinks/plugins/audio_service/darwin`)
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
@@ -35,6 +42,10 @@ DEPENDENCIES:
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_service:
|
||||
:path: ".symlinks/plugins/audio_service/darwin"
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
media_kit_libs_ios_video:
|
||||
@@ -57,6 +68,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -42,6 +43,9 @@ 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();
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
@@ -13,6 +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 '../utils/orientation_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
@@ -164,6 +166,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
final savedVolume = settingsService.getVolume();
|
||||
player!.setVolume(savedVolume);
|
||||
|
||||
// Initialize audio session for proper OS integration
|
||||
await _initializeAudioSession();
|
||||
|
||||
// Update media service manager with new player
|
||||
await _updateMediaService();
|
||||
|
||||
// Notify that player is ready
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -228,6 +236,82 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeAudioSession() async {
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback,
|
||||
avAudioSessionMode: AVAudioSessionMode.moviePlayback,
|
||||
androidAudioAttributes: AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.movie,
|
||||
usage: AndroidAudioUsage.media,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
));
|
||||
|
||||
// Handle interruptions (phone calls, other apps, etc.)
|
||||
session.interruptionEventStream.listen((event) {
|
||||
if (event.begin) {
|
||||
// Pause on interruption
|
||||
player?.pause();
|
||||
appLogger.i('Playback paused due to interruption');
|
||||
} else {
|
||||
// Resume after interruption if needed (user can manually play)
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.pause:
|
||||
case AudioInterruptionType.duck:
|
||||
// Don't auto-resume for these types
|
||||
break;
|
||||
case AudioInterruptionType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle audio becoming noisy (headphones unplugged)
|
||||
session.becomingNoisyEventStream.listen((_) {
|
||||
player?.pause();
|
||||
appLogger.i('Playback paused due to audio becoming noisy (headphones unplugged?)');
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to configure audio session', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
return;
|
||||
@@ -260,6 +344,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
_nextEpisode = next;
|
||||
_previousEpisode = previous;
|
||||
});
|
||||
|
||||
// Update media service navigation controls
|
||||
MediaServiceManager.instance.updateNavigationActions(
|
||||
hasNext: _nextEpisode != null,
|
||||
hasPrevious: _previousEpisode != null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors
|
||||
@@ -386,6 +476,10 @@ 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 {
|
||||
@@ -427,21 +521,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
setState(() {
|
||||
_boxFitMode = (_boxFitMode + 1) % 3;
|
||||
});
|
||||
final modes = [
|
||||
'contain (letterbox)',
|
||||
'cover (fill screen)',
|
||||
'fill (stretch)',
|
||||
];
|
||||
appLogger.d('BoxFit mode: ${modes[_boxFitMode]}');
|
||||
}
|
||||
|
||||
/// Toggle between contain and cover modes only (for pinch gesture)
|
||||
void _toggleContainCover() {
|
||||
setState(() {
|
||||
_boxFitMode = _boxFitMode == 0 ? 1 : 0; // Toggle between 0 and 1
|
||||
_boxFitMode = _boxFitMode == 0 ? 1 : 0;
|
||||
});
|
||||
final modes = ['contain (letterbox)', 'cover (fill screen)'];
|
||||
appLogger.d('BoxFit mode toggled: ${modes[_boxFitMode]}');
|
||||
}
|
||||
|
||||
/// Get current BoxFit based on mode
|
||||
@@ -469,6 +555,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
_logSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
|
||||
// Stop media service and clear OS controls
|
||||
MediaServiceManager.instance.stop();
|
||||
|
||||
// Send final stopped state
|
||||
_sendProgress('stopped');
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// AudioHandler that bridges media_kit Player with OS media controls
|
||||
class MediaKitAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
Player? _player;
|
||||
VoidCallback? _onNext;
|
||||
VoidCallback? _onPrevious;
|
||||
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<bool>? _completedSubscription;
|
||||
|
||||
MediaKitAudioHandler({
|
||||
required Player? player,
|
||||
VoidCallback? onNext,
|
||||
VoidCallback? onPrevious,
|
||||
}) : _player = player,
|
||||
_onNext = onNext,
|
||||
_onPrevious = onPrevious {
|
||||
if (_player != null) {
|
||||
_initializeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeListeners() {
|
||||
if (_player == null) return;
|
||||
|
||||
_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();
|
||||
} else {
|
||||
// No player, broadcast idle state
|
||||
_broadcastIdleState();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final playing = _player!.state.playing;
|
||||
final position = _player!.state.position;
|
||||
final duration = _player!.state.duration;
|
||||
final rate = _player!.state.rate;
|
||||
|
||||
// Determine processing state
|
||||
AudioProcessingState processingState;
|
||||
if (_player!.state.completed) {
|
||||
processingState = AudioProcessingState.completed;
|
||||
} else if (duration.inMilliseconds > 0) {
|
||||
processingState = AudioProcessingState.ready;
|
||||
} else {
|
||||
processingState = AudioProcessingState.loading;
|
||||
}
|
||||
|
||||
// Build control list
|
||||
final controls = <MediaControl>[
|
||||
if (_onPrevious != null) MediaControl.skipToPrevious,
|
||||
playing ? MediaControl.pause : MediaControl.play,
|
||||
MediaControl.stop,
|
||||
if (_onNext != null) MediaControl.skipToNext,
|
||||
];
|
||||
|
||||
final compactIndices = _calculateCompactActionIndices(controls);
|
||||
|
||||
playbackState.add(PlaybackState(
|
||||
controls: controls,
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.seekForward,
|
||||
MediaAction.seekBackward,
|
||||
},
|
||||
androidCompactActionIndices: compactIndices,
|
||||
playing: playing,
|
||||
updatePosition: position,
|
||||
speed: rate,
|
||||
processingState: processingState,
|
||||
));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
if (_player == null) return;
|
||||
await _player!.seek(position);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
_onNext?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
_onPrevious?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> fastForward() async {
|
||||
if (_player == null) return;
|
||||
final newPosition = _player!.state.position + const Duration(seconds: 15);
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
appLogger.d('Disposing MediaKitAudioHandler');
|
||||
await _cleanupSubscriptions();
|
||||
await stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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',
|
||||
),
|
||||
);
|
||||
|
||||
_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;
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_service
|
||||
import audio_session
|
||||
import hotkey_manager_macos
|
||||
import macos_window_utils
|
||||
import media_kit_libs_macos_video
|
||||
@@ -20,6 +22,8 @@ import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
PODS:
|
||||
- audio_service (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- HotKey (0.2.1)
|
||||
- hotkey_manager_macos (0.0.1):
|
||||
@@ -33,6 +38,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/darwin`)
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||
@@ -53,6 +60,10 @@ SPEC REPOS:
|
||||
- HotKey
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_service:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
hotkey_manager_macos:
|
||||
@@ -83,6 +94,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
|
||||
@@ -41,6 +41,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_service
|
||||
sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.18"
|
||||
audio_service_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_service_platform_interface
|
||||
sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
audio_service_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_service_web
|
||||
sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
audio_session:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -400,6 +432,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -25,6 +25,8 @@ dependencies:
|
||||
provider: ^6.1.2
|
||||
hotkey_manager: ^0.2.3
|
||||
flex_color_picker: ^3.6.0
|
||||
audio_service: ^0.18.18
|
||||
audio_session: ^0.2.1
|
||||
|
||||
dependency_overrides:
|
||||
media_kit:
|
||||
|
||||