Fix: remove orphaned old class body from KryzAutoMediaBrowserService #5

Merged
kfj001 merged 2 commits from agents/android-auto-apple-carplay-support into main 2026-05-15 23:17:10 -07:00
Showing only changes of commit 52f5ec577b - Show all commits
@@ -0,0 +1,361 @@
package com.example.kryz_go_flutter
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat
/**
* MediaBrowserService for Android Auto.
*
* Android Auto connects to this service to browse content and control playback.
*
* Two playback modes:
* 1. Cold start (Flutter not running): the service plays the stream directly via
* MediaPlayer. This avoids the unreliable startActivity → Flutter bridge.
* 2. Warm start (Flutter running): MainActivity registers play/stop callbacks so
* Auto commands are forwarded through the method channel to just_audio.
*
* When the Flutter app opens while the service is playing, MainActivity stops the
* service's MediaPlayer and Flutter takes over seamlessly.
*
* Brand color (#E36A18) is declared on this service via KryzAutoTheme in styles.xml
* so Android Auto accents its media UI with the KRYZ orange.
*/
class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
companion object {
private const val TAG = "KryzAutoService"
private const val MEDIA_ID_ROOT = "kryz_root"
private const val MEDIA_ID_LIVE = "kryz_live_stream"
private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a"
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
private const val NOTIFICATION_ID = 1001
private var instance: KryzAutoMediaBrowserService? = null
// Called by MainActivity to mirror Flutter playback state into the MediaSession.
fun updatePlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
instance?.applyPlaybackState(isPlaying, isBuffering)
}
// Called by MainActivity when Flutter is alive and taking over from the service player.
fun stopServicePlayer() {
instance?.stopServicePlayback()
}
fun isServicePlaying(): Boolean {
return try {
instance?.serviceMediaPlayer?.isPlaying == true
} catch (_: IllegalStateException) {
false
}
}
// Flutter-side play/stop callbacks registered by MainActivity when Flutter is ready.
private var directPlayCallback: (() -> Unit)? = null
private var directStopCallback: (() -> Unit)? = null
fun registerDirectCallbacks(play: () -> Unit, stop: () -> Unit) {
directPlayCallback = play
directStopCallback = stop
}
fun unregisterDirectCallbacks() {
directPlayCallback = null
directStopCallback = null
}
}
private lateinit var mediaSession: MediaSessionCompat
private var serviceMediaPlayer: MediaPlayer? = null
private var audioFocusRequest: AudioFocusRequest? = null
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
Log.d(TAG, "onPlay()")
triggerPlay()
}
override fun onStop() {
Log.d(TAG, "onStop()")
val stop = directStopCallback
if (stop != null) {
stop()
} else {
stopServicePlayback()
}
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(TAG, "onPlayFromMediaId: $mediaId")
if (mediaId == MEDIA_ID_LIVE) triggerPlay()
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(TAG, "onPlayFromSearch: $query")
triggerPlay()
}
}
private fun triggerPlay() {
val play = directPlayCallback
if (play != null) {
// Flutter is running — signal buffering then let Flutter handle audio.
applyPlaybackState(isPlaying = false, isBuffering = true)
play()
} else {
// Flutter is not running — play directly via MediaPlayer.
startServicePlayback()
}
}
override fun onCreate() {
super.onCreate()
instance = this
Log.d(TAG, "onCreate()")
createNotificationChannel()
try {
mediaSession = MediaSessionCompat(
this,
TAG,
ComponentName(this, com.ryanheise.audioservice.MediaButtonReceiver::class.java),
null,
).apply {
setCallback(mediaSessionCallback)
@Suppress("DEPRECATION")
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
)
.setState(PlaybackStateCompat.STATE_STOPPED, 0, 1f)
.build()
)
setMetadata(buildLiveStreamMetadata())
isActive = true
}
sessionToken = mediaSession.sessionToken
Log.d(TAG, "MediaSession created, sessionToken set")
} catch (e: Exception) {
Log.e(TAG, "Failed to create MediaSession", e)
}
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
Log.d(TAG, "onGetRoot() from $clientPackageName")
return BrowserRoot(MEDIA_ID_ROOT, null)
}
override fun onLoadChildren(
parentId: String,
result: Result<List<MediaItem>>
) {
Log.d(TAG, "onLoadChildren($parentId)")
if (parentId == MEDIA_ID_ROOT) {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
val description = MediaDescriptionCompat.Builder()
.setMediaId(MEDIA_ID_LIVE)
.setTitle("KRYZ Live Stream")
.setSubtitle("KRYZ Radio")
.setIconUri(iconUri)
.setMediaUri(Uri.parse(STREAM_URL))
.build()
result.sendResult(listOf(MediaItem(description, MediaItem.FLAG_PLAYABLE)))
} else {
result.sendResult(emptyList())
}
}
override fun onDestroy() {
Log.d(TAG, "onDestroy()")
stopServicePlayback()
instance = null
if (::mediaSession.isInitialized) mediaSession.release()
super.onDestroy()
}
// Direct MediaPlayer playback used when Flutter is not running (cold Auto start).
private fun startServicePlayback() {
Log.d(TAG, "startServicePlayback()")
stopServicePlayback()
applyPlaybackState(isPlaying = false, isBuffering = true)
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = false))
requestAudioFocus()
val player = MediaPlayer()
try {
player.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
player.setDataSource(STREAM_URL)
player.setOnPreparedListener { mp ->
Log.d(TAG, "MediaPlayer prepared → starting")
mp.start()
applyPlaybackState(isPlaying = true, isBuffering = false)
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = true))
}
player.setOnInfoListener { _, what, _ ->
when (what) {
MediaPlayer.MEDIA_INFO_BUFFERING_START ->
applyPlaybackState(isPlaying = false, isBuffering = true)
MediaPlayer.MEDIA_INFO_BUFFERING_END ->
applyPlaybackState(isPlaying = true, isBuffering = false)
}
true
}
player.setOnErrorListener { _, what, extra ->
Log.e(TAG, "MediaPlayer error what=$what extra=$extra")
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
true
}
player.prepareAsync()
serviceMediaPlayer = player
} catch (e: Exception) {
Log.e(TAG, "Failed to start service playback: ${e.message}")
player.release()
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
}
}
private fun stopServicePlayback() {
val player = serviceMediaPlayer ?: return
Log.d(TAG, "stopServicePlayback()")
serviceMediaPlayer = null
try {
if (player.isPlaying) player.stop()
player.release()
} catch (e: Exception) {
Log.e(TAG, "Error releasing MediaPlayer: ${e.message}")
}
abandonAudioFocus()
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
}
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
if (!::mediaSession.isInitialized) return
val state = when {
isPlaying -> PlaybackStateCompat.STATE_PLAYING
isBuffering -> PlaybackStateCompat.STATE_BUFFERING
else -> PlaybackStateCompat.STATE_STOPPED
}
val actions = if (isPlaying || isBuffering) {
PlaybackStateCompat.ACTION_STOP
} else {
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
}
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.build()
)
Log.d(TAG, "applyPlaybackState: playing=$isPlaying buffering=$isBuffering")
}
private fun requestAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.build()
audioFocusRequest = req
audioManager.requestAudioFocus(req)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
}
}
private fun abandonAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"KRYZ Auto Playback",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "KRYZ Radio Android Auto playback"
setShowBadge(false)
}
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mgr.createNotificationChannel(channel)
}
}
private fun buildNotification(isPlaying: Boolean): Notification {
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("KRYZ Radio")
.setContentText(if (isPlaying) "Playing via Android Auto" else "Connecting…")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(isPlaying)
.setSilent(true)
.build()
}
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
return MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
.build()
}
}