Merge pull request 'refactors design to flutter driven service core.' (#6) from central_flutter_service into main

Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
2026-05-16 21:22:16 -07:00
9 changed files with 582 additions and 405 deletions
@@ -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

-96
View File
@@ -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)
}
}
+222
View File
@@ -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
View File
@@ -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;
+115 -61
View File
@@ -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();
} }
} }
+1
View File
@@ -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
View File
@@ -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);
}); });
} }