@@ -7,19 +7,24 @@ import android.app.Service
77import android.content.Context
88import android.content.Intent
99import android.content.pm.ServiceInfo
10+ import android.database.ContentObserver
1011import android.media.AudioAttributes
1112import android.media.AudioFormat
1213import android.media.AudioManager
1314import android.media.AudioPlaybackCaptureConfiguration
1415import android.media.AudioRecord
16+ import android.media.MediaMetadata
1517import android.media.VolumeProvider
1618import android.media.projection.MediaProjection
1719import android.media.projection.MediaProjectionManager
1820import android.media.session.MediaSession
1921import android.media.session.PlaybackState
2022import android.net.wifi.WifiManager
23+ import android.os.Handler
2124import android.os.IBinder
25+ import android.os.Looper
2226import android.os.PowerManager
27+ import android.provider.Settings
2328import java.net.DatagramPacket
2429import java.net.DatagramSocket
2530import java.net.InetAddress
@@ -45,13 +50,11 @@ class AudioStreamService : Service() {
4550 const val EXTRA_LOG_SCALING = " logScaling"
4651 const val EXTRA_MUTE_LOCAL = " muteLocal"
4752
48- // Airwire protocol signature bytes
4953 const val SIGNATURE_BYTE_1 : Byte = 13
5054 const val SIGNATURE_BYTE_2 : Byte = 37
5155 const val SIGNATURE_SIZE = 2
5256 const val PACKET_ID_SIZE = 8
5357
54- // Static instance so AudioCaptureModule can call updateVolume
5558 @Volatile
5659 var instance: AudioStreamService ? = null
5760 private set
@@ -69,26 +72,30 @@ class AudioStreamService : Service() {
6972 @Volatile var currentGain: Float = 1.0f
7073 @Volatile var currentLogScaling: Boolean = false
7174
72- // Locks
7375 private var wakeLock: PowerManager .WakeLock ? = null
7476 private var wifiLock: WifiManager .WifiLock ? = null
7577
76- // Mute
7778 private var audioManager: AudioManager ? = null
7879 private var savedVolume: Int = - 1
7980 private var didMute = false
8081
81- // MediaSession
8282 private var mediaSession: MediaSession ? = null
8383 private var volumeProvider: VolumeProvider ? = null
8484
85+ // Fallback: watch system volume changes in case VolumeProvider callbacks
86+ // are not fired (known Android 12+ issue)
87+ private var volumeObserver: ContentObserver ? = null
88+ private var lastObservedSystemVol: Int = - 1
89+ private var systemMaxVol: Int = 15
90+
8591 override fun onBind (intent : Intent ? ): IBinder ? = null
8692
8793 override fun onCreate () {
8894 super .onCreate()
8995 instance = this
9096 createNotificationChannel()
9197 audioManager = getSystemService(Context .AUDIO_SERVICE ) as AudioManager
98+ systemMaxVol = audioManager?.getStreamMaxVolume(AudioManager .STREAM_MUSIC ) ? : 15
9299 log(" info" , " [Service] onCreate" )
93100 }
94101
@@ -128,7 +135,6 @@ class AudioStreamService : Service() {
128135 return START_NOT_STICKY
129136 }
130137
131- // Set up MediaSession before startForeground so the notification can reference it
132138 setupMediaSession(volume, logScaling)
133139
134140 try {
@@ -165,6 +171,9 @@ class AudioStreamService : Service() {
165171 muteLocalAudio()
166172 }
167173
174+ // Start fallback volume observer
175+ startVolumeObserver(volume)
176+
168177 startStreaming(host, port, sampleRate, channels, frameSize, packetPacing, repeatPackets, verboseLogging, volume, logScaling)
169178
170179 return START_NOT_STICKY
@@ -224,20 +233,47 @@ class AudioStreamService : Service() {
224233 private fun setupMediaSession (initialVolume : Int , logScaling : Boolean ) {
225234 try {
226235 mediaSession = MediaSession (this , " AirwireStreamer" ).apply {
236+ // Set metadata so the system treats this as a real media session
237+ setMetadata(
238+ MediaMetadata .Builder ()
239+ .putString(MediaMetadata .METADATA_KEY_TITLE , " Airwire Streamer" )
240+ .putString(MediaMetadata .METADATA_KEY_ARTIST , " Streaming audio" )
241+ .putLong(MediaMetadata .METADATA_KEY_DURATION , - 1L )
242+ .build()
243+ )
244+
227245 setPlaybackState(
228246 PlaybackState .Builder ()
229247 .setState(PlaybackState .STATE_PLAYING , 0 , 1.0f )
230- .setActions(PlaybackState .ACTION_PLAY or PlaybackState .ACTION_STOP )
248+ .setActions(
249+ PlaybackState .ACTION_PLAY or
250+ PlaybackState .ACTION_STOP or
251+ PlaybackState .ACTION_PAUSE
252+ )
231253 .build()
232254 )
233255
256+ // Set a callback for media button events
257+ setCallback(object : MediaSession .Callback () {
258+ override fun onPlay () {
259+ log(" info" , " [MediaSession] onPlay callback" )
260+ }
261+ override fun onStop () {
262+ log(" info" , " [MediaSession] onStop callback" )
263+ }
264+ override fun onPause () {
265+ log(" info" , " [MediaSession] onPause callback" )
266+ }
267+ }, Handler (Looper .getMainLooper()))
268+
234269 val vp = object : VolumeProvider (
235270 VOLUME_CONTROL_ABSOLUTE ,
236271 100 ,
237272 initialVolume.coerceIn(0 , 100 )
238273 ) {
239274 override fun onSetVolumeTo (volume : Int ) {
240275 val clamped = volume.coerceIn(0 , 100 )
276+ log(" info" , " [VolumeProvider] onSetVolumeTo: $clamped " )
241277 setCurrentVolume(clamped)
242278 applyVolume(clamped, currentLogScaling)
243279 AudioStreamEventEmitter .emit(" volume_change" , clamped.toString())
@@ -252,6 +288,7 @@ class AudioStreamService : Service() {
252288 AudioManager .ADJUST_UNMUTE -> current
253289 else -> current
254290 }
291+ log(" info" , " [VolumeProvider] onAdjustVolume: direction=$direction , $current ->$newVol " )
255292 setCurrentVolume(newVol)
256293 applyVolume(newVol, currentLogScaling)
257294 AudioStreamEventEmitter .emit(" volume_change" , newVol.toString())
@@ -267,7 +304,42 @@ class AudioStreamService : Service() {
267304 }
268305 }
269306
270- /* * Called from AudioCaptureModule.updateVolume or from MediaSession VolumeProvider */
307+ /* *
308+ * Fallback: If VolumeProvider callbacks don't fire (Android 12+ bug),
309+ * observe system STREAM_MUSIC volume changes and map them to our gain.
310+ * When the user drags the system "cast" slider, on broken Android versions
311+ * it changes STREAM_MUSIC directly instead of calling VolumeProvider.
312+ */
313+ private fun startVolumeObserver (initialAppVolume : Int ) {
314+ val am = audioManager ? : return
315+ lastObservedSystemVol = am.getStreamVolume(AudioManager .STREAM_MUSIC )
316+
317+ val handler = Handler (Looper .getMainLooper())
318+ volumeObserver = object : ContentObserver (handler) {
319+ override fun onChange (selfChange : Boolean ) {
320+ val newSysVol = am.getStreamVolume(AudioManager .STREAM_MUSIC )
321+ if (newSysVol != lastObservedSystemVol) {
322+ val delta = newSysVol - lastObservedSystemVol
323+ lastObservedSystemVol = newSysVol
324+
325+ // Map system volume (0..systemMaxVol) to app volume (0..100)
326+ val appVol = ((newSysVol.toFloat() / systemMaxVol.toFloat()) * 100 ).toInt().coerceIn(0 , 100 )
327+ log(" info" , " [VolumeObserver] System vol changed: $newSysVol /$systemMaxVol -> appVol=$appVol (delta=$delta )" )
328+ applyVolume(appVol, currentLogScaling)
329+ syncMediaSessionVolume(appVol)
330+ AudioStreamEventEmitter .emit(" volume_change" , appVol.toString())
331+ }
332+ }
333+ }
334+
335+ contentResolver.registerContentObserver(
336+ Settings .System .CONTENT_URI ,
337+ true ,
338+ volumeObserver!!
339+ )
340+ log(" info" , " [Service] Volume observer registered (sysVol=$lastObservedSystemVol /$systemMaxVol )" )
341+ }
342+
271343 fun applyVolume (volume : Int , logScaling : Boolean ) {
272344 val clamped = volume.coerceIn(0 , 100 )
273345 currentLogScaling = logScaling
@@ -279,7 +351,6 @@ class AudioStreamService : Service() {
279351 }
280352 }
281353
282- /* * Called from AudioCaptureModule to sync MediaSession's volume display */
283354 fun syncMediaSessionVolume (volume : Int ) {
284355 volumeProvider?.setCurrentVolume(volume.coerceIn(0 , 100 ))
285356 }
@@ -303,7 +374,6 @@ class AudioStreamService : Service() {
303374 .setSmallIcon(android.R .drawable.ic_media_play)
304375 .setOngoing(true )
305376
306- // Attach MediaSession token so system volume UI routes here
307377 val session = mediaSession
308378 if (session != null ) {
309379 builder.setStyle(
@@ -399,7 +469,6 @@ class AudioStreamService : Service() {
399469 val recState = audioRecord?.recordingState
400470 log(" info" , " [Stream] AudioRecord recordingState=$recState (3=recording)" )
401471
402- // Set initial gain
403472 applyVolume(volume, logScaling)
404473 log(" info" , " [Stream] Initial volume=$volume %, logScaling=$logScaling , gain=$currentGain " )
405474
@@ -429,7 +498,6 @@ class AudioStreamService : Service() {
429498 }
430499 if (shortsRead == 0 ) continue
431500
432- // Read the volatile gain (may be updated by slider or MediaSession at any time)
433501 val gain = currentGain
434502 if (gain < 1.0f ) {
435503 for (s in 0 until shortsRead) {
@@ -514,6 +582,12 @@ class AudioStreamService : Service() {
514582 mediaSession = null
515583 volumeProvider = null
516584
585+ // Unregister volume observer
586+ try {
587+ volumeObserver?.let { contentResolver.unregisterContentObserver(it) }
588+ } catch (_: Exception ) {}
589+ volumeObserver = null
590+
517591 restoreLocalAudio()
518592
519593 try {
0 commit comments