From 5ea7442c7cc672668e3084cedd2ae5aba1ace871 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sat, 16 May 2026 20:52:39 -0700 Subject: [PATCH 1/3] refactors design to flutter driven service core. --- .../KryzAutoMediaBrowserService.kt | 341 ++++++++++-------- .../example/kryz_go_flutter/MainActivity.kt | 48 --- ios/Runner/AppDelegate.swift | 96 ----- lib/kryz_audio_handler.dart | 222 ++++++++++++ lib/main.dart | 32 +- lib/streaming_audio_business_object.dart | 176 +++++---- pubspec.yaml | 1 + test/widget_test.dart | 16 +- 8 files changed, 532 insertions(+), 400 deletions(-) create mode 100644 lib/kryz_audio_handler.dart diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt index 046ed96..a21c92d 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt @@ -3,36 +3,28 @@ package com.example.kryz_go_flutter import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.content.ComponentName import android.content.Context -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.media.MediaPlayer +import android.support.v4.media.MediaBrowserCompat import android.net.Uri import android.os.Build import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import androidx.core.app.NotificationCompat import androidx.media.MediaBrowserServiceCompat +import com.ryanheise.audioservice.AudioService /** * MediaBrowserService for Android Auto. * - * Android Auto connects to this service to browse content and control playback. - * - * Two playback modes: - * 1. Cold start (Flutter not running): the service plays the stream directly via - * MediaPlayer. This avoids the unreliable startActivity → Flutter bridge. - * 2. Warm start (Flutter running): MainActivity registers play/stop callbacks so - * Auto commands are forwarded through the method channel to just_audio. - * - * When the Flutter app opens while the service is playing, MainActivity stops the - * service's MediaPlayer and Flutter takes over seamlessly. + * Android Auto connects to this service to browse content and send transport + * controls. Playback execution is delegated to Flutter's audio_service session. * * Brand color (#E36A18) is declared on this service via KryzAutoTheme in styles.xml * so Android Auto accents its media UI with the KRYZ orange. @@ -46,45 +38,51 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a" private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel" private const val NOTIFICATION_ID = 1001 - - private var instance: KryzAutoMediaBrowserService? = null - - // Called by MainActivity to mirror Flutter playback state into the MediaSession. - fun updatePlaybackState(isPlaying: Boolean, isBuffering: Boolean) { - instance?.applyPlaybackState(isPlaying, isBuffering) - } - - // Called by MainActivity when Flutter is alive and taking over from the service player. - fun stopServicePlayer() { - instance?.stopServicePlayback() - } - - fun isServicePlaying(): Boolean { - return try { - instance?.serviceMediaPlayer?.isPlaying == true - } catch (_: IllegalStateException) { - false - } - } - - // Flutter-side play/stop callbacks registered by MainActivity when Flutter is ready. - private var directPlayCallback: (() -> Unit)? = null - private var directStopCallback: (() -> Unit)? = null - - fun registerDirectCallbacks(play: () -> Unit, stop: () -> Unit) { - directPlayCallback = play - directStopCallback = stop - } - - fun unregisterDirectCallbacks() { - directPlayCallback = null - directStopCallback = null - } } private lateinit var mediaSession: MediaSessionCompat - private var serviceMediaPlayer: MediaPlayer? = null - private var audioFocusRequest: AudioFocusRequest? = null + private var audioServiceBrowser: MediaBrowserCompat? = null + private var audioServiceController: MediaControllerCompat? = null + private var pendingPlayRequest: Boolean = false + private var pendingStopRequest: 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") + disconnectFromFlutterAudioService() + } + + override fun onConnectionFailed() { + Log.e(TAG, "audio_service browser connection failed") + disconnectFromFlutterAudioService() + } + } private val mediaSessionCallback = object : MediaSessionCompat.Callback() { override fun onPlay() { @@ -94,12 +92,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { override fun onStop() { Log.d(TAG, "onStop()") - val stop = directStopCallback - if (stop != null) { - stop() - } else { - stopServicePlayback() - } + dispatchStopCommand() } override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { @@ -114,20 +107,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { } private fun triggerPlay() { - val play = directPlayCallback - if (play != null) { - // Flutter is running — signal buffering then let Flutter handle audio. - applyPlaybackState(isPlaying = false, isBuffering = true) - play() - } else { - // Flutter is not running — play directly via MediaPlayer. - startServicePlayback() - } + dispatchPlayCommand() } override fun onCreate() { super.onCreate() - instance = this Log.d(TAG, "onCreate()") createNotificationChannel() @@ -154,6 +138,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { } sessionToken = mediaSession.sessionToken Log.d(TAG, "MediaSession created, sessionToken set") + connectToFlutterAudioService() } override fun onGetRoot( @@ -187,78 +172,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { override fun onDestroy() { Log.d(TAG, "onDestroy()") - stopServicePlayback() - instance = null + disconnectFromFlutterAudioService() if (::mediaSession.isInitialized) mediaSession.release() super.onDestroy() } - // Direct MediaPlayer playback used when Flutter is not running (cold Auto start). - private fun startServicePlayback() { - Log.d(TAG, "startServicePlayback()") - stopServicePlayback() - applyPlaybackState(isPlaying = false, isBuffering = true) - startForeground(NOTIFICATION_ID, buildNotification(isPlaying = false)) - requestAudioFocus() - - val player = MediaPlayer() - try { - player.setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - player.setDataSource(STREAM_URL) - player.setOnPreparedListener { mp -> - Log.d(TAG, "MediaPlayer prepared → starting") - mp.start() - applyPlaybackState(isPlaying = true, isBuffering = false) - startForeground(NOTIFICATION_ID, buildNotification(isPlaying = true)) - } - player.setOnInfoListener { _, what, _ -> - when (what) { - MediaPlayer.MEDIA_INFO_BUFFERING_START -> - applyPlaybackState(isPlaying = false, isBuffering = true) - MediaPlayer.MEDIA_INFO_BUFFERING_END -> - applyPlaybackState(isPlaying = true, isBuffering = false) - } - true - } - player.setOnErrorListener { _, what, extra -> - Log.e(TAG, "MediaPlayer error what=$what extra=$extra") - applyPlaybackState(isPlaying = false, isBuffering = false) - @Suppress("DEPRECATION") - stopForeground(true) - true - } - player.prepareAsync() - serviceMediaPlayer = player - } catch (e: Exception) { - Log.e(TAG, "Failed to start service playback: ${e.message}") - player.release() - applyPlaybackState(isPlaying = false, isBuffering = false) - @Suppress("DEPRECATION") - stopForeground(true) - } - } - - private fun stopServicePlayback() { - val player = serviceMediaPlayer ?: return - Log.d(TAG, "stopServicePlayback()") - serviceMediaPlayer = null - try { - if (player.isPlaying) player.stop() - player.release() - } catch (e: Exception) { - Log.e(TAG, "Error releasing MediaPlayer: ${e.message}") - } - abandonAudioFocus() - applyPlaybackState(isPlaying = false, isBuffering = false) - @Suppress("DEPRECATION") - stopForeground(true) - } - private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) { if (!::mediaSession.isInitialized) return val state = when { @@ -279,34 +197,66 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { .setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f) .build() ) + startForeground(NOTIFICATION_ID, buildNotification(isPlaying = isPlaying, isBuffering = isBuffering)) Log.d(TAG, "applyPlaybackState: playing=$isPlaying buffering=$isBuffering") } - private fun requestAudioFocus() { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - .build() - audioFocusRequest = req - audioManager.requestAudioFocus(req) - } else { - @Suppress("DEPRECATION") - audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) + private fun dispatchPlayCommand() { + val controller = audioServiceController + if (controller != null) { + pendingPlayRequest = false + pendingStopRequest = false + applyPlaybackState(isPlaying = false, isBuffering = true) + controller.transportControls.play() + return + } + + pendingStopRequest = false + pendingPlayRequest = true + applyPlaybackState(isPlaying = false, isBuffering = true) + connectToFlutterAudioService() + } + + 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() { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } - audioFocusRequest = null + private fun applyPlaybackStateFromFlutter(state: PlaybackStateCompat?) { + if (state == null) { + applyPlaybackState(isPlaying = false, isBuffering = false) + return } + + 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() { @@ -324,16 +274,91 @@ 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) .setContentTitle("KRYZ Radio") - .setContentText(if (isPlaying) "Playing via Android Auto" else "Connecting…") + .setContentText(statusText) .setSmallIcon(R.mipmap.ic_launcher) - .setOngoing(isPlaying) + .setOngoing(isPlaying || isBuffering) .setSilent(true) .build() } + private fun applyNowPlayingMetadata( + title: String?, + subtitle: String?, + album: String?, + mediaUri: String?, + ) { + if (!::mediaSession.isInitialized) return + + val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher") + 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() { + val existing = audioServiceBrowser + if (existing?.isConnected == true) { + return + } + + val browser = MediaBrowserCompat( + this, + ComponentName(this, AudioService::class.java), + browserConnectionCallback, + null, + ) + + audioServiceBrowser = browser + browser.connect() + } + + private fun disconnectFromFlutterAudioService() { + audioServiceController?.unregisterCallback(mediaControllerCallback) + audioServiceController = null + + audioServiceBrowser?.let { browser -> + if (browser.isConnected) { + browser.disconnect() + } + } + audioServiceBrowser = null + } + private fun buildLiveStreamMetadata(): MediaMetadataCompat { val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher") return MediaMetadataCompat.Builder() diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt index 896a20f..7edd5f1 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt @@ -27,7 +27,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev companion object { private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods" 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 @@ -35,9 +34,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev private var chooserDialog: MediaRouteChooserDialog? = null private val mainHandler = Handler(Looper.getMainLooper()) private var activeRemoteMediaClient: RemoteMediaClient? = null - private var playbackMethodChannel: MethodChannel? = null - private var flutterReady = false - private var pendingAutoPlay = false private var statusPayload: MutableMap = mutableMapOf( "status" to "idle", @@ -106,38 +102,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) .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("isPlaying") ?: false - val isBuffering = call.argument("isBuffering") ?: false - KryzAutoMediaBrowserService.updatePlaybackState(isPlaying, isBuffering) - result.success(null) - } - else -> result.notImplemented() - } - } - initializeCastContext() } @@ -181,8 +145,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev chooserDialog?.dismiss() chooserDialog = null - KryzAutoMediaBrowserService.unregisterDirectCallbacks() - castContext?.sessionManager?.removeSessionManagerListener( castSessionManagerListener, CastSession::class.java, @@ -192,16 +154,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev super.onDestroy() } - private fun invokeFlutterPlay() { - if (!flutterReady) { - pendingAutoPlay = true - return - } - mainHandler.post { - playbackMethodChannel?.invokeMethod("play", null) - } - } - private fun initializeCastContext() { try { castContext = CastContext.getSharedInstance(this) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 7aebd5f..7b64419 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -178,99 +178,3 @@ import AVKit 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) - } -} diff --git a/lib/kryz_audio_handler.dart b/lib/kryz_audio_handler.dart new file mode 100644 index 0000000..90836f6 --- /dev/null +++ b/lib/kryz_audio_handler.dart @@ -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: { + 'isLive': true, + 'seekable': false, + }, + ); + + final AudioPlayer _player = AudioPlayer(); + late final StreamSubscription _playerStateSubscription; + Timer? _metadataRefreshTimer; + bool _isAudioSessionConfigured = false; + bool _isSourceLoaded = false; + bool _playRequested = false; + + Future 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: { + 'isLive': true, + 'seekable': false, + 'starts': current.starts, + 'ends': current.ends, + 'schedulerTime': snapshot?.schedulerTime, + }, + ), + ); + } + + @override + Future play() async { + _playRequested = true; + playbackState.add( + playbackState.value.copyWith( + controls: const [MediaControl.stop], + systemActions: const {}, + 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.play], + systemActions: const {}, + processingState: AudioProcessingState.idle, + playing: false, + ), + ); + rethrow; + } + } + + @override + Future stop() async { + _playRequested = false; + _isSourceLoaded = false; + _metadataRefreshTimer?.cancel(); + _metadataRefreshTimer = null; + await _player.stop(); + playbackState.add( + playbackState.value.copyWith( + controls: const [MediaControl.play], + systemActions: const {}, + processingState: AudioProcessingState.idle, + playing: false, + ), + ); + } + + @override + Future seek(Duration position) async { + // Live stream is intentionally non-seekable. + } + + @override + Future playFromMediaId(String mediaId, [Map? extras]) { + return play(); + } + + @override + Future playFromSearch(String query, [Map? extras]) { + return play(); + } + + Future dispose() async { + _metadataRefreshTimer?.cancel(); + await _playerStateSubscription.cancel(); + await _player.dispose(); + } + + Future _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: [ + if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play, + ], + systemActions: const {}, + 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 _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) { + return; + } + + final snapshot = LiveInfoSnapshot.fromJson(decoded); + await updateMetadataFromLiveInfo(snapshot); + } catch (_) { + // Keep existing metadata if live-info fetch fails. + } + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6e5ea1f..69e66e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; -import 'package:just_audio_background/just_audio_background.dart'; import 'package:flutter/services.dart'; import 'package:timezone/data/latest.dart' as tz; import 'app_theme_business_object.dart'; @@ -12,13 +13,7 @@ import 'streaming_audio_business_object.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); tz.initializeTimeZones(); - await JustAudioBackground.init( - androidNotificationChannelId: 'com.kryzgoflutter.audio.channel', - androidNotificationChannelName: 'KRYZ Audio Playback', - androidNotificationOngoing: true, - ); - - WidgetsFlutterBinding.ensureInitialized(); + unawaited(StreamingAudioBusinessObject.initializeBackgroundAudio()); await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, @@ -139,11 +134,6 @@ class _MainPageState extends State { _liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.start(); _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('flutterReady'); - } } Future _handlePlaybackMethodCall(MethodCall call) async { @@ -163,19 +153,11 @@ class _MainPageState extends State { setState(() { // Rebuild when playback state changes from the player. }); - - _syncAutoPlaybackState(); - } - - void _syncAutoPlaybackState() { - if (defaultTargetPlatform != TargetPlatform.android) return; - _playbackChannel.invokeMethod('updatePlaybackState', { - 'isPlaying': _audio.isPlaying, - 'isBuffering': _audio.isBuffering, - }); } void _handleLiveInfoChanged() { + _audio.updateMetadataFromLiveInfo(_liveInfo.snapshot); + if (!mounted) { return; } @@ -213,8 +195,8 @@ class _MainPageState extends State { try { await _casting.startCastingLiveStream( streamUrl: _audio.streamUrl, - title: 'KRYZ Live Stream', - subtitle: 'KRYZ Radio', + title: _audio.currentTitle, + subtitle: _audio.currentSubtitle, ); _castStreamStartedForActiveSession = true; diff --git a/lib/streaming_audio_business_object.dart b/lib/streaming_audio_business_object.dart index 1396220..ab3f1f7 100644 --- a/lib/streaming_audio_business_object.dart +++ b/lib/streaming_audio_business_object.dart @@ -1,48 +1,94 @@ import 'dart:async'; -import 'package:audio_session/audio_session.dart'; +import 'package:audio_service/audio_service.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 } class StreamingAudioBusinessObject extends ChangeNotifier { - StreamingAudioBusinessObject({ - this.streamUrl = 'https://kryz.out.airtime.pro/kryz_a', - }) { - _playerStateSubscription = _player.playerStateStream.listen((state) { - _setPlaybackStatus(_mapPlayerStateToStatus(state)); - }); + StreamingAudioBusinessObject() { + if (_audioHandler == null && _audioHandlerInitFuture == null) { + initializeBackgroundAudio(); + } + + final initFuture = _audioHandlerInitFuture; + if (initFuture != null) { + initFuture.then((handler) { + if (_isDisposed) { + return; + } + _attachHandler(handler); + }); + } + + final handler = _audioHandler; + if (handler != null) { + _attachHandler(handler); + } } - final String streamUrl; - final AudioPlayer _player = AudioPlayer(); - late final StreamSubscription _playerStateSubscription; - bool _isSourceLoaded = false; + static AudioHandler? _audioHandler; + static Future? _audioHandlerInitFuture; + + static Future 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? _playbackStateSubscription; + StreamSubscription? _mediaItemSubscription; StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped; - bool _isAudioSessionConfigured = false; - bool _playRequested = false; + bool _isDisposed = false; + bool _isAttached = false; + String _currentTitle = 'KRYZ Live Stream'; + String _currentSubtitle = 'KRYZ Radio'; StreamingPlaybackStatus get playbackStatus => _playbackStatus; bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing; bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering; bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped; + String get currentTitle => _currentTitle; + String get currentSubtitle => _currentSubtitle; - StreamingPlaybackStatus _mapPlayerStateToStatus(PlayerState state) { - if (state.processingState == ProcessingState.completed) { + StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) { + if (state.processingState == AudioProcessingState.completed) { return StreamingPlaybackStatus.stopped; } if (state.playing) { - if (state.processingState == ProcessingState.ready) { + if (state.processingState == AudioProcessingState.ready) { return StreamingPlaybackStatus.playing; } return StreamingPlaybackStatus.buffering; } - if (_playRequested && state.processingState != ProcessingState.completed) { + if (state.processingState == AudioProcessingState.buffering || + state.processingState == AudioProcessingState.loading) { return StreamingPlaybackStatus.buffering; } @@ -58,58 +104,66 @@ class StreamingAudioBusinessObject extends ChangeNotifier { notifyListeners(); } - Future _configureAudioSessionIfNeeded() async { - if (_isAudioSessionConfigured) { - return; - } - - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.music()); - _isAudioSessionConfigured = true; - } - Future play() async { - _playRequested = true; - await _configureAudioSessionIfNeeded(); _setPlaybackStatus(StreamingPlaybackStatus.buffering); - - try { - 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; - } + final handler = await _requireHandler(); + await handler.play(); } Future stop() async { - _playRequested = false; - _isSourceLoaded = false; + final handler = await _requireHandler(); + await handler.stop(); _setPlaybackStatus(StreamingPlaybackStatus.stopped); - await _player.stop(); + } + + Future updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async { + final handler = await _requireHandler(); + await handler.updateMetadataFromLiveInfo(snapshot); + } + + Future _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 void dispose() { - _playerStateSubscription.cancel(); - _player.dispose(); + _isDisposed = true; + _playbackStateSubscription?.cancel(); + _mediaItemSubscription?.cancel(); super.dispose(); } } diff --git a/pubspec.yaml b/pubspec.yaml index ef89bf5..c19cf07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: cupertino_icons: ^1.0.8 just_audio: ^0.10.5 just_audio_background: ^0.0.1-beta.17 + audio_service: ^0.18.18 audio_session: ^0.2.2 webview_flutter: ^4.13.0 http: ^1.5.0 diff --git a/test/widget_test.dart b/test/widget_test.dart index 4d957e1..e1d3899 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,20 +11,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:kryz_go_flutter/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + testWidgets('App renders title and controls', (WidgetTester tester) async { 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(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('KRYZ Go!'), findsWidgets); + expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget); + expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget); }); } From 85740b399b576414ec27ab1e2a39e211ff81c968 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sat, 16 May 2026 21:05:34 -0700 Subject: [PATCH 2/3] bug fixes for android auto --- .../KryzAutoMediaBrowserService.kt | 49 +++++++++++++++++-- lib/main.dart | 16 +++--- lib/streaming_audio_business_object.dart | 8 +-- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt index a21c92d..97f434b 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt @@ -5,6 +5,8 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.ComponentName import android.content.Context +import android.os.Handler +import android.os.Looper import android.support.v4.media.MediaBrowserCompat import android.net.Uri import android.os.Build @@ -43,8 +45,10 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { private lateinit var mediaSession: MediaSessionCompat private var audioServiceBrowser: MediaBrowserCompat? = 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?) { @@ -75,12 +79,14 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { override fun onConnectionSuspended() { Log.w(TAG, "audio_service browser connection suspended") - disconnectFromFlutterAudioService() + handleControllerDisconnected() + scheduleBrowserReconnect() } override fun onConnectionFailed() { Log.e(TAG, "audio_service browser connection failed") - disconnectFromFlutterAudioService() + handleControllerDisconnected() + scheduleBrowserReconnect() } } @@ -331,11 +337,24 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { } 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), @@ -348,8 +367,10 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { } private fun disconnectFromFlutterAudioService() { - audioServiceController?.unregisterCallback(mediaControllerCallback) - audioServiceController = null + reconnectScheduled = false + mainHandler.removeCallbacksAndMessages(null) + + handleControllerDisconnected() audioServiceBrowser?.let { browser -> if (browser.isConnected) { @@ -359,6 +380,26 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { 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 { val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher") return MediaMetadataCompat.Builder() diff --git a/lib/main.dart b/lib/main.dart index 69e66e6..ad20574 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,13 +13,16 @@ import 'streaming_audio_business_object.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); tz.initializeTimeZones(); - unawaited(StreamingAudioBusinessObject.initializeBackgroundAudio()); - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - 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 { @@ -36,6 +39,7 @@ class _MyAppState extends State { @override void initState() { super.initState(); + unawaited(_audio.ensureInitialized()); _casting.initialize(); } diff --git a/lib/streaming_audio_business_object.dart b/lib/streaming_audio_business_object.dart index ab3f1f7..d364047 100644 --- a/lib/streaming_audio_business_object.dart +++ b/lib/streaming_audio_business_object.dart @@ -10,10 +10,6 @@ enum StreamingPlaybackStatus { stopped, buffering, playing } class StreamingAudioBusinessObject extends ChangeNotifier { StreamingAudioBusinessObject() { - if (_audioHandler == null && _audioHandlerInitFuture == null) { - initializeBackgroundAudio(); - } - final initFuture = _audioHandlerInitFuture; if (initFuture != null) { initFuture.then((handler) { @@ -74,6 +70,10 @@ class StreamingAudioBusinessObject extends ChangeNotifier { String get currentTitle => _currentTitle; String get currentSubtitle => _currentSubtitle; + Future ensureInitialized() async { + await initializeBackgroundAudio(); + } + StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) { if (state.processingState == AudioProcessingState.completed) { return StreamingPlaybackStatus.stopped; From df2338e5e979fc1f59f9e499df3165f419193c40 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sat, 16 May 2026 21:21:34 -0700 Subject: [PATCH 3/3] higher resolution android auto logo --- .../KryzAutoMediaBrowserService.kt | 2 +- .../main/res/drawable-nodpi/kryz_auto_art.png | Bin 0 -> 16146 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 android/app/src/main/res/drawable-nodpi/kryz_auto_art.png diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt index 97f434b..e15cd3f 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt @@ -303,7 +303,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() { ) { if (!::mediaSession.isInitialized) return - val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher") + val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art") mediaSession.setMetadata( MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream") diff --git a/android/app/src/main/res/drawable-nodpi/kryz_auto_art.png b/android/app/src/main/res/drawable-nodpi/kryz_auto_art.png new file mode 100644 index 0000000000000000000000000000000000000000..167958ad7b178271488766800f01245ee3560ff4 GIT binary patch literal 16146 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 H27&(vf`|z1 literal 0 HcmV?d00001