Compare commits

...
Author SHA1 Message Date
kfj001 c523d0121f additional carplay changes 2026-05-17 18:35:16 -07:00
kfj001 b8e86263d9 functional carplay experience in simulators 2026-05-17 18:35:01 -07:00
kfj001 e3288e044a fixes build issues related to using ios 14 apis in ios 13 project. restores runnable and listenable on ios. 2026-05-17 17:57:03 -07:00
kfj001 b8d624343b Merge pull request 'Alters the metadata sent to google cast to reflect the station rather the song when cast began.' (#8) from cast_station_name_not_current_track into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/8
2026-05-17 14:32:58 -07:00
kfj001 4c4113f66f Alters the metadata sent to google cast to reflect the station rather the song when cast began. 2026-05-17 14:32:38 -07:00
kfj001 5f3801e65f Merge pull request 'corrects problem where google cast was disconnected from player state when app launched from head unit' (#7) from fix_cast into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/7
2026-05-17 14:21:45 -07:00
kfj001 a2b5da3669 corrects problem where google cast was disconnected from player state when app launched from head unit 2026-05-16 23:45:11 -07:00
kfj001 1c7e27b83d Merge pull request 'refactors design to flutter driven service core.' (#6) from central_flutter_service into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/6
2026-05-16 21:22:16 -07:00
kfj001 df2338e5e9 higher resolution android auto logo 2026-05-16 21:21:34 -07:00
kfj001 85740b399b bug fixes for android auto 2026-05-16 21:05:34 -07:00
kfj001 5ea7442c7c refactors design to flutter driven service core. 2026-05-16 20:52:39 -07:00
kfj001 533a3d74f3 Merge pull request 'Fix: remove orphaned old class body from KryzAutoMediaBrowserService' (#5) from agents/android-auto-apple-carplay-support into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/5
2026-05-15 23:17:10 -07:00
kfj001 065d3c8e47 Merge pull request 'Fixes bug where canceling cast dialog would lockout cast button' (#4) from bug_fix_cast_button into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/4
2026-05-15 23:14:37 -07:00
kfj001 83dd1a28b8 First pass adding android auto and apple carplay support. Android auto tuned on emulator. 2026-05-15 21:30:23 -07:00
kfj001andCopilot 52f5ec577b Fix: remove orphaned old class body from KryzAutoMediaBrowserService
The previous rewrite left the entire old class body (companion object,
mediaSessionCallback, lifecycle overrides, launchMainActivity helpers)
dangling outside the new class closing brace — a Kotlin syntax error.
Gradle was using the last successfully compiled APK, so Android Auto
received a broken service and showed 'doesn't seem to be working'.

Remove lines 362-581 (old code), leaving only the new clean class.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 21:17:21 -07:00
kfj001 5827d129f6 Fixes bug where canceling cast dialog would lockout cast button 2026-05-15 07:13:54 +00:00
kfj001 da0779cafb Merge pull request 'fixes timestamping across timezones' (#3) from fix_time into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/3
2026-05-14 23:57:38 -07:00
kfj001 d32720a2e0 fixes timestamping across timezones 2026-05-14 22:51:04 -07:00
kfj001 cbdc3f7710 Merge pull request 'Adds Apple airplay/Google cast support to the app' (#2) from cast into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/2
2026-05-14 22:19:45 -07:00
kfj001 283a918b81 corrects minor branding issue with cast/airplay 2026-05-14 22:17:32 -07:00
kfj001 f7979a70e6 additional changes 2026-05-14 22:02:50 -07:00
kfj001 c9ef69c8b5 Adds Apple airplay/Google cast support to the app 2026-05-14 22:02:37 -07:00
kfj001 3bb2630e1a Refactors duration formatting logic for improved clarity and correctness. 2026-05-11 20:48:09 +00:00
kfj001 65e5db2d35 Removed unneeded imports after refactor. 2026-05-11 20:21:27 +00:00
kfj001 318bb095a1 Corrects formatting issue quirk unique to airtime 2026-05-10 02:40:31 +00:00
kfj001 f1fd59c199 Fixes app orientation to vertical for mobile phones 2026-05-10 01:20:05 +00:00
kfj001 0c6442e260 Updates formatting for last updated 2026-05-10 01:19:48 +00:00
kfj001 a5e1e36ebf Fixes time display of current/next song UI. Adds localized formatting code for start times of shows current and next. 2026-05-10 00:45:31 +00:00
kfj001 de0a243388 Alters devcontainer config to allow for building and running on Linux desktop. 2026-05-10 00:13:34 +00:00
kfj001andCopilot 6893758a16 Refactors visual widgets from live_info_panel into their own widgets.
removes the play history widget from the live info panel.

Co-authored-by: Copilot <copilot@github.com>
2026-05-08 22:42:10 -07:00
kfj001 42a34712a2 Minor renaming 2026-05-01 20:22:12 -07:00
kfj001 e3656a64a8 Removes dropdown selector for refresh cycle. Changes refresh to 20 seconds. 2026-04-30 21:20:35 -07:00
kfj001 15fc7e3d6f Merge pull request 'corrected icon text. created info panel to replace web view with station stream info from airtime' (#1) from info_at_top into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/1
2026-04-30 19:30:19 -07:00
kfj001andCopilot d7d094d70a Linux build system correction. New visual treatment
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 02:29:20 +00:00
kfj001 deaa4db158 refactored live info panel missing form last checkin 2026-04-30 07:43:23 +00:00
kfj001 af7eb3b7f8 corrected icon text. created info panel to replace web view with station stream info from airtime 2026-04-30 07:42:46 +00:00
76 changed files with 3928 additions and 214 deletions
+1 -1
View File
@@ -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
+3
View File
@@ -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"
}, },
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"flutter": true
}
}
+7
View File
@@ -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
+23
View File
@@ -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
}
}
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

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>
+13 -2
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+11 -11
View File
@@ -25,9 +25,9 @@
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.44427667" inkscape:zoom="0.68550502"
inkscape:cx="707.89222" inkscape:cx="485.04386"
inkscape:cy="284.73248" inkscape:cy="511.30187"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="962" inkscape:window-height="962"
inkscape:window-x="0" inkscape:window-x="0"
@@ -132,32 +132,32 @@
xml:space="preserve" xml:space="preserve"
style="font-weight:bold;font-size:100.118px;line-height:0;font-family:'Noto Sans Mono CJK KR';-inkscape-font-specification:'Noto Sans Mono CJK KR Bold';letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:#000000;stroke-width:1.65284" style="font-weight:bold;font-size:100.118px;line-height:0;font-family:'Noto Sans Mono CJK KR';-inkscape-font-specification:'Noto Sans Mono CJK KR Bold';letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:#000000;stroke-width:1.65284"
x="22.209259" x="22.209259"
y="505.82516" y="501.82516"
id="text8"><tspan id="text8"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan7" id="tspan7"
x="22.209259" x="22.209259"
y="505.82516" y="501.82516"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:100.118px;font-family:C059;-inkscape-font-specification:'C059, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke:none;stroke-width:1.65284">98.5 LPFM</tspan></text> style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:100.118px;font-family:C059;-inkscape-font-specification:'C059, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke:none;stroke-width:1.65284">98.5 LPFM</tspan></text>
<text <text
xml:space="preserve" xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:169.76px;line-height:0.8;font-family:Z003;-inkscape-font-specification:'Z003, Medium Italic';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:2.80256" style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:169.76px;line-height:0.8;font-family:Z003;-inkscape-font-specification:'Z003, Medium Italic';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:2.80256"
x="25.539324" x="25.539324"
y="677.90143" y="669.90143"
id="text10"><tspan id="text10"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan10" id="tspan10"
x="25.539324" x="25.539324"
y="677.90143" y="669.90143"
style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan
sodipodi:role="line" sodipodi:role="line"
x="25.539324" x="25.539324"
y="813.70941" y="805.70941"
id="tspan11" id="tspan11"
style="line-height:0.8;stroke-width:2.80256">Public</tspan><tspan style="line-height:0.8;stroke-width:2.80256">Community</tspan><tspan
sodipodi:role="line" sodipodi:role="line"
x="25.539324" x="25.539324"
y="949.5174" y="941.5174"
style="line-height:0.8;stroke-width:2.80256" style="line-height:0.8;stroke-width:2.80256"
id="tspan13">Radio</tspan></text> id="tspan13">Radio</tspan></text>
<text <text
@@ -167,5 +167,5 @@
y="762.51642" y="762.51642"
id="text12"><tspan id="text12"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan12"></tspan></text> id="tspan12" /></text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+9
View File
@@ -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;
+407 -1
View File
@@ -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()
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+224
View File
@@ -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()
}
}
+11
View File
@@ -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>
+13
View File
@@ -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>
+84 -17
View File
@@ -3,6 +3,12 @@ import 'package:flutter/material.dart';
class AppThemeColors { class AppThemeColors {
const AppThemeColors({ const AppThemeColors({
required this.primarySeed, required this.primarySeed,
required this.secondarySeed,
required this.scaffoldGradientStart,
required this.scaffoldGradientEnd,
required this.shellSurface,
required this.shellBorder,
required this.accent,
required this.webViewBorder, required this.webViewBorder,
required this.webViewBackground, required this.webViewBackground,
required this.controlsGradientStart, required this.controlsGradientStart,
@@ -12,6 +18,12 @@ class AppThemeColors {
}); });
final Color primarySeed; final Color primarySeed;
final Color secondarySeed;
final Color scaffoldGradientStart;
final Color scaffoldGradientEnd;
final Color shellSurface;
final Color shellBorder;
final Color accent;
final Color webViewBorder; final Color webViewBorder;
final Color webViewBackground; final Color webViewBackground;
final Color controlsGradientStart; final Color controlsGradientStart;
@@ -22,23 +34,35 @@ class AppThemeColors {
class AppThemeBusinessObject { class AppThemeBusinessObject {
static const AppThemeColors lightColors = AppThemeColors( static const AppThemeColors lightColors = AppThemeColors(
primarySeed: Color(0xFF607D8B), primarySeed: Color(0xFFE36A18),
webViewBorder: Color(0x42000000), secondarySeed: Color(0xFF1663C7),
scaffoldGradientStart: Color(0xFFFFF3E3),
scaffoldGradientEnd: Color(0xFFE6F2FF),
shellSurface: Color(0xF7FFFFFF),
shellBorder: Color(0x1A1A4D86),
accent: Color(0xFF0F5DB6),
webViewBorder: Color(0x261A4D86),
webViewBackground: Color(0xFFFFFFFF), webViewBackground: Color(0xFFFFFFFF),
controlsGradientStart: Color(0xFFE1F5FE), controlsGradientStart: Color(0xFFFFB465),
controlsGradientEnd: Color(0xFFB3E5FC), controlsGradientEnd: Color(0xFF69ADFF),
controlsShadow: Color(0x22000000), controlsShadow: Color(0x24114B7A),
statusText: Color(0xDD000000), statusText: Color(0xFF13243D),
); );
static const AppThemeColors darkColors = AppThemeColors( static const AppThemeColors darkColors = AppThemeColors(
primarySeed: Color(0xFF37474F), primarySeed: Color(0xFFFF9D59),
webViewBorder: Color(0x66FFFFFF), secondarySeed: Color(0xFF76B8FF),
webViewBackground: Color(0xFF0F1417), scaffoldGradientStart: Color(0xFF0F1C30),
controlsGradientStart: Color(0xFF1B2731), scaffoldGradientEnd: Color(0xFF31190C),
controlsGradientEnd: Color(0xFF233744), shellSurface: Color(0xD9162438),
shellBorder: Color(0x3366AAFF),
accent: Color(0xFFFFBC7E),
webViewBorder: Color(0x3378B7FF),
webViewBackground: Color(0xFF0C1420),
controlsGradientStart: Color(0xFF275792),
controlsGradientEnd: Color(0xFF9A4F1F),
controlsShadow: Color(0x44000000), controlsShadow: Color(0x44000000),
statusText: Color(0xE6FFFFFF), statusText: Color(0xFFF4F7FF),
); );
static bool isDarkMode(BuildContext context) { static bool isDarkMode(BuildContext context) {
@@ -50,19 +74,62 @@ class AppThemeBusinessObject {
} }
static ThemeData lightTheme() { static ThemeData lightTheme() {
final ColorScheme scheme =
ColorScheme.fromSeed(
seedColor: lightColors.primarySeed,
brightness: Brightness.light,
).copyWith(
primary: lightColors.primarySeed,
secondary: lightColors.secondarySeed,
tertiary: const Color(0xFF2C85E1),
surface: const Color(0xFFFFFBF7),
onSurface: lightColors.statusText,
);
return ThemeData( return ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: lightColors.primarySeed),
useMaterial3: true, useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
); );
} }
static ThemeData darkTheme() { static ThemeData darkTheme() {
final ColorScheme scheme =
ColorScheme.fromSeed(
seedColor: darkColors.primarySeed,
brightness: Brightness.dark,
).copyWith(
primary: darkColors.primarySeed,
secondary: darkColors.secondarySeed,
tertiary: const Color(0xFF4E97E8),
surface: const Color(0xFF111A27),
onSurface: darkColors.statusText,
);
return ThemeData( return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: darkColors.primarySeed,
brightness: Brightness.dark,
),
useMaterial3: true, useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
); );
} }
} }
+79
View File
@@ -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.
}
}
}
+261
View File
@@ -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;
}
}
}
+234
View File
@@ -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.
}
}
}
+356
View File
@@ -0,0 +1,356 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
class LiveInfoBusinessObject extends ChangeNotifier {
LiveInfoBusinessObject({
this.endpoint = const String.fromEnvironment(
'KRYZ_LIVE_INFO_URL',
defaultValue: 'http://kryz.airtime.pro/api/live-info',
),
int refreshIntervalSeconds = 20,
this.requestTimeout = const Duration(seconds: 8),
}) : _refreshIntervalSeconds = refreshIntervalSeconds;
final String endpoint;
final Duration requestTimeout;
Timer? _pollTimer;
LiveInfoSnapshot? _snapshot;
bool _isLoading = false;
String? _errorMessage;
DateTime? _lastUpdatedAt;
int _refreshIntervalSeconds;
final List<RecentPlayedItem> _recentPlayed = <RecentPlayedItem>[];
final Set<String> _recentKeys = <String>{};
LiveInfoSnapshot? get snapshot => _snapshot;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
DateTime? get lastUpdatedAt => _lastUpdatedAt;
int get refreshIntervalSeconds => _refreshIntervalSeconds;
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
Future<void> start() async {
await refresh();
_restartTimer();
}
void setRefreshIntervalSeconds(int seconds) {
if (seconds <= 0 || seconds == _refreshIntervalSeconds) {
return;
}
_refreshIntervalSeconds = seconds;
_restartTimer();
notifyListeners();
}
Future<void> refresh() async {
_isLoading = true;
notifyListeners();
try {
final response = await http
.get(Uri.parse(endpoint))
.timeout(requestTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw StateError('HTTP ${response.statusCode} from live-info API');
}
final dynamic decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw StateError('Unexpected live-info payload shape');
}
final LiveInfoSnapshot next = LiveInfoSnapshot.fromJson(decoded);
_snapshot = next;
_lastUpdatedAt = DateTime.now();
_errorMessage = null;
_updateRecentPlayed(next);
} catch (error) {
_errorMessage = error.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
void _restartTimer() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(
Duration(seconds: _refreshIntervalSeconds),
(_) => refresh(),
);
}
void _updateRecentPlayed(LiveInfoSnapshot snapshot) {
void addIfTrack(LiveInfoTrackInfo? track, {required String source}) {
if (track == null || track.type != 'track') {
return;
}
final String key =
'$source:${track.metadata.id ?? 'none'}:${track.starts ?? 'unknown'}';
if (_recentKeys.contains(key)) {
return;
}
_recentKeys.add(key);
_recentPlayed.insert(
0,
RecentPlayedItem(
title: track.displayTitle,
subtitle: track.displaySubtitle,
starts: track.starts,
ends: track.ends,
source: source,
),
);
}
addIfTrack(snapshot.current, source: 'current');
addIfTrack(snapshot.previous, source: 'previous');
const int maxItems = 40;
if (_recentPlayed.length > maxItems) {
final List<RecentPlayedItem> removed = _recentPlayed
.sublist(maxItems)
.toList(growable: false);
_recentPlayed.removeRange(maxItems, _recentPlayed.length);
for (final RecentPlayedItem item in removed) {
_recentKeys.remove(item.key);
}
}
}
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
}
class LiveInfoSnapshot {
LiveInfoSnapshot({
required this.env,
required this.schedulerTime,
required this.previous,
required this.current,
required this.next,
required this.currentShow,
required this.nextShow,
required this.timezone,
required this.timezoneOffset,
required this.sourceEnabled,
});
final String? env;
final String? schedulerTime;
final LiveInfoTrackInfo? previous;
final LiveInfoTrackInfo? current;
final LiveInfoTrackInfo? next;
final List<LiveInfoShowInfo> currentShow;
final List<LiveInfoShowInfo> nextShow;
final String? timezone;
final String? timezoneOffset;
final String? sourceEnabled;
factory LiveInfoSnapshot.fromJson(Map<String, dynamic> json) {
List<LiveInfoShowInfo> parseShows(dynamic value) {
if (value is! List) {
return const <LiveInfoShowInfo>[];
}
return value
.whereType<Map<String, dynamic>>()
.map(LiveInfoShowInfo.fromJson)
.toList(growable: false);
}
return LiveInfoSnapshot(
env: json['env'] as String?,
schedulerTime: json['schedulerTime'] as String?,
previous: LiveInfoTrackInfo.fromUnknown(json['previous']),
current: LiveInfoTrackInfo.fromUnknown(json['current']),
next: LiveInfoTrackInfo.fromUnknown(json['next']),
currentShow: parseShows(json['currentShow']),
nextShow: parseShows(json['nextShow']),
timezone: json['timezone'] as String?,
timezoneOffset: json['timezoneOffset']?.toString(),
sourceEnabled: json['source_enabled'] as String?,
);
}
}
class LiveInfoTrackInfo {
LiveInfoTrackInfo({
required this.starts,
required this.ends,
required this.type,
required this.name,
required this.metadata,
});
final String? starts;
final String? ends;
final String? type;
final String? name;
final LiveInfoTrackMetadata metadata;
String get displayTitle {
final String candidate = _decodeHtml(
metadata.trackTitle ??
metadata.filepath ??
metadata.name ??
name ??
'Unknown Track',
);
return candidate.trim().isEmpty ? 'Unknown Track' : candidate;
}
String get displaySubtitle {
final String artist = _decodeHtml(metadata.artistName ?? '').trim();
final String album = _decodeHtml(metadata.albumTitle ?? '').trim();
if (artist.isNotEmpty && album.isNotEmpty) {
return '$artist$album';
}
if (artist.isNotEmpty) {
return artist;
}
if (album.isNotEmpty) {
return album;
}
return _decodeHtml(name ?? '').trim();
}
factory LiveInfoTrackInfo.fromUnknown(dynamic value) {
if (value is! Map<String, dynamic>) {
return LiveInfoTrackInfo(
starts: null,
ends: null,
type: null,
name: null,
metadata: LiveInfoTrackMetadata.empty(),
);
}
final Map<String, dynamic> metadataMap =
value['metadata'] is Map<String, dynamic>
? value['metadata'] as Map<String, dynamic>
: <String, dynamic>{};
return LiveInfoTrackInfo(
starts: value['starts']?.toString(),
ends: value['ends']?.toString(),
type: value['type']?.toString(),
name: value['name']?.toString(),
metadata: LiveInfoTrackMetadata.fromJson(metadataMap),
);
}
}
class LiveInfoTrackMetadata {
LiveInfoTrackMetadata({
required this.id,
required this.name,
required this.filepath,
required this.trackTitle,
required this.artistName,
required this.albumTitle,
required this.genre,
required this.year,
});
final int? id;
final String? name;
final String? filepath;
final String? trackTitle;
final String? artistName;
final String? albumTitle;
final String? genre;
final String? year;
factory LiveInfoTrackMetadata.fromJson(Map<String, dynamic> json) {
return LiveInfoTrackMetadata(
id: json['id'] as int?,
name: json['name']?.toString(),
filepath: json['filepath']?.toString(),
trackTitle: json['track_title']?.toString(),
artistName: json['artist_name']?.toString(),
albumTitle: json['album_title']?.toString(),
genre: json['genre']?.toString(),
year: json['year']?.toString(),
);
}
factory LiveInfoTrackMetadata.empty() {
return LiveInfoTrackMetadata(
id: null,
name: null,
filepath: null,
trackTitle: null,
artistName: null,
albumTitle: null,
genre: null,
year: null,
);
}
}
class LiveInfoShowInfo {
LiveInfoShowInfo({
required this.name,
required this.description,
required this.starts,
required this.ends,
});
final String? name;
final String? description;
final String? starts;
final String? ends;
factory LiveInfoShowInfo.fromJson(Map<String, dynamic> json) {
return LiveInfoShowInfo(
name: json['name']?.toString(),
description: json['description']?.toString(),
starts: json['starts']?.toString(),
ends: json['ends']?.toString(),
);
}
}
class RecentPlayedItem {
RecentPlayedItem({
required this.title,
required this.subtitle,
required this.starts,
required this.ends,
required this.source,
});
final String title;
final String subtitle;
final String? starts;
final String? ends;
final String source;
String get key => '$source:$starts:$title';
}
String _decodeHtml(String raw) {
if (raw.isEmpty) {
return raw;
}
return raw
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'app_theme_business_object.dart';
import 'live_info_business_object.dart';
import 'show_card.dart';
import 'track_card.dart';
class LiveInfoPanel extends StatelessWidget {
const LiveInfoPanel({
super.key,
required this.liveInfo,
required this.themeColors,
required this.intervalOptions,
});
final LiveInfoBusinessObject liveInfo;
final AppThemeColors themeColors;
final List<int> intervalOptions;
@override
Widget build(BuildContext context) {
final LiveInfoSnapshot? snapshot = liveInfo.snapshot;
final LiveInfoTrackInfo? current = snapshot?.current;
final LiveInfoTrackInfo? next = snapshot?.next;
final LiveInfoShowInfo? currentShow = snapshot?.currentShow.isEmpty ?? true
? null
: snapshot!.currentShow.first;
final LiveInfoShowInfo? nextShow = snapshot?.nextShow.isEmpty ?? true
? null
: snapshot!.nextShow.first;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
themeColors.controlsGradientStart.withValues(alpha: 0.9),
themeColors.controlsGradientEnd.withValues(alpha: 0.95),
],
),
boxShadow: [
BoxShadow(
color: themeColors.controlsShadow,
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.graphic_eq_rounded,
color: themeColors.statusText,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Live Studio Feed',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: themeColors.statusText,
),
),
),
],
),
const SizedBox(height: 8),
if (liveInfo.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Live info warning: ${liveInfo.errorMessage}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Row(
children: [
Expanded(
child: TrackCard(
label: 'Now Playing',
track: current,
titleColor: themeColors.statusText,
),
),
const SizedBox(width: 8),
Expanded(
child: TrackCard(
label: 'Coming Next',
track: next,
titleColor: themeColors.statusText,
),
),
],
),
),
const SizedBox(height: 8),
ShowCard(
label: 'On Air Show',
show: currentShow,
sourceTimezone: snapshot?.timezone,
titleColor: themeColors.statusText,
),
const SizedBox(height: 6),
ShowCard(
label: 'Up Next Show',
show: nextShow,
sourceTimezone: snapshot?.timezone,
titleColor: themeColors.statusText,
),
const SizedBox(height: 6),
Text(
liveInfo.lastUpdatedAt == null
? 'Waiting for API response...'
: 'Last update (Local): ${DateFormat.yMd().format(liveInfo.lastUpdatedAt!.toLocal())} ${DateFormat.jm().format(liveInfo.lastUpdatedAt!.toLocal())}',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}
}
+364 -106
View File
@@ -1,19 +1,29 @@
import 'package:flutter/material.dart'; import 'dart:async';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:webview_flutter/webview_flutter.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_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 {
@@ -25,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();
} }
@@ -35,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;
late final WebViewController _webViewController; CastingBusinessObject get _casting => widget.casting;
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
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 'Buffering...';
return 'Stopped';
case StreamingPlaybackStatus.buffering:
return 'Buffering...';
case StreamingPlaybackStatus.playing:
return 'Playing';
} }
if (_isPlaybackPlaying) {
return 'Playing';
}
return 'Stopped';
} }
static const _playbackChannel = MethodChannel(
'kryz_go_flutter/playback/methods',
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_webViewController = WebViewController() WidgetsBinding.instance.addObserver(this);
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(Uri.parse('https://kryzradio.org'));
_audio.addListener(_handleAudioStateChanged); _audio.addListener(_handleAudioStateChanged);
_casting.addListener(_handleCastingStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged);
_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() {
@@ -88,102 +185,250 @@ class _MainPageState extends State<MainPage> {
}); });
} }
void _handleLiveInfoChanged() {
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
if (!mounted) {
return;
}
setState(() {
// Rebuild when live metadata updates from the polling API.
});
}
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.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeColors = AppThemeBusinessObject.colorsFor(context); final themeColors = AppThemeBusinessObject.colorsFor(context);
final theme = Theme.of(context);
return Scaffold( return Scaffold(
body: SafeArea( body: DecoratedBox(
child: Column( decoration: BoxDecoration(
children: [ gradient: LinearGradient(
Expanded( begin: Alignment.topLeft,
flex: 9, end: Alignment.bottomRight,
child: Padding( colors: [
padding: const EdgeInsets.all(12), themeColors.scaffoldGradientStart,
child: Container( themeColors.scaffoldGradientEnd,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( color: themeColors.shellSurface,
color: themeColors.webViewBorder, borderRadius: BorderRadius.circular(24),
width: 2, border: Border.all(color: themeColors.shellBorder),
), boxShadow: [
borderRadius: BorderRadius.circular(8), BoxShadow(
color: themeColors.webViewBackground, color: themeColors.controlsShadow,
blurRadius: 18,
offset: const Offset(0, 10),
),
],
), ),
child: ClipRRect( child: Row(
borderRadius: BorderRadius.circular(6), children: [
child: Column( ClipRRect(
children: [ borderRadius: BorderRadius.circular(18),
Container( child: Image.asset(
width: double.infinity, 'assets/icon/app_icon.png',
padding: const EdgeInsets.symmetric( width: 72,
horizontal: 12, height: 72,
vertical: 8, fit: BoxFit.cover,
), ),
decoration: BoxDecoration( ),
border: Border( const SizedBox(width: 14),
bottom: BorderSide( Expanded(
color: themeColors.webViewBorder, child: Text(
), 'KRYZ Go!',
), textAlign: TextAlign.center,
), style: theme.textTheme.headlineMedium?.copyWith(
child: Column( fontWeight: FontWeight.w900,
crossAxisAlignment: CrossAxisAlignment.start, letterSpacing: 0.4,
mainAxisSize: MainAxisSize.min, color: themeColors.statusText,
children: [
Text(
'Station Information',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: themeColors.statusText,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'https://kryzradio.org',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
),
),
],
), ),
), ),
Expanded( ),
child: WebViewWidget(controller: _webViewController), const SizedBox(width: 86),
), ],
],
),
), ),
), ),
), const SizedBox(height: 12),
), Expanded(
Expanded( child: LiveInfoPanel(
flex: 1, liveInfo: _liveInfo,
child: Padding( themeColors: themeColors,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), intervalOptions: _intervalOptions,
child: Container( ),
),
const SizedBox(height: 12),
Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(24),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
@@ -196,33 +441,46 @@ class _MainPageState extends State<MainPage> {
BoxShadow( BoxShadow(
color: themeColors.controlsShadow, color: themeColors.controlsShadow,
blurRadius: 14, blurRadius: 14,
offset: Offset(0, 6), offset: const Offset(0, 6),
), ),
], ],
), ),
child: Row( child: Wrap(
mainAxisAlignment: MainAxisAlignment.center, alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 12,
children: [ children: [
ElevatedButton( ElevatedButton.icon(
onPressed: _audio.isStopped ? _play : null, onPressed: _isPlaybackStopped ? _play : null,
child: const Text('▶️'), icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'),
), ),
const SizedBox(width: 12), ElevatedButton.icon(
ElevatedButton( onPressed: _isPlaybackStopped ? null : _stop,
onPressed: _audio.isStopped ? null : _stop, icon: const Icon(Icons.stop_rounded),
child: const Text('⏹️'), label: const Text('Stop'),
),
ElevatedButton.icon(
onPressed: _casting.status == CastingStatus.connecting
? null
: _toggleCasting,
icon: Icon(_castingIcon()),
label: Text(_castingLabel()),
), ),
const SizedBox(width: 16),
Text( Text(
_playbackStatusLabel(), _playbackStatusLabel(),
style: TextStyle(color: themeColors.statusText), style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: themeColors.statusText,
),
), ),
], ],
), ),
), ),
), ],
), ),
], ),
), ),
), ),
); );
+87
View File
@@ -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),
);
},
),
),
),
],
);
}
}
+55
View File
@@ -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,
),
],
),
);
}
}
+115 -60
View File
@@ -1,48 +1,94 @@
import 'dart:async'; import 'dart:async';
import 'package:audio_session/audio_session.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:just_audio/just_audio.dart'; import 'kryz_audio_handler.dart';
import 'live_info_business_object.dart';
enum StreamingPlaybackStatus { stopped, buffering, playing } enum StreamingPlaybackStatus { stopped, buffering, playing }
class StreamingAudioBusinessObject extends ChangeNotifier { class StreamingAudioBusinessObject extends ChangeNotifier {
StreamingAudioBusinessObject({ StreamingAudioBusinessObject() {
this.streamUrl = 'https://kryz.out.airtime.pro/kryz_a', final initFuture = _audioHandlerInitFuture;
}) { if (initFuture != null) {
_playerStateSubscription = _player.playerStateStream.listen((state) { initFuture.then((handler) {
_setPlaybackStatus(_mapPlayerStateToStatus(state)); if (_isDisposed) {
}); return;
}
_attachHandler(handler);
});
}
final handler = _audioHandler;
if (handler != null) {
_attachHandler(handler);
}
} }
final String streamUrl; static AudioHandler? _audioHandler;
final AudioPlayer _player = AudioPlayer(); static Future<AudioHandler>? _audioHandlerInitFuture;
late final StreamSubscription<PlayerState> _playerStateSubscription;
bool _isSourceLoaded = false; static Future<void> initializeBackgroundAudio() async {
if (_audioHandler != null) {
return;
}
_audioHandlerInitFuture ??= AudioService.init(
builder: () => KryzAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: 'KRYZ Audio Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
).then((handler) {
_audioHandler = handler;
return handler;
}).catchError((error) {
_audioHandlerInitFuture = null;
throw error;
});
await _audioHandlerInitFuture;
}
String get streamUrl => KryzAudioHandler.streamUrl;
StreamSubscription<PlaybackState>? _playbackStateSubscription;
StreamSubscription<MediaItem?>? _mediaItemSubscription;
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped; StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
bool _isAudioSessionConfigured = false; bool _isDisposed = false;
bool _playRequested = false; bool _isAttached = false;
String _currentTitle = 'KRYZ Live Stream';
String _currentSubtitle = 'KRYZ Radio';
StreamingPlaybackStatus get playbackStatus => _playbackStatus; StreamingPlaybackStatus get playbackStatus => _playbackStatus;
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing; bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering; bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped; bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
String get currentTitle => _currentTitle;
String get currentSubtitle => _currentSubtitle;
StreamingPlaybackStatus _mapPlayerStateToStatus(PlayerState state) { Future<void> ensureInitialized() async {
if (state.processingState == ProcessingState.completed) { await initializeBackgroundAudio();
}
StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) {
if (state.processingState == AudioProcessingState.completed) {
return StreamingPlaybackStatus.stopped; return StreamingPlaybackStatus.stopped;
} }
if (state.playing) { if (state.playing) {
if (state.processingState == ProcessingState.ready) { if (state.processingState == AudioProcessingState.ready) {
return StreamingPlaybackStatus.playing; return StreamingPlaybackStatus.playing;
} }
return StreamingPlaybackStatus.buffering; return StreamingPlaybackStatus.buffering;
} }
if (_playRequested && state.processingState != ProcessingState.completed) { if (state.processingState == AudioProcessingState.buffering ||
state.processingState == AudioProcessingState.loading) {
return StreamingPlaybackStatus.buffering; return StreamingPlaybackStatus.buffering;
} }
@@ -58,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();
} }
} }
+136
View File
@@ -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,
);
}
+68
View File
@@ -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),
),
],
),
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.13) cmake_minimum_required(VERSION 3.13)
project(KRYZ Go! LANGUAGES CXX) project(KRYZ_Go! LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+6
View File
@@ -36,8 +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
intl: ^0.20.2
timezone: ^0.10.1
dev_dependencies: dev_dependencies:
flutter_name_manager: ^1.0.0 flutter_name_manager: ^1.0.0
@@ -62,6 +66,8 @@ flutter:
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/icon/app_icon.png
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
+106
View File
@@ -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
View File
@@ -11,20 +11,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:kryz_go_flutter/main.dart'; import 'package:kryz_go_flutter/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('App renders title and controls', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); await tester.pump();
// Verify that our counter has incremented. expect(find.text('KRYZ Go!'), findsWidgets);
expect(find.text('0'), findsNothing); expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget);
expect(find.text('1'), findsOneWidget); expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget);
}); });
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB