refactors design to flutter driven service core.

This commit is contained in:
2026-05-16 20:52:39 -07:00
parent 533a3d74f3
commit 5ea7442c7c
8 changed files with 532 additions and 400 deletions
@@ -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()
@@ -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<String, Any?> = 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<Boolean>("isPlaying") ?: false
val isBuffering = call.argument<Boolean>("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)
-96
View File
@@ -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)
}
}
+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.
}
}
}
+7 -25
View File
@@ -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<void> 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<MainPage> {
_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<void>('flutterReady');
}
}
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
@@ -163,19 +153,11 @@ class _MainPageState extends State<MainPage> {
setState(() {
// 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() {
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
if (!mounted) {
return;
}
@@ -213,8 +195,8 @@ class _MainPageState extends State<MainPage> {
try {
await _casting.startCastingLiveStream(
streamUrl: _audio.streamUrl,
title: 'KRYZ Live Stream',
subtitle: 'KRYZ Radio',
title: _audio.currentTitle,
subtitle: _audio.currentSubtitle,
);
_castStreamStartedForActiveSession = true;
+115 -61
View File
@@ -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<PlayerState> _playerStateSubscription;
bool _isSourceLoaded = false;
static AudioHandler? _audioHandler;
static Future<AudioHandler>? _audioHandlerInitFuture;
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;
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<void> _configureAudioSessionIfNeeded() async {
if (_isAudioSessionConfigured) {
return;
}
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_isAudioSessionConfigured = true;
}
Future<void> 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<void> stop() async {
_playRequested = false;
_isSourceLoaded = false;
final handler = await _requireHandler();
await handler.stop();
_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
void dispose() {
_playerStateSubscription.cancel();
_player.dispose();
_isDisposed = true;
_playbackStateSubscription?.cancel();
_mediaItemSubscription?.cancel();
super.dispose();
}
}
+1
View File
@@ -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
+4 -12
View File
@@ -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);
});
}