From eaabca69e049a1cc0aa377141ce417dd2eb6a0af Mon Sep 17 00:00:00 2001 From: wyrindev Date: Fri, 25 Sep 2026 12:25:07 +0800 Subject: [PATCH 1/3] fix(android): resolve media button handling --- .gitignore | 89 +++++++++-- android/src/main/AndroidManifest.xml | 5 +- .../FlutterMediaSessionPlugin.kt | 81 +++++++++- .../FlutterMediaSessionService.kt | 144 +++++++++--------- .../MediaButtonReceiver.kt | 52 +++++++ example/android/app/build.gradle.kts | 50 +++++- example/lib/main.dart | 71 +++++++-- example/lib/widgets/settings_panel.dart | 36 +++++ .../Flutter/GeneratedPluginRegistrant.swift | 20 --- example/pubspec.lock | 88 +++++++++-- example/pubspec.yaml | 3 +- 11 files changed, 501 insertions(+), 138 deletions(-) create mode 100644 android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonReceiver.kt delete mode 100644 example/macos/Flutter/GeneratedPluginRegistrant.swift diff --git a/.gitignore b/.gitignore index f706398..13e9088 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Miscellaneous *.class +*.lock *.log *.pyc *.swp @@ -24,34 +25,74 @@ migrate_working_dir/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.packages.generated +.pub-preload-cache/ +.pub/ /build/ -/coverage/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds # Android related -**/android/local.properties -**/android/flutter_media_session_android.iml +**/android/**/gradle-wrapper.jar +.gradle/ **/android/.gradle **/android/captures/ **/android/gradle/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks **/android/.cxx **/android/.externalNativeBuild +**/android/flutter_media_session_android.iml -# iOS/macOS related -**/ios/Runner/GeneratedPluginRegistrant.[mh] -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/flutter_export_environment.sh -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/DerivedData/ +# iOS/Xcode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata **/ios/.generated/ **/ios/.sentry-native/ -**/ios/Pods/ -**/ios/.symlinks/ **/ios/Config/Generated.xcconfig +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* **/ios/Runner/project.xcworkspace -# Windows related +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows **/windows/flutter/generated_plugin_registrant.cc **/windows/flutter/generated_plugin_registrant.h **/windows/flutter/generated_plugins.cmake @@ -61,4 +102,24 @@ migrate_working_dir/ **/windows/cmake-build-*/ **/windows/x64/ **/windows/out/ -**/windows/build/ \ No newline at end of file +**/windows/build/ + +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Coverage +coverage/ +/coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock \ No newline at end of file diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 25254b2..abb08ad 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + @@ -27,7 +26,7 @@ - diff --git a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt index eade015..eff5034 100644 --- a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt +++ b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt @@ -101,7 +101,11 @@ class FlutterMediaSessionPlugin: FlutterPlugin, MethodCallHandler, ActivityAware } } try { - ContextCompat.startForegroundService(context, intent) + try { + context.startService(intent) + } catch (e: Exception) { + android.util.Log.w("FlutterMediaSession", "startService failed, falling back to bindService only", e) + } context.bindService(intent, serviceConnection!!, Context.BIND_AUTO_CREATE) } catch (e: Exception) { pendingActivateResult?.error("SERVICE_ERROR", "Failed to start or bind service: ${e.message}", null) @@ -207,21 +211,95 @@ class FlutterMediaSessionPlugin: FlutterPlugin, MethodCallHandler, ActivityAware return false } + private var originalWindowCallback: android.view.Window.Callback? = null + + private fun attachWindowCallback(act: Activity) { + val window = act.window ?: return + val currentCallback = window.callback + if (currentCallback is MediaWindowCallback) return + originalWindowCallback = currentCallback + window.callback = MediaWindowCallback(currentCallback) + } + + private fun detachWindowCallback(act: Activity?) { + val window = act?.window ?: return + if (window.callback is MediaWindowCallback) { + window.callback = originalWindowCallback + } + originalWindowCallback = null + } + + private inner class MediaWindowCallback( + private val localOriginalCallback: android.view.Window.Callback? + ) : android.view.Window.Callback by (localOriginalCallback ?: DummyWindowCallback()) { + override fun dispatchKeyEvent(event: android.view.KeyEvent?): Boolean { + if (event != null) { + when (event.keyCode) { + android.view.KeyEvent.KEYCODE_MEDIA_PLAY, + android.view.KeyEvent.KEYCODE_MEDIA_PAUSE, + android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + android.view.KeyEvent.KEYCODE_HEADSETHOOK, + android.view.KeyEvent.KEYCODE_MEDIA_NEXT, + android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, + android.view.KeyEvent.KEYCODE_MEDIA_STOP, + android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, + android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { + val handled = FlutterMediaSessionService.instance?.handleMediaKeyEvent(event) ?: false + if (handled) { + return true + } + } + } + } + return localOriginalCallback?.dispatchKeyEvent(event) ?: false + } + } + + private class DummyWindowCallback : android.view.Window.Callback { + override fun dispatchKeyEvent(event: android.view.KeyEvent?): Boolean = false + override fun dispatchKeyShortcutEvent(event: android.view.KeyEvent?): Boolean = false + override fun dispatchTouchEvent(event: android.view.MotionEvent?): Boolean = false + override fun dispatchTrackballEvent(event: android.view.MotionEvent?): Boolean = false + override fun dispatchGenericMotionEvent(event: android.view.MotionEvent?): Boolean = false + override fun dispatchPopulateAccessibilityEvent(event: android.view.accessibility.AccessibilityEvent?): Boolean = false + override fun onCreatePanelView(featureId: Int): android.view.View? = null + override fun onCreatePanelMenu(featureId: Int, menu: android.view.Menu): Boolean = false + override fun onPreparePanel(featureId: Int, view: android.view.View?, menu: android.view.Menu): Boolean = false + override fun onMenuOpened(featureId: Int, menu: android.view.Menu): Boolean = false + override fun onMenuItemSelected(featureId: Int, item: android.view.MenuItem): Boolean = false + override fun onWindowAttributesChanged(attrs: android.view.WindowManager.LayoutParams?) {} + override fun onContentChanged() {} + override fun onWindowFocusChanged(hasFocus: Boolean) {} + override fun onAttachedToWindow() {} + override fun onDetachedFromWindow() {} + override fun onPanelClosed(featureId: Int, menu: android.view.Menu) {} + override fun onSearchRequested(): Boolean = false + override fun onSearchRequested(searchEvent: android.view.SearchEvent?): Boolean = false + override fun onWindowStartingActionMode(callback: android.view.ActionMode.Callback?): android.view.ActionMode? = null + override fun onWindowStartingActionMode(callback: android.view.ActionMode.Callback?, type: Int): android.view.ActionMode? = null + override fun onActionModeStarted(mode: android.view.ActionMode?) {} + override fun onActionModeFinished(mode: android.view.ActionMode?) {} + } + override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity binding.addRequestPermissionsResultListener(this) + attachWindowCallback(binding.activity) } override fun onDetachedFromActivityForConfigChanges() { + detachWindowCallback(activity) activity = null } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity binding.addRequestPermissionsResultListener(this) + attachWindowCallback(binding.activity) } override fun onDetachedFromActivity() { + detachWindowCallback(activity) activity = null } @@ -242,6 +320,7 @@ class FlutterMediaSessionPlugin: FlutterPlugin, MethodCallHandler, ActivityAware * @param args Optional arguments for the action (e.g., seek position). */ fun sendAction(action: String, args: Any? = null) { + android.util.Log.i("FlutterMediaSession", "sendAction: action=$action, args=$args, eventSink=${eventSink != null}") android.os.Handler(android.os.Looper.getMainLooper()).post { if (args != null) { eventSink?.success(mapOf("action" to action, "args" to args)) diff --git a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt index a63ad2b..47ab05c 100644 --- a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt +++ b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt @@ -250,17 +250,6 @@ class FlutterMediaSessionService : MediaSessionService() { player.updateAvailableActions(initialActionNames) } - // Set up explicit MediaButtonReceiver PendingIntent for media buttons - val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply { - setClass(this@FlutterMediaSessionService, androidx.media3.session.MediaButtonReceiver::class.java) - } - val mediaButtonPendingIntent = PendingIntent.getBroadcast( - this, - 0, - mediaButtonIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - // Build the session mediaSession = MediaSession.Builder(this, player) .setSessionActivity(pendingIntent) @@ -271,19 +260,6 @@ class FlutterMediaSessionService : MediaSessionService() { // setMediaNotificationProvider(FlutterMediaNotificationProvider(this)) super.onCreate() - // Set media button receiver on the legacy SessionCompat for pre-Media3 / AVRCP hardware keys - try { - val getSessionCompatMethod = mediaSession?.javaClass?.getDeclaredMethod("getSessionCompat") - getSessionCompatMethod?.isAccessible = true - val sessionCompat = getSessionCompatMethod?.invoke(mediaSession) - if (sessionCompat != null) { - val setReceiverMethod = sessionCompat.javaClass.getMethod("setMediaButtonReceiver", PendingIntent::class.java) - setReceiverMethod.invoke(sessionCompat, mediaButtonPendingIntent) - } - } catch (e: Exception) { - android.util.Log.w("FlutterMediaSession", "Failed to setMediaButtonReceiver on sessionCompat", e) - } - // Register the session with the service so Media3's // MediaNotificationManager creates its internal MediaController // and starts the notification pipeline. @@ -294,6 +270,22 @@ class FlutterMediaSessionService : MediaSessionService() { FlutterMediaSessionPlugin.instance?.onServiceCreated() } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null && Intent.ACTION_MEDIA_BUTTON == intent.action) { + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, android.view.KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? android.view.KeyEvent + } + if (keyEvent != null) { + android.util.Log.i("FlutterMediaSession", "onStartCommand MEDIA_BUTTON: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") + handleMediaKeyEvent(keyEvent) + } + } + return super.onStartCommand(intent, flags, startId) + } + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } @@ -615,57 +607,65 @@ class FlutterMediaSessionService : MediaSessionService() { intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? android.view.KeyEvent } if (keyEvent != null) { - android.util.Log.d("FlutterMediaSession", "onMediaButtonEvent: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") - if (keyEvent.action == android.view.KeyEvent.ACTION_DOWN) { - when (keyEvent.keyCode) { - android.view.KeyEvent.KEYCODE_MEDIA_PLAY -> { - FlutterMediaSessionPlugin.instance?.sendAction("play") - return true - } - android.view.KeyEvent.KEYCODE_MEDIA_PAUSE -> { - FlutterMediaSessionPlugin.instance?.sendAction("pause") - return true - } - android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - android.view.KeyEvent.KEYCODE_HEADSETHOOK -> { - val action = if (player.isCurrentlyPlaying()) "pause" else "play" - FlutterMediaSessionPlugin.instance?.sendAction(action) - return true - } - android.view.KeyEvent.KEYCODE_MEDIA_NEXT, - android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - FlutterMediaSessionPlugin.instance?.sendAction("skipToNext") - return true - } - android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, - android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { - FlutterMediaSessionPlugin.instance?.sendAction("skipToPrevious") - return true - } - android.view.KeyEvent.KEYCODE_MEDIA_STOP -> { - FlutterMediaSessionPlugin.instance?.sendAction("stop") - return true - } - } - } else if (keyEvent.action == android.view.KeyEvent.ACTION_UP) { - // Consume ACTION_UP for handled keys so default media receivers don't double trigger - when (keyEvent.keyCode) { - android.view.KeyEvent.KEYCODE_MEDIA_PLAY, - android.view.KeyEvent.KEYCODE_MEDIA_PAUSE, - android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - android.view.KeyEvent.KEYCODE_HEADSETHOOK, - android.view.KeyEvent.KEYCODE_MEDIA_NEXT, - android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, - android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, - android.view.KeyEvent.KEYCODE_MEDIA_REWIND, - android.view.KeyEvent.KEYCODE_MEDIA_STOP -> return true - } + android.util.Log.i("FlutterMediaSession", "onMediaButtonEvent: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") + if (handleMediaKeyEvent(keyEvent)) { + return true } } return super.onMediaButtonEvent(session, controllerInfo, intent) } } + internal fun handleMediaKeyEvent(keyEvent: android.view.KeyEvent): Boolean { + android.util.Log.i("FlutterMediaSession", "handleMediaKeyEvent: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") + if (keyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + when (keyEvent.keyCode) { + android.view.KeyEvent.KEYCODE_MEDIA_PLAY -> { + FlutterMediaSessionPlugin.instance?.sendAction("play") + return true + } + android.view.KeyEvent.KEYCODE_MEDIA_PAUSE -> { + FlutterMediaSessionPlugin.instance?.sendAction("pause") + return true + } + android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + android.view.KeyEvent.KEYCODE_HEADSETHOOK -> { + val action = if (player.isCurrentlyPlaying()) "pause" else "play" + FlutterMediaSessionPlugin.instance?.sendAction(action) + return true + } + android.view.KeyEvent.KEYCODE_MEDIA_NEXT, + android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + FlutterMediaSessionPlugin.instance?.sendAction("skipToNext") + return true + } + android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, + android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { + FlutterMediaSessionPlugin.instance?.sendAction("skipToPrevious") + return true + } + android.view.KeyEvent.KEYCODE_MEDIA_STOP -> { + FlutterMediaSessionPlugin.instance?.sendAction("stop") + return true + } + } + } else if (keyEvent.action == android.view.KeyEvent.ACTION_UP) { + // Consume ACTION_UP for handled keys so default media receivers don't double trigger + when (keyEvent.keyCode) { + android.view.KeyEvent.KEYCODE_MEDIA_PLAY, + android.view.KeyEvent.KEYCODE_MEDIA_PAUSE, + android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + android.view.KeyEvent.KEYCODE_HEADSETHOOK, + android.view.KeyEvent.KEYCODE_MEDIA_NEXT, + android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, + android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, + android.view.KeyEvent.KEYCODE_MEDIA_REWIND, + android.view.KeyEvent.KEYCODE_MEDIA_STOP -> return true + } + } + return false + } + /** * Custom player implementation that forwards state changes to Media3 and commands to Flutter. */ @@ -807,6 +807,12 @@ class FlutterMediaSessionService : MediaSessionService() { .setRepeatMode(repeatMode) .setShuffleModeEnabled(shuffleModeEnabled) .setCurrentMediaItemIndex(0) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build() + ) .setPlaylist(listOf( MediaItemData.Builder("channel_0") .setMediaItem(MediaItem.Builder().setMediaId("channel_0").setMediaMetadata(currentMetadata).build()) diff --git a/android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonReceiver.kt b/android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonReceiver.kt new file mode 100644 index 0000000..af90dd3 --- /dev/null +++ b/android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonReceiver.kt @@ -0,0 +1,52 @@ +package dev.wyrin.flutter_media_session + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import android.view.KeyEvent + +/** + * Custom BroadcastReceiver for handling MEDIA_BUTTON actions. + * Avoids background ForegroundServiceStartNotAllowedException on Android 12+ (API 31+) + * by dispatching directly to the active FlutterMediaSessionService in memory. + */ +class MediaButtonReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent == null || Intent.ACTION_MEDIA_BUTTON != intent.action) { + return + } + + var keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent + } + + if (keyEvent == null) { + // Fallback for intent extras sent as integer or string (e.g. adb broadcast or OEM proxies) + val keyCodeInt = intent.getIntExtra(Intent.EXTRA_KEY_EVENT, -1).takeIf { it != -1 } + ?: intent.getStringExtra(Intent.EXTRA_KEY_EVENT)?.toIntOrNull() + if (keyCodeInt != null) { + keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, keyCodeInt) + } + } + + if (keyEvent != null) { + Log.i("FlutterMediaSession", "MediaButtonReceiver received: keyCode=${keyEvent.keyCode}, action=${keyEvent.action}") + val service = FlutterMediaSessionService.instance + if (service != null) { + if (service.handleMediaKeyEvent(keyEvent)) { + if (isOrderedBroadcast) { + abortBroadcast() + } + return + } + } else { + Log.w("FlutterMediaSession", "MediaButtonReceiver: FlutterMediaSessionService is not running") + } + } + } +} diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts index 4d7f1ce..e75a997 100644 --- a/example/android/app/build.gradle.kts +++ b/example/android/app/build.gradle.kts @@ -1,12 +1,28 @@ import java.util.Properties +// Detect AGP version to handle the built-in Kotlin migration (AGP 9.0+) +val agpVersion = try { + val versionClass = Class.forName("com.android.Version") + versionClass.getField("ANDROID_GRADLE_PLUGIN_VERSION").get(null) as String +} catch (e: Exception) { + "0.0.0" +} + +val isAgp9OrHigher = agpVersion.startsWith("9.") || + (agpVersion.split(".").firstOrNull()?.toIntOrNull() ?: 0) >= 9 + plugins { id("com.android.application") - id("kotlin-android") + // Note: Do not explicitly apply org.jetbrains.kotlin.android here to satisfy AGP 9.0+ guidelines // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Dynamically apply KGP for AGP 8.x and below to ensure Kotlin sources are compiled +if (!isAgp9OrHigher) { + apply(plugin = "org.jetbrains.kotlin.android") +} + val localProperties = Properties() val localPropertiesFile = rootProject.file("local.properties") if (localPropertiesFile.exists()) { @@ -23,10 +39,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "dev.wyrin.flutter_media_session_example" @@ -50,3 +62,31 @@ android { flutter { source = "../.." } + +// Safely configure jvmTarget for both KGP 1.x and 2.x without using the strict kotlinOptions DSL +tasks.configureEach { + if (name.startsWith("compile") && name.endsWith("Kotlin")) { + try { + // Target KGP 2.x compilerOptions + val compilerOptions = property("compilerOptions") + if (compilerOptions != null) { + val jvmTargetMethod = compilerOptions.javaClass.getMethod("getJvmTarget") + val jvmTargetProperty = jvmTargetMethod.invoke(compilerOptions) + val setMethod = jvmTargetProperty.javaClass.getMethod("set", Any::class.java) + + val jvmTargetClass = Class.forName("org.jetbrains.kotlin.gradle.dsl.JvmTarget") + val jvm17 = jvmTargetClass.getField("JVM_17").get(null) + setMethod.invoke(jvmTargetProperty, jvm17) + } + } catch (e: Exception) { + // Fallback for KGP 1.x kotlinOptions + try { + val kotlinOptions = property("kotlinOptions") + val setJvmTarget = kotlinOptions?.javaClass?.getMethod("setJvmTarget", String::class.java) + setJvmTarget?.invoke(kotlinOptions, "17") + } catch (ignored: Exception) { + logger.warn("Failed to set Kotlin JVM target to 17 for task ${name}: ${ignored.message}") + } + } + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index d13ec7b..1b24a00 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'dart:math'; import 'package:audioplayers/audioplayers.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; import 'models/track.dart'; @@ -115,15 +116,17 @@ class _PlayerHomeState extends State { final List _audioSubscriptions = []; Timer? _positionSyncTimer; - final List _playlist = List.generate(17, (index) { - final id = index + 1; - return Track( - title: 'SoundHelix Song $id', - artist: 'SoundHelix', - artwork: 'https://picsum.photos/400/400?seed=$id', - url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-$id.mp3', - ); - }); + AudioSourceType _audioSource = AudioSourceType.soundHelix; + + List get _playlist => List.generate(17, (index) { + final id = index + 1; + return Track( + title: 'SoundHelix Song $id', + artist: 'SoundHelix', + artwork: 'https://picsum.photos/400/400?seed=$id', + url: '${_audioSource.baseUrl}/SoundHelix-Song-$id.mp3', + ); + }); Track get current => _playlist[_currentIndex]; @@ -134,6 +137,7 @@ class _PlayerHomeState extends State { void initState() { super.initState(); _loadAppVersion(); + _loadSavedAudioSource(); _adapter = _ExamplePlayerAdapter(this); _availableActions = { MediaAction.play, @@ -165,6 +169,21 @@ class _PlayerHomeState extends State { } } + Future _loadSavedAudioSource() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedSource = prefs.getString('preferred_audio_source'); + if (savedSource != null && mounted) { + final matched = AudioSourceType.values.where((e) => e.name == savedSource); + if (matched.isNotEmpty && matched.first != _audioSource) { + setState(() { + _audioSource = matched.first; + }); + } + } + } catch (_) {} + } + void _listenMediaSessionActions() { _plugin.bind(_adapter); } @@ -446,6 +465,36 @@ class _PlayerHomeState extends State { _updatePlayback(); } + void _changeAudioSource(AudioSourceType newSource) async { + if (_audioSource == newSource) return; + setState(() { + _audioSource = newSource; + }); + + SharedPreferences.getInstance().then((prefs) { + prefs.setString('preferred_audio_source', newSource.name); + }).catchError((_) {}); + + // If currently playing or loaded, reload track from new source preserving position if possible + if (_status == PlaybackStatus.playing) { + final currentPos = _position; + _loadedUrl = null; + setState(() { + _isBuffering = true; + }); + _updatePlayback(); + try { + await _audioPlayer.stop(); + _loadedUrl = current.url; + await _audioPlayer.play(UrlSource(current.url), position: currentPos); + } catch (e) { + _handleError(); + } + } else { + _loadedUrl = null; + } + } + void _playIndex(int newIndex, {bool pushHistory = true}) async { if (pushHistory) { _history.add(_currentIndex); @@ -650,6 +699,8 @@ class _PlayerHomeState extends State { setState(() => _backgroundKeepAlive = val); _plugin.setBackgroundKeepAlive(val); }, + audioSource: _audioSource, + onAudioSourceChanged: _changeAudioSource, ), ], ), @@ -711,6 +762,8 @@ class _PlayerHomeState extends State { setState(() => _backgroundKeepAlive = val); _plugin.setBackgroundKeepAlive(val); }, + audioSource: _audioSource, + onAudioSourceChanged: _changeAudioSource, ), ], ), diff --git a/example/lib/widgets/settings_panel.dart b/example/lib/widgets/settings_panel.dart index b6c4d0b..b27efbd 100644 --- a/example/lib/widgets/settings_panel.dart +++ b/example/lib/widgets/settings_panel.dart @@ -3,6 +3,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_media_session/flutter_media_session.dart'; +enum AudioSourceType { + soundHelix('Official', 'https://www.soundhelix.com/examples/mp3'), + wyrin('CDN', 'https://static.wyrin.dev'); + + final String label; + final String baseUrl; + const AudioSourceType(this.label, this.baseUrl); +} + class SettingsPanel extends StatelessWidget { final bool active; final VoidCallback onActivate; @@ -15,6 +24,8 @@ class SettingsPanel extends StatelessWidget { final void Function(bool) onHandleInterruptionsChanged; final bool backgroundKeepAlive; final void Function(bool) onBackgroundKeepAliveChanged; + final AudioSourceType audioSource; + final ValueChanged onAudioSourceChanged; const SettingsPanel({ super.key, @@ -29,6 +40,8 @@ class SettingsPanel extends StatelessWidget { required this.onHandleInterruptionsChanged, required this.backgroundKeepAlive, required this.onBackgroundKeepAliveChanged, + required this.audioSource, + required this.onAudioSourceChanged, }); @override @@ -146,6 +159,29 @@ class SettingsPanel extends StatelessWidget { ) : const SizedBox.shrink(), ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text("Audio Source"), + trailing: SegmentedButton( + segments: const [ + ButtonSegment( + value: AudioSourceType.soundHelix, + label: Text('Official'), + ), + ButtonSegment( + value: AudioSourceType.wyrin, + label: Text('CDN'), + ), + ], + selected: {audioSource}, + onSelectionChanged: (set) { + onAudioSourceChanged(set.first); + }, + ), + ), ], ); } diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 614073b..0000000 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import audioplayers_darwin -import flutter_media_session -import package_info_plus -import screen_retriever_macos -import window_manager - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) - FlutterMediaSessionPlugin.register(with: registry.registrar(forPlugin: "FlutterMediaSessionPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) - WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) -} diff --git a/example/pubspec.lock b/example/pubspec.lock index e112207..30a7403 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -29,58 +29,58 @@ packages: dependency: "direct main" description: name: audioplayers - sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.8.1" audioplayers_android: dependency: transitive description: name: audioplayers_android - sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "5.3.0" audioplayers_darwin: dependency: transitive description: name: audioplayers_darwin - sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.5.0" audioplayers_linux: dependency: transitive description: name: audioplayers_linux - sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.3.0" audioplayers_platform_interface: dependency: transitive description: name: audioplayers_platform_interface - sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.2.0" audioplayers_web: dependency: transitive description: name: audioplayers_web - sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.0" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -550,6 +550,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -708,5 +764,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 68744b9..014a143 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -15,9 +15,10 @@ dependencies: path: ../ cupertino_icons: ^1.0.8 - audioplayers: ^6.6.0 + audioplayers: ^6.8.1 window_manager: ^0.4.3 package_info_plus: ^10.2.1 + shared_preferences: ^2.2.0 dev_dependencies: integration_test: From f2ef1b55c9fb6645551d6283e34239d7f1fd35f9 Mon Sep 17 00:00:00 2001 From: wyrindev Date: Fri, 25 Sep 2026 13:02:44 +0800 Subject: [PATCH 2/3] style: format code --- example/lib/main.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 1b24a00..8634693 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -174,7 +174,8 @@ class _PlayerHomeState extends State { final prefs = await SharedPreferences.getInstance(); final savedSource = prefs.getString('preferred_audio_source'); if (savedSource != null && mounted) { - final matched = AudioSourceType.values.where((e) => e.name == savedSource); + final matched = + AudioSourceType.values.where((e) => e.name == savedSource); if (matched.isNotEmpty && matched.first != _audioSource) { setState(() { _audioSource = matched.first; From cb5f62ae0d18e8b84e0c6b5baf8a125ea1b398e4 Mon Sep 17 00:00:00 2001 From: wyrindev Date: Sat, 26 Sep 2026 21:14:01 +0800 Subject: [PATCH 3/3] fix(android): resolve media button dispatch routing --- .gitignore | 7 +- android/build.gradle.kts | 1 + android/consumer-rules.pro | 4 + android/src/main/AndroidManifest.xml | 2 +- .../FlutterMediaSessionPlugin.kt | 4 + .../FlutterMediaSessionService.kt | 51 +++- .../MediaButtonRoutingCompat.kt | 247 ++++++++++++++++++ 7 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 android/consumer-rules.pro create mode 100644 android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonRoutingCompat.kt diff --git a/.gitignore b/.gitignore index 13e9088..74fc245 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ migrate_working_dir/ *.iws .idea/ +# VS Code related +.vscode/ + # Flutter/Dart/Pub related /pubspec.lock **/doc/api/ @@ -122,4 +125,6 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock \ No newline at end of file +!/dev/ci/**/Gemfile.lock + +temp/ \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8d57b65..e664a90 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -42,6 +42,7 @@ android { defaultConfig { minSdk = 24 + consumerProguardFiles("consumer-rules.pro") } testOptions { diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 0000000..90f4e98 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,4 @@ +-keep class androidx.media3.session.** { *; } +-keep interface androidx.media3.session.** { *; } +-keep class android.support.v4.media.session.** { *; } +-keep interface android.support.v4.media.session.** { *; } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index abb08ad..388d225 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ - diff --git a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt index eff5034..92d77ac 100644 --- a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt +++ b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionPlugin.kt @@ -177,6 +177,9 @@ class FlutterMediaSessionPlugin: FlutterPlugin, MethodCallHandler, ActivityAware FlutterMediaSessionService.instance?.applyBackgroundKeepAlive(enabled) result.success(null) } + "setSkipIntervals" -> { + result.success(null) + } else -> result.notImplemented() } } @@ -283,6 +286,7 @@ class FlutterMediaSessionPlugin: FlutterPlugin, MethodCallHandler, ActivityAware override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity + binding.activity.volumeControlStream = android.media.AudioManager.STREAM_MUSIC binding.addRequestPermissionsResultListener(this) attachWindowCallback(binding.activity) } diff --git a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt index 47ab05c..e07fc94 100644 --- a/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt +++ b/android/src/main/kotlin/dev/wyrin/flutter_media_session/FlutterMediaSessionService.kt @@ -268,10 +268,19 @@ class FlutterMediaSessionService : MediaSessionService() { // Sync any data that was sent to the plugin before the service was ready FlutterMediaSessionPlugin.instance?.syncPendingData() FlutterMediaSessionPlugin.instance?.onServiceCreated() + + // On Android 11 (API 30) and older, route media buttons using compatibility helper + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + MediaButtonRoutingCompat.setupLegacyRouting(this, mediaSession) + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent != null && Intent.ACTION_MEDIA_BUTTON == intent.action) { + // Satisfy the 5-second foreground requirement for MediaButtonReceiver on API <= 30 + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + ensureForegroundServiceLegacy() + } val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, android.view.KeyEvent::class.java) } else { @@ -279,13 +288,35 @@ class FlutterMediaSessionService : MediaSessionService() { intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? android.view.KeyEvent } if (keyEvent != null) { - android.util.Log.i("FlutterMediaSession", "onStartCommand MEDIA_BUTTON: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") handleMediaKeyEvent(keyEvent) } } return super.onStartCommand(intent, flags, startId) } + /** + * Posts a minimal foreground notification on API <= 30 when started by MediaButtonReceiver + * to fulfill the Android framework requirement. + */ + private fun ensureForegroundServiceLegacy() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + try { + val channelId = "default" + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + if (nm.getNotificationChannel(channelId) == null) { + nm.createNotificationChannel( + android.app.NotificationChannel(channelId, "Media playback", android.app.NotificationManager.IMPORTANCE_LOW) + ) + } + val notification = android.app.Notification.Builder(this, channelId) + .setSmallIcon(applicationInfo.icon) + .build() + startForeground(1001, notification) + } catch (e: Exception) { + android.util.Log.w("FlutterMediaSession", "ensureForegroundServiceLegacy failed: ${e.message}") + } + } + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } @@ -312,6 +343,9 @@ class FlutterMediaSessionService : MediaSessionService() { it.release() mediaSession = null } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + MediaButtonRoutingCompat.onSessionDestroy() + } super.onDestroy() } @@ -595,6 +629,21 @@ class FlutterMediaSessionService : MediaSessionService() { return super.onCustomCommand(session, controller, customCommand, args) } + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture { + android.util.Log.i("FlutterMediaSession", "onPlaybackResumption called from ${controller.packageName}") + FlutterMediaSessionPlugin.instance?.sendAction("play") + val mediaItem = mediaSession.player.currentMediaItem ?: MediaItem.Builder().setMediaId("resumption").build() + val result = MediaSession.MediaItemsWithStartPosition( + listOf(mediaItem), + mediaSession.player.currentMediaItemIndex, + mediaSession.player.currentPosition + ) + return Futures.immediateFuture(result) + } + override fun onMediaButtonEvent( session: MediaSession, controllerInfo: MediaSession.ControllerInfo, diff --git a/android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonRoutingCompat.kt b/android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonRoutingCompat.kt new file mode 100644 index 0000000..9e65693 --- /dev/null +++ b/android/src/main/kotlin/dev/wyrin/flutter_media_session/MediaButtonRoutingCompat.kt @@ -0,0 +1,247 @@ +package dev.wyrin.flutter_media_session + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.media3.session.MediaSession + +/** + * Compatibility handler for media button routing on Android 11 (API <= 30) and older versions. + * + * Configures legacy session flags, registers an initial silent AudioTrack for system audio policy + * tracking, and intercepts media button events before they reach Media3's legacy stub. + */ +internal object MediaButtonRoutingCompat { + + private const val TAG = "FlutterMediaSession" + + private const val FLAG_HANDLES_MEDIA_BUTTONS = 1 + private const val FLAG_HANDLES_TRANSPORT_CONTROLS = 2 + + fun setupLegacyRouting(context: Context, mediaSession: MediaSession?) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R || mediaSession == null) { + return + } + + configureSessionCompat(context, mediaSession) + ensureAudioPlaybackRegistration() + } + + private var persistentSilentTrack: android.media.AudioTrack? = null + + fun onSessionDestroy() { + try { + persistentSilentTrack?.stop() + persistentSilentTrack?.release() + persistentSilentTrack = null + } catch (_: Exception) {} + } + + private fun ensureAudioPlaybackRegistration() { + if (persistentSilentTrack != null) return + try { + val sampleRate = 8000 + val minBuf = android.media.AudioTrack.getMinBufferSize( + sampleRate, + android.media.AudioFormat.CHANNEL_OUT_MONO, + android.media.AudioFormat.ENCODING_PCM_16BIT + ) + val audioAttributes = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + val audioFormat = android.media.AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT) + .build() + val track = android.media.AudioTrack( + audioAttributes, + audioFormat, + minBuf, + android.media.AudioTrack.MODE_STREAM, + android.media.AudioManager.AUDIO_SESSION_ID_GENERATE + ) + track.play() + val silentBytes = ByteArray(minBuf) + track.write(silentBytes, 0, minBuf) + track.pause() + persistentSilentTrack = track + Log.d(TAG, "MediaButtonRoutingCompat: Registered silent AudioTrack for UID tracking") + } catch (e: Exception) { + Log.w(TAG, "MediaButtonRoutingCompat: Failed to register AudioTrack: ${e.message}") + } + } + + private fun configureSessionCompat(context: Context, mediaSession: MediaSession) { + try { + val sessionCompat = findSessionCompat(mediaSession) + if (sessionCompat != null) { + val flags = FLAG_HANDLES_MEDIA_BUTTONS or FLAG_HANDLES_TRANSPORT_CONTROLS + invokeMethod(sessionCompat, "setFlags", arrayOf(Int::class.javaPrimitiveType ?: java.lang.Integer.TYPE), arrayOf(flags)) + invokeMethod(sessionCompat, "setActive", arrayOf(Boolean::class.javaPrimitiveType ?: java.lang.Boolean.TYPE), arrayOf(true)) + + var c: Class<*>? = sessionCompat.javaClass + var impl: Any? = null + while (c != null && c != Any::class.java) { + try { + val f = c.getDeclaredField("mImpl").apply { isAccessible = true } + impl = f.get(sessionCompat) + break + } catch (_: NoSuchFieldException) {} + c = c.superclass + } + if (impl != null) { + var implClass: Class<*>? = impl.javaClass + while (implClass != null && implClass != Any::class.java) { + try { + val f = implClass.getDeclaredField("mSessionFwk").apply { isAccessible = true } + val sessionFwk = f.get(impl) as? android.media.session.MediaSession + if (sessionFwk != null) { + sessionFwk.setFlags(flags) + sessionFwk.isActive = true + } + break + } catch (_: NoSuchFieldException) {} + implClass = implClass.superclass + } + } + + hookCallbackMessageHandler(context, sessionCompat) + } + } catch (e: Exception) { + Log.w(TAG, "MediaButtonRoutingCompat: Could not configure sessionCompat: ${e.message}") + } + } + + private fun hookCallbackMessageHandler(context: Context, sessionCompat: Any) { + try { + var c: Class<*>? = sessionCompat.javaClass + var impl: Any? = null + while (c != null && c != Any::class.java) { + try { + val f = c.getDeclaredField("mImpl").apply { isAccessible = true } + impl = f.get(sessionCompat) + break + } catch (_: NoSuchFieldException) {} + c = c.superclass + } + + if (impl != null) { + var implClass: Class<*>? = impl.javaClass + while (implClass != null && implClass != Any::class.java) { + try { + val sessionFwkField = implClass.getDeclaredField("mSessionFwk").apply { isAccessible = true } + val sessionFwk = sessionFwkField.get(impl) as? android.media.session.MediaSession + if (sessionFwk != null) { + var fwkClass: Class<*>? = sessionFwk.javaClass + while (fwkClass != null && fwkClass != Any::class.java) { + try { + val cbField = fwkClass.getDeclaredField("mCallback").apply { isAccessible = true } + val originalHandler = cbField.get(sessionFwk) as? android.os.Handler + if (originalHandler != null) { + val mCallbackField = android.os.Handler::class.java.getDeclaredField("mCallback").apply { isAccessible = true } + val existingCallback = mCallbackField.get(originalHandler) as? android.os.Handler.Callback + mCallbackField.set(originalHandler, android.os.Handler.Callback { msg -> + try { + var intent = msg.obj as? Intent + if (intent == null && msg.obj != null) { + val objClass = msg.obj.javaClass + if (objClass.name == "android.util.Pair") { + try { + val secondField = objClass.getField("second") + intent = secondField.get(msg.obj) as? Intent + } catch (_: Exception) {} + } + } + val keyEvent = intent?.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + if (keyEvent != null || msg.what == 2) { + if (keyEvent != null) { + FlutterMediaSessionService.instance?.handleMediaKeyEvent(keyEvent) + return@Callback true + } + } + return@Callback existingCallback?.handleMessage(msg) ?: false + } catch (t: Throwable) { + Log.w(TAG, "MediaButtonRoutingCompat: Error handling media button event: ${t.message}") + return@Callback true + } + }) + } + break + } catch (_: NoSuchFieldException) {} + fwkClass = fwkClass.superclass + } + } + break + } catch (_: NoSuchFieldException) {} + implClass = implClass.superclass + } + } + } catch (e: Exception) { + Log.w(TAG, "MediaButtonRoutingCompat: hookCallbackMessageHandler failed: ${e.message}") + } + } + + private fun findSessionCompat(obj: Any?): Any? { + if (obj == null) return null + try { + val implField = obj.javaClass.getDeclaredField("impl").apply { isAccessible = true } + val impl = implField.get(obj) + if (impl != null) { + var curr: Class<*>? = impl.javaClass + while (curr != null && curr != Any::class.java) { + try { + val sessionCompatField = curr.getDeclaredField("sessionCompat").apply { isAccessible = true } + val sessionCompat = sessionCompatField.get(impl) + if (sessionCompat != null) return sessionCompat + } catch (_: NoSuchFieldException) {} + curr = curr.superclass + } + } + } catch (e: Exception) { + Log.d(TAG, "MediaButtonRoutingCompat: Direct sessionCompat lookup failed: ${e.message}") + } + + fun search(currObj: Any?, depth: Int): Any? { + if (currObj == null || depth > 3) return null + var c: Class<*>? = currObj.javaClass + while (c != null && c != Any::class.java) { + for (f in c.declaredFields) { + try { + f.isAccessible = true + val v = f.get(currObj) ?: continue + if (v.javaClass.name.contains("MediaSessionCompat") || f.type.name.contains("MediaSessionCompat")) { + return v + } + if (depth < 2 && !f.type.isPrimitive && !f.type.name.startsWith("java.") && !f.type.name.startsWith("android.")) { + val found = search(v, depth + 1) + if (found != null) return found + } + } catch (_: Exception) {} + } + c = c.superclass + } + return null + } + return search(obj, 0) + } + + private fun invokeMethod(target: Any, methodName: String, paramTypes: Array>, args: Array): Boolean { + var c: Class<*>? = target.javaClass + while (c != null && c != Any::class.java) { + try { + val method = c.getMethod(methodName, *paramTypes) + method.invoke(target, *args) + return true + } catch (_: NoSuchMethodException) { + c = c.superclass + } catch (e: Exception) { + Log.w(TAG, "MediaButtonRoutingCompat: Invoking $methodName failed: ${e.message}") + return false + } + } + return false + } +}