feat: os media controls
@@ -3,6 +3,11 @@
|
|||||||
<!-- Internet access permissions -->
|
<!-- Internet access permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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 -->
|
<!-- 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_AUDIO" tools:node="remove" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
|
||||||
@@ -40,6 +45,27 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.edde746.plezy
|
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:
|
PODS:
|
||||||
|
- audio_service (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- media_kit_libs_ios_video (1.0.4):
|
- media_kit_libs_ios_video (1.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -23,6 +28,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- audio_service (from `.symlinks/plugins/audio_service/darwin`)
|
||||||
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||||
- media_kit_video (from `.symlinks/plugins/media_kit_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`)
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
audio_service:
|
||||||
|
:path: ".symlinks/plugins/audio_service/darwin"
|
||||||
|
audio_session:
|
||||||
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
media_kit_libs_ios_video:
|
media_kit_libs_ios_video:
|
||||||
@@ -57,6 +68,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
|
||||||
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'services/macos_titlebar_service.dart';
|
|||||||
import 'services/fullscreen_state_manager.dart';
|
import 'services/fullscreen_state_manager.dart';
|
||||||
import 'services/update_service.dart';
|
import 'services/update_service.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
|
import 'services/media_service_manager.dart';
|
||||||
import 'providers/user_profile_provider.dart';
|
import 'providers/user_profile_provider.dart';
|
||||||
import 'providers/plex_client_provider.dart';
|
import 'providers/plex_client_provider.dart';
|
||||||
import 'providers/theme_provider.dart';
|
import 'providers/theme_provider.dart';
|
||||||
@@ -42,6 +43,9 @@ void main() async {
|
|||||||
// Initialize MediaKit
|
// Initialize MediaKit
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize OS media controls
|
||||||
|
await MediaServiceManager.instance.initialize();
|
||||||
|
|
||||||
// Note: Orientation will be set dynamically based on device type in MainApp
|
// Note: Orientation will be set dynamically based on device type in MainApp
|
||||||
|
|
||||||
await StorageService.getInstance();
|
await StorageService.getInstance();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:audio_session/audio_session.dart';
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../models/plex_user_profile.dart';
|
import '../models/plex_user_profile.dart';
|
||||||
import '../providers/plex_client_provider.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/language_codes.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/media_service_manager.dart';
|
||||||
import '../utils/orientation_helper.dart';
|
import '../utils/orientation_helper.dart';
|
||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
@@ -164,6 +166,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
final savedVolume = settingsService.getVolume();
|
final savedVolume = settingsService.getVolume();
|
||||||
player!.setVolume(savedVolume);
|
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
|
// Notify that player is ready
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
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 {
|
Future<void> _loadAdjacentEpisodes() async {
|
||||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||||
return;
|
return;
|
||||||
@@ -260,6 +344,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
_nextEpisode = next;
|
_nextEpisode = next;
|
||||||
_previousEpisode = previous;
|
_previousEpisode = previous;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update media service navigation controls
|
||||||
|
MediaServiceManager.instance.updateNavigationActions(
|
||||||
|
hasNext: _nextEpisode != null,
|
||||||
|
hasPrevious: _previousEpisode != null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle errors
|
// Silently handle errors
|
||||||
@@ -386,6 +476,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
// Start playback after seeking
|
// Start playback after seeking
|
||||||
await player!.play();
|
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
|
// Wait for tracks to be loaded, then apply preferred tracks
|
||||||
_waitForTracksAndApply();
|
_waitForTracksAndApply();
|
||||||
} else {
|
} else {
|
||||||
@@ -427,21 +521,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_boxFitMode = (_boxFitMode + 1) % 3;
|
_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)
|
/// Toggle between contain and cover modes only (for pinch gesture)
|
||||||
void _toggleContainCover() {
|
void _toggleContainCover() {
|
||||||
setState(() {
|
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
|
/// Get current BoxFit based on mode
|
||||||
@@ -469,6 +555,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
_logSubscription?.cancel();
|
_logSubscription?.cancel();
|
||||||
_errorSubscription?.cancel();
|
_errorSubscription?.cancel();
|
||||||
|
|
||||||
|
// Stop media service and clear OS controls
|
||||||
|
MediaServiceManager.instance.stop();
|
||||||
|
|
||||||
// Send final stopped state
|
// Send final stopped state
|
||||||
_sendProgress('stopped');
|
_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 FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import audio_service
|
||||||
|
import audio_session
|
||||||
import hotkey_manager_macos
|
import hotkey_manager_macos
|
||||||
import macos_window_utils
|
import macos_window_utils
|
||||||
import media_kit_libs_macos_video
|
import media_kit_libs_macos_video
|
||||||
@@ -20,6 +22,8 @@ import wakelock_plus
|
|||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
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"))
|
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- audio_service (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
- HotKey (0.2.1)
|
- HotKey (0.2.1)
|
||||||
- hotkey_manager_macos (0.0.1):
|
- hotkey_manager_macos (0.0.1):
|
||||||
@@ -33,6 +38,8 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
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`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||||
@@ -53,6 +60,10 @@ SPEC REPOS:
|
|||||||
- HotKey
|
- HotKey
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
audio_service:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin
|
||||||
|
audio_session:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
hotkey_manager_macos:
|
hotkey_manager_macos:
|
||||||
@@ -83,6 +94,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
|
||||||
|
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||||
|
|||||||
@@ -41,6 +41,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -400,6 +432,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ dependencies:
|
|||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
hotkey_manager: ^0.2.3
|
hotkey_manager: ^0.2.3
|
||||||
flex_color_picker: ^3.6.0
|
flex_color_picker: ^3.6.0
|
||||||
|
audio_service: ^0.18.18
|
||||||
|
audio_session: ^0.2.1
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
media_kit:
|
media_kit:
|
||||||
|
|||||||