feat: re-do os media controls
This commit is contained in:
@@ -3,8 +3,7 @@
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Audio service permissions -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- Media session permissions for OS media controls -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
|
||||
@@ -45,23 +44,6 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Audio service -->
|
||||
<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 -->
|
||||
<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 com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : AudioServiceActivity()
|
||||
class MainActivity : FlutterActivity()
|
||||
|
||||
+6
-13
@@ -1,14 +1,11 @@
|
||||
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
|
||||
- media_kit_video (0.0.1):
|
||||
- Flutter
|
||||
- os_media_controls (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -28,11 +25,10 @@ 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`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
@@ -42,16 +38,14 @@ 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:
|
||||
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
||||
media_kit_video:
|
||||
:path: ".symlinks/plugins/media_kit_video/ios"
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -68,11 +62,10 @@ 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
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -8,6 +9,18 @@ import UIKit
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
// Configure audio session for media playback
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
|
||||
application.beginReceivingRemoteControlEvents()
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:os_media_controls/os_media_controls.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
@@ -14,7 +15,6 @@ import '../widgets/video_controls/video_controls.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/audio_service_manager.dart';
|
||||
import '../utils/orientation_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
@@ -56,6 +56,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
StreamSubscription<String>? _errorSubscription;
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<bool>? _completedSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<dynamic>? _mediaControlSubscription;
|
||||
bool _isReplacingWithVideo =
|
||||
false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
|
||||
@@ -215,14 +217,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Listen to MPV errors
|
||||
_errorSubscription = player!.stream.error.listen(_onPlayerError);
|
||||
|
||||
// Listen to position updates for media controls
|
||||
_positionSubscription = player!.stream.position.listen((_) {
|
||||
_updateMediaControlsPosition();
|
||||
});
|
||||
|
||||
// Initialize OS media controls
|
||||
_initializeMediaControls();
|
||||
|
||||
// Start periodic progress updates
|
||||
_startProgressTracking();
|
||||
|
||||
// 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) {
|
||||
@@ -279,52 +286,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
_nextEpisode = next;
|
||||
_previousEpisode = previous;
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -519,17 +486,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
_completedSubscription?.cancel();
|
||||
_logSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
_positionSubscription?.cancel();
|
||||
_mediaControlSubscription?.cancel();
|
||||
|
||||
// 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();
|
||||
}
|
||||
// Clear OS media controls completely
|
||||
OsMediaControls.clear();
|
||||
|
||||
// Send final stopped state
|
||||
_sendProgress('stopped');
|
||||
@@ -1123,6 +1084,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
void _onPlayingStateChanged(bool isPlaying) {
|
||||
// Send timeline update when playback state changes
|
||||
_sendProgress(isPlaying ? 'playing' : 'paused');
|
||||
|
||||
// Update OS media controls playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
}
|
||||
|
||||
void _sendProgress(String state) {
|
||||
@@ -1185,6 +1149,129 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.e('[MPV ERROR] $error');
|
||||
}
|
||||
|
||||
// OS Media Controls Integration
|
||||
|
||||
void _initializeMediaControls() async {
|
||||
// Listen to media control events
|
||||
_mediaControlSubscription = OsMediaControls.controlEvents.listen((event) {
|
||||
if (event is PlayEvent) {
|
||||
player?.play();
|
||||
} else if (event is PauseEvent) {
|
||||
player?.pause();
|
||||
} else if (event is SeekEvent) {
|
||||
player?.seek(event.position);
|
||||
} else if (event is NextTrackEvent) {
|
||||
if (_nextEpisode != null) {
|
||||
_playNext();
|
||||
}
|
||||
} else if (event is PreviousTrackEvent) {
|
||||
if (_previousEpisode != null) {
|
||||
_playPrevious();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable next/previous track controls based on content type
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
if (isEpisode) {
|
||||
// Enable next/previous track controls for episodes
|
||||
await OsMediaControls.enableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
]);
|
||||
} else {
|
||||
// Disable next/previous track controls for movies
|
||||
await OsMediaControls.disableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
]);
|
||||
}
|
||||
|
||||
// Set initial metadata
|
||||
await _updateMediaMetadata();
|
||||
}
|
||||
|
||||
Future<void> _updateMediaMetadata() async {
|
||||
if (!mounted) {
|
||||
appLogger.w('Cannot update media metadata: widget not mounted');
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = widget.metadata;
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
|
||||
// Get artwork URL
|
||||
String? artworkUrl;
|
||||
if (client == null) {
|
||||
appLogger.w('Cannot get artwork URL for media controls: Plex client is null');
|
||||
} else {
|
||||
final thumbUrl = metadata.type.toLowerCase() == 'episode'
|
||||
? metadata.grandparentThumb ?? metadata.thumb
|
||||
: metadata.thumb;
|
||||
|
||||
if (thumbUrl != null) {
|
||||
try {
|
||||
artworkUrl = client.getThumbnailUrl(thumbUrl);
|
||||
appLogger.d('Artwork URL for media controls: $artworkUrl');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to get artwork URL for media controls', error: e);
|
||||
}
|
||||
} else {
|
||||
appLogger.d('No thumbnail URL available for media controls');
|
||||
}
|
||||
}
|
||||
|
||||
// Build title/artist based on content type
|
||||
String title = metadata.title;
|
||||
String? artist;
|
||||
String? album;
|
||||
|
||||
if (metadata.type.toLowerCase() == 'episode') {
|
||||
title = metadata.title;
|
||||
artist = metadata.grandparentTitle; // Show name
|
||||
if (metadata.parentIndex != null) {
|
||||
album = 'Season ${metadata.parentIndex}';
|
||||
}
|
||||
}
|
||||
|
||||
await OsMediaControls.setMetadata(MediaMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
duration: metadata.duration != null
|
||||
? Duration(milliseconds: metadata.duration!)
|
||||
: null,
|
||||
artworkUrl: artworkUrl,
|
||||
));
|
||||
|
||||
// Set initial playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
}
|
||||
|
||||
void _updateMediaControlsPlaybackState() {
|
||||
if (player == null) return;
|
||||
|
||||
OsMediaControls.setPlaybackState(MediaPlaybackState(
|
||||
state: player!.state.playing ? PlaybackState.playing : PlaybackState.paused,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
));
|
||||
}
|
||||
|
||||
void _updateMediaControlsPosition() {
|
||||
if (player == null) return;
|
||||
|
||||
// Only update if playing to avoid excessive updates
|
||||
if (player!.state.playing) {
|
||||
OsMediaControls.setPlaybackState(MediaPlaybackState(
|
||||
state: PlaybackState.playing,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playNext() async {
|
||||
if (_nextEpisode == null || _isLoadingNext) return;
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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,314 +0,0 @@
|
||||
import 'dart:async';
|
||||
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';
|
||||
|
||||
/// Audio handler that bridges media_kit player with OS media controls
|
||||
class MediaKitAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
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<Duration>? _durationSubscription;
|
||||
StreamSubscription<Duration>? _bufferSubscription;
|
||||
|
||||
// Queue management
|
||||
final BehaviorSubject<List<MediaItem>> _queueSubject = BehaviorSubject.seeded([]);
|
||||
|
||||
MediaKitAudioHandler({
|
||||
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');
|
||||
}
|
||||
|
||||
String? _getArtworkUrl(PlexMetadata metadata) {
|
||||
String? thumbPath;
|
||||
|
||||
// For episodes, prefer show poster over episode thumbnail
|
||||
if (metadata.type.toLowerCase() == 'episode') {
|
||||
thumbPath = metadata.grandparentThumb ?? metadata.thumb;
|
||||
} else {
|
||||
thumbPath = metadata.thumb;
|
||||
}
|
||||
|
||||
if (thumbPath == null) return null;
|
||||
|
||||
// Build full URL with authentication
|
||||
return '$plexServerUrl$thumbPath?X-Plex-Token=$authToken';
|
||||
}
|
||||
|
||||
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 showName;
|
||||
}
|
||||
|
||||
// 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.buffering) {
|
||||
processingState = AudioProcessingState.buffering;
|
||||
} else if (_player.state.completed) {
|
||||
processingState = AudioProcessingState.completed;
|
||||
} else {
|
||||
processingState = AudioProcessingState.ready;
|
||||
}
|
||||
|
||||
// Build controls based on state and available navigation
|
||||
final controls = <MediaControl>[
|
||||
if (onSkipToPrevious != null) MediaControl.skipToPrevious,
|
||||
MediaControl.rewind,
|
||||
if (isPlaying) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.fastForward,
|
||||
if (onSkipToNext != null) MediaControl.skipToNext,
|
||||
];
|
||||
|
||||
playbackState.add(PlaybackState(
|
||||
controls: controls,
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.seekForward,
|
||||
MediaAction.seekBackward,
|
||||
},
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
appLogger.d('Audio handler: play');
|
||||
await _player.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
appLogger.d('Audio handler: pause');
|
||||
await _player.pause();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
appLogger.d('Audio handler: seek to ${position.inSeconds}s');
|
||||
await _player.seek(position);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
appLogger.d('Audio handler: stop');
|
||||
await _player.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> fastForward() async {
|
||||
appLogger.d('Audio handler: fast forward');
|
||||
final newPosition = _player.state.position + const Duration(seconds: 10);
|
||||
await _player.seek(newPosition);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rewind() async {
|
||||
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 {
|
||||
// Cancel subscriptions
|
||||
await _playingSubscription?.cancel();
|
||||
await _positionSubscription?.cancel();
|
||||
await _durationSubscription?.cancel();
|
||||
await _bufferSubscription?.cancel();
|
||||
await _queueSubject.close();
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,11 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_service
|
||||
import audio_session
|
||||
import hotkey_manager_macos
|
||||
import macos_window_utils
|
||||
import media_kit_libs_macos_video
|
||||
import media_kit_video
|
||||
import os_media_controls
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import screen_retriever_macos
|
||||
@@ -22,12 +21,11 @@ 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"))
|
||||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
|
||||
+6
-13
@@ -1,9 +1,4 @@
|
||||
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):
|
||||
@@ -15,6 +10,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- media_kit_video (0.0.1):
|
||||
- FlutterMacOS
|
||||
- os_media_controls (0.0.1):
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -38,13 +35,12 @@ 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`)
|
||||
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||
- media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
|
||||
- os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
|
||||
@@ -60,10 +56,6 @@ 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:
|
||||
@@ -74,6 +66,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
|
||||
media_kit_video:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
|
||||
os_media_controls:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
@@ -94,14 +88,13 @@ 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
|
||||
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
|
||||
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
|
||||
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
|
||||
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
|
||||
+11
-43
@@ -41,39 +41,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: audio_service
|
||||
ref: "4ca42f391183854095188a5a10fa6b59cd641e68"
|
||||
resolved-ref: "4ca42f391183854095188a5a10fa6b59cd641e68"
|
||||
url: "https://github.com/marckornberger/audio_service.git"
|
||||
source: git
|
||||
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: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.25"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -433,14 +400,6 @@ 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:
|
||||
@@ -632,6 +591,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
os_media_controls:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: main
|
||||
resolved-ref: "95ee0786deca2b6a45ac227df20d1055c9204f1a"
|
||||
url: "https://github.com/edde746/os-media-controls"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -793,7 +761,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rxdart:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
|
||||
@@ -1278,5 +1246,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
+3
-6
@@ -26,13 +26,10 @@ dependencies:
|
||||
hotkey_manager: ^0.2.3
|
||||
flex_color_picker: ^3.6.0
|
||||
qr_flutter: ^4.1.0
|
||||
audio_session: ^0.1.25
|
||||
rxdart: ^0.27.2
|
||||
audio_service:
|
||||
os_media_controls:
|
||||
git:
|
||||
url: https://github.com/marckornberger/audio_service.git
|
||||
ref: 4ca42f391183854095188a5a10fa6b59cd641e68
|
||||
path: audio_service
|
||||
url: https://github.com/edde746/os-media-controls
|
||||
ref: main
|
||||
|
||||
dependency_overrides:
|
||||
media_kit:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
|
||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||
#include <os_media_controls/os_media_controls_plugin.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||
OsMediaControlsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("OsMediaControlsPlugin"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
hotkey_manager_windows
|
||||
media_kit_libs_windows_video
|
||||
media_kit_video
|
||||
os_media_controls
|
||||
screen_retriever_windows
|
||||
url_launcher_windows
|
||||
volume_controller
|
||||
|
||||
Reference in New Issue
Block a user