Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c523d0121f | ||
|
|
b8e86263d9 | ||
|
|
e3288e044a | ||
|
|
b8d624343b | ||
|
|
4c4113f66f | ||
|
|
5f3801e65f | ||
|
|
a2b5da3669 | ||
|
|
1c7e27b83d | ||
|
|
df2338e5e9 | ||
|
|
85740b399b | ||
|
|
5ea7442c7c | ||
|
|
533a3d74f3 | ||
|
|
065d3c8e47 | ||
|
|
83dd1a28b8 | ||
|
|
52f5ec577b | ||
|
|
5827d129f6 | ||
|
|
da0779cafb | ||
|
|
d32720a2e0 | ||
|
|
cbdc3f7710 | ||
|
|
283a918b81 | ||
|
|
f7979a70e6 | ||
|
|
c9ef69c8b5 | ||
|
|
3bb2630e1a | ||
|
|
65e5db2d35 | ||
|
|
318bb095a1 | ||
|
|
f1fd59c199 | ||
|
|
0c6442e260 | ||
|
|
a5e1e36ebf | ||
|
|
de0a243388 | ||
|
|
6893758a16 | ||
|
|
42a34712a2 | ||
|
|
e3656a64a8 |
@@ -4,7 +4,7 @@ ENV PATH="$PATH:/opt/flutter/bin"
|
|||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
wget curl unzip git xz-utils zip libglu1-mesa openjdk-17-jdk\
|
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Become non-root
|
# Become non-root
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "kryz-go",
|
"name": "kryz-go",
|
||||||
|
"mounts": [
|
||||||
|
"source=/dev/dri,target=/dev/dri,type=bind"
|
||||||
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"dockerfile": "Dockerfile"
|
"dockerfile": "Dockerfile"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"flutter": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.google.android.gms:play-services-cast-framework:22.1.0")
|
||||||
|
implementation("androidx.mediarouter:mediarouter:1.7.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.media:media:1.7.0")
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.kryz_go_flutter"
|
namespace = "com.example.kryz_go_flutter"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="KRYZ Go!"
|
android:label="KRYZ Go!"
|
||||||
@@ -37,6 +38,14 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
android:value="com.example.kryz_go_flutter.CastOptionsProvider" />
|
||||||
|
|
||||||
|
<!-- Declares this app as an Android Auto media app. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
@@ -44,6 +53,20 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
tools:ignore="Instantiatable">
|
tools:ignore="Instantiatable">
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Dedicated MediaBrowserService for Android Auto. Android Auto connects here
|
||||||
|
to browse content and send transport controls. Styled with KryzAutoTheme
|
||||||
|
so Auto accents its media UI with the KRYZ brand orange (#E36A18).
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name="com.example.kryz_go_flutter.KryzAutoMediaBrowserService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
android:theme="@style/KryzAutoTheme"
|
||||||
|
tools:ignore="Instantiatable">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.kryz_go_flutter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.android.gms.cast.LaunchOptions
|
||||||
|
import com.google.android.gms.cast.framework.CastOptions
|
||||||
|
import com.google.android.gms.cast.framework.OptionsProvider
|
||||||
|
import com.google.android.gms.cast.framework.SessionProvider
|
||||||
|
|
||||||
|
class CastOptionsProvider : OptionsProvider {
|
||||||
|
override fun getCastOptions(context: Context): CastOptions {
|
||||||
|
return CastOptions.Builder()
|
||||||
|
.setReceiverApplicationId(context.getString(R.string.cast_receiver_app_id))
|
||||||
|
.setLaunchOptions(
|
||||||
|
LaunchOptions.Builder()
|
||||||
|
.setAndroidReceiverCompatible(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAdditionalSessionProviders(context: Context): MutableList<SessionProvider>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
+443
@@ -0,0 +1,443 @@
|
|||||||
|
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.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
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 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.
|
||||||
|
*/
|
||||||
|
class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "KryzAutoService"
|
||||||
|
private const val MEDIA_ID_ROOT = "kryz_root"
|
||||||
|
private const val MEDIA_ID_LIVE = "kryz_live_stream"
|
||||||
|
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 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?) {
|
||||||
|
applyPlaybackStateFromFlutter(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
|
||||||
|
applyNowPlayingMetadataFromFlutter(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val browserConnectionCallback = object : MediaBrowserCompat.ConnectionCallback() {
|
||||||
|
override fun onConnected() {
|
||||||
|
val browser = audioServiceBrowser ?: return
|
||||||
|
try {
|
||||||
|
val controller = MediaControllerCompat(this@KryzAutoMediaBrowserService, browser.sessionToken)
|
||||||
|
audioServiceController?.unregisterCallback(mediaControllerCallback)
|
||||||
|
audioServiceController = controller
|
||||||
|
controller.registerCallback(mediaControllerCallback)
|
||||||
|
applyPlaybackStateFromFlutter(controller.playbackState)
|
||||||
|
applyNowPlayingMetadataFromFlutter(controller.metadata)
|
||||||
|
consumePendingTransportCommands()
|
||||||
|
Log.d(TAG, "Connected to Flutter audio_service session")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "Unable to bind media controller: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionSuspended() {
|
||||||
|
Log.w(TAG, "audio_service browser connection suspended")
|
||||||
|
handleControllerDisconnected()
|
||||||
|
scheduleBrowserReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionFailed() {
|
||||||
|
Log.e(TAG, "audio_service browser connection failed")
|
||||||
|
handleControllerDisconnected()
|
||||||
|
scheduleBrowserReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
|
||||||
|
override fun onPlay() {
|
||||||
|
Log.d(TAG, "onPlay()")
|
||||||
|
triggerPlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
Log.d(TAG, "onStop()")
|
||||||
|
dispatchStopCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
|
Log.d(TAG, "onPlayFromMediaId: $mediaId")
|
||||||
|
if (mediaId == MEDIA_ID_LIVE) triggerPlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||||
|
Log.d(TAG, "onPlayFromSearch: $query")
|
||||||
|
triggerPlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerPlay() {
|
||||||
|
dispatchPlayCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "onCreate()")
|
||||||
|
createNotificationChannel()
|
||||||
|
|
||||||
|
mediaSession = MediaSessionCompat(this, TAG).apply {
|
||||||
|
setCallback(mediaSessionCallback)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
setFlags(
|
||||||
|
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
|
||||||
|
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
|
||||||
|
)
|
||||||
|
setPlaybackState(
|
||||||
|
PlaybackStateCompat.Builder()
|
||||||
|
.setActions(
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_STOP or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||||
|
)
|
||||||
|
.setState(PlaybackStateCompat.STATE_STOPPED, 0, 1f)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
setMetadata(buildLiveStreamMetadata())
|
||||||
|
isActive = true
|
||||||
|
}
|
||||||
|
sessionToken = mediaSession.sessionToken
|
||||||
|
Log.d(TAG, "MediaSession created, sessionToken set")
|
||||||
|
connectToFlutterAudioService()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetRoot(
|
||||||
|
clientPackageName: String,
|
||||||
|
clientUid: Int,
|
||||||
|
rootHints: Bundle?
|
||||||
|
): BrowserRoot {
|
||||||
|
Log.d(TAG, "onGetRoot() from $clientPackageName")
|
||||||
|
return BrowserRoot(MEDIA_ID_ROOT, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadChildren(
|
||||||
|
parentId: String,
|
||||||
|
result: Result<List<MediaItem>>
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "onLoadChildren($parentId)")
|
||||||
|
if (parentId == MEDIA_ID_ROOT) {
|
||||||
|
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||||
|
val description = MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(MEDIA_ID_LIVE)
|
||||||
|
.setTitle("KRYZ Live Stream")
|
||||||
|
.setSubtitle("KRYZ Radio")
|
||||||
|
.setIconUri(iconUri)
|
||||||
|
.setMediaUri(Uri.parse(STREAM_URL))
|
||||||
|
.build()
|
||||||
|
result.sendResult(listOf(MediaItem(description, MediaItem.FLAG_PLAYABLE)))
|
||||||
|
} else {
|
||||||
|
result.sendResult(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.d(TAG, "onDestroy()")
|
||||||
|
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||||
|
disconnectFromFlutterAudioService()
|
||||||
|
if (::mediaSession.isInitialized) mediaSession.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
Log.d(TAG, "onTaskRemoved()")
|
||||||
|
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: Intent?): Boolean {
|
||||||
|
Log.d(TAG, "onUnbind()")
|
||||||
|
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopFlutterPlaybackForHeadUnitDisconnect() {
|
||||||
|
pendingPlayRequest = false
|
||||||
|
pendingStopRequest = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioServiceController?.transportControls?.stop()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Unable to stop Flutter audio on head unit disconnect: ${error.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
|
||||||
|
if (!::mediaSession.isInitialized) return
|
||||||
|
val state = when {
|
||||||
|
isPlaying -> PlaybackStateCompat.STATE_PLAYING
|
||||||
|
isBuffering -> PlaybackStateCompat.STATE_BUFFERING
|
||||||
|
else -> PlaybackStateCompat.STATE_STOPPED
|
||||||
|
}
|
||||||
|
val actions = if (isPlaying || isBuffering) {
|
||||||
|
PlaybackStateCompat.ACTION_STOP
|
||||||
|
} else {
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||||
|
}
|
||||||
|
mediaSession.setPlaybackState(
|
||||||
|
PlaybackStateCompat.Builder()
|
||||||
|
.setActions(actions)
|
||||||
|
.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 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 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() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
"KRYZ Auto Playback",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = "KRYZ Radio Android Auto playback"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(statusText)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.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}/drawable/kryz_auto_art")
|
||||||
|
mediaSession.setMetadata(
|
||||||
|
MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
|
||||||
|
if (metadata == null) {
|
||||||
|
applyNowPlayingMetadata(
|
||||||
|
title = "KRYZ Live Stream",
|
||||||
|
subtitle = "KRYZ Radio",
|
||||||
|
album = "Live",
|
||||||
|
mediaUri = STREAM_URL,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyNowPlayingMetadata(
|
||||||
|
title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE),
|
||||||
|
subtitle = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST),
|
||||||
|
album = metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM),
|
||||||
|
mediaUri = metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectToFlutterAudioService() {
|
||||||
|
if (audioServiceController != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val existing = audioServiceBrowser
|
||||||
|
if (existing?.isConnected == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
try {
|
||||||
|
existing.connect()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Failed reconnect attempt: ${error.message}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val browser = MediaBrowserCompat(
|
||||||
|
this,
|
||||||
|
ComponentName(this, AudioService::class.java),
|
||||||
|
browserConnectionCallback,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
audioServiceBrowser = browser
|
||||||
|
browser.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectFromFlutterAudioService() {
|
||||||
|
reconnectScheduled = false
|
||||||
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
|
handleControllerDisconnected()
|
||||||
|
|
||||||
|
audioServiceBrowser?.let { browser ->
|
||||||
|
if (browser.isConnected) {
|
||||||
|
browser.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioServiceBrowser = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleControllerDisconnected() {
|
||||||
|
audioServiceController?.unregisterCallback(mediaControllerCallback)
|
||||||
|
audioServiceController = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleBrowserReconnect() {
|
||||||
|
if (reconnectScheduled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectScheduled = true
|
||||||
|
mainHandler.postDelayed(
|
||||||
|
{
|
||||||
|
reconnectScheduled = false
|
||||||
|
connectToFlutterAudioService()
|
||||||
|
},
|
||||||
|
1200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
||||||
|
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||||
|
return MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,623 @@
|
|||||||
package com.example.kryz_go_flutter
|
package com.example.kryz_go_flutter
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
|
import androidx.mediarouter.app.MediaRouteChooserDialog
|
||||||
|
import androidx.mediarouter.media.MediaRouteSelector
|
||||||
|
import com.google.android.gms.cast.CastMediaControlIntent
|
||||||
|
import com.google.android.gms.cast.MediaInfo
|
||||||
|
import com.google.android.gms.cast.MediaLoadRequestData
|
||||||
|
import com.google.android.gms.cast.MediaMetadata
|
||||||
|
import com.google.android.gms.cast.MediaStatus
|
||||||
|
import com.google.android.gms.cast.framework.CastContext
|
||||||
|
import com.google.android.gms.cast.framework.CastSession
|
||||||
|
import com.google.android.gms.cast.framework.SessionManagerListener
|
||||||
|
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
||||||
|
import com.google.android.gms.common.api.CommonStatusCodes
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class MainActivity : AudioServiceActivity()
|
class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||||
|
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 MAX_PENDING_CAST_AUTOPLAY_RETRIES = 5
|
||||||
|
private const val REMOTE_STATUS_POLL_INTERVAL_MS = 500L
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PendingCastRequest(
|
||||||
|
val streamUrl: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var castContext: CastContext? = null
|
||||||
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
private var chooserDialog: MediaRouteChooserDialog? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var activeRemoteMediaClient: RemoteMediaClient? = null
|
||||||
|
private var pendingCastRequest: PendingCastRequest? = null
|
||||||
|
private var pendingCastRequestInFlight: Boolean = false
|
||||||
|
private var pendingCastAutoplayRetryCount: Int = 0
|
||||||
|
private var remoteCastStatusPollingRunnable: Runnable? = null
|
||||||
|
|
||||||
|
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
|
||||||
|
"status" to "idle",
|
||||||
|
"playbackStatus" to "stopped",
|
||||||
|
"platform" to "android",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val remoteMediaClientCallback = object : RemoteMediaClient.Callback() {
|
||||||
|
override fun onStatusUpdated() {
|
||||||
|
emitRemotePlaybackStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMetadataUpdated() {
|
||||||
|
emitRemotePlaybackStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val castSessionManagerListener = object : SessionManagerListener<CastSession> {
|
||||||
|
override fun onSessionStarting(session: CastSession) {
|
||||||
|
emitStatus("connecting")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionStarted(session: CastSession, sessionId: String) {
|
||||||
|
attachRemoteClientCallback(session)
|
||||||
|
startRemoteStatusPolling()
|
||||||
|
emitStatus("connected", session.castDevice?.friendlyName)
|
||||||
|
startPendingCastRequest(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionStartFailed(session: CastSession, error: Int) {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
emitStatus("error", message = "Cast session failed to start ($error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionEnding(session: CastSession) {
|
||||||
|
detachRemoteClientCallback()
|
||||||
|
// The Cast framework reports the final state in onSessionEnded.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionEnded(session: CastSession, error: Int) {
|
||||||
|
stopRemoteStatusPolling()
|
||||||
|
clearPendingCastRequest()
|
||||||
|
// If app was launched from Android Auto, restart audio service on cast disconnect
|
||||||
|
restartAudioServiceSession()
|
||||||
|
emitStatus("idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionResuming(session: CastSession, sessionId: String) {
|
||||||
|
emitStatus("connecting")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
|
||||||
|
attachRemoteClientCallback(session)
|
||||||
|
startRemoteStatusPolling()
|
||||||
|
emitStatus("connected", session.castDevice?.friendlyName)
|
||||||
|
startPendingCastRequest(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionResumeFailed(session: CastSession, error: Int) {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
emitStatus("error", message = "Cast session failed to resume ($error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionSuspended(session: CastSession, reason: Int) {
|
||||||
|
stopRemoteStatusPolling()
|
||||||
|
detachRemoteClientCallback()
|
||||||
|
emitStatus("idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||||
|
.apply { setMethodCallHandler(this@MainActivity) }
|
||||||
|
|
||||||
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
||||||
|
.setStreamHandler(this)
|
||||||
|
|
||||||
|
initializeCastContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"getStatus" -> result.success(statusPayload)
|
||||||
|
"showDevicePicker" -> {
|
||||||
|
showDevicePicker(call)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"startCastingLiveStream" -> {
|
||||||
|
startCastingLiveStream(call, result)
|
||||||
|
}
|
||||||
|
"disconnect" -> {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
castContext?.sessionManager?.endCurrentSession(true)
|
||||||
|
emitStatus("idle")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopCastingPlayback" -> {
|
||||||
|
stopCastingPlayback(result)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||||
|
eventSink = events
|
||||||
|
events.success(statusPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
chooserDialog?.dismiss()
|
||||||
|
chooserDialog = null
|
||||||
|
|
||||||
|
castContext?.sessionManager?.removeSessionManagerListener(
|
||||||
|
castSessionManagerListener,
|
||||||
|
CastSession::class.java,
|
||||||
|
)
|
||||||
|
detachRemoteClientCallback()
|
||||||
|
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeCastContext() {
|
||||||
|
try {
|
||||||
|
castContext = CastContext.getSharedInstance(this)
|
||||||
|
castContext?.sessionManager?.addSessionManagerListener(
|
||||||
|
castSessionManagerListener,
|
||||||
|
CastSession::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||||
|
if (currentSession != null && currentSession.isConnected) {
|
||||||
|
attachRemoteClientCallback(currentSession)
|
||||||
|
emitStatus("connected", currentSession.castDevice?.friendlyName)
|
||||||
|
} else {
|
||||||
|
emitStatus("idle")
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
emitStatus("unavailable", message = error.message ?: "Cast unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDevicePicker(call: MethodCall) {
|
||||||
|
if (castContext == null) {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
emitStatus("unavailable", message = "Cast services unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingCastRequest(parsePendingCastRequest(call.arguments))
|
||||||
|
|
||||||
|
val receiverAppId = getString(R.string.cast_receiver_app_id)
|
||||||
|
val routeSelector = MediaRouteSelector.Builder()
|
||||||
|
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverAppId))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val themedContext = ContextThemeWrapper(this, androidx.appcompat.R.style.Theme_AppCompat_DayNight)
|
||||||
|
|
||||||
|
chooserDialog?.dismiss()
|
||||||
|
chooserDialog = MediaRouteChooserDialog(themedContext)
|
||||||
|
chooserDialog?.routeSelector = routeSelector
|
||||||
|
chooserDialog?.setCanceledOnTouchOutside(true)
|
||||||
|
chooserDialog?.setOnDismissListener {
|
||||||
|
handlePickerDismissed()
|
||||||
|
}
|
||||||
|
chooserDialog?.show()
|
||||||
|
|
||||||
|
emitStatus("connecting")
|
||||||
|
} catch (error: IllegalArgumentException) {
|
||||||
|
emitStatus("error", message = "Unable to open cast picker: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePickerDismissed() {
|
||||||
|
chooserDialog = null
|
||||||
|
|
||||||
|
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||||
|
val currentStatus = statusPayload["status"] as? String
|
||||||
|
|
||||||
|
if (currentSession == null && currentStatus == "connecting") {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
emitStatus("idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCastingLiveStream(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val arguments = call.arguments as? Map<*, *>
|
||||||
|
if (arguments == null) {
|
||||||
|
result.error("invalid_arguments", "Missing cast media payload", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val streamUrl = arguments["streamUrl"] as? String
|
||||||
|
val title = "KRYZ 98.5 LPFM Mariposa Community Radio"
|
||||||
|
val subtitle = "Live Stream"
|
||||||
|
|
||||||
|
if (streamUrl.isNullOrBlank()) {
|
||||||
|
result.error("invalid_stream_url", "streamUrl is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||||
|
if (currentSession == null || !currentSession.isConnected) {
|
||||||
|
result.error("no_cast_session", "Connect to a cast device first", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadRequestData = buildLoadRequestData(
|
||||||
|
streamUrl = streamUrl,
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
loadMediaWithRetry(
|
||||||
|
session = currentSession,
|
||||||
|
loadRequestData = loadRequestData,
|
||||||
|
attempt = 1,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPendingCastRequest(request: PendingCastRequest?) {
|
||||||
|
pendingCastRequest = request
|
||||||
|
pendingCastRequestInFlight = false
|
||||||
|
pendingCastAutoplayRetryCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPendingCastRequest() {
|
||||||
|
setPendingCastRequest(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePendingCastRequest(arguments: Any?): PendingCastRequest? {
|
||||||
|
val map = arguments as? Map<*, *> ?: return null
|
||||||
|
val shouldAutoplay = map["autoplay"] as? Boolean ?: false
|
||||||
|
if (!shouldAutoplay) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val streamUrl = map["streamUrl"] as? String
|
||||||
|
if (streamUrl.isNullOrBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return PendingCastRequest(
|
||||||
|
streamUrl = streamUrl,
|
||||||
|
title = map["title"] as? String ?: "Live Stream",
|
||||||
|
subtitle = map["subtitle"] as? String ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPendingCastRequest(session: CastSession) {
|
||||||
|
val request = pendingCastRequest ?: return
|
||||||
|
if (pendingCastRequestInFlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingCastRequestInFlight = true
|
||||||
|
|
||||||
|
loadMediaWithRetry(
|
||||||
|
session = session,
|
||||||
|
loadRequestData = buildLoadRequestData(
|
||||||
|
streamUrl = request.streamUrl,
|
||||||
|
title = request.title,
|
||||||
|
subtitle = request.subtitle,
|
||||||
|
),
|
||||||
|
attempt = 1,
|
||||||
|
result = object : MethodChannel.Result {
|
||||||
|
override fun success(result: Any?) {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||||
|
pendingCastRequestInFlight = false
|
||||||
|
schedulePendingCastRequestRetry(errorMessage ?: errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun notImplemented() {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedulePendingCastRequestRetry(message: String) {
|
||||||
|
val request = pendingCastRequest
|
||||||
|
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||||
|
if (request == null || currentSession == null || !currentSession.isConnected) {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
emitStatus("error", message = message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingCastAutoplayRetryCount >= MAX_PENDING_CAST_AUTOPLAY_RETRIES) {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
emitStatus("error", message = message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCastAutoplayRetryCount += 1
|
||||||
|
mainHandler.postDelayed(
|
||||||
|
{
|
||||||
|
val resumedSession = castContext?.sessionManager?.currentCastSession
|
||||||
|
if (resumedSession != null && resumedSession.isConnected) {
|
||||||
|
startPendingCastRequest(resumedSession)
|
||||||
|
} else {
|
||||||
|
clearPendingCastRequest()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLoadRequestData(
|
||||||
|
streamUrl: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
): MediaLoadRequestData {
|
||||||
|
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
|
||||||
|
putString(MediaMetadata.KEY_TITLE, title)
|
||||||
|
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaInfo = MediaInfo.Builder(streamUrl)
|
||||||
|
.setStreamType(MediaInfo.STREAM_TYPE_LIVE)
|
||||||
|
.setContentType("audio/mpeg")
|
||||||
|
.setMetadata(metadata)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return MediaLoadRequestData.Builder()
|
||||||
|
.setMediaInfo(mediaInfo)
|
||||||
|
.setAutoplay(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMediaWithRetry(
|
||||||
|
session: CastSession,
|
||||||
|
loadRequestData: MediaLoadRequestData,
|
||||||
|
attempt: Int,
|
||||||
|
result: MethodChannel.Result,
|
||||||
|
) {
|
||||||
|
val remoteMediaClient = session.remoteMediaClient
|
||||||
|
if (remoteMediaClient == null) {
|
||||||
|
if (attempt >= 8) {
|
||||||
|
val message = "Cast client not ready after session connection"
|
||||||
|
emitStatus("error", message = message)
|
||||||
|
result.error("no_remote_media_client", message, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emitStatus("connecting", session.castDevice?.friendlyName, playbackStatus = "buffering")
|
||||||
|
mainHandler.postDelayed(
|
||||||
|
{
|
||||||
|
loadMediaWithRetry(
|
||||||
|
session = session,
|
||||||
|
loadRequestData = loadRequestData,
|
||||||
|
attempt = attempt + 1,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
350,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
|
||||||
|
if (mediaChannelResult.status.isSuccess) {
|
||||||
|
// Stop local playback immediately when remote load succeeds
|
||||||
|
// This MUST be done in Kotlin (not Flutter) because the audio service
|
||||||
|
// session may be active from Android Auto and won't respond to Flutter calls
|
||||||
|
stopAudioServiceSession()
|
||||||
|
|
||||||
|
emitStatus("connected", session.castDevice?.friendlyName)
|
||||||
|
// Force initial remote status update and continue polling
|
||||||
|
emitRemotePlaybackStatus()
|
||||||
|
result.success(null)
|
||||||
|
return@setResultCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusCode = mediaChannelResult.status.statusCode
|
||||||
|
val statusText = CommonStatusCodes.getStatusCodeString(statusCode)
|
||||||
|
val message = "Cast load failed: $statusText ($statusCode)"
|
||||||
|
emitStatus("error", message = message)
|
||||||
|
result.error("cast_load_failed", message, null)
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
result.error("cast_load_failed", error.message, JSONObject.NULL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopCastingPlayback(result: MethodChannel.Result) {
|
||||||
|
val session = castContext?.sessionManager?.currentCastSession
|
||||||
|
if (session == null || !session.isConnected) {
|
||||||
|
result.error("no_cast_session", "Connect to a cast device first", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteMediaClient = session.remoteMediaClient
|
||||||
|
if (remoteMediaClient == null) {
|
||||||
|
result.error("no_remote_media_client", "Unable to control cast media", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
remoteMediaClient.stop().setResultCallback { stopResult ->
|
||||||
|
if (stopResult.status.isSuccess) {
|
||||||
|
emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped")
|
||||||
|
result.success(null)
|
||||||
|
return@setResultCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = stopResult.status.statusCode
|
||||||
|
val statusText = CommonStatusCodes.getStatusCodeString(code)
|
||||||
|
result.error("cast_stop_failed", "Cast stop failed: $statusText ($code)", null)
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
result.error("cast_stop_failed", error.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachRemoteClientCallback(session: CastSession) {
|
||||||
|
val remoteMediaClient = session.remoteMediaClient
|
||||||
|
if (remoteMediaClient == null) {
|
||||||
|
detachRemoteClientCallback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeRemoteMediaClient === remoteMediaClient) {
|
||||||
|
emitRemotePlaybackStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detachRemoteClientCallback()
|
||||||
|
activeRemoteMediaClient = remoteMediaClient
|
||||||
|
remoteMediaClient.registerCallback(remoteMediaClientCallback)
|
||||||
|
startRemoteStatusPolling()
|
||||||
|
if (pendingCastRequest != null) {
|
||||||
|
startPendingCastRequest(session)
|
||||||
|
}
|
||||||
|
emitRemotePlaybackStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detachRemoteClientCallback() {
|
||||||
|
stopRemoteStatusPolling()
|
||||||
|
activeRemoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
|
||||||
|
activeRemoteMediaClient = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRemoteStatusPolling() {
|
||||||
|
if (remoteCastStatusPollingRunnable != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val pollRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
try {
|
||||||
|
if (activeRemoteMediaClient != null) {
|
||||||
|
emitRemotePlaybackStatus()
|
||||||
|
// Use mainHandler to keep polling alive across lifecycle changes
|
||||||
|
mainHandler.postDelayed(this, REMOTE_STATUS_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Safely handle any errors to keep polling thread alive
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteCastStatusPollingRunnable = pollRunnable
|
||||||
|
mainHandler.post(pollRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRemoteStatusPolling() {
|
||||||
|
val runnable = remoteCastStatusPollingRunnable ?: return
|
||||||
|
mainHandler.removeCallbacks(runnable)
|
||||||
|
remoteCastStatusPollingRunnable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitRemotePlaybackStatus() {
|
||||||
|
val remoteMediaClient = activeRemoteMediaClient
|
||||||
|
if (remoteMediaClient == null) return
|
||||||
|
|
||||||
|
val mediaStatus = remoteMediaClient.mediaStatus
|
||||||
|
val playbackStatus = when (mediaStatus?.playerState) {
|
||||||
|
MediaStatus.PLAYER_STATE_PLAYING -> "playing"
|
||||||
|
MediaStatus.PLAYER_STATE_BUFFERING,
|
||||||
|
MediaStatus.PLAYER_STATE_LOADING -> "buffering"
|
||||||
|
MediaStatus.PLAYER_STATE_PAUSED -> "stopped"
|
||||||
|
MediaStatus.PLAYER_STATE_IDLE,
|
||||||
|
MediaStatus.PLAYER_STATE_UNKNOWN,
|
||||||
|
null -> "stopped"
|
||||||
|
else -> "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||||
|
if (currentSession != null && currentSession.isConnected) {
|
||||||
|
emitStatus("connected", currentSession.castDevice?.friendlyName, playbackStatus = playbackStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLocalAudioPlayback() {
|
||||||
|
try {
|
||||||
|
// Tell Flutter side to stop local audio via method channel
|
||||||
|
// This prevents audio from playing on both mobile and cast device
|
||||||
|
methodChannel?.invokeMethod("stopLocalAudio", null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Silently handle if Flutter side doesn't implement this method
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopAudioServiceSession() {
|
||||||
|
try {
|
||||||
|
// Stop the audio service session directly (works even if app is in background via Android Auto)
|
||||||
|
// This ensures the shared audio handler doesn't keep playing when cast device takes over
|
||||||
|
// Access the audio service through the session token if available
|
||||||
|
methodChannel?.invokeMethod("stopAudioService", null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Audio service might not be available, silently continue
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartAudioServiceSession() {
|
||||||
|
try {
|
||||||
|
// When cast session ends, allow the audio service to resume management
|
||||||
|
// This re-enables local playback for the Flutter side
|
||||||
|
methodChannel?.invokeMethod("resumeLocalAudio", null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If Flutter handler isn't ready, that's ok - audio service will be managed externally
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitStatus(
|
||||||
|
status: String,
|
||||||
|
deviceName: String? = null,
|
||||||
|
message: String? = null,
|
||||||
|
playbackStatus: String? = null,
|
||||||
|
) {
|
||||||
|
val resolvedPlaybackStatus = playbackStatus
|
||||||
|
?: statusPayload["playbackStatus"] as? String
|
||||||
|
?: "stopped"
|
||||||
|
|
||||||
|
if (status == "idle" || status == "unavailable") {
|
||||||
|
statusPayload["playbackStatus"] = "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
val payload = mutableMapOf<String, Any?>(
|
||||||
|
"status" to status,
|
||||||
|
"playbackStatus" to if (status == "idle" || status == "unavailable") "stopped" else resolvedPlaybackStatus,
|
||||||
|
"platform" to "android",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!deviceName.isNullOrBlank()) {
|
||||||
|
payload["deviceName"] = deviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.isNullOrBlank()) {
|
||||||
|
payload["message"] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
statusPayload = payload
|
||||||
|
eventSink?.success(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -12,7 +12,8 @@
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
<item name="android:windowIsTranslucent">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Brand colors exposed to Android Auto. Android Auto's media UI reads colorPrimary
|
||||||
|
from the app theme to accent its browsing and now-playing screens.
|
||||||
|
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<color name="kryz_primary">#E36A18</color>
|
||||||
|
<color name="kryz_primary_dark">#B84E08</color>
|
||||||
|
<color name="kryz_accent">#1663C7</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="cast_receiver_app_id">CC1AD845</string>
|
||||||
|
</resources>
|
||||||
@@ -12,7 +12,18 @@
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">@android:color/white</item>
|
||||||
|
<item name="android:windowIsTranslucent">false</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Theme used by KryzAutoMediaBrowserService. Android Auto reads colorPrimary
|
||||||
|
from the service's theme to accent its media browsing UI with the KRYZ brand orange.
|
||||||
|
-->
|
||||||
|
<style name="KryzAutoTheme" parent="Theme.AppCompat">
|
||||||
|
<item name="colorPrimary">@color/kryz_primary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/kryz_primary_dark</item>
|
||||||
|
<item name="colorAccent">@color/kryz_accent</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="media"/>
|
||||||
|
</automotiveApp>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
|
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
@@ -56,6 +57,8 @@
|
|||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -158,10 +161,12 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||||
97C147021CF9000F007C117D /* Info.plist */,
|
97C147021CF9000F007C117D /* Info.plist */,
|
||||||
|
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */,
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
|
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
@@ -382,6 +387,7 @@
|
|||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
|
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -473,6 +479,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -656,6 +663,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -679,6 +687,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
|||||||
@@ -1,9 +1,92 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
import MediaPlayer
|
||||||
|
|
||||||
|
private struct CarPlayPlaybackSnapshot {
|
||||||
|
var playbackStatus: String
|
||||||
|
var title: String
|
||||||
|
var subtitle: String
|
||||||
|
var album: String
|
||||||
|
var isLive: Bool
|
||||||
|
var seekable: Bool
|
||||||
|
|
||||||
|
static let `default` = CarPlayPlaybackSnapshot(
|
||||||
|
playbackStatus: "stopped",
|
||||||
|
title: "KRYZ Live Stream",
|
||||||
|
subtitle: "KRYZ Radio",
|
||||||
|
album: "Live",
|
||||||
|
isLive: true,
|
||||||
|
seekable: false
|
||||||
|
)
|
||||||
|
|
||||||
|
init(
|
||||||
|
playbackStatus: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
album: String,
|
||||||
|
isLive: Bool,
|
||||||
|
seekable: Bool
|
||||||
|
) {
|
||||||
|
self.playbackStatus = playbackStatus
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.album = album
|
||||||
|
self.isLive = isLive
|
||||||
|
self.seekable = seekable
|
||||||
|
}
|
||||||
|
|
||||||
|
init(payload: [String: Any], fallback: CarPlayPlaybackSnapshot) {
|
||||||
|
let status = (payload["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let title = (payload["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let subtitle = (payload["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let album = (payload["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
playbackStatus = (status?.isEmpty == false) ? status! : fallback.playbackStatus
|
||||||
|
self.title = (title?.isEmpty == false) ? title! : fallback.title
|
||||||
|
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : fallback.subtitle
|
||||||
|
self.album = (album?.isEmpty == false) ? album! : fallback.album
|
||||||
|
isLive = payload["isLive"] as? Bool ?? fallback.isLive
|
||||||
|
seekable = payload["seekable"] as? Bool ?? fallback.seekable
|
||||||
|
}
|
||||||
|
|
||||||
|
func asDictionary() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"playbackStatus": playbackStatus,
|
||||||
|
"title": title,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"album": album,
|
||||||
|
"isLive": isLive,
|
||||||
|
"seekable": seekable
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
|
||||||
|
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
|
||||||
|
private let castingEventChannelName = "kryz_go_flutter/casting/events"
|
||||||
|
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
|
||||||
|
private let carPlayMethodChannelName = "kryz_go_flutter/carplay/methods"
|
||||||
|
|
||||||
|
static let carPlaySnapshotDidChangeNotification = Notification.Name("KRYZCarPlaySnapshotDidChange")
|
||||||
|
static let carPlaySnapshotUserInfoKey = "snapshot"
|
||||||
|
|
||||||
|
private var castingEventSink: FlutterEventSink?
|
||||||
|
private var castingStatus: [String: Any] = [
|
||||||
|
"status": "idle",
|
||||||
|
"platform": "ios"
|
||||||
|
]
|
||||||
|
private var playbackMethodChannel: FlutterMethodChannel?
|
||||||
|
private var carPlayMethodChannel: FlutterMethodChannel?
|
||||||
|
private var carPlaySnapshot = CarPlayPlaybackSnapshot.default
|
||||||
|
private var headlessFlutterEngine: FlutterEngine?
|
||||||
|
private var playCommandTarget: Any?
|
||||||
|
private var togglePlayPauseCommandTarget: Any?
|
||||||
|
private var pauseCommandTarget: Any?
|
||||||
|
private var stopCommandTarget: Any?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
@@ -17,10 +100,333 @@ import AVFoundation
|
|||||||
NSLog("Failed to configure AVAudioSession: \(error)")
|
NSLog("Failed to configure AVAudioSession: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAudioRouteChange),
|
||||||
|
name: AVAudioSession.routeChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
updateCastingStatusForCurrentRoute()
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
configureChannels(with: engineBridge.pluginRegistry)
|
||||||
|
|
||||||
|
if let engine = headlessFlutterEngine {
|
||||||
|
engine.destroyContext()
|
||||||
|
headlessFlutterEngine = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||||
|
func invokePlay() {
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
playbackMethodChannel?.invokeMethod("play", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||||
|
func invokeStop() {
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentCarPlaySnapshot() -> [String: Any] {
|
||||||
|
carPlaySnapshot.asDictionary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCarPlaySnapshot() {
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
carPlayMethodChannel?.invokeMethod("getStateSnapshot", arguments: nil) { [weak self] response in
|
||||||
|
guard let payload = response as? [String: Any] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.applyCarPlaySnapshot(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureFlutterBridgeReadyForCarPlay() {
|
||||||
|
if playbackMethodChannel != nil, carPlayMethodChannel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let engine = headlessFlutterEngine {
|
||||||
|
configureChannels(with: engine)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = FlutterEngine(name: "kryz_carplay_headless_engine", project: nil, allowHeadlessExecution: true)
|
||||||
|
headlessFlutterEngine = engine
|
||||||
|
engine.run()
|
||||||
|
GeneratedPluginRegistrant.register(with: engine)
|
||||||
|
configureChannels(with: engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureChannels(with registry: FlutterPluginRegistry) {
|
||||||
|
guard let registrar = registry.registrar(forPlugin: "CastingBridge") else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let methodChannel = FlutterMethodChannel(
|
||||||
|
name: castingMethodChannelName,
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
let eventChannel = FlutterEventChannel(
|
||||||
|
name: castingEventChannelName,
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
|
||||||
|
eventChannel.setStreamHandler(self)
|
||||||
|
|
||||||
|
methodChannel.setMethodCallHandler { [weak self] call, result in
|
||||||
|
self?.handleCastingMethod(call: call, result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackMethodChannel = FlutterMethodChannel(
|
||||||
|
name: playbackMethodChannelName,
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
|
||||||
|
carPlayMethodChannel = FlutterMethodChannel(
|
||||||
|
name: carPlayMethodChannelName,
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
carPlayMethodChannel?.setMethodCallHandler { [weak self] call, result in
|
||||||
|
self?.handleCarPlayMethod(call: call, result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
configureRemoteCommandCenterIfNeeded()
|
||||||
|
requestCarPlaySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleAudioRouteChange() {
|
||||||
|
updateCastingStatusForCurrentRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleCarPlayMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
case "syncState":
|
||||||
|
guard let payload = call.arguments as? [String: Any] else {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "invalid_payload",
|
||||||
|
message: "Expected map payload for syncState",
|
||||||
|
details: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCarPlaySnapshot(payload)
|
||||||
|
result(nil)
|
||||||
|
case "getCachedState":
|
||||||
|
result(carPlaySnapshot.asDictionary())
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCarPlaySnapshot(_ payload: [String: Any]) {
|
||||||
|
carPlaySnapshot = CarPlayPlaybackSnapshot(payload: payload, fallback: carPlaySnapshot)
|
||||||
|
updateNowPlayingInfoCenter(using: carPlaySnapshot)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: Self.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: self,
|
||||||
|
userInfo: [Self.carPlaySnapshotUserInfoKey: carPlaySnapshot.asDictionary()]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNowPlayingInfoCenter(using snapshot: CarPlayPlaybackSnapshot) {
|
||||||
|
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyTitle] = snapshot.title
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtist] = snapshot.subtitle
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = snapshot.album
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = snapshot.isLive
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = snapshot.playbackStatus == "playing" ? 1.0 : 0.0
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
let playbackState: MPNowPlayingPlaybackState = switch snapshot.playbackStatus {
|
||||||
|
case "playing":
|
||||||
|
.playing
|
||||||
|
case "buffering":
|
||||||
|
.playing
|
||||||
|
default:
|
||||||
|
.stopped
|
||||||
|
}
|
||||||
|
MPNowPlayingInfoCenter.default().playbackState = playbackState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureRemoteCommandCenterIfNeeded() {
|
||||||
|
if playCommandTarget != nil ||
|
||||||
|
togglePlayPauseCommandTarget != nil ||
|
||||||
|
pauseCommandTarget != nil ||
|
||||||
|
stopCommandTarget != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
||||||
|
commandCenter.seekForwardCommand.isEnabled = false
|
||||||
|
commandCenter.seekBackwardCommand.isEnabled = false
|
||||||
|
commandCenter.nextTrackCommand.isEnabled = false
|
||||||
|
commandCenter.previousTrackCommand.isEnabled = false
|
||||||
|
|
||||||
|
commandCenter.playCommand.isEnabled = true
|
||||||
|
playCommandTarget = commandCenter.playCommand.addTarget { [weak self] _ in
|
||||||
|
self?.invokePlay()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||||
|
togglePlayPauseCommandTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.carPlaySnapshot.playbackStatus {
|
||||||
|
case "playing", "buffering":
|
||||||
|
self.invokeStop()
|
||||||
|
default:
|
||||||
|
self.invokePlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.pauseCommand.isEnabled = true
|
||||||
|
pauseCommandTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
self?.invokeStop()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.stopCommand.isEnabled = true
|
||||||
|
stopCommandTarget = commandCenter.stopCommand.addTarget { [weak self] _ in
|
||||||
|
self?.invokeStop()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardownRemoteCommandCenter() {
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
if let target = playCommandTarget {
|
||||||
|
commandCenter.playCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
if let target = togglePlayPauseCommandTarget {
|
||||||
|
commandCenter.togglePlayPauseCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
if let target = pauseCommandTarget {
|
||||||
|
commandCenter.pauseCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
if let target = stopCommandTarget {
|
||||||
|
commandCenter.stopCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
playCommandTarget = nil
|
||||||
|
togglePlayPauseCommandTarget = nil
|
||||||
|
pauseCommandTarget = nil
|
||||||
|
stopCommandTarget = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
teardownRemoteCommandCenter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import CarPlay
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CarPlay scene delegate for KRYZ Go!
|
||||||
|
*
|
||||||
|
* Shows a CPListTemplate with the KRYZ Live Stream item. Tapping it triggers
|
||||||
|
* playback via AppDelegate.invokePlay(), then presents CPNowPlayingTemplate.
|
||||||
|
*
|
||||||
|
* CarPlay enforces its own design system for safety — colors cannot be customised.
|
||||||
|
* The app icon (which carries the KRYZ brand orange) is used as item artwork to
|
||||||
|
* provide visual continuity with the phone app.
|
||||||
|
*
|
||||||
|
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
|
||||||
|
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
|
||||||
|
*/
|
||||||
|
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPListTemplateDelegate {
|
||||||
|
|
||||||
|
private struct Snapshot {
|
||||||
|
var playbackStatus: String
|
||||||
|
var title: String
|
||||||
|
var subtitle: String
|
||||||
|
var album: String
|
||||||
|
|
||||||
|
init(playbackStatus: String, title: String, subtitle: String, album: String) {
|
||||||
|
self.playbackStatus = playbackStatus
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.album = album
|
||||||
|
}
|
||||||
|
|
||||||
|
static let `default` = Snapshot(
|
||||||
|
playbackStatus: "stopped",
|
||||||
|
title: "KRYZ Live Stream",
|
||||||
|
subtitle: "KRYZ Radio",
|
||||||
|
album: "Live"
|
||||||
|
)
|
||||||
|
|
||||||
|
init(payload: [String: Any]?) {
|
||||||
|
let base = Snapshot.default
|
||||||
|
let status = (payload?["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let title = (payload?["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let subtitle = (payload?["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let album = (payload?["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
playbackStatus = (status?.isEmpty == false) ? status! : base.playbackStatus
|
||||||
|
self.title = (title?.isEmpty == false) ? title! : base.title
|
||||||
|
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : base.subtitle
|
||||||
|
self.album = (album?.isEmpty == false) ? album! : base.album
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var carPlayInterfaceController: CPInterfaceController?
|
||||||
|
private var listTemplate: CPListTemplate?
|
||||||
|
private var streamItem: CPListItem?
|
||||||
|
private var snapshot: Snapshot = .default
|
||||||
|
|
||||||
|
// MARK: - CPTemplateApplicationSceneDelegate
|
||||||
|
|
||||||
|
func templateApplicationScene(
|
||||||
|
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||||
|
didConnect interfaceController: CPInterfaceController
|
||||||
|
) {
|
||||||
|
appDelegate?.ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
carPlayInterfaceController = interfaceController
|
||||||
|
snapshot = Snapshot(payload: appDelegate?.currentCarPlaySnapshot())
|
||||||
|
|
||||||
|
let rootTemplate = makeListTemplate()
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil)
|
||||||
|
} else {
|
||||||
|
// iOS 13 fallback: use API variant available before iOS 14.
|
||||||
|
interfaceController.setRootTemplate(rootTemplate, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleCarPlaySnapshotChanged(_:)),
|
||||||
|
name: AppDelegate.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
appDelegate?.requestCarPlaySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateApplicationScene(
|
||||||
|
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||||
|
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||||
|
) {
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: AppDelegate.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
carPlayInterfaceController = nil
|
||||||
|
listTemplate = nil
|
||||||
|
appDelegate?.invokeStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Template construction
|
||||||
|
|
||||||
|
private func makeListTemplate() -> CPListTemplate {
|
||||||
|
let icon = UIImage(named: "AppIcon")?
|
||||||
|
.withRenderingMode(.alwaysOriginal)
|
||||||
|
|
||||||
|
let item = CPListItem(
|
||||||
|
text: snapshot.title,
|
||||||
|
detailText: detailText(for: snapshot),
|
||||||
|
image: icon
|
||||||
|
)
|
||||||
|
streamItem = item
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
item.handler = { [weak self] _, completion in
|
||||||
|
self?.handleStreamItemTapped()
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let section = CPListSection(items: [item])
|
||||||
|
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
|
||||||
|
|
||||||
|
if #unavailable(iOS 14.0) {
|
||||||
|
// iOS 13 fallback: use list delegate selection callback.
|
||||||
|
template.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
template.tabTitle = "KRYZ Go!"
|
||||||
|
template.tabImage = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
listTemplate = template
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailText(for snapshot: Snapshot) -> String {
|
||||||
|
let statusText: String = switch snapshot.playbackStatus {
|
||||||
|
case "playing":
|
||||||
|
"Playing"
|
||||||
|
case "buffering":
|
||||||
|
"Buffering"
|
||||||
|
default:
|
||||||
|
"Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return statusText
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(snapshot.subtitle) • \(statusText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleCarPlaySnapshotChanged(_ notification: Notification) {
|
||||||
|
guard
|
||||||
|
let payload = notification.userInfo?[AppDelegate.carPlaySnapshotUserInfoKey] as? [String: Any]
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = Snapshot(payload: payload)
|
||||||
|
refreshTemplatesForSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshTemplatesForSnapshot() {
|
||||||
|
guard let interfaceController = carPlayInterfaceController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *), interfaceController.topTemplate is CPNowPlayingTemplate {
|
||||||
|
// Preserve Now Playing navigation stack while metadata updates are flowing.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedTemplate = makeListTemplate()
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
interfaceController.setRootTemplate(updatedTemplate, animated: false, completion: nil)
|
||||||
|
} else {
|
||||||
|
interfaceController.setRootTemplate(updatedTemplate, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback
|
||||||
|
|
||||||
|
private func handleStreamItemTapped() {
|
||||||
|
appDelegate?.invokePlay()
|
||||||
|
appDelegate?.requestCarPlaySnapshot()
|
||||||
|
guard let interfaceController = carPlayInterfaceController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
interfaceController.pushTemplate(
|
||||||
|
CPNowPlayingTemplate.shared,
|
||||||
|
animated: true,
|
||||||
|
completion: nil
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// iOS 13 fallback: no CPNowPlayingTemplate.shared API.
|
||||||
|
// Keep playback running and remain on the list template.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var appDelegate: AppDelegate? {
|
||||||
|
UIApplication.shared.delegate as? AppDelegate
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CPListTemplateDelegate (iOS 13 fallback)
|
||||||
|
|
||||||
|
func listTemplate(
|
||||||
|
_ listTemplate: CPListTemplate,
|
||||||
|
didSelect item: CPListItem,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
defer { completionHandler() }
|
||||||
|
|
||||||
|
guard item == streamItem else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStreamItemTapped()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,17 @@
|
|||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>CPTemplateApplicationScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>CarPlay</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CarPlay audio entitlement. This must also be enabled on the Apple Developer Portal
|
||||||
|
(Certificates, Identifiers & Profiles → your App ID → Capabilities → CarPlay)
|
||||||
|
and added to the target in Xcode: Signing & Capabilities → + Capability → CarPlay.
|
||||||
|
-->
|
||||||
|
<key>com.apple.developer.carplay-audio</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'streaming_audio_business_object.dart';
|
||||||
|
|
||||||
|
class CarPlayBridge {
|
||||||
|
CarPlayBridge({required StreamingAudioBusinessObject audio}) : _audio = audio;
|
||||||
|
|
||||||
|
static const MethodChannel _channel = MethodChannel(
|
||||||
|
'kryz_go_flutter/carplay/methods',
|
||||||
|
);
|
||||||
|
|
||||||
|
final StreamingAudioBusinessObject _audio;
|
||||||
|
bool _isStarted = false;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isStarted = true;
|
||||||
|
_channel.setMethodCallHandler(_handleNativeMethodCall);
|
||||||
|
_audio.addListener(_onAudioChanged);
|
||||||
|
await _pushSnapshotToNative();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (!_isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isStarted = false;
|
||||||
|
_audio.removeListener(_onAudioChanged);
|
||||||
|
_channel.setMethodCallHandler(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _snapshot() {
|
||||||
|
final String playbackStatus = switch (_audio.playbackStatus) {
|
||||||
|
StreamingPlaybackStatus.playing => 'playing',
|
||||||
|
StreamingPlaybackStatus.buffering => 'buffering',
|
||||||
|
StreamingPlaybackStatus.stopped => 'stopped',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'playbackStatus': playbackStatus,
|
||||||
|
'isPlaying': _audio.isPlaying,
|
||||||
|
'isBuffering': _audio.isBuffering,
|
||||||
|
'isLive': true,
|
||||||
|
'seekable': false,
|
||||||
|
'title': _audio.currentTitle,
|
||||||
|
'subtitle': _audio.currentSubtitle,
|
||||||
|
'album': 'Live',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _handleNativeMethodCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'getStateSnapshot':
|
||||||
|
return _snapshot();
|
||||||
|
default:
|
||||||
|
throw MissingPluginException(
|
||||||
|
'Unknown CarPlay bridge method: ${call.method}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAudioChanged() {
|
||||||
|
_pushSnapshotToNative();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushSnapshotToNative() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('syncState', _snapshot());
|
||||||
|
} on MissingPluginException {
|
||||||
|
// CarPlay bridge only exists on iOS native builds.
|
||||||
|
} on PlatformException {
|
||||||
|
// Keep playback working even if native sync temporarily fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
enum CastingStatus { unavailable, idle, connecting, connected, error }
|
||||||
|
|
||||||
|
enum CastingPlaybackStatus { unknown, stopped, buffering, playing }
|
||||||
|
|
||||||
|
class CastingBusinessObject extends ChangeNotifier {
|
||||||
|
static const MethodChannel _methodChannel = MethodChannel(
|
||||||
|
'kryz_go_flutter/casting/methods',
|
||||||
|
);
|
||||||
|
static const EventChannel _eventChannel = EventChannel(
|
||||||
|
'kryz_go_flutter/casting/events',
|
||||||
|
);
|
||||||
|
|
||||||
|
StreamSubscription<dynamic>? _eventSubscription;
|
||||||
|
CastingStatus _status = CastingStatus.unavailable;
|
||||||
|
CastingPlaybackStatus _playbackStatus = CastingPlaybackStatus.unknown;
|
||||||
|
String? _connectedDeviceName;
|
||||||
|
String? _lastError;
|
||||||
|
|
||||||
|
/// Callback for when native side requests local audio to stop
|
||||||
|
VoidCallback? onStopLocalAudioRequested;
|
||||||
|
|
||||||
|
CastingStatus get status => _status;
|
||||||
|
CastingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||||
|
String? get connectedDeviceName => _connectedDeviceName;
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
|
||||||
|
bool get isSupportedOnThisPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultTargetPlatform == TargetPlatform.iOS ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isConnected => _status == CastingStatus.connected;
|
||||||
|
bool get isPlaying => _playbackStatus == CastingPlaybackStatus.playing;
|
||||||
|
bool get isBuffering => _playbackStatus == CastingPlaybackStatus.buffering;
|
||||||
|
bool get isStopped => _playbackStatus == CastingPlaybackStatus.stopped;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (!isSupportedOnThisPlatform) {
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_eventSubscription?.cancel();
|
||||||
|
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||||
|
_handleEvent,
|
||||||
|
onError: (Object error) {
|
||||||
|
_lastError = error.toString();
|
||||||
|
_setStatus(CastingStatus.error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up handler for native method calls (e.g., stopLocalAudio)
|
||||||
|
_methodChannel.setMethodCallHandler(_handleMethodCall);
|
||||||
|
|
||||||
|
await refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshStatus() async {
|
||||||
|
if (!isSupportedOnThisPlatform) {
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dynamic status = await _methodChannel.invokeMethod<dynamic>(
|
||||||
|
'getStatus',
|
||||||
|
);
|
||||||
|
_handleEvent(status);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_lastError = error.message ?? error.code;
|
||||||
|
_setStatus(CastingStatus.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showDevicePicker({
|
||||||
|
bool autoplay = false,
|
||||||
|
String? streamUrl,
|
||||||
|
String? title,
|
||||||
|
String? subtitle,
|
||||||
|
}) async {
|
||||||
|
if (!isSupportedOnThisPlatform) {
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
_setStatus(CastingStatus.connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _methodChannel.invokeMethod<void>('showDevicePicker', {
|
||||||
|
'autoplay': autoplay,
|
||||||
|
'streamUrl': streamUrl,
|
||||||
|
'title': title,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_lastError = error.message ?? error.code;
|
||||||
|
_setStatus(CastingStatus.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startCastingLiveStream({
|
||||||
|
required String streamUrl,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
}) async {
|
||||||
|
if (!isSupportedOnThisPlatform) {
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _methodChannel.invokeMethod<void>('startCastingLiveStream', {
|
||||||
|
'streamUrl': streamUrl,
|
||||||
|
'title': title,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_lastError = error.message ?? error.code;
|
||||||
|
// Do NOT set status to error here — native emits its own status events.
|
||||||
|
// Overriding status from Dart corrupts the UI when native considers the
|
||||||
|
// session still connected (e.g. during a retry after a race condition).
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (!isSupportedOnThisPlatform) {
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _methodChannel.invokeMethod<void>('disconnect');
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_lastError = error.message ?? error.code;
|
||||||
|
_setStatus(CastingStatus.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopPlayback() async {
|
||||||
|
if (!isSupportedOnThisPlatform) {
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _methodChannel.invokeMethod<void>('stopCastingPlayback');
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_lastError = error.message ?? error.code;
|
||||||
|
_setStatus(CastingStatus.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleEvent(dynamic event) {
|
||||||
|
if (event is! Map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String? status = event['status'] as String?;
|
||||||
|
final String? playbackStatus = event['playbackStatus'] as String?;
|
||||||
|
final String? deviceName = event['deviceName'] as String?;
|
||||||
|
final String? message = event['message'] as String?;
|
||||||
|
|
||||||
|
if (deviceName != null && deviceName.isNotEmpty) {
|
||||||
|
_connectedDeviceName = deviceName;
|
||||||
|
} else if (status != 'connected') {
|
||||||
|
_connectedDeviceName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != null && message.isNotEmpty) {
|
||||||
|
_lastError = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setPlaybackStatus(_parsePlaybackStatus(playbackStatus));
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'idle':
|
||||||
|
_setStatus(CastingStatus.idle);
|
||||||
|
break;
|
||||||
|
case 'connecting':
|
||||||
|
_setStatus(CastingStatus.connecting);
|
||||||
|
break;
|
||||||
|
case 'connected':
|
||||||
|
_setStatus(CastingStatus.connected);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
_setStatus(CastingStatus.error);
|
||||||
|
break;
|
||||||
|
case 'unavailable':
|
||||||
|
default:
|
||||||
|
_setStatus(CastingStatus.unavailable);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setStatus(CastingStatus status) {
|
||||||
|
if (_status == status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = status;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
CastingPlaybackStatus _parsePlaybackStatus(String? playbackStatus) {
|
||||||
|
switch (playbackStatus) {
|
||||||
|
case 'playing':
|
||||||
|
return CastingPlaybackStatus.playing;
|
||||||
|
case 'buffering':
|
||||||
|
return CastingPlaybackStatus.buffering;
|
||||||
|
case 'stopped':
|
||||||
|
return CastingPlaybackStatus.stopped;
|
||||||
|
default:
|
||||||
|
return CastingPlaybackStatus.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setPlaybackStatus(CastingPlaybackStatus status) {
|
||||||
|
if (_playbackStatus == status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_playbackStatus = status;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles method calls from native code
|
||||||
|
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'stopLocalAudio':
|
||||||
|
onStopLocalAudioRequested?.call();
|
||||||
|
return null;
|
||||||
|
case 'stopAudioService':
|
||||||
|
// Stop the audio service directly (called when transferring to cast)
|
||||||
|
onStopLocalAudioRequested?.call();
|
||||||
|
return null;
|
||||||
|
case 'resumeLocalAudio':
|
||||||
|
// Audio service is resuming after cast disconnect; no action needed on Flutter side
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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> pause() async {
|
||||||
|
// CarPlay and lock-screen transport may issue pause for live streams.
|
||||||
|
// Treat pause as stop to keep behavior consistent across platforms.
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ class LiveInfoBusinessObject extends ChangeNotifier {
|
|||||||
'KRYZ_LIVE_INFO_URL',
|
'KRYZ_LIVE_INFO_URL',
|
||||||
defaultValue: 'http://kryz.airtime.pro/api/live-info',
|
defaultValue: 'http://kryz.airtime.pro/api/live-info',
|
||||||
),
|
),
|
||||||
int refreshIntervalSeconds = 15,
|
int refreshIntervalSeconds = 20,
|
||||||
this.requestTimeout = const Duration(seconds: 8),
|
this.requestTimeout = const Duration(seconds: 8),
|
||||||
}) : _refreshIntervalSeconds = refreshIntervalSeconds;
|
}) : _refreshIntervalSeconds = refreshIntervalSeconds;
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ String _decodeHtml(String raw) {
|
|||||||
return raw
|
return raw
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('"', '"')
|
.replaceAll('"', '"')
|
||||||
.replaceAll(''', "'")
|
.replaceAll(''', "'")
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>');
|
.replaceAll('>', '>');
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-239
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import 'app_theme_business_object.dart';
|
import 'app_theme_business_object.dart';
|
||||||
import 'live_info_business_object.dart';
|
import 'live_info_business_object.dart';
|
||||||
|
import 'show_card.dart';
|
||||||
|
import 'track_card.dart';
|
||||||
|
|
||||||
class LiveInfoPanel extends StatelessWidget {
|
class LiveInfoPanel extends StatelessWidget {
|
||||||
const LiveInfoPanel({
|
const LiveInfoPanel({
|
||||||
@@ -68,61 +71,6 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 5,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
color: Colors.black.withValues(alpha: 0.12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
snapshot?.sourceEnabled ?? 'Unknown Source',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
PopupMenuButton<int>(
|
|
||||||
tooltip: 'Set refresh interval',
|
|
||||||
initialValue: liveInfo.refreshIntervalSeconds,
|
|
||||||
onSelected: liveInfo.setRefreshIntervalSeconds,
|
|
||||||
itemBuilder: (BuildContext context) {
|
|
||||||
return intervalOptions
|
|
||||||
.map(
|
|
||||||
(int seconds) => PopupMenuItem<int>(
|
|
||||||
value: seconds,
|
|
||||||
child: Text('$seconds seconds'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(growable: false);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${liveInfo.refreshIntervalSeconds}s',
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Refresh now',
|
|
||||||
onPressed: liveInfo.isLoading
|
|
||||||
? null
|
|
||||||
: () => liveInfo.refresh(),
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -143,7 +91,7 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _TrackCard(
|
child: TrackCard(
|
||||||
label: 'Now Playing',
|
label: 'Now Playing',
|
||||||
track: current,
|
track: current,
|
||||||
titleColor: themeColors.statusText,
|
titleColor: themeColors.statusText,
|
||||||
@@ -151,7 +99,7 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _TrackCard(
|
child: TrackCard(
|
||||||
label: 'Coming Next',
|
label: 'Coming Next',
|
||||||
track: next,
|
track: next,
|
||||||
titleColor: themeColors.statusText,
|
titleColor: themeColors.statusText,
|
||||||
@@ -161,82 +109,24 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ShowCard(
|
ShowCard(
|
||||||
label: 'On Air Show',
|
label: 'On Air Show',
|
||||||
show: currentShow,
|
show: currentShow,
|
||||||
|
sourceTimezone: snapshot?.timezone,
|
||||||
titleColor: themeColors.statusText,
|
titleColor: themeColors.statusText,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_ShowCard(
|
ShowCard(
|
||||||
label: 'Up Next Show',
|
label: 'Up Next Show',
|
||||||
show: nextShow,
|
show: nextShow,
|
||||||
|
sourceTimezone: snapshot?.timezone,
|
||||||
titleColor: themeColors.statusText,
|
titleColor: themeColors.statusText,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
'Recently Played (runtime)',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: liveInfo.recentPlayed.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Text(
|
|
||||||
liveInfo.isLoading
|
|
||||||
? 'Loading recent items...'
|
|
||||||
: 'No recent tracks captured yet',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.separated(
|
|
||||||
itemCount: liveInfo.recentPlayed.length,
|
|
||||||
separatorBuilder: (BuildContext context, int index) =>
|
|
||||||
Divider(
|
|
||||||
height: 1,
|
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
final RecentPlayedItem item =
|
|
||||||
liveInfo.recentPlayed[index];
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 0,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
|
||||||
?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
item.subtitle.isEmpty
|
|
||||||
? _formatTimeWindow(item.starts, item.ends)
|
|
||||||
: '${item.subtitle} | ${_formatTimeWindow(item.starts, item.ends)}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.queue_music_rounded),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
liveInfo.lastUpdatedAt == null
|
liveInfo.lastUpdatedAt == null
|
||||||
? 'Waiting for API response...'
|
? 'Waiting for API response...'
|
||||||
: 'Last update: ${liveInfo.lastUpdatedAt!.toLocal().toString().substring(11, 19)} • ${snapshot?.timezone ?? '--'}',
|
: 'Last update (Local): ${DateFormat.yMd().format(liveInfo.lastUpdatedAt!.toLocal())} ${DateFormat.jm().format(liveInfo.lastUpdatedAt!.toLocal())}',
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -245,122 +135,3 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrackCard extends StatelessWidget {
|
|
||||||
const _TrackCard({
|
|
||||||
required this.label,
|
|
||||||
required this.track,
|
|
||||||
required this.titleColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final LiveInfoTrackInfo? track;
|
|
||||||
final Color titleColor;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final String title = track?.displayTitle ?? 'Unavailable';
|
|
||||||
final String subtitle = (track?.displaySubtitle ?? '').trim();
|
|
||||||
final String window = _formatTimeWindow(track?.starts, track?.ends);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(color: Colors.black.withValues(alpha: 0.12)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: titleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
subtitle.isEmpty ? 'No metadata provided' : subtitle,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
window,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ShowCard extends StatelessWidget {
|
|
||||||
const _ShowCard({
|
|
||||||
required this.label,
|
|
||||||
required this.show,
|
|
||||||
required this.titleColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final LiveInfoShowInfo? show;
|
|
||||||
final Color titleColor;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
|
||||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.mic_external_on_rounded, color: titleColor, size: 18),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'$label: ${show?.name ?? 'No scheduled show'}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_formatTimeWindow(show?.starts, show?.ends),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatTimeWindow(String? starts, String? ends) {
|
|
||||||
String formatOne(String? value) {
|
|
||||||
if (value == null || value.length < 16) {
|
|
||||||
return '--:--';
|
|
||||||
}
|
|
||||||
return value.substring(11, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '${formatOne(starts)} - ${formatOne(ends)}';
|
|
||||||
}
|
|
||||||
|
|||||||
+259
-19
@@ -1,20 +1,29 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
import 'package:just_audio_background/just_audio_background.dart';
|
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
import 'app_theme_business_object.dart';
|
import 'app_theme_business_object.dart';
|
||||||
|
import 'carplay_bridge.dart';
|
||||||
|
import 'casting_business_object.dart';
|
||||||
import 'live_info_business_object.dart';
|
import 'live_info_business_object.dart';
|
||||||
import 'live_info_panel.dart';
|
import 'live_info_panel.dart';
|
||||||
import 'streaming_audio_business_object.dart';
|
import 'streaming_audio_business_object.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await JustAudioBackground.init(
|
tz.initializeTimeZones();
|
||||||
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
|
||||||
androidNotificationChannelName: 'KRYZ Audio Playback',
|
|
||||||
androidNotificationOngoing: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
|
|
||||||
|
// Keep startup non-blocking so UI can render even if background media
|
||||||
|
// services are initializing after an Android Auto cold start.
|
||||||
|
unawaited(
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
DeviceOrientation.portraitDown,
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
@@ -26,9 +35,22 @@ class MyApp extends StatefulWidget {
|
|||||||
|
|
||||||
class _MyAppState extends State<MyApp> {
|
class _MyAppState extends State<MyApp> {
|
||||||
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
||||||
|
final CastingBusinessObject _casting = CastingBusinessObject();
|
||||||
|
late final CarPlayBridge _carPlayBridge;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_carPlayBridge = CarPlayBridge(audio: _audio);
|
||||||
|
unawaited(_audio.ensureInitialized());
|
||||||
|
unawaited(_carPlayBridge.start());
|
||||||
|
_casting.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
unawaited(_carPlayBridge.dispose());
|
||||||
|
_casting.dispose();
|
||||||
_audio.dispose();
|
_audio.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -36,47 +58,121 @@ class _MyAppState extends State<MyApp> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Split Screen Player',
|
title: 'KRYZ Go!',
|
||||||
theme: AppThemeBusinessObject.lightTheme(),
|
theme: AppThemeBusinessObject.lightTheme(),
|
||||||
darkTheme: AppThemeBusinessObject.darkTheme(),
|
darkTheme: AppThemeBusinessObject.darkTheme(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: MainPage(audio: _audio),
|
home: MainPage(audio: _audio, casting: _casting),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainPage extends StatefulWidget {
|
class MainPage extends StatefulWidget {
|
||||||
const MainPage({super.key, required this.audio});
|
const MainPage({super.key, required this.audio, required this.casting});
|
||||||
|
|
||||||
final StreamingAudioBusinessObject audio;
|
final StreamingAudioBusinessObject audio;
|
||||||
|
final CastingBusinessObject casting;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MainPage> createState() => _MainPageState();
|
State<MainPage> createState() => _MainPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainPageState extends State<MainPage> {
|
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||||
StreamingAudioBusinessObject get _audio => widget.audio;
|
StreamingAudioBusinessObject get _audio => widget.audio;
|
||||||
|
CastingBusinessObject get _casting => widget.casting;
|
||||||
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
||||||
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
|
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
|
||||||
|
bool _isStartingCastStream = false;
|
||||||
|
bool _pendingRemotePlay = false;
|
||||||
|
|
||||||
|
bool get _usesExternalCastPlayback =>
|
||||||
|
_casting.isConnected && defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
|
bool get _isIosAirPlay => defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
|
||||||
|
bool get _isUsingRemotePlayback => _usesExternalCastPlayback;
|
||||||
|
|
||||||
|
bool get _isPlaybackStopped {
|
||||||
|
if (_isUsingRemotePlayback) {
|
||||||
|
return _casting.isStopped ||
|
||||||
|
_casting.playbackStatus == CastingPlaybackStatus.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _audio.isStopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isPlaybackPlaying {
|
||||||
|
if (_isUsingRemotePlayback) {
|
||||||
|
return _casting.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _audio.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isPlaybackBuffering {
|
||||||
|
if (_isUsingRemotePlayback) {
|
||||||
|
return _casting.isBuffering;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _audio.isBuffering;
|
||||||
|
}
|
||||||
|
|
||||||
String _playbackStatusLabel() {
|
String _playbackStatusLabel() {
|
||||||
switch (_audio.playbackStatus) {
|
if (_isPlaybackBuffering) {
|
||||||
case StreamingPlaybackStatus.stopped:
|
|
||||||
return 'Stopped';
|
|
||||||
case StreamingPlaybackStatus.buffering:
|
|
||||||
return 'Buffering...';
|
return 'Buffering...';
|
||||||
case StreamingPlaybackStatus.playing:
|
}
|
||||||
|
|
||||||
|
if (_isPlaybackPlaying) {
|
||||||
return 'Playing';
|
return 'Playing';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 'Stopped';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _playbackChannel = MethodChannel(
|
||||||
|
'kryz_go_flutter/playback/methods',
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_audio.addListener(_handleAudioStateChanged);
|
_audio.addListener(_handleAudioStateChanged);
|
||||||
|
_casting.addListener(_handleCastingStateChanged);
|
||||||
_liveInfo.addListener(_handleLiveInfoChanged);
|
_liveInfo.addListener(_handleLiveInfoChanged);
|
||||||
_liveInfo.start();
|
_liveInfo.start();
|
||||||
|
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
|
||||||
|
|
||||||
|
// When native code requests local audio to stop (cast media loaded)
|
||||||
|
_casting.onStopLocalAudioRequested = () {
|
||||||
|
unawaited(_audio.stop());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
// Re-initialize (not just refresh) to re-establish the EventChannel
|
||||||
|
// subscription. In a cold-start Android Auto scenario the Flutter engine
|
||||||
|
// boots headlessly before MainActivity.configureFlutterEngine() runs, so
|
||||||
|
// the native EventChannel StreamHandler is not yet registered when
|
||||||
|
// initialize() first fires. The "listen" message is dropped, eventSink
|
||||||
|
// stays null, and all subsequent emitStatus() calls are silently lost.
|
||||||
|
// Calling initialize() here cancels that dead subscription and creates a
|
||||||
|
// fresh one now that the handler is registered; onListen immediately
|
||||||
|
// re-emits statusPayload so the Dart state is fully re-synced.
|
||||||
|
unawaited(_casting.initialize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'play':
|
||||||
|
await _play();
|
||||||
|
case 'stop':
|
||||||
|
await _stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleAudioStateChanged() {
|
void _handleAudioStateChanged() {
|
||||||
@@ -90,6 +186,8 @@ class _MainPageState extends State<MainPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLiveInfoChanged() {
|
void _handleLiveInfoChanged() {
|
||||||
|
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,17 +197,152 @@ class _MainPageState extends State<MainPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleCastingStateChanged() {
|
||||||
|
final currentStatus = _casting.status;
|
||||||
|
|
||||||
|
if (_casting.isPlaying || _casting.isBuffering) {
|
||||||
|
_pendingRemotePlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStatus != CastingStatus.connected) {
|
||||||
|
_isStartingCastStream = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_usesExternalCastPlayback &&
|
||||||
|
_pendingRemotePlay &&
|
||||||
|
!_isStartingCastStream) {
|
||||||
|
// Only load when _pendingRemotePlay was set by _play() while connecting.
|
||||||
|
// Do NOT trigger on justConnected — native pendingCastRequest handles
|
||||||
|
// all autoplay from showDevicePicker(autoplay: true).
|
||||||
|
_startCastingLiveStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_usesExternalCastPlayback &&
|
||||||
|
currentStatus != CastingStatus.connecting) {
|
||||||
|
_pendingRemotePlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
// Rebuild when cast state changes from the native platforms.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startCastingLiveStream() async {
|
||||||
|
_isStartingCastStream = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _casting.startCastingLiveStream(
|
||||||
|
streamUrl: _audio.streamUrl,
|
||||||
|
title: _audio.currentTitle,
|
||||||
|
subtitle: _audio.currentSubtitle,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingRemotePlay = false;
|
||||||
|
|
||||||
|
if (_usesExternalCastPlayback && !_audio.isStopped) {
|
||||||
|
await _audio.stop();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_pendingRemotePlay = !_audio.isStopped;
|
||||||
|
} finally {
|
||||||
|
_isStartingCastStream = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _play() async {
|
Future<void> _play() async {
|
||||||
|
if (_casting.status == CastingStatus.connecting) {
|
||||||
|
_pendingRemotePlay = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_usesExternalCastPlayback) {
|
||||||
|
if (_casting.isPlaying || _isStartingCastStream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _startCastingLiveStream();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRemotePlay = false;
|
||||||
await _audio.play();
|
await _audio.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _stop() async {
|
Future<void> _stop() async {
|
||||||
|
if (_usesExternalCastPlayback) {
|
||||||
|
await _casting.stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _audio.stop();
|
await _audio.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleCasting() async {
|
||||||
|
if (_casting.isConnected) {
|
||||||
|
await _casting.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldAutoplay = !_audio.isStopped;
|
||||||
|
// Do NOT set _pendingRemotePlay when native handles autoplay.
|
||||||
|
// The native pendingCastRequest already handles autoplay via
|
||||||
|
// showDevicePicker(autoplay: true). Setting _pendingRemotePlay here
|
||||||
|
// causes a duplicate load from Flutter that races the native load and
|
||||||
|
// corrupts the cast status (especially in the Android Auto path).
|
||||||
|
// _pendingRemotePlay is only set by _play() when the user explicitly
|
||||||
|
// presses Play while the cast session is still connecting.
|
||||||
|
_pendingRemotePlay = false;
|
||||||
|
await _casting.showDevicePicker(
|
||||||
|
autoplay: shouldAutoplay,
|
||||||
|
streamUrl: shouldAutoplay ? _audio.streamUrl : null,
|
||||||
|
title: shouldAutoplay ? _audio.currentTitle : null,
|
||||||
|
subtitle: shouldAutoplay ? _audio.currentSubtitle : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _castingLabel() {
|
||||||
|
final idleLabel = _isIosAirPlay ? 'AirPlay' : 'Cast';
|
||||||
|
final connectingLabel = _isIosAirPlay ? 'AirPlay...' : 'Casting...';
|
||||||
|
final errorLabel = _isIosAirPlay ? 'AirPlay Error' : 'Cast Error';
|
||||||
|
final unavailableLabel = _isIosAirPlay ? 'No AirPlay' : 'No Cast';
|
||||||
|
|
||||||
|
switch (_casting.status) {
|
||||||
|
case CastingStatus.idle:
|
||||||
|
return idleLabel;
|
||||||
|
case CastingStatus.connecting:
|
||||||
|
return connectingLabel;
|
||||||
|
case CastingStatus.connected:
|
||||||
|
final deviceName = _casting.connectedDeviceName;
|
||||||
|
if (deviceName != null && deviceName.isNotEmpty) {
|
||||||
|
return 'On $deviceName';
|
||||||
|
}
|
||||||
|
return _isIosAirPlay ? 'AirPlaying' : 'Casting';
|
||||||
|
case CastingStatus.error:
|
||||||
|
return errorLabel;
|
||||||
|
case CastingStatus.unavailable:
|
||||||
|
return unavailableLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _castingIcon() {
|
||||||
|
if (_isIosAirPlay) {
|
||||||
|
return Icons.airplay_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _casting.isConnected
|
||||||
|
? Icons.cast_connected_rounded
|
||||||
|
: Icons.cast_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_audio.removeListener(_handleAudioStateChanged);
|
_audio.removeListener(_handleAudioStateChanged);
|
||||||
|
_casting.removeListener(_handleCastingStateChanged);
|
||||||
_liveInfo.removeListener(_handleLiveInfoChanged);
|
_liveInfo.removeListener(_handleLiveInfoChanged);
|
||||||
_liveInfo.dispose();
|
_liveInfo.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -219,15 +452,22 @@ class _MainPageState extends State<MainPage> {
|
|||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _audio.isStopped ? _play : null,
|
onPressed: _isPlaybackStopped ? _play : null,
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: const Text('Play'),
|
label: const Text('Play'),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _audio.isStopped ? null : _stop,
|
onPressed: _isPlaybackStopped ? null : _stop,
|
||||||
icon: const Icon(Icons.stop_rounded),
|
icon: const Icon(Icons.stop_rounded),
|
||||||
label: const Text('Stop'),
|
label: const Text('Stop'),
|
||||||
),
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _casting.status == CastingStatus.connecting
|
||||||
|
? null
|
||||||
|
: _toggleCasting,
|
||||||
|
icon: Icon(_castingIcon()),
|
||||||
|
label: Text(_castingLabel()),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
_playbackStatusLabel(),
|
_playbackStatusLabel(),
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'live_info_business_object.dart';
|
||||||
|
import 'time_formatter.dart';
|
||||||
|
|
||||||
|
class RecentlyPlayedList extends StatelessWidget {
|
||||||
|
const RecentlyPlayedList({
|
||||||
|
super.key,
|
||||||
|
required this.recentPlayed,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<RecentPlayedItem> recentPlayed;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Recently Played (runtime)',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: recentPlayed.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
isLoading
|
||||||
|
? 'Loading recent items...'
|
||||||
|
: 'No recent tracks captured yet',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: recentPlayed.length,
|
||||||
|
separatorBuilder: (BuildContext context, int index) =>
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final RecentPlayedItem item = recentPlayed[index];
|
||||||
|
final String window = formatTimeWindow(
|
||||||
|
item.starts,
|
||||||
|
item.ends,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 0,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
item.subtitle.isEmpty
|
||||||
|
? window
|
||||||
|
: '${item.subtitle} | $window',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
leading: const Icon(Icons.queue_music_rounded),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'live_info_business_object.dart';
|
||||||
|
import 'time_formatter.dart';
|
||||||
|
|
||||||
|
class ShowCard extends StatelessWidget {
|
||||||
|
const ShowCard({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.show,
|
||||||
|
required this.sourceTimezone,
|
||||||
|
required this.titleColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final LiveInfoShowInfo? show;
|
||||||
|
final String? sourceTimezone;
|
||||||
|
final Color titleColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.mic_external_on_rounded, color: titleColor, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$label: ${show?.name ?? 'No scheduled show'}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
formatStartingTime(
|
||||||
|
show?.starts,
|
||||||
|
sourceTimezone: sourceTimezone,
|
||||||
|
),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,94 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio_background/just_audio_background.dart';
|
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'kryz_audio_handler.dart';
|
||||||
|
import 'live_info_business_object.dart';
|
||||||
|
|
||||||
enum StreamingPlaybackStatus { stopped, buffering, playing }
|
enum StreamingPlaybackStatus { stopped, buffering, playing }
|
||||||
|
|
||||||
class StreamingAudioBusinessObject extends ChangeNotifier {
|
class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||||
StreamingAudioBusinessObject({
|
StreamingAudioBusinessObject() {
|
||||||
this.streamUrl = 'https://kryz.out.airtime.pro/kryz_a',
|
final initFuture = _audioHandlerInitFuture;
|
||||||
}) {
|
if (initFuture != null) {
|
||||||
_playerStateSubscription = _player.playerStateStream.listen((state) {
|
initFuture.then((handler) {
|
||||||
_setPlaybackStatus(_mapPlayerStateToStatus(state));
|
if (_isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_attachHandler(handler);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final String streamUrl;
|
final handler = _audioHandler;
|
||||||
final AudioPlayer _player = AudioPlayer();
|
if (handler != null) {
|
||||||
late final StreamSubscription<PlayerState> _playerStateSubscription;
|
_attachHandler(handler);
|
||||||
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;
|
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
||||||
bool _isAudioSessionConfigured = false;
|
bool _isDisposed = false;
|
||||||
bool _playRequested = false;
|
bool _isAttached = false;
|
||||||
|
String _currentTitle = 'KRYZ Live Stream';
|
||||||
|
String _currentSubtitle = 'KRYZ Radio';
|
||||||
|
|
||||||
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||||
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
||||||
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
|
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
|
||||||
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
|
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
|
||||||
|
String get currentTitle => _currentTitle;
|
||||||
|
String get currentSubtitle => _currentSubtitle;
|
||||||
|
|
||||||
StreamingPlaybackStatus _mapPlayerStateToStatus(PlayerState state) {
|
Future<void> ensureInitialized() async {
|
||||||
if (state.processingState == ProcessingState.completed) {
|
await initializeBackgroundAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) {
|
||||||
|
if (state.processingState == AudioProcessingState.completed) {
|
||||||
return StreamingPlaybackStatus.stopped;
|
return StreamingPlaybackStatus.stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.playing) {
|
if (state.playing) {
|
||||||
if (state.processingState == ProcessingState.ready) {
|
if (state.processingState == AudioProcessingState.ready) {
|
||||||
return StreamingPlaybackStatus.playing;
|
return StreamingPlaybackStatus.playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
return StreamingPlaybackStatus.buffering;
|
return StreamingPlaybackStatus.buffering;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_playRequested && state.processingState != ProcessingState.completed) {
|
if (state.processingState == AudioProcessingState.buffering ||
|
||||||
|
state.processingState == AudioProcessingState.loading) {
|
||||||
return StreamingPlaybackStatus.buffering;
|
return StreamingPlaybackStatus.buffering;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,57 +104,66 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _configureAudioSessionIfNeeded() async {
|
|
||||||
if (_isAudioSessionConfigured) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = await AudioSession.instance;
|
|
||||||
await session.configure(const AudioSessionConfiguration.music());
|
|
||||||
_isAudioSessionConfigured = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
_playRequested = true;
|
|
||||||
await _configureAudioSessionIfNeeded();
|
|
||||||
_setPlaybackStatus(StreamingPlaybackStatus.buffering);
|
_setPlaybackStatus(StreamingPlaybackStatus.buffering);
|
||||||
|
final handler = await _requireHandler();
|
||||||
try {
|
await handler.play();
|
||||||
if (!_isSourceLoaded) {
|
|
||||||
await _player.setAudioSource(
|
|
||||||
AudioSource.uri(
|
|
||||||
Uri.parse(streamUrl),
|
|
||||||
tag: const MediaItem(
|
|
||||||
id: 'kryz-live-stream',
|
|
||||||
title: 'KRYZ Live Stream',
|
|
||||||
artist: 'KRYZ Radio',
|
|
||||||
album: 'Live',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_isSourceLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = await AudioSession.instance;
|
|
||||||
await session.setActive(true);
|
|
||||||
await _player.play();
|
|
||||||
} catch (_) {
|
|
||||||
_playRequested = false;
|
|
||||||
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
_playRequested = false;
|
final handler = await _requireHandler();
|
||||||
|
await handler.stop();
|
||||||
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
||||||
await _player.stop();
|
}
|
||||||
|
|
||||||
|
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
|
||||||
|
final handler = await _requireHandler();
|
||||||
|
await handler.updateMetadataFromLiveInfo(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<KryzAudioHandler> _requireHandler() async {
|
||||||
|
await initializeBackgroundAudio();
|
||||||
|
final handler = _audioHandler;
|
||||||
|
if (handler is! KryzAudioHandler) {
|
||||||
|
throw StateError('KryzAudioHandler is not initialized');
|
||||||
|
}
|
||||||
|
_attachHandler(handler);
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attachHandler(AudioHandler handler) {
|
||||||
|
if (_isAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAttached = true;
|
||||||
|
_playbackStateSubscription = handler.playbackState.listen((state) {
|
||||||
|
_setPlaybackStatus(_mapPlaybackStateToStatus(state));
|
||||||
|
});
|
||||||
|
_mediaItemSubscription = handler.mediaItem.listen((item) {
|
||||||
|
if (item == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title;
|
||||||
|
final subtitle =
|
||||||
|
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : item.artist!.trim();
|
||||||
|
|
||||||
|
if (title == _currentTitle && subtitle == _currentSubtitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentTitle = title;
|
||||||
|
_currentSubtitle = subtitle;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_playerStateSubscription.cancel();
|
_isDisposed = true;
|
||||||
_player.dispose();
|
_playbackStateSubscription?.cancel();
|
||||||
|
_mediaItemSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
// Source - https://stackoverflow.com/a/54775297
|
||||||
|
// Posted by diegoveloper, modified by community. See post 'Timeline' for change history
|
||||||
|
// Retrieved 2026-05-09, License - CC BY-SA 4.0
|
||||||
|
|
||||||
|
String _printDuration(Duration duration) {
|
||||||
|
String negativeSign = duration.isNegative ? '-' : '';
|
||||||
|
String twoDigits(int n) => n.toString().padLeft(2, "0");
|
||||||
|
String twoDigitMinutes = twoDigits(duration.inMinutes.abs());
|
||||||
|
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
|
||||||
|
return "$negativeSign$twoDigitMinutes:$twoDigitSeconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatTimeWindow(String? starts, String? ends) {
|
||||||
|
if (starts != null && ends != null) {
|
||||||
|
DateTime? startDt = _tryParseServerDateTime(starts);
|
||||||
|
DateTime? endDt = _tryParseServerDateTime(ends);
|
||||||
|
if (startDt == null || endDt == null) {
|
||||||
|
return '--:--';
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration songLength = endDt.difference(startDt);
|
||||||
|
|
||||||
|
return _printDuration(songLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatStartingTime(String? startingTime, {String? sourceTimezone}) {
|
||||||
|
if (startingTime != null) {
|
||||||
|
final DateTime? localStart = _serverTimeToLocal(
|
||||||
|
startingTime,
|
||||||
|
sourceTimezone: sourceTimezone,
|
||||||
|
);
|
||||||
|
if (localStart == null) {
|
||||||
|
return '--:--';
|
||||||
|
}
|
||||||
|
return DateFormat('jm').format(localStart);
|
||||||
|
}
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _serverTimeToLocal(String rawTime, {String? sourceTimezone}) {
|
||||||
|
final DateTime? parsed = _tryParseServerDateTime(rawTime);
|
||||||
|
if (parsed == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasExplicitTimezone(rawTime)) {
|
||||||
|
return parsed.toLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// API timestamps are wall-clock values in the API timezone field.
|
||||||
|
if (sourceTimezone == null || sourceTimezone.trim().isEmpty) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tz.Location sourceLocation = tz.getLocation(sourceTimezone.trim());
|
||||||
|
final tz.TZDateTime sourceTime = tz.TZDateTime(
|
||||||
|
sourceLocation,
|
||||||
|
parsed.year,
|
||||||
|
parsed.month,
|
||||||
|
parsed.day,
|
||||||
|
parsed.hour,
|
||||||
|
parsed.minute,
|
||||||
|
parsed.second,
|
||||||
|
parsed.millisecond,
|
||||||
|
parsed.microsecond,
|
||||||
|
);
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
sourceTime.millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
).toLocal();
|
||||||
|
} catch (_) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasExplicitTimezone(String value) {
|
||||||
|
final String trimmed = value.trim();
|
||||||
|
|
||||||
|
if (trimmed.endsWith('Z') || trimmed.endsWith('z')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final RegExp offsetPattern = RegExp(r'[+-]\d{2}:?\d{2}$');
|
||||||
|
return offsetPattern.hasMatch(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _tryParseServerDateTime(String value) {
|
||||||
|
final String trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime? isoAttempt = DateTime.tryParse(trimmed);
|
||||||
|
if (isoAttempt != null) {
|
||||||
|
return isoAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
final RegExp serverDateTimePattern = RegExp(
|
||||||
|
r'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$',
|
||||||
|
);
|
||||||
|
final RegExpMatch? match = serverDateTimePattern.firstMatch(trimmed);
|
||||||
|
if (match == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int parsePart(int group) => int.parse(match.group(group)!);
|
||||||
|
|
||||||
|
final int year = parsePart(1);
|
||||||
|
final int month = parsePart(2);
|
||||||
|
final int day = parsePart(3);
|
||||||
|
final int hour = parsePart(4);
|
||||||
|
final int minute = parsePart(5);
|
||||||
|
final int second = parsePart(6);
|
||||||
|
|
||||||
|
final String fraction = (match.group(7) ?? '').padRight(6, '0');
|
||||||
|
final int microseconds =
|
||||||
|
fraction.isEmpty ? 0 : int.parse(fraction.substring(0, 6));
|
||||||
|
|
||||||
|
return DateTime(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
second,
|
||||||
|
microseconds ~/ 1000,
|
||||||
|
microseconds % 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'live_info_business_object.dart';
|
||||||
|
import 'time_formatter.dart';
|
||||||
|
|
||||||
|
class TrackCard extends StatelessWidget {
|
||||||
|
const TrackCard({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.track,
|
||||||
|
required this.titleColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final LiveInfoTrackInfo? track;
|
||||||
|
final Color titleColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final String title = track?.displayTitle ?? 'Unavailable';
|
||||||
|
final String subtitle = (track?.displaySubtitle ?? '').trim();
|
||||||
|
final String window = formatTimeWindow(track?.starts, track?.ends);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.black.withValues(alpha: 0.12)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: titleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle.isEmpty ? 'No metadata provided' : subtitle,
|
||||||
|
maxLines: 6,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
window,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,9 +36,12 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
just_audio: ^0.10.5
|
just_audio: ^0.10.5
|
||||||
just_audio_background: ^0.0.1-beta.17
|
just_audio_background: ^0.0.1-beta.17
|
||||||
|
audio_service: ^0.18.18
|
||||||
audio_session: ^0.2.2
|
audio_session: ^0.2.2
|
||||||
webview_flutter: ^4.13.0
|
webview_flutter: ^4.13.0
|
||||||
http: ^1.5.0
|
http: ^1.5.0
|
||||||
|
intl: ^0.20.2
|
||||||
|
timezone: ^0.10.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_name_manager: ^1.0.0
|
flutter_name_manager: ^1.0.0
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:kryz_go_flutter/time_formatter.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
tz.initializeTimeZones();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('formatStartingTime', () {
|
||||||
|
test('returns local display from source timezone naive server time', () {
|
||||||
|
const String sourceTime = '2026-05-14 20:00:00';
|
||||||
|
const String sourceZone = 'America/Los_Angeles';
|
||||||
|
|
||||||
|
final tz.TZDateTime source = tz.TZDateTime(
|
||||||
|
tz.getLocation(sourceZone),
|
||||||
|
2026,
|
||||||
|
5,
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
final String expected = DateFormat('jm').format(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
source.millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
).toLocal(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatStartingTime(sourceTime, sourceTimezone: sourceZone),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to parsed local time when timezone is invalid', () {
|
||||||
|
final DateTime parsed = DateTime.parse('2026-05-14 20:00:00');
|
||||||
|
final String expected = DateFormat('jm').format(parsed);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatStartingTime(
|
||||||
|
'2026-05-14 20:00:00',
|
||||||
|
sourceTimezone: 'Invalid/Timezone',
|
||||||
|
),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns placeholder for invalid input', () {
|
||||||
|
expect(formatStartingTime('not-a-time'), '--:--');
|
||||||
|
expect(formatStartingTime(null), '--:--');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not double-convert timestamps with explicit UTC designator', () {
|
||||||
|
const String utcStamp = '2026-05-15T03:00:00Z';
|
||||||
|
final String expected = DateFormat('jm').format(DateTime.parse(utcStamp).toLocal());
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatStartingTime(
|
||||||
|
utcStamp,
|
||||||
|
sourceTimezone: 'America/Los_Angeles',
|
||||||
|
),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps wall-clock time when local matches source zone for naive input', () {
|
||||||
|
const String sourceTime = '2026-05-14 20:00:00';
|
||||||
|
const String sourceZone = 'America/Los_Angeles';
|
||||||
|
|
||||||
|
final tz.TZDateTime source = tz.TZDateTime(
|
||||||
|
tz.getLocation(sourceZone),
|
||||||
|
2026,
|
||||||
|
5,
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
final String expected = DateFormat('jm').format(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
source.millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
).toLocal(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatStartingTime(sourceTime, sourceTimezone: sourceZone),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('formatTimeWindow', () {
|
||||||
|
test('returns mm:ss style duration from server times', () {
|
||||||
|
expect(
|
||||||
|
formatTimeWindow('2026-05-15 05:18:46.000000', '2026-05-15 05:23:21.000000'),
|
||||||
|
'04:35',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns placeholder for invalid windows', () {
|
||||||
|
expect(formatTimeWindow('nope', '2026-05-15 05:23:21.000000'), '--:--');
|
||||||
|
expect(formatTimeWindow(null, '2026-05-15 05:23:21.000000'), '--:--');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+4
-12
@@ -11,20 +11,12 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:kryz_go_flutter/main.dart';
|
import 'package:kryz_go_flutter/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('App renders title and controls', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
expect(find.text('KRYZ Go!'), findsWidgets);
|
||||||
expect(find.text('0'), findsNothing);
|
expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget);
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user