From 5ea7442c7cc672668e3084cedd2ae5aba1ace871 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sat, 16 May 2026 20:52:39 -0700 Subject: [PATCH] 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); }); }