refactors design to flutter driven service core. #6
+224
-158
@@ -3,36 +3,30 @@ package com.example.kryz_go_flutter
|
|||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioAttributes
|
import android.os.Handler
|
||||||
import android.media.AudioFocusRequest
|
import android.os.Looper
|
||||||
import android.media.AudioManager
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.media.MediaPlayer
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
|
import com.ryanheise.audioservice.AudioService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MediaBrowserService for Android Auto.
|
* MediaBrowserService for Android Auto.
|
||||||
*
|
*
|
||||||
* Android Auto connects to this service to browse content and control playback.
|
* Android Auto connects to this service to browse content and send transport
|
||||||
*
|
* controls. Playback execution is delegated to Flutter's audio_service session.
|
||||||
* 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
|
* Brand color (#E36A18) is declared on this service via KryzAutoTheme in styles.xml
|
||||||
* so Android Auto accents its media UI with the KRYZ orange.
|
* so Android Auto accents its media UI with the KRYZ orange.
|
||||||
@@ -46,45 +40,55 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a"
|
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_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
|
||||||
private const val NOTIFICATION_ID = 1001
|
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 lateinit var mediaSession: MediaSessionCompat
|
||||||
private var serviceMediaPlayer: MediaPlayer? = null
|
private var audioServiceBrowser: MediaBrowserCompat? = null
|
||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
private var audioServiceController: MediaControllerCompat? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var pendingPlayRequest: Boolean = false
|
||||||
|
private var pendingStopRequest: Boolean = false
|
||||||
|
private var reconnectScheduled: Boolean = false
|
||||||
|
|
||||||
|
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
|
||||||
|
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
||||||
|
applyPlaybackStateFromFlutter(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
|
||||||
|
applyNowPlayingMetadataFromFlutter(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val browserConnectionCallback = object : MediaBrowserCompat.ConnectionCallback() {
|
||||||
|
override fun onConnected() {
|
||||||
|
val browser = audioServiceBrowser ?: return
|
||||||
|
try {
|
||||||
|
val controller = MediaControllerCompat(this@KryzAutoMediaBrowserService, browser.sessionToken)
|
||||||
|
audioServiceController?.unregisterCallback(mediaControllerCallback)
|
||||||
|
audioServiceController = controller
|
||||||
|
controller.registerCallback(mediaControllerCallback)
|
||||||
|
applyPlaybackStateFromFlutter(controller.playbackState)
|
||||||
|
applyNowPlayingMetadataFromFlutter(controller.metadata)
|
||||||
|
consumePendingTransportCommands()
|
||||||
|
Log.d(TAG, "Connected to Flutter audio_service session")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "Unable to bind media controller: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionSuspended() {
|
||||||
|
Log.w(TAG, "audio_service browser connection suspended")
|
||||||
|
handleControllerDisconnected()
|
||||||
|
scheduleBrowserReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionFailed() {
|
||||||
|
Log.e(TAG, "audio_service browser connection failed")
|
||||||
|
handleControllerDisconnected()
|
||||||
|
scheduleBrowserReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
|
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
@@ -94,12 +98,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
Log.d(TAG, "onStop()")
|
Log.d(TAG, "onStop()")
|
||||||
val stop = directStopCallback
|
dispatchStopCommand()
|
||||||
if (stop != null) {
|
|
||||||
stop()
|
|
||||||
} else {
|
|
||||||
stopServicePlayback()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
@@ -114,20 +113,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerPlay() {
|
private fun triggerPlay() {
|
||||||
val play = directPlayCallback
|
dispatchPlayCommand()
|
||||||
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
|
||||||
Log.d(TAG, "onCreate()")
|
Log.d(TAG, "onCreate()")
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
@@ -154,6 +144,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
sessionToken = mediaSession.sessionToken
|
sessionToken = mediaSession.sessionToken
|
||||||
Log.d(TAG, "MediaSession created, sessionToken set")
|
Log.d(TAG, "MediaSession created, sessionToken set")
|
||||||
|
connectToFlutterAudioService()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGetRoot(
|
override fun onGetRoot(
|
||||||
@@ -187,78 +178,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.d(TAG, "onDestroy()")
|
Log.d(TAG, "onDestroy()")
|
||||||
stopServicePlayback()
|
disconnectFromFlutterAudioService()
|
||||||
instance = null
|
|
||||||
if (::mediaSession.isInitialized) mediaSession.release()
|
if (::mediaSession.isInitialized) mediaSession.release()
|
||||||
super.onDestroy()
|
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) {
|
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
|
||||||
if (!::mediaSession.isInitialized) return
|
if (!::mediaSession.isInitialized) return
|
||||||
val state = when {
|
val state = when {
|
||||||
@@ -279,34 +203,66 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
|
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = isPlaying, isBuffering = isBuffering))
|
||||||
Log.d(TAG, "applyPlaybackState: playing=$isPlaying buffering=$isBuffering")
|
Log.d(TAG, "applyPlaybackState: playing=$isPlaying buffering=$isBuffering")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
private fun dispatchPlayCommand() {
|
||||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val controller = audioServiceController
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (controller != null) {
|
||||||
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
pendingPlayRequest = false
|
||||||
.setAudioAttributes(
|
pendingStopRequest = false
|
||||||
AudioAttributes.Builder()
|
applyPlaybackState(isPlaying = false, isBuffering = true)
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
controller.transportControls.play()
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
return
|
||||||
.build()
|
}
|
||||||
)
|
|
||||||
.build()
|
pendingStopRequest = false
|
||||||
audioFocusRequest = req
|
pendingPlayRequest = true
|
||||||
audioManager.requestAudioFocus(req)
|
applyPlaybackState(isPlaying = false, isBuffering = true)
|
||||||
} else {
|
connectToFlutterAudioService()
|
||||||
@Suppress("DEPRECATION")
|
}
|
||||||
audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
|
||||||
|
private fun dispatchStopCommand() {
|
||||||
|
val controller = audioServiceController
|
||||||
|
if (controller != null) {
|
||||||
|
pendingPlayRequest = false
|
||||||
|
pendingStopRequest = false
|
||||||
|
controller.transportControls.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlayRequest = false
|
||||||
|
pendingStopRequest = true
|
||||||
|
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||||
|
connectToFlutterAudioService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consumePendingTransportCommands() {
|
||||||
|
val controller = audioServiceController ?: return
|
||||||
|
|
||||||
|
if (pendingStopRequest) {
|
||||||
|
pendingStopRequest = false
|
||||||
|
controller.transportControls.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingPlayRequest) {
|
||||||
|
pendingPlayRequest = false
|
||||||
|
controller.transportControls.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun abandonAudioFocus() {
|
private fun applyPlaybackStateFromFlutter(state: PlaybackStateCompat?) {
|
||||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
if (state == null) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
return
|
||||||
audioFocusRequest = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isPlaying = state.state == PlaybackStateCompat.STATE_PLAYING
|
||||||
|
val isBuffering = state.state == PlaybackStateCompat.STATE_BUFFERING ||
|
||||||
|
state.state == PlaybackStateCompat.STATE_CONNECTING
|
||||||
|
|
||||||
|
applyPlaybackState(isPlaying = isPlaying, isBuffering = isBuffering)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
@@ -324,16 +280,126 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(isPlaying: Boolean): Notification {
|
private fun buildNotification(isPlaying: Boolean, isBuffering: Boolean): Notification {
|
||||||
|
val statusText = when {
|
||||||
|
isPlaying -> "Playing from Flutter audio handler"
|
||||||
|
isBuffering -> "Connecting via Flutter audio handler..."
|
||||||
|
else -> "Stopped"
|
||||||
|
}
|
||||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle("KRYZ Radio")
|
.setContentTitle("KRYZ Radio")
|
||||||
.setContentText(if (isPlaying) "Playing via Android Auto" else "Connecting…")
|
.setContentText(statusText)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setOngoing(isPlaying)
|
.setOngoing(isPlaying || isBuffering)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyNowPlayingMetadata(
|
||||||
|
title: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
album: String?,
|
||||||
|
mediaUri: String?,
|
||||||
|
) {
|
||||||
|
if (!::mediaSession.isInitialized) return
|
||||||
|
|
||||||
|
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art")
|
||||||
|
mediaSession.setMetadata(
|
||||||
|
MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
|
||||||
|
if (metadata == null) {
|
||||||
|
applyNowPlayingMetadata(
|
||||||
|
title = "KRYZ Live Stream",
|
||||||
|
subtitle = "KRYZ Radio",
|
||||||
|
album = "Live",
|
||||||
|
mediaUri = STREAM_URL,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyNowPlayingMetadata(
|
||||||
|
title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE),
|
||||||
|
subtitle = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST),
|
||||||
|
album = metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM),
|
||||||
|
mediaUri = metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectToFlutterAudioService() {
|
||||||
|
if (audioServiceController != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val existing = audioServiceBrowser
|
||||||
|
if (existing?.isConnected == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
try {
|
||||||
|
existing.connect()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Failed reconnect attempt: ${error.message}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val browser = MediaBrowserCompat(
|
||||||
|
this,
|
||||||
|
ComponentName(this, AudioService::class.java),
|
||||||
|
browserConnectionCallback,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
audioServiceBrowser = browser
|
||||||
|
browser.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectFromFlutterAudioService() {
|
||||||
|
reconnectScheduled = false
|
||||||
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
|
handleControllerDisconnected()
|
||||||
|
|
||||||
|
audioServiceBrowser?.let { browser ->
|
||||||
|
if (browser.isConnected) {
|
||||||
|
browser.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioServiceBrowser = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleControllerDisconnected() {
|
||||||
|
audioServiceController?.unregisterCallback(mediaControllerCallback)
|
||||||
|
audioServiceController = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleBrowserReconnect() {
|
||||||
|
if (reconnectScheduled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectScheduled = true
|
||||||
|
mainHandler.postDelayed(
|
||||||
|
{
|
||||||
|
reconnectScheduled = false
|
||||||
|
connectToFlutterAudioService()
|
||||||
|
},
|
||||||
|
1200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
||||||
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||||
return MediaMetadataCompat.Builder()
|
return MediaMetadataCompat.Builder()
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
companion object {
|
companion object {
|
||||||
private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods"
|
private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods"
|
||||||
private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events"
|
private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events"
|
||||||
private const val PLAYBACK_METHOD_CHANNEL = "kryz_go_flutter/playback/methods"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var castContext: CastContext? = null
|
private var castContext: CastContext? = null
|
||||||
@@ -35,9 +34,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
private var chooserDialog: MediaRouteChooserDialog? = null
|
private var chooserDialog: MediaRouteChooserDialog? = null
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private var activeRemoteMediaClient: RemoteMediaClient? = null
|
private var activeRemoteMediaClient: RemoteMediaClient? = null
|
||||||
private var playbackMethodChannel: MethodChannel? = null
|
|
||||||
private var flutterReady = false
|
|
||||||
private var pendingAutoPlay = false
|
|
||||||
|
|
||||||
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
|
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
|
||||||
"status" to "idle",
|
"status" to "idle",
|
||||||
@@ -106,38 +102,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
||||||
.setStreamHandler(this)
|
.setStreamHandler(this)
|
||||||
|
|
||||||
playbackMethodChannel = MethodChannel(
|
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
|
||||||
PLAYBACK_METHOD_CHANNEL,
|
|
||||||
)
|
|
||||||
|
|
||||||
playbackMethodChannel?.setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"flutterReady" -> {
|
|
||||||
flutterReady = true
|
|
||||||
// Stop the service's MediaPlayer and hand off to Flutter.
|
|
||||||
val wasServicePlaying = KryzAutoMediaBrowserService.isServicePlaying()
|
|
||||||
KryzAutoMediaBrowserService.stopServicePlayer()
|
|
||||||
KryzAutoMediaBrowserService.registerDirectCallbacks(
|
|
||||||
play = { mainHandler.post { invokeFlutterPlay() } },
|
|
||||||
stop = { mainHandler.post { playbackMethodChannel?.invokeMethod("stop", null) } },
|
|
||||||
)
|
|
||||||
if (pendingAutoPlay || wasServicePlaying) {
|
|
||||||
pendingAutoPlay = false
|
|
||||||
invokeFlutterPlay()
|
|
||||||
}
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
"updatePlaybackState" -> {
|
|
||||||
val isPlaying = call.argument<Boolean>("isPlaying") ?: false
|
|
||||||
val isBuffering = call.argument<Boolean>("isBuffering") ?: false
|
|
||||||
KryzAutoMediaBrowserService.updatePlaybackState(isPlaying, isBuffering)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeCastContext()
|
initializeCastContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,8 +145,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
chooserDialog?.dismiss()
|
chooserDialog?.dismiss()
|
||||||
chooserDialog = null
|
chooserDialog = null
|
||||||
|
|
||||||
KryzAutoMediaBrowserService.unregisterDirectCallbacks()
|
|
||||||
|
|
||||||
castContext?.sessionManager?.removeSessionManagerListener(
|
castContext?.sessionManager?.removeSessionManagerListener(
|
||||||
castSessionManagerListener,
|
castSessionManagerListener,
|
||||||
CastSession::class.java,
|
CastSession::class.java,
|
||||||
@@ -192,16 +154,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invokeFlutterPlay() {
|
|
||||||
if (!flutterReady) {
|
|
||||||
pendingAutoPlay = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mainHandler.post {
|
|
||||||
playbackMethodChannel?.invokeMethod("play", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeCastContext() {
|
private fun initializeCastContext() {
|
||||||
try {
|
try {
|
||||||
castContext = CastContext.getSharedInstance(this)
|
castContext = CastContext.getSharedInstance(this)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -178,99 +178,3 @@ import AVKit
|
|||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleCastingMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
switch call.method {
|
|
||||||
case "getStatus":
|
|
||||||
updateCastingStatusForCurrentRoute()
|
|
||||||
result(castingStatus)
|
|
||||||
case "showDevicePicker":
|
|
||||||
showAirPlayPicker()
|
|
||||||
result(nil)
|
|
||||||
case "startCastingLiveStream":
|
|
||||||
// AirPlay route selection controls where iOS outputs audio. No explicit load call needed.
|
|
||||||
result(nil)
|
|
||||||
case "disconnect":
|
|
||||||
// iOS does not expose direct route disconnect. Users select route via picker.
|
|
||||||
showAirPlayPicker()
|
|
||||||
result(nil)
|
|
||||||
default:
|
|
||||||
result(FlutterMethodNotImplemented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showAirPlayPicker() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let rootViewController = self.activeRootViewController() else {
|
|
||||||
self.emitCastingEvent(status: "error", message: "Unable to open AirPlay picker")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let routePickerView = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
||||||
routePickerView.alpha = 0.01
|
|
||||||
rootViewController.view.addSubview(routePickerView)
|
|
||||||
|
|
||||||
for subview in routePickerView.subviews {
|
|
||||||
if let button = subview as? UIButton {
|
|
||||||
button.sendActions(for: .touchUpInside)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
|
||||||
routePickerView.removeFromSuperview()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func activeRootViewController() -> UIViewController? {
|
|
||||||
let activeScene = UIApplication.shared.connectedScenes
|
|
||||||
.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
|
||||||
let keyWindow = activeScene?.windows.first { $0.isKeyWindow }
|
|
||||||
return keyWindow?.rootViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateCastingStatusForCurrentRoute() {
|
|
||||||
let currentOutputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
|
||||||
if let airPlayOutput = currentOutputs.first(where: { $0.portType == .airPlay }) {
|
|
||||||
castingStatus = [
|
|
||||||
"status": "connected",
|
|
||||||
"platform": "ios",
|
|
||||||
"deviceName": airPlayOutput.portName
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
castingStatus = [
|
|
||||||
"status": "idle",
|
|
||||||
"platform": "ios"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
castingEventSink?(castingStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emitCastingEvent(status: String, message: String) {
|
|
||||||
let event: [String: Any] = [
|
|
||||||
"status": status,
|
|
||||||
"platform": "ios",
|
|
||||||
"message": message
|
|
||||||
]
|
|
||||||
|
|
||||||
castingStatus = event
|
|
||||||
castingEventSink?(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
|
||||||
castingEventSink = events
|
|
||||||
events(castingStatus)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
|
||||||
castingEventSink = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:audio_session/audio_session.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
import 'live_info_business_object.dart';
|
||||||
|
|
||||||
|
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||||
|
KryzAudioHandler() {
|
||||||
|
mediaItem.add(_defaultMediaItem);
|
||||||
|
_playerStateSubscription = _player.playerStateStream.listen(
|
||||||
|
_onPlayerStateChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
||||||
|
static const String mediaId = 'kryz-live-stream';
|
||||||
|
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info';
|
||||||
|
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
||||||
|
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
||||||
|
|
||||||
|
static const MediaItem _defaultMediaItem = MediaItem(
|
||||||
|
id: mediaId,
|
||||||
|
title: 'KRYZ Live Stream',
|
||||||
|
artist: 'KRYZ Radio',
|
||||||
|
album: 'Live',
|
||||||
|
extras: <String, dynamic>{
|
||||||
|
'isLive': true,
|
||||||
|
'seekable': false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
late final StreamSubscription<PlayerState> _playerStateSubscription;
|
||||||
|
Timer? _metadataRefreshTimer;
|
||||||
|
bool _isAudioSessionConfigured = false;
|
||||||
|
bool _isSourceLoaded = false;
|
||||||
|
bool _playRequested = false;
|
||||||
|
|
||||||
|
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
|
||||||
|
final current = snapshot?.current;
|
||||||
|
if (current == null || current.type != 'track') {
|
||||||
|
mediaItem.add(_defaultMediaItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String subtitle = current.displaySubtitle.trim();
|
||||||
|
mediaItem.add(
|
||||||
|
MediaItem(
|
||||||
|
id: mediaId,
|
||||||
|
title: current.displayTitle,
|
||||||
|
artist: subtitle.isEmpty ? 'KRYZ Radio' : subtitle,
|
||||||
|
album: 'Live',
|
||||||
|
extras: <String, dynamic>{
|
||||||
|
'isLive': true,
|
||||||
|
'seekable': false,
|
||||||
|
'starts': current.starts,
|
||||||
|
'ends': current.ends,
|
||||||
|
'schedulerTime': snapshot?.schedulerTime,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() async {
|
||||||
|
_playRequested = true;
|
||||||
|
playbackState.add(
|
||||||
|
playbackState.value.copyWith(
|
||||||
|
controls: const <MediaControl>[MediaControl.stop],
|
||||||
|
systemActions: const <MediaAction>{},
|
||||||
|
processingState: AudioProcessingState.buffering,
|
||||||
|
playing: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _configureAudioSessionIfNeeded();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!_isSourceLoaded) {
|
||||||
|
await _player.setAudioSource(
|
||||||
|
AudioSource.uri(
|
||||||
|
Uri.parse(streamUrl),
|
||||||
|
tag: mediaItem.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_isSourceLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = await AudioSession.instance;
|
||||||
|
await session.setActive(true);
|
||||||
|
await _player.play();
|
||||||
|
await _refreshMetadataFromApi();
|
||||||
|
_startMetadataRefreshTimer();
|
||||||
|
} catch (_) {
|
||||||
|
_playRequested = false;
|
||||||
|
playbackState.add(
|
||||||
|
playbackState.value.copyWith(
|
||||||
|
controls: const <MediaControl>[MediaControl.play],
|
||||||
|
systemActions: const <MediaAction>{},
|
||||||
|
processingState: AudioProcessingState.idle,
|
||||||
|
playing: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
_playRequested = false;
|
||||||
|
_isSourceLoaded = false;
|
||||||
|
_metadataRefreshTimer?.cancel();
|
||||||
|
_metadataRefreshTimer = null;
|
||||||
|
await _player.stop();
|
||||||
|
playbackState.add(
|
||||||
|
playbackState.value.copyWith(
|
||||||
|
controls: const <MediaControl>[MediaControl.play],
|
||||||
|
systemActions: const <MediaAction>{},
|
||||||
|
processingState: AudioProcessingState.idle,
|
||||||
|
playing: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seek(Duration position) async {
|
||||||
|
// Live stream is intentionally non-seekable.
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) {
|
||||||
|
return play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> playFromSearch(String query, [Map<String, dynamic>? extras]) {
|
||||||
|
return play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_metadataRefreshTimer?.cancel();
|
||||||
|
await _playerStateSubscription.cancel();
|
||||||
|
await _player.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _configureAudioSessionIfNeeded() async {
|
||||||
|
if (_isAudioSessionConfigured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = await AudioSession.instance;
|
||||||
|
await session.configure(const AudioSessionConfiguration.music());
|
||||||
|
_isAudioSessionConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlayerStateChanged(PlayerState state) {
|
||||||
|
final AudioProcessingState processingState = switch (state.processingState) {
|
||||||
|
ProcessingState.idle => AudioProcessingState.idle,
|
||||||
|
ProcessingState.loading => AudioProcessingState.loading,
|
||||||
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||||
|
ProcessingState.ready => AudioProcessingState.ready,
|
||||||
|
ProcessingState.completed => AudioProcessingState.completed,
|
||||||
|
};
|
||||||
|
|
||||||
|
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
|
||||||
|
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
|
||||||
|
(_playRequested && !state.playing && state.processingState != ProcessingState.completed);
|
||||||
|
|
||||||
|
playbackState.add(
|
||||||
|
playbackState.value.copyWith(
|
||||||
|
controls: <MediaControl>[
|
||||||
|
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
||||||
|
],
|
||||||
|
systemActions: const <MediaAction>{},
|
||||||
|
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
|
||||||
|
playing: isPlaying,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPlaying || isBuffering) {
|
||||||
|
_startMetadataRefreshTimer();
|
||||||
|
} else {
|
||||||
|
_metadataRefreshTimer?.cancel();
|
||||||
|
_metadataRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startMetadataRefreshTimer() {
|
||||||
|
if (_metadataRefreshTimer != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_metadataRefreshTimer = Timer.periodic(_metadataRefreshInterval, (_) {
|
||||||
|
_refreshMetadataFromApi();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshMetadataFromApi() async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(_liveInfoEndpoint))
|
||||||
|
.timeout(_liveInfoTimeout);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final snapshot = LiveInfoSnapshot.fromJson(decoded);
|
||||||
|
await updateMetadataFromLiveInfo(snapshot);
|
||||||
|
} catch (_) {
|
||||||
|
// Keep existing metadata if live-info fetch fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-30
@@ -1,6 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio_background/just_audio_background.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tz;
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
import 'app_theme_business_object.dart';
|
import 'app_theme_business_object.dart';
|
||||||
@@ -12,19 +13,16 @@ import 'streaming_audio_business_object.dart';
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
tz.initializeTimeZones();
|
tz.initializeTimeZones();
|
||||||
await JustAudioBackground.init(
|
|
||||||
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
|
||||||
androidNotificationChannelName: 'KRYZ Audio Playback',
|
|
||||||
androidNotificationOngoing: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
await SystemChrome.setPreferredOrientations([
|
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
DeviceOrientation.portraitDown,
|
|
||||||
]);
|
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
|
|
||||||
|
// Keep startup non-blocking so UI can render even if background media
|
||||||
|
// services are initializing after an Android Auto cold start.
|
||||||
|
unawaited(
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
DeviceOrientation.portraitDown,
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
@@ -41,6 +39,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
unawaited(_audio.ensureInitialized());
|
||||||
_casting.initialize();
|
_casting.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,11 +138,6 @@ class _MainPageState extends State<MainPage> {
|
|||||||
_liveInfo.addListener(_handleLiveInfoChanged);
|
_liveInfo.addListener(_handleLiveInfoChanged);
|
||||||
_liveInfo.start();
|
_liveInfo.start();
|
||||||
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
|
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
|
||||||
// Tell native that Flutter is ready to receive method calls.
|
|
||||||
// On Android this unblocks any pending auto_play from Android Auto.
|
|
||||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
|
||||||
_playbackChannel.invokeMethod<void>('flutterReady');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
|
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
|
||||||
@@ -163,19 +157,11 @@ class _MainPageState extends State<MainPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
// Rebuild when playback state changes from the player.
|
// Rebuild when playback state changes from the player.
|
||||||
});
|
});
|
||||||
|
|
||||||
_syncAutoPlaybackState();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncAutoPlaybackState() {
|
|
||||||
if (defaultTargetPlatform != TargetPlatform.android) return;
|
|
||||||
_playbackChannel.invokeMethod<void>('updatePlaybackState', {
|
|
||||||
'isPlaying': _audio.isPlaying,
|
|
||||||
'isBuffering': _audio.isBuffering,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLiveInfoChanged() {
|
void _handleLiveInfoChanged() {
|
||||||
|
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -213,8 +199,8 @@ class _MainPageState extends State<MainPage> {
|
|||||||
try {
|
try {
|
||||||
await _casting.startCastingLiveStream(
|
await _casting.startCastingLiveStream(
|
||||||
streamUrl: _audio.streamUrl,
|
streamUrl: _audio.streamUrl,
|
||||||
title: 'KRYZ Live Stream',
|
title: _audio.currentTitle,
|
||||||
subtitle: 'KRYZ Radio',
|
subtitle: _audio.currentSubtitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
_castStreamStartedForActiveSession = true;
|
_castStreamStartedForActiveSession = true;
|
||||||
|
|||||||
@@ -1,48 +1,94 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio_background/just_audio_background.dart';
|
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'kryz_audio_handler.dart';
|
||||||
|
import 'live_info_business_object.dart';
|
||||||
|
|
||||||
enum StreamingPlaybackStatus { stopped, buffering, playing }
|
enum StreamingPlaybackStatus { stopped, buffering, playing }
|
||||||
|
|
||||||
class StreamingAudioBusinessObject extends ChangeNotifier {
|
class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||||
StreamingAudioBusinessObject({
|
StreamingAudioBusinessObject() {
|
||||||
this.streamUrl = 'https://kryz.out.airtime.pro/kryz_a',
|
final initFuture = _audioHandlerInitFuture;
|
||||||
}) {
|
if (initFuture != null) {
|
||||||
_playerStateSubscription = _player.playerStateStream.listen((state) {
|
initFuture.then((handler) {
|
||||||
_setPlaybackStatus(_mapPlayerStateToStatus(state));
|
if (_isDisposed) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
_attachHandler(handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final handler = _audioHandler;
|
||||||
|
if (handler != null) {
|
||||||
|
_attachHandler(handler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final String streamUrl;
|
static AudioHandler? _audioHandler;
|
||||||
final AudioPlayer _player = AudioPlayer();
|
static Future<AudioHandler>? _audioHandlerInitFuture;
|
||||||
late final StreamSubscription<PlayerState> _playerStateSubscription;
|
|
||||||
bool _isSourceLoaded = false;
|
static Future<void> initializeBackgroundAudio() async {
|
||||||
|
if (_audioHandler != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_audioHandlerInitFuture ??= AudioService.init(
|
||||||
|
builder: () => KryzAudioHandler(),
|
||||||
|
config: const AudioServiceConfig(
|
||||||
|
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
||||||
|
androidNotificationChannelName: 'KRYZ Audio Playback',
|
||||||
|
androidNotificationOngoing: true,
|
||||||
|
androidStopForegroundOnPause: true,
|
||||||
|
),
|
||||||
|
).then((handler) {
|
||||||
|
_audioHandler = handler;
|
||||||
|
return handler;
|
||||||
|
}).catchError((error) {
|
||||||
|
_audioHandlerInitFuture = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await _audioHandlerInitFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get streamUrl => KryzAudioHandler.streamUrl;
|
||||||
|
|
||||||
|
StreamSubscription<PlaybackState>? _playbackStateSubscription;
|
||||||
|
StreamSubscription<MediaItem?>? _mediaItemSubscription;
|
||||||
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
||||||
bool _isAudioSessionConfigured = false;
|
bool _isDisposed = false;
|
||||||
bool _playRequested = false;
|
bool _isAttached = false;
|
||||||
|
String _currentTitle = 'KRYZ Live Stream';
|
||||||
|
String _currentSubtitle = 'KRYZ Radio';
|
||||||
|
|
||||||
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||||
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
||||||
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
|
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
|
||||||
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
|
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
|
||||||
|
String get currentTitle => _currentTitle;
|
||||||
|
String get currentSubtitle => _currentSubtitle;
|
||||||
|
|
||||||
StreamingPlaybackStatus _mapPlayerStateToStatus(PlayerState state) {
|
Future<void> ensureInitialized() async {
|
||||||
if (state.processingState == ProcessingState.completed) {
|
await initializeBackgroundAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) {
|
||||||
|
if (state.processingState == AudioProcessingState.completed) {
|
||||||
return StreamingPlaybackStatus.stopped;
|
return StreamingPlaybackStatus.stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.playing) {
|
if (state.playing) {
|
||||||
if (state.processingState == ProcessingState.ready) {
|
if (state.processingState == AudioProcessingState.ready) {
|
||||||
return StreamingPlaybackStatus.playing;
|
return StreamingPlaybackStatus.playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
return StreamingPlaybackStatus.buffering;
|
return StreamingPlaybackStatus.buffering;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_playRequested && state.processingState != ProcessingState.completed) {
|
if (state.processingState == AudioProcessingState.buffering ||
|
||||||
|
state.processingState == AudioProcessingState.loading) {
|
||||||
return StreamingPlaybackStatus.buffering;
|
return StreamingPlaybackStatus.buffering;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,58 +104,66 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _configureAudioSessionIfNeeded() async {
|
|
||||||
if (_isAudioSessionConfigured) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = await AudioSession.instance;
|
|
||||||
await session.configure(const AudioSessionConfiguration.music());
|
|
||||||
_isAudioSessionConfigured = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
_playRequested = true;
|
|
||||||
await _configureAudioSessionIfNeeded();
|
|
||||||
_setPlaybackStatus(StreamingPlaybackStatus.buffering);
|
_setPlaybackStatus(StreamingPlaybackStatus.buffering);
|
||||||
|
final handler = await _requireHandler();
|
||||||
try {
|
await handler.play();
|
||||||
if (!_isSourceLoaded) {
|
|
||||||
await _player.setAudioSource(
|
|
||||||
AudioSource.uri(
|
|
||||||
Uri.parse(streamUrl),
|
|
||||||
tag: const MediaItem(
|
|
||||||
id: 'kryz-live-stream',
|
|
||||||
title: 'KRYZ Live Stream',
|
|
||||||
artist: 'KRYZ Radio',
|
|
||||||
album: 'Live',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_isSourceLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = await AudioSession.instance;
|
|
||||||
await session.setActive(true);
|
|
||||||
await _player.play();
|
|
||||||
} catch (_) {
|
|
||||||
_playRequested = false;
|
|
||||||
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
_playRequested = false;
|
final handler = await _requireHandler();
|
||||||
_isSourceLoaded = false;
|
await handler.stop();
|
||||||
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
||||||
await _player.stop();
|
}
|
||||||
|
|
||||||
|
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
|
||||||
|
final handler = await _requireHandler();
|
||||||
|
await handler.updateMetadataFromLiveInfo(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<KryzAudioHandler> _requireHandler() async {
|
||||||
|
await initializeBackgroundAudio();
|
||||||
|
final handler = _audioHandler;
|
||||||
|
if (handler is! KryzAudioHandler) {
|
||||||
|
throw StateError('KryzAudioHandler is not initialized');
|
||||||
|
}
|
||||||
|
_attachHandler(handler);
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attachHandler(AudioHandler handler) {
|
||||||
|
if (_isAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAttached = true;
|
||||||
|
_playbackStateSubscription = handler.playbackState.listen((state) {
|
||||||
|
_setPlaybackStatus(_mapPlaybackStateToStatus(state));
|
||||||
|
});
|
||||||
|
_mediaItemSubscription = handler.mediaItem.listen((item) {
|
||||||
|
if (item == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title;
|
||||||
|
final subtitle =
|
||||||
|
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : item.artist!.trim();
|
||||||
|
|
||||||
|
if (title == _currentTitle && subtitle == _currentSubtitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentTitle = title;
|
||||||
|
_currentSubtitle = subtitle;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_playerStateSubscription.cancel();
|
_isDisposed = true;
|
||||||
_player.dispose();
|
_playbackStateSubscription?.cancel();
|
||||||
|
_mediaItemSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
just_audio: ^0.10.5
|
just_audio: ^0.10.5
|
||||||
just_audio_background: ^0.0.1-beta.17
|
just_audio_background: ^0.0.1-beta.17
|
||||||
|
audio_service: ^0.18.18
|
||||||
audio_session: ^0.2.2
|
audio_session: ^0.2.2
|
||||||
webview_flutter: ^4.13.0
|
webview_flutter: ^4.13.0
|
||||||
http: ^1.5.0
|
http: ^1.5.0
|
||||||
|
|||||||
+4
-12
@@ -11,20 +11,12 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:kryz_go_flutter/main.dart';
|
import 'package:kryz_go_flutter/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('App renders title and controls', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
expect(find.text('KRYZ Go!'), findsWidgets);
|
||||||
expect(find.text('0'), findsNothing);
|
expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget);
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user