diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt new file mode 100644 index 0000000..3e36858 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt @@ -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> + ) { + 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() + } +} +