Compare commits

33 Commits
Author SHA1 Message Date
kfj001 58ea55f359 Updates build_whitelabel to regenerate album art for android auto app 2026-07-20 18:20:42 -07:00
kfj001 f069fcdbfb updates themeing to align better with website harmonies 2026-07-12 01:38:14 -07:00
kfj001 9706cc9a6d adds available annotation to carplay logic 2026-07-11 23:41:42 -07:00
Your Name ea1bc88a70 Adds app whitelabling feature from kmountainflower site dumps 2026-07-07 10:00:16 +00: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
164 changed files with 3513 additions and 597 deletions
Regular → Executable
+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 python3 pip pipx 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
Regular → Executable
+10 -1
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"
}, },
@@ -11,5 +14,11 @@
] ]
} }
}, },
"postCreateCommand": "flutter doctor -v" "postCreateCommand": "flutter pub get && python3 -m venv .venv && . .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && curl -fsSL https://claude.ai/install.sh | bash",
"postStartCommand": ".venv/bin/activate",
"containerEnv": {
"ANTHROPIC_BASE_URL": "http://192.168.1.108:1234",
"ANTHROPIC_API_KEY": "lmstudio",
"ANTHROPIC_BASE_MODEL": "qwen/qwen3.6-27b"
}
} }
Regular → Executable
+9 -1
View File
@@ -1,3 +1,6 @@
# Generated whitelabel config — rebuilt by build_whitelabel.py
lib/generated/
# ---> Dart # ---> Dart
# See https://www.dartlang.org/guides/libraries/private-files # See https://www.dartlang.org/guides/libraries/private-files
@@ -69,7 +72,7 @@ google-services.json
.LSOverride .LSOverride
# Icon must end with two \r # Icon must end with two \r
Icon Icon
# Thumbnails # Thumbnails
._* ._*
@@ -90,3 +93,8 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
# Python venv
.venv/
# Whitelabel app theming
theme.json
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+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
View File
+24 -1
View File
@@ -5,9 +5,10 @@
<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="KMountain Flower Radio"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -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,466 @@
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.graphics.Bitmap
import android.graphics.BitmapFactory
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://libretime.westerntechnologies.duckdns.org/main"
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
/**
* Album-art bitmap loaded from the drawable resource.
* Embedded directly into MediaMetadata so Android Auto cannot serve a stale URI cache.
*/
private val albumArtBitmap: Bitmap by lazy {
BitmapFactory.decodeResource(resources, R.drawable.kryz_auto_art)
}
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()
}
/**
* Build an android.resource Uri for the Auto album-art drawable.
* Uses R.drawable.kryz_auto_art so the resource shrinker sees the reference
* and keeps the image in release APKs.
*/
private fun getAutoArtUri(): Uri {
return Uri.Builder()
.scheme("android.resource")
.authority(packageName)
.appendPath(resources.getResourceTypeName(R.drawable.kryz_auto_art))
.appendPath(resources.getResourceEntryName(R.drawable.kryz_auto_art))
.build()
}
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 = getAutoArtUri()
val description = MediaDescriptionCompat.Builder()
.setMediaId(MEDIA_ID_LIVE)
.setTitle("KMTN Live Stream")
.setSubtitle("KMountain 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,
"KMountain Flower Radio Auto Playback",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "KMountain Flower 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("KMountain 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 = getAutoArtUri()
mediaSession.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KMTN Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KMountain 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)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
.build()
)
}
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
if (metadata == null) {
applyNowPlayingMetadata(
title = "KMTN Live Stream",
subtitle = "KMountain 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 {
return MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KMTN Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KMountain Radio")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
.build()
}
}
+619 -1
View File
@@ -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: 811 KiB

View File
View File
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+3 -2
View File
@@ -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>
+11
View File
@@ -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">#1a3a5c</color>
<color name="kryz_primary_dark">#0e2440</color>
<color name="kryz_accent">#e87a2e</color>
</resources>
+4
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media"/>
</automotiveApp>
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 422 KiB

-171
View File
@@ -1,171 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1000"
height="1000"
viewBox="0 0 1000 1000"
version="1.1"
id="svg7"
sodipodi:docname="icon_2.svg"
inkscape:version="1.4.3 (fcd0343856, 2026-01-01)"
inkscape:export-filename="app_icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs7" />
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.68550502"
inkscape:cx="485.04386"
inkscape:cy="511.30187"
inkscape:window-width="1920"
inkscape:window-height="962"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg7" />
<!-- Background -->
<rect
x="-2"
y="-2"
width="1002"
height="1002"
fill="#0b2e59"
id="rect1"
style="stroke-width:1.77131" />
<!-- Accent bands -->
<rect
x="19.82901"
y="426.9852"
width="960.34198"
height="79.542099"
fill="#f58220"
opacity="0.9"
id="rect2"
style="stroke-width:1.54503" />
<rect
x="19.82901"
y="506.52728"
width="960.34198"
height="39.771049"
fill="#ffb347"
opacity="0.9"
id="rect3"
style="stroke-width:1.54503" />
<!-- Subtle inner border -->
<rect
x="21.657974"
y="21.657974"
width="956.68402"
height="956.68408"
fill="none"
stroke="#f58220"
stroke-width="7.31594"
id="rect4" />
<!-- Letters group -->
<g
font-family="system-ui, '-apple-system', BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-weight="800"
text-anchor="middle"
dominant-baseline="middle"
id="g7"
transform="matrix(1.4298147,0,0,1.4298147,-109.59816,77.582873)">
<!-- K -->
<text
x="150"
y="190"
font-size="160px"
fill="#f58220"
id="text4">K</text>
<!-- R -->
<text
x="320"
y="190"
font-size="160px"
fill="#ffffff"
id="text5">R</text>
<!-- Y -->
<text
x="490"
y="190"
font-size="160px"
fill="#f58220"
id="text6">Y</text>
<!-- G in a blue block -->
<rect
x="580"
y="80"
width="170"
height="220"
rx="18"
ry="18"
fill="#123b73"
id="rect6" />
<text
x="665"
y="190"
font-size="160px"
fill="#ffb347"
id="text7">Z</text>
</g>
<!-- Small underline accent under the word -->
<rect
x="19.82901"
y="415.05389"
width="960.34204"
height="11.931314"
fill="#ffb347"
opacity="0.85"
id="rect7"
style="stroke-width:2.08331" />
<text
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"
x="22.209259"
y="501.82516"
id="text8"><tspan
sodipodi:role="line"
id="tspan7"
x="22.209259"
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>
<text
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"
x="25.539324"
y="669.90143"
id="text10"><tspan
sodipodi:role="line"
id="tspan10"
x="25.539324"
y="669.90143"
style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan
sodipodi:role="line"
x="25.539324"
y="805.70941"
id="tspan11"
style="line-height:0.8;stroke-width:2.80256">Community</tspan><tspan
sodipodi:role="line"
x="25.539324"
y="941.5174"
style="line-height:0.8;stroke-width:2.80256"
id="tspan13">Radio</tspan></text>
<text
xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:348.901px;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:5.76"
x="867.28961"
y="762.51642"
id="text12"><tspan
sodipodi:role="line"
id="tspan12" /></text>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

+585
View File
@@ -0,0 +1,585 @@
#!/usr/bin/env python3
"""
Whitelabel build script for KRYZ Go Flutter.
Reads theme.json and generates:
- lib/generated/app_config.dart (compile-time constants)
- lib/generated/theme_colors.dart (light + dark AppThemeColors)
- assets/icon/app_icon.png (from embedded logo SVG)
Patches native platform files (AndroidManifest, Info.plist, automotive_colors.xml, Kotlin).
Invokes flutter_launcher_icons and flutter_name_manager.
Usage:
python3 build_whitelabel.py [--theme theme.json] [--no-tools]
"""
import argparse
import base64
import json
import math
import os
import re
import subprocess
import sys
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
def hex_to_rgba(hex_color: str, alpha: float = 1.0) -> str:
"""Convert '#RRGGBB' hex to '0xAARRGGBB' Dart Color format."""
hex_color = hex_color.lstrip("#")
assert len(hex_color) == 6, f"Expected 6 hex digits, got {len(hex_color)}"
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
a = int(round(alpha * 255))
return f"Color(0x{a:02X}{r:02X}{g:02X}{b:02X})"
def darken(hex_color: str, factor: float) -> str:
"""Darken a hex color by multiplying RGB channels by factor (0-1)."""
hex_color = hex_color.lstrip("#")
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
r = int(min(r * factor, 255))
g = int(min(g * factor, 255))
b = int(min(b * factor, 255))
return f"Color(0xFF{r:02X}{g:02X}{b:02X})"
def dart_escape(value: str) -> str:
"""Escape a string for use in a Dart const string literal."""
return value.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
def read_theme(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
# ---------------------------------------------------------------------------
# Config generation
# ---------------------------------------------------------------------------
def generate_app_config(theme: dict) -> str:
station = theme["station"]
streams = theme["streams"]
name_primary = station.get("name_primary", "")
name_secondary = station.get("name_secondary", "")
callsign = station.get("callsign", "")
tagline = station.get("tagline", "")
frequency = station.get("frequency", "")
stream_url = streams.get("url", "")
metadata_url = streams.get("metadata_url")
app_name = f"{name_primary} {name_secondary}".strip()
default_title = f"{callsign or name_primary} Live Stream"
default_artist = f"{name_primary} Radio"
metadata_url_line = ""
if metadata_url:
metadata_url_line = f" static const String metadataUrl = '{dart_escape(metadata_url)}';\n"
else:
metadata_url_line = " static const String? metadataUrl = null;\n"
return f'''// GENERATED FILE — DO NOT EDIT.
// Run `python3 build_whitelabel.py` to regenerate.
class AppConfig {{
static const String callsign = '{dart_escape(callsign)}';
static const String appName = '{dart_escape(app_name)}';
static const String tagline = '{dart_escape(tagline)}';
static const String frequency = '{dart_escape(frequency)}';
static const String streamUrl = '{dart_escape(stream_url)}';
{metadata_url_line.rstrip()}
static const String defaultTitle = '{dart_escape(default_title)}';
static const String defaultArtist = '{dart_escape(default_artist)}';
}}
'''
# ---------------------------------------------------------------------------
# Theme colors generation
# ---------------------------------------------------------------------------
def generate_theme_colors(theme: dict) -> str:
c = theme["colors"]
# Light mode — mapped to match companion website color usage
light_props = [
("headingColor", hex_to_rgba(c["primary"])),
("accentColor", hex_to_rgba(c["accent"])),
("scaffoldGradientStart", hex_to_rgba(c["background"])),
("scaffoldGradientEnd", hex_to_rgba(c["light"])),
("shellSurface", hex_to_rgba(c["white"], 0.97)),
("shellBorder", hex_to_rgba(c["medium"], 0.10)),
("mutedText", hex_to_rgba(c["medium"])),
("textOnDark", hex_to_rgba(c["white"])),
("textOnDarkSecondary", hex_to_rgba(c["light"])),
("controlsGradientStart", hex_to_rgba(c["primary"])),
("controlsGradientEnd", hex_to_rgba(c["primary_light"])),
("controlsShadow", hex_to_rgba(c["black"], 0.14)),
("bodyText", hex_to_rgba(c["text"])),
("cardSurface", hex_to_rgba(c["white"], 0.08)),
("cardBorder", hex_to_rgba(c["white"], 0.12)),
("successColor", hex_to_rgba(c["success"])),
("dangerColor", hex_to_rgba(c["danger"])),
]
# Dark mode — mapped to match companion website color usage
dark_props = [
("headingColor", hex_to_rgba(c["primary_light"])),
("accentColor", hex_to_rgba(c["accent_light"])),
("scaffoldGradientStart", hex_to_rgba(c["black"])),
("scaffoldGradientEnd", darken(c["primary_dark"], 0.30)),
("shellSurface", hex_to_rgba(c["primary_dark"], 0.85)),
("shellBorder", hex_to_rgba(c["medium"], 0.20)),
("mutedText", hex_to_rgba(c["medium"])),
("textOnDark", hex_to_rgba(c["white"])),
("textOnDarkSecondary", hex_to_rgba(c["light"])),
("controlsGradientStart", hex_to_rgba(c["primary_dark"])),
("controlsGradientEnd", hex_to_rgba(c["primary"])),
("controlsShadow", hex_to_rgba(c["black"], 0.27)),
("bodyText", hex_to_rgba(c["light"])),
("cardSurface", hex_to_rgba(c["white"], 0.08)),
("cardBorder", hex_to_rgba(c["white"], 0.12)),
("successColor", hex_to_rgba(c["success"])),
("dangerColor", hex_to_rgba(c["danger"])),
]
def props_block(props, indent: str = " ") -> str:
lines = []
for name, color_expr in props:
lines.append(f"{indent}{name}: {color_expr},")
return "\n".join(lines)
return f'''// GENERATED FILE — DO NOT EDIT.
// Run `python3 build_whitelabel.py` to regenerate.
import 'package:flutter/material.dart';
import '../app_theme_business_object.dart';
class GeneratedThemeColors {{
static const AppThemeColors lightColors = AppThemeColors(
{props_block(light_props)}
);
static const AppThemeColors darkColors = AppThemeColors(
{props_block(dark_props)}
);
}}
'''
# ---------------------------------------------------------------------------
# Icon generation
# ---------------------------------------------------------------------------
def generate_icon(theme: dict, dry_run: bool = False) -> None:
"""Decode embedded logo (JPEG, PNG, or SVG) and write app_icon.png."""
embedded = theme.get("images_embedded", {})
logo = embedded.get("logo", {})
data_b64 = logo.get("data", "")
content_type = logo.get("content_type", "")
if not data_b64:
print(" [icon] No embedded logo data — skipping icon generation")
return
raw_bytes = base64.b64decode(data_b64)
icon_path = os.path.join(PROJECT_ROOT, "assets", "icon", "app_icon.png")
if dry_run:
print(f" [icon] Would generate PNG from embedded {content_type or 'image'} at assets/icon/app_icon.png")
return
# --- JPEG / PNG: convert to PNG via Pillow, or write as-is ---
if content_type in ("image/jpeg", "image/png"):
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")
img = img.resize((1024, 1024), Image.LANCZOS)
img.save(icon_path, "PNG")
print(f" [icon] Converted {content_type} → PNG (Pillow, 1024×1024)")
return
except ImportError:
pass
except Exception as e:
print(f" [icon] Pillow conversion failed: {e} — writing raw bytes")
# Fallback: write raw bytes directly (flutter_launcher_icons accepts JPEG)
if content_type == "image/jpeg":
fallback = os.path.join(PROJECT_ROOT, "assets", "icon", "app_icon.jpg")
else:
fallback = icon_path
with open(fallback, "wb") as f:
f.write(raw_bytes)
print(f" [icon] Wrote embedded {content_type} (no Pillow — using raw bytes)")
# If we wrote a JPEG, rename the pubspec image_path is still .png,
# so try to at least rename it
if fallback != icon_path:
os.replace(fallback, icon_path)
return
# --- SVG: render to PNG via cairosvg or rsvg-convert ---
try:
import cairosvg
cairosvg.svg2png(bytestring=raw_bytes, write_to=icon_path, output_width=1024, output_height=1024)
print(" [icon] Rendered SVG → PNG (cairosvg)")
return
except ImportError:
pass
except Exception as e:
print(f" [icon] cairosvg failed: {e} — trying rsvg-convert")
try:
import tempfile
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
tmp.write(raw_bytes)
tmp_path = tmp.name
subprocess.run(
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", icon_path, tmp_path],
check=True,
capture_output=True,
)
os.unlink(tmp_path)
print(" [icon] Rendered SVG → PNG (rsvg-convert)")
except Exception as e:
print(f" [icon] WARNING: Could not render icon: {e}")
print(" [icon] Install cairosvg (`pip install cairosvg`) or rsvg-convert")
def generate_android_auto_art(theme: dict, dry_run: bool = False) -> None:
"""Decode embedded hero_icon (JPEG, PNG, or SVG) and write kryz_auto_art.png."""
embedded = theme["images_embedded"]
hero_data = embedded["hero_icon"]["data"]
hero_content_type = embedded["hero_icon"].get("content_type", "")
print(" [android-auto-art] Using 'hero_icon' from images_embedded")
raw_bytes = base64.b64decode(hero_data)
icon_path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "drawable-nodpi", "kryz_auto_art.png")
if dry_run:
print(f" [android-auto-art] Would generate PNG from embedded {hero_content_type or 'image'} at android/app/src/main/res/drawable-nodpi/kryz_auto_art.png")
return
# --- JPEG / PNG: convert to PNG via Pillow, or write as-is ---
if hero_content_type in ("image/jpeg", "image/png"):
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")
img = img.resize((1024, 1024), Image.LANCZOS)
img.save(icon_path, "PNG")
print(f" [android-auto-art] Converted {hero_content_type} → PNG (Pillow, 1024×1024)")
return
except ImportError:
pass
except Exception as e:
print(f" [android-auto-art] Pillow conversion failed: {e} — writing raw bytes")
# Fallback: write raw bytes directly
if hero_content_type == "image/jpeg":
fallback = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "drawable-nodpi", "kryz_auto_art.jpg")
else:
fallback = icon_path
with open(fallback, "wb") as f:
f.write(raw_bytes)
print(f" [android-auto-art] Wrote embedded {hero_content_type} (no Pillow — using raw bytes)")
if fallback != icon_path:
os.replace(fallback, icon_path)
return
# --- SVG: render to PNG via cairosvg or rsvg-convert ---
try:
import cairosvg
cairosvg.svg2png(bytestring=raw_bytes, write_to=icon_path, output_width=1024, output_height=1024)
print(" [android-auto-art] Rendered SVG → PNG (cairosvg)")
return
except ImportError:
pass
except Exception as e:
print(f" [android-auto-art] cairosvg failed: {e} — trying rsvg-convert")
try:
import tempfile
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
tmp.write(raw_bytes)
tmp_path = tmp.name
subprocess.run(
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", icon_path, tmp_path],
check=True,
capture_output=True,
)
os.unlink(tmp_path)
print(" [android-auto-art] Rendered SVG → PNG (rsvg-convert)")
except Exception as e:
print(f" [android-auto-art] WARNING: Could not render icon: {e}")
print(" [android-auto-art] Install cairosvg (`pip install cairosvg`) or rsvg-convert")
# ---------------------------------------------------------------------------
# Native file patching
# ---------------------------------------------------------------------------
def patch_android_manifest(theme: dict, dry_run: bool = False) -> None:
path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "AndroidManifest.xml")
if not os.path.exists(path):
print(" [android] AndroidManifest.xml not found — skipping")
return
station = theme["station"]
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
with open(path, "r", encoding="utf-8") as f:
content = f.read()
new_content = re.sub(
r'android:label="[^"]*"',
f'android:label="{app_name}"',
content,
)
if dry_run:
print(f" [android] Would patch AndroidManifest.xml label → '{app_name}'")
return
with open(path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f" [android] Patched AndroidManifest.xml label → '{app_name}'")
def patch_info_plist(theme: dict, dry_run: bool = False) -> None:
path = os.path.join(PROJECT_ROOT, "ios", "Runner", "Info.plist")
if not os.path.exists(path):
print(" [ios] Info.plist not found — skipping")
return
station = theme["station"]
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Replace CFBundleDisplayName value
content = re.sub(
r'(<key>CFBundleDisplayName</key>\s*<string>)[^<]*(</string>)',
rf'\1{app_name}\2',
content,
)
# Replace CFBundleName value
content = re.sub(
r'(<key>CFBundleName</key>\s*<string>)[^<]*(</string>)',
rf'\1{app_name}\2',
content,
)
if dry_run:
print(f" [ios] Would patch Info.plist CFBundleDisplayName/CFBundleName → '{app_name}'")
return
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print(f" [ios] Patched Info.plist → '{app_name}'")
def patch_automotive_colors(theme: dict, dry_run: bool = False) -> None:
path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "values", "automotive_colors.xml")
if not os.path.exists(path):
print(" [android-auto] automotive_colors.xml not found — skipping")
return
c = theme["colors"]
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Replace hex values in color tags
content = re.sub(
r'(<color name="kryz_primary">)#[^<]*</color>',
rf'\1{c["primary"]}</color>',
content,
)
content = re.sub(
r'(<color name="kryz_primary_dark">)#[^<]*</color>',
rf'\1{c["primary_dark"]}</color>',
content,
)
content = re.sub(
r'(<color name="kryz_accent">)#[^<]*</color>',
rf'\1{c["accent"]}</color>',
content,
)
if dry_run:
print(" [android-auto] Would patch automotive_colors.xml")
return
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print(" [android-auto] Patched automotive_colors.xml")
def patch_kotlin_service(theme: dict, dry_run: bool = False) -> None:
kts = list(
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter", f)
for f in os.listdir(
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter")
)
) if os.path.isdir(
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter")
) else []
if not kts:
print(" [kotlin] No Kotlin files found — skipping")
return
station = theme["station"]
streams = theme["streams"]
name_primary = station.get("name_primary", "KRYZ")
callsign = station.get("callsign", name_primary)
app_name = f"{name_primary} {station.get('name_secondary','')}".strip()
default_title = f"{callsign} Live Stream"
default_artist = f"{name_primary} Radio"
stream_url = streams.get("url", "")
replacements = [
# STREAM_URL constant
(r'(private const val STREAM_URL = ")[^"]*(")', rf'\g<1>{stream_url}\2'),
# "KRYZ Live Stream" → default title
(r'"KRYZ Live Stream"', f'"{default_title}"'),
# "KRYZ Radio" → default artist
(r'"KRYZ Radio"', f'"{default_artist}"'),
# "KRYZ Auto Playback" → app name + " Auto Playback"
(r'"KRYZ Auto Playback"', f'"{app_name} Auto Playback"'),
# "KRYZ Radio Android Auto playback" → descriptive text
(r'"KRYZ Radio Android Auto playback"', f'"{app_name} Android Auto playback"'),
]
for kfile in kts:
if not kfile.endswith(".kt"):
continue
with open(kfile, "r", encoding="utf-8") as f:
content = f.read()
original = content
for pattern, replacement in replacements:
content = re.sub(pattern, replacement, content)
if content == original:
continue
if dry_run:
print(f" [kotlin] Would patch {os.path.basename(kfile)}")
return
with open(kfile, "w", encoding="utf-8") as f:
f.write(content)
print(f" [kotlin] Patched {os.path.basename(kfile)}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Whitelabel build script")
parser.add_argument("--theme", default=os.path.join(PROJECT_ROOT, "theme.json"), help="Path to theme.json")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without writing files")
parser.add_argument("--no-tools", action="store_true", help="Skip invoking flutter_launcher_icons and flutter_name_manager")
args = parser.parse_args()
theme_path = args.theme
if not os.path.exists(theme_path):
print(f"Error: theme.json not found at {theme_path}")
sys.exit(1)
print(f"Reading {theme_path} ...")
theme = read_theme(theme_path)
station = theme.get("station", {})
if not station.get("name_primary"):
print("Error: theme.json is missing station.name_primary")
sys.exit(1)
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
print(f"Whitelabel: {app_name}")
print()
# Generate Dart files
gen_dir = os.path.join(PROJECT_ROOT, "lib", "generated")
os.makedirs(gen_dir, exist_ok=True)
app_config_path = os.path.join(gen_dir, "app_config.dart")
theme_colors_path = os.path.join(gen_dir, "theme_colors.dart")
if args.dry_run:
print(" [gen] Would generate lib/generated/app_config.dart")
print(" [gen] Would generate lib/generated/theme_colors.dart")
else:
with open(app_config_path, "w", encoding="utf-8") as f:
f.write(generate_app_config(theme))
print(" [gen] Generated lib/generated/app_config.dart")
with open(theme_colors_path, "w", encoding="utf-8") as f:
f.write(generate_theme_colors(theme))
print(" [gen] Generated lib/generated/theme_colors.dart")
# Generate icon
print()
generate_icon(theme, dry_run=args.dry_run)
# Generate Android Auto album art
print()
generate_android_auto_art(theme, dry_run=args.dry_run)
# Patch native files
print()
patch_android_manifest(theme, dry_run=args.dry_run)
patch_info_plist(theme, dry_run=args.dry_run)
patch_automotive_colors(theme, dry_run=args.dry_run)
patch_kotlin_service(theme, dry_run=args.dry_run)
# Invoke downstream tools
if not args.dry_run and not args.no_tools:
print()
print("Running flutter_launcher_icons ...")
try:
subprocess.run(
["dart", "run", "flutter_launcher_icons"],
cwd=PROJECT_ROOT,
check=True,
)
print(" flutter_launcher_icons succeeded")
except subprocess.CalledProcessError as e:
print(f" WARNING: flutter_launcher_icons failed: {e}")
except FileNotFoundError:
print(" WARNING: 'dart' not found — run `flutter pub get` first")
print()
print("Running flutter_name_manager ...")
try:
subprocess.run(
["dart", "run", "flutter_name_manager", "--app-name", app_name],
cwd=PROJECT_ROOT,
check=True,
)
print(" flutter_name_manager succeeded")
except subprocess.CalledProcessError as e:
print(f" WARNING: flutter_name_manager failed: {e}")
except FileNotFoundError:
print(" WARNING: 'dart' not found — run `flutter pub get` first")
print()
print("Done!")
if __name__ == "__main__":
main()
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+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;
View File
View File
Generated Regular → Executable
View File
View File
View File
Regular → Executable
+155 -1
View File
@@ -1,9 +1,21 @@
import Flutter import Flutter
import UIKit import UIKit
import AVFoundation import AVFoundation
import AVKit
@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 var castingEventSink: FlutterEventSink?
private var castingStatus: [String: Any] = [
"status": "idle",
"platform": "ios"
]
private var playbackMethodChannel: FlutterMethodChannel?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -17,10 +29,152 @@ 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()
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)
guard let registrar = engineBridge.pluginRegistry.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()
)
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokePlay() {
playbackMethodChannel?.invokeMethod("play", arguments: nil)
}
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokeStop() {
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
}
@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 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)
} }
} }
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File
View File

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

View File

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

View File

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

View File
View File
View File
+86
View File
@@ -0,0 +1,86 @@
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.
*/
@available(iOS 14.0, *)
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var carPlayInterfaceController: CPInterfaceController?
private var listTemplate: CPListTemplate?
// MARK: - CPTemplateApplicationSceneDelegate
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
carPlayInterfaceController = interfaceController
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil)
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
carPlayInterfaceController = nil
listTemplate = nil
appDelegate?.invokeStop()
}
// MARK: - Template construction
private func makeListTemplate() -> CPListTemplate {
let artwork = CPListItem.maximumImageSize
let icon = UIImage(named: "AppIcon")?
.withRenderingMode(.alwaysOriginal)
let item = CPListItem(
text: "KRYZ Live Stream",
detailText: "KRYZ Radio",
image: icon
)
item.handler = { [weak self] _, completion in
self?.handleStreamItemTapped()
completion()
}
let section = CPListSection(items: [item])
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
template.tabTitle = "KRYZ Go!"
template.tabImage = icon
listTemplate = template
// Silence unused-variable warning
_ = artwork
return template
}
// MARK: - Playback
private func handleStreamItemTapped() {
appDelegate?.invokePlay()
carPlayInterfaceController?.pushTemplate(
CPNowPlayingTemplate.shared,
animated: true,
completion: nil
)
}
// MARK: - Helpers
private var appDelegate: AppDelegate? {
UIApplication.shared.delegate as? AppDelegate
}
}
Regular → Executable
+13 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>KRYZ Go!</string> <string>KMountain Flower Radio</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>KRYZ Go!</string> <string>KMountain Flower Radio</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -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>
View File
+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>
Regular → Executable
View File
View File
+63 -65
View File
@@ -1,69 +1,67 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'generated/theme_colors.dart';
class AppThemeColors { class AppThemeColors {
const AppThemeColors({ const AppThemeColors({
required this.primarySeed, required this.headingColor,
required this.secondarySeed, required this.accentColor,
required this.scaffoldGradientStart, required this.scaffoldGradientStart,
required this.scaffoldGradientEnd, required this.scaffoldGradientEnd,
required this.shellSurface, required this.shellSurface,
required this.shellBorder, required this.shellBorder,
required this.accent, required this.mutedText,
required this.webViewBorder, required this.textOnDark,
required this.webViewBackground, required this.textOnDarkSecondary,
required this.controlsGradientStart, required this.controlsGradientStart,
required this.controlsGradientEnd, required this.controlsGradientEnd,
required this.controlsShadow, required this.controlsShadow,
required this.statusText, required this.bodyText,
required this.cardSurface,
required this.cardBorder,
required this.successColor,
required this.dangerColor,
}); });
final Color primarySeed; /// Website: `primary` → headings (h1h6)
final Color secondarySeed; final Color headingColor;
/// Website: `accent` → links & accent highlights
final Color accentColor;
/// Page background gradient start — website: `background` (cream)
final Color scaffoldGradientStart; final Color scaffoldGradientStart;
/// Page background gradient end — website: `light`
final Color scaffoldGradientEnd; final Color scaffoldGradientEnd;
/// Top header card surface
final Color shellSurface; final Color shellSurface;
/// Top header card border
final Color shellBorder; final Color shellBorder;
final Color accent; /// Website: `medium` → muted / secondary text
final Color webViewBorder; final Color mutedText;
final Color webViewBackground; /// Website: `white` → text on dark surfaces
final Color textOnDark;
/// Website: `light` → secondary text on dark surfaces
final Color textOnDarkSecondary;
/// Controls bar / card surface gradient start — website: `primary_dark`
final Color controlsGradientStart; final Color controlsGradientStart;
/// Controls bar / card surface gradient end — website: `primary_dark`
final Color controlsGradientEnd; final Color controlsGradientEnd;
/// Shadow color for elevated surfaces
final Color controlsShadow; final Color controlsShadow;
final Color statusText; /// Website: `text` → body text
final Color bodyText;
/// Inner card background overlay — derived from `primary_dark` / `primary_light`
final Color cardSurface;
/// Inner card border — derived from `primary_dark` / `primary_light`
final Color cardBorder;
/// Website: `success` → live indicator, success states
final Color successColor;
/// Website: `danger` → error states
final Color dangerColor;
} }
class AppThemeBusinessObject { class AppThemeBusinessObject {
static const AppThemeColors lightColors = AppThemeColors( static const AppThemeColors lightColors = GeneratedThemeColors.lightColors;
primarySeed: Color(0xFFE36A18), static const AppThemeColors darkColors = GeneratedThemeColors.darkColors;
secondarySeed: Color(0xFF1663C7),
scaffoldGradientStart: Color(0xFFFFF3E3),
scaffoldGradientEnd: Color(0xFFE6F2FF),
shellSurface: Color(0xF7FFFFFF),
shellBorder: Color(0x1A1A4D86),
accent: Color(0xFF0F5DB6),
webViewBorder: Color(0x261A4D86),
webViewBackground: Color(0xFFFFFFFF),
controlsGradientStart: Color(0xFFFFB465),
controlsGradientEnd: Color(0xFF69ADFF),
controlsShadow: Color(0x24114B7A),
statusText: Color(0xFF13243D),
);
static const AppThemeColors darkColors = AppThemeColors(
primarySeed: Color(0xFFFF9D59),
secondarySeed: Color(0xFF76B8FF),
scaffoldGradientStart: Color(0xFF0F1C30),
scaffoldGradientEnd: Color(0xFF31190C),
shellSurface: Color(0xD9162438),
shellBorder: Color(0x3366AAFF),
accent: Color(0xFFFFBC7E),
webViewBorder: Color(0x3378B7FF),
webViewBackground: Color(0xFF0C1420),
controlsGradientStart: Color(0xFF275792),
controlsGradientEnd: Color(0xFF9A4F1F),
controlsShadow: Color(0x44000000),
statusText: Color(0xFFF4F7FF),
);
static bool isDarkMode(BuildContext context) { static bool isDarkMode(BuildContext context) {
return MediaQuery.platformBrightnessOf(context) == Brightness.dark; return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
@@ -74,17 +72,17 @@ class AppThemeBusinessObject {
} }
static ThemeData lightTheme() { static ThemeData lightTheme() {
final ColorScheme scheme = final ColorScheme scheme = ColorScheme.light(
ColorScheme.fromSeed( brightness: Brightness.light,
seedColor: lightColors.primarySeed, primary: lightColors.headingColor,
brightness: Brightness.light, onPrimary: lightColors.textOnDark,
).copyWith( secondary: lightColors.accentColor,
primary: lightColors.primarySeed, onSecondary: lightColors.textOnDark,
secondary: lightColors.secondarySeed, surface: lightColors.shellSurface,
tertiary: const Color(0xFF2C85E1), onSurface: lightColors.bodyText,
surface: const Color(0xFFFFFBF7), error: lightColors.dangerColor,
onSurface: lightColors.statusText, onError: lightColors.textOnDark,
); );
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
@@ -104,17 +102,17 @@ class AppThemeBusinessObject {
} }
static ThemeData darkTheme() { static ThemeData darkTheme() {
final ColorScheme scheme = final ColorScheme scheme = ColorScheme.dark(
ColorScheme.fromSeed( brightness: Brightness.dark,
seedColor: darkColors.primarySeed, primary: darkColors.headingColor,
brightness: Brightness.dark, onPrimary: darkColors.textOnDark,
).copyWith( secondary: darkColors.accentColor,
primary: darkColors.primarySeed, onSecondary: darkColors.textOnDark,
secondary: darkColors.secondarySeed, surface: darkColors.shellSurface,
tertiary: const Color(0xFF4E97E8), onSurface: darkColors.bodyText,
surface: const Color(0xFF111A27), error: darkColors.dangerColor,
onSurface: darkColors.statusText, onError: darkColors.textOnDark,
); );
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
+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;
}
}
}
+223
View File
@@ -0,0 +1,223 @@
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 'generated/app_config.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 = AppConfig.streamUrl;
static const String mediaId = 'kryz-live-stream';
static const String _liveInfoEndpoint = AppConfig.metadataUrl ?? '';
static const Duration _liveInfoTimeout = Duration(seconds: 8);
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
static const MediaItem _defaultMediaItem = MediaItem(
id: mediaId,
title: AppConfig.defaultTitle,
artist: AppConfig.defaultArtist,
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 ? AppConfig.defaultArtist : subtitle,
album: 'Live',
extras: <String, dynamic>{
'isLive': true,
'seekable': false,
'starts': current.starts,
'ends': current.ends,
'schedulerTime': snapshot?.schedulerTime,
},
),
);
}
@override
Future<void> play() async {
_playRequested = true;
playbackState.add(
playbackState.value.copyWith(
controls: const <MediaControl>[MediaControl.stop],
systemActions: const <MediaAction>{},
processingState: AudioProcessingState.buffering,
playing: false,
),
);
await _configureAudioSessionIfNeeded();
try {
if (!_isSourceLoaded) {
await _player.setAudioSource(
AudioSource.uri(
Uri.parse(streamUrl),
tag: mediaItem.value,
),
);
_isSourceLoaded = true;
}
final session = await AudioSession.instance;
await session.setActive(true);
await _player.play();
await _refreshMetadataFromApi();
_startMetadataRefreshTimer();
} catch (_) {
_playRequested = false;
playbackState.add(
playbackState.value.copyWith(
controls: const <MediaControl>[MediaControl.play],
systemActions: const <MediaAction>{},
processingState: AudioProcessingState.idle,
playing: false,
),
);
rethrow;
}
}
@override
Future<void> stop() async {
_playRequested = false;
_isSourceLoaded = false;
_metadataRefreshTimer?.cancel();
_metadataRefreshTimer = null;
await _player.stop();
playbackState.add(
playbackState.value.copyWith(
controls: const <MediaControl>[MediaControl.play],
systemActions: const <MediaAction>{},
processingState: AudioProcessingState.idle,
playing: false,
),
);
}
@override
Future<void> seek(Duration position) async {
// Live stream is intentionally non-seekable.
}
@override
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) {
return play();
}
@override
Future<void> playFromSearch(String query, [Map<String, dynamic>? extras]) {
return play();
}
Future<void> dispose() async {
_metadataRefreshTimer?.cancel();
await _playerStateSubscription.cancel();
await _player.dispose();
}
Future<void> _configureAudioSessionIfNeeded() async {
if (_isAudioSessionConfigured) {
return;
}
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_isAudioSessionConfigured = true;
}
void _onPlayerStateChanged(PlayerState state) {
final AudioProcessingState processingState = switch (state.processingState) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
(_playRequested && !state.playing && state.processingState != ProcessingState.completed);
playbackState.add(
playbackState.value.copyWith(
controls: <MediaControl>[
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
],
systemActions: const <MediaAction>{},
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
playing: isPlaying,
),
);
if (isPlaying || isBuffering) {
_startMetadataRefreshTimer();
} else {
_metadataRefreshTimer?.cancel();
_metadataRefreshTimer = null;
}
}
void _startMetadataRefreshTimer() {
if (_metadataRefreshTimer != null) {
return;
}
_metadataRefreshTimer = Timer.periodic(_metadataRefreshInterval, (_) {
_refreshMetadataFromApi();
});
}
Future<void> _refreshMetadataFromApi() async {
try {
final response = await http
.get(Uri.parse(_liveInfoEndpoint))
.timeout(_liveInfoTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
return;
}
final dynamic decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return;
}
final snapshot = LiveInfoSnapshot.fromJson(decoded);
await updateMetadataFromLiveInfo(snapshot);
} catch (_) {
// Keep existing metadata if live-info fetch fails.
}
}
}
+26 -8
View File
@@ -4,17 +4,31 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'generated/app_config.dart';
class LiveInfoBusinessObject extends ChangeNotifier { class LiveInfoBusinessObject extends ChangeNotifier {
LiveInfoBusinessObject({ LiveInfoBusinessObject({
this.endpoint = const String.fromEnvironment( String? endpoint,
'KRYZ_LIVE_INFO_URL', int refreshIntervalSeconds = 20,
defaultValue: 'http://kryz.airtime.pro/api/live-info',
),
int refreshIntervalSeconds = 15,
this.requestTimeout = const Duration(seconds: 8), this.requestTimeout = const Duration(seconds: 8),
}) : _refreshIntervalSeconds = refreshIntervalSeconds; }) : _endpointOverride = endpoint,
_refreshIntervalSeconds = refreshIntervalSeconds;
final String endpoint; /// Explicit endpoint passed at construction, if any.
final String? _endpointOverride;
/// The effective polling endpoint: construction override > env var > AppConfig.
String get endpoint {
final override = _endpointOverride;
if (override != null && override.isNotEmpty) {
return override;
}
const envUrl = String.fromEnvironment('KRYZ_LIVE_INFO_URL', defaultValue: '');
if (envUrl.isNotEmpty) {
return envUrl;
}
return AppConfig.metadataUrl ?? '';
}
final Duration requestTimeout; final Duration requestTimeout;
Timer? _pollTimer; Timer? _pollTimer;
@@ -34,6 +48,10 @@ class LiveInfoBusinessObject extends ChangeNotifier {
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed); List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
Future<void> start() async { Future<void> start() async {
// Skip polling if no endpoint is configured
if (endpoint.isEmpty && (AppConfig.metadataUrl ?? '').isEmpty) {
return;
}
await refresh(); await refresh();
_restartTimer(); _restartTimer();
} }
@@ -350,7 +368,7 @@ String _decodeHtml(String raw) {
return raw return raw
.replaceAll('&amp;', '&') .replaceAll('&amp;', '&')
.replaceAll('&quot;', '"') .replaceAll('&quot;', '"')
.replaceAll('&#39;', "'") .replaceAll('&#039;', "'")
.replaceAll('&lt;', '<') .replaceAll('&lt;', '<')
.replaceAll('&gt;', '>'); .replaceAll('&gt;', '>');
} }
Regular → Executable
+24 -245
View File
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'app_theme_business_object.dart'; import 'app_theme_business_object.dart';
import 'live_info_business_object.dart'; import 'live_info_business_object.dart';
import 'show_card.dart';
import 'track_card.dart';
class LiveInfoPanel extends StatelessWidget { class LiveInfoPanel extends StatelessWidget {
const LiveInfoPanel({ const LiveInfoPanel({
@@ -55,7 +58,7 @@ class LiveInfoPanel extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.graphic_eq_rounded, Icons.graphic_eq_rounded,
color: themeColors.statusText, color: themeColors.textOnDarkSecondary,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -64,65 +67,10 @@ class LiveInfoPanel extends StatelessWidget {
'Live Studio Feed', 'Live Studio Feed',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: themeColors.statusText, color: themeColors.textOnDark,
), ),
), ),
), ),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Colors.black.withValues(alpha: 0.12),
),
child: Text(
snapshot?.sourceEnabled ?? 'Unknown Source',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
PopupMenuButton<int>(
tooltip: 'Set refresh interval',
initialValue: liveInfo.refreshIntervalSeconds,
onSelected: liveInfo.setRefreshIntervalSeconds,
itemBuilder: (BuildContext context) {
return intervalOptions
.map(
(int seconds) => PopupMenuItem<int>(
value: seconds,
child: Text('$seconds seconds'),
),
)
.toList(growable: false);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.black.withValues(alpha: 0.08),
),
child: Text(
'${liveInfo.refreshIntervalSeconds}s',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
IconButton(
tooltip: 'Refresh now',
onPressed: liveInfo.isLoading
? null
: () => liveInfo.refresh(),
icon: const Icon(Icons.refresh_rounded),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -143,100 +91,50 @@ class LiveInfoPanel extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: _TrackCard( child: TrackCard(
label: 'Now Playing', label: 'Now Playing',
track: current, track: current,
titleColor: themeColors.statusText, titleColor: themeColors.textOnDark,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: _TrackCard( child: TrackCard(
label: 'Coming Next', label: 'Coming Next',
track: next, track: next,
titleColor: themeColors.statusText, titleColor: themeColors.textOnDark,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_ShowCard( ShowCard(
label: 'On Air Show', label: 'On Air Show',
show: currentShow, show: currentShow,
titleColor: themeColors.statusText, sourceTimezone: snapshot?.timezone,
titleColor: themeColors.textOnDarkSecondary,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
_ShowCard( ShowCard(
label: 'Up Next Show', label: 'Up Next Show',
show: nextShow, show: nextShow,
titleColor: themeColors.statusText, sourceTimezone: snapshot?.timezone,
), titleColor: themeColors.textOnDarkSecondary,
const SizedBox(height: 10), cardSurface: themeColors.cardSurface,
Text( cardBorder: themeColors.cardBorder,
'Recently Played (runtime)',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: liveInfo.recentPlayed.isEmpty
? Center(
child: Text(
liveInfo.isLoading
? 'Loading recent items...'
: 'No recent tracks captured yet',
style: Theme.of(context).textTheme.bodySmall,
),
)
: ListView.separated(
itemCount: liveInfo.recentPlayed.length,
separatorBuilder: (BuildContext context, int index) =>
Divider(
height: 1,
color: Colors.black.withValues(alpha: 0.08),
),
itemBuilder: (BuildContext context, int index) {
final RecentPlayedItem item =
liveInfo.recentPlayed[index];
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
item.subtitle.isEmpty
? _formatTimeWindow(item.starts, item.ends)
: '${item.subtitle} | ${_formatTimeWindow(item.starts, item.ends)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
leading: const Icon(Icons.queue_music_rounded),
);
},
),
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
liveInfo.lastUpdatedAt == null liveInfo.lastUpdatedAt == null
? 'Waiting for API response...' ? 'Waiting for API response...'
: 'Last update: ${liveInfo.lastUpdatedAt!.toLocal().toString().substring(11, 19)} ${snapshot?.timezone ?? '--'}', : 'Last update (Local): ${DateFormat.yMd().format(liveInfo.lastUpdatedAt!.toLocal())} ${DateFormat.jm().format(liveInfo.lastUpdatedAt!.toLocal())}',
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
], ],
@@ -245,122 +143,3 @@ class LiveInfoPanel extends StatelessWidget {
); );
} }
} }
class _TrackCard extends StatelessWidget {
const _TrackCard({
required this.label,
required this.track,
required this.titleColor,
});
final String label;
final LiveInfoTrackInfo? track;
final Color titleColor;
@override
Widget build(BuildContext context) {
final String title = track?.displayTitle ?? 'Unavailable';
final String subtitle = (track?.displaySubtitle ?? '').trim();
final String window = _formatTimeWindow(track?.starts, track?.ends);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.black.withValues(alpha: 0.12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: titleColor,
),
),
const SizedBox(height: 6),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 3),
Text(
subtitle.isEmpty ? 'No metadata provided' : subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
const Spacer(),
Text(
window,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
);
}
}
class _ShowCard extends StatelessWidget {
const _ShowCard({
required this.label,
required this.show,
required this.titleColor,
});
final String label;
final LiveInfoShowInfo? show;
final Color titleColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.black.withValues(alpha: 0.06),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
),
child: Row(
children: [
Icon(Icons.mic_external_on_rounded, color: titleColor, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'$label: ${show?.name ?? 'No scheduled show'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
Text(
_formatTimeWindow(show?.starts, show?.ends),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
);
}
}
String _formatTimeWindow(String? starts, String? ends) {
String formatOne(String? value) {
if (value == null || value.length < 16) {
return '--:--';
}
return value.substring(11, 16);
}
return '${formatOne(starts)} - ${formatOne(ends)}';
}
Regular → Executable
+261 -24
View File
@@ -1,20 +1,29 @@
import 'package:flutter/material.dart'; import 'dart:async';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'generated/app_config.dart';
import 'app_theme_business_object.dart'; import 'app_theme_business_object.dart';
import 'casting_business_object.dart';
import 'live_info_business_object.dart'; import 'live_info_business_object.dart';
import 'live_info_panel.dart'; import 'live_info_panel.dart';
import 'streaming_audio_business_object.dart'; import 'streaming_audio_business_object.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await JustAudioBackground.init( tz.initializeTimeZones();
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: 'KRYZ Audio Playback',
androidNotificationOngoing: true,
);
runApp(const MyApp()); runApp(const MyApp());
// Keep startup non-blocking so UI can render even if background media
// services are initializing after an Android Auto cold start.
unawaited(
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]),
);
} }
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {
@@ -26,9 +35,18 @@ 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();
@override
void initState() {
super.initState();
unawaited(_audio.ensureInitialized());
_casting.initialize();
}
@override @override
void dispose() { void dispose() {
_casting.dispose();
_audio.dispose(); _audio.dispose();
super.dispose(); super.dispose();
} }
@@ -36,47 +54,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: AppConfig.appName,
theme: AppThemeBusinessObject.lightTheme(), theme: AppThemeBusinessObject.lightTheme(),
darkTheme: AppThemeBusinessObject.darkTheme(), darkTheme: AppThemeBusinessObject.darkTheme(),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
home: MainPage(audio: _audio), home: MainPage(audio: _audio, casting: _casting),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );
} }
} }
class MainPage extends StatefulWidget { class MainPage extends StatefulWidget {
const MainPage({super.key, required this.audio}); const MainPage({super.key, required this.audio, required this.casting});
final StreamingAudioBusinessObject audio; final StreamingAudioBusinessObject audio;
final CastingBusinessObject casting;
@override @override
State<MainPage> createState() => _MainPageState(); State<MainPage> createState() => _MainPageState();
} }
class _MainPageState extends State<MainPage> { class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
StreamingAudioBusinessObject get _audio => widget.audio; StreamingAudioBusinessObject get _audio => widget.audio;
CastingBusinessObject get _casting => widget.casting;
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject(); final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60]; static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
bool _isStartingCastStream = false;
bool _pendingRemotePlay = false;
bool get _usesExternalCastPlayback =>
_casting.isConnected && defaultTargetPlatform == TargetPlatform.android;
bool get _isIosAirPlay => defaultTargetPlatform == TargetPlatform.iOS;
bool get _isUsingRemotePlayback => _usesExternalCastPlayback;
bool get _isPlaybackStopped {
if (_isUsingRemotePlayback) {
return _casting.isStopped ||
_casting.playbackStatus == CastingPlaybackStatus.unknown;
}
return _audio.isStopped;
}
bool get _isPlaybackPlaying {
if (_isUsingRemotePlayback) {
return _casting.isPlaying;
}
return _audio.isPlaying;
}
bool get _isPlaybackBuffering {
if (_isUsingRemotePlayback) {
return _casting.isBuffering;
}
return _audio.isBuffering;
}
String _playbackStatusLabel() { String _playbackStatusLabel() {
switch (_audio.playbackStatus) { if (_isPlaybackBuffering) {
case StreamingPlaybackStatus.stopped: return '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();
WidgetsBinding.instance.addObserver(this);
_audio.addListener(_handleAudioStateChanged); _audio.addListener(_handleAudioStateChanged);
_casting.addListener(_handleCastingStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.addListener(_handleLiveInfoChanged);
_liveInfo.start(); _liveInfo.start();
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
// When native code requests local audio to stop (cast media loaded)
_casting.onStopLocalAudioRequested = () {
unawaited(_audio.stop());
};
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Re-initialize (not just refresh) to re-establish the EventChannel
// subscription. In a cold-start Android Auto scenario the Flutter engine
// boots headlessly before MainActivity.configureFlutterEngine() runs, so
// the native EventChannel StreamHandler is not yet registered when
// initialize() first fires. The "listen" message is dropped, eventSink
// stays null, and all subsequent emitStatus() calls are silently lost.
// Calling initialize() here cancels that dead subscription and creates a
// fresh one now that the handler is registered; onListen immediately
// re-emits statusPayload so the Dart state is fully re-synced.
unawaited(_casting.initialize());
}
}
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
switch (call.method) {
case 'play':
await _play();
case 'stop':
await _stop();
}
} }
void _handleAudioStateChanged() { void _handleAudioStateChanged() {
@@ -90,6 +182,8 @@ class _MainPageState extends State<MainPage> {
} }
void _handleLiveInfoChanged() { void _handleLiveInfoChanged() {
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -99,17 +193,152 @@ class _MainPageState extends State<MainPage> {
}); });
} }
void _handleCastingStateChanged() {
final currentStatus = _casting.status;
if (_casting.isPlaying || _casting.isBuffering) {
_pendingRemotePlay = false;
}
if (currentStatus != CastingStatus.connected) {
_isStartingCastStream = false;
}
if (_usesExternalCastPlayback &&
_pendingRemotePlay &&
!_isStartingCastStream) {
// Only load when _pendingRemotePlay was set by _play() while connecting.
// Do NOT trigger on justConnected — native pendingCastRequest handles
// all autoplay from showDevicePicker(autoplay: true).
_startCastingLiveStream();
}
if (!_usesExternalCastPlayback &&
currentStatus != CastingStatus.connecting) {
_pendingRemotePlay = false;
}
if (!mounted) {
return;
}
setState(() {
// Rebuild when cast state changes from the native platforms.
});
}
Future<void> _startCastingLiveStream() async {
_isStartingCastStream = true;
try {
await _casting.startCastingLiveStream(
streamUrl: _audio.streamUrl,
title: _audio.currentTitle,
subtitle: _audio.currentSubtitle,
);
_pendingRemotePlay = false;
if (_usesExternalCastPlayback && !_audio.isStopped) {
await _audio.stop();
}
} catch (_) {
_pendingRemotePlay = !_audio.isStopped;
} finally {
_isStartingCastStream = false;
}
}
Future<void> _play() async { Future<void> _play() async {
if (_casting.status == CastingStatus.connecting) {
_pendingRemotePlay = true;
return;
}
if (_usesExternalCastPlayback) {
if (_casting.isPlaying || _isStartingCastStream) {
return;
}
await _startCastingLiveStream();
return;
}
_pendingRemotePlay = false;
await _audio.play(); await _audio.play();
} }
Future<void> _stop() async { Future<void> _stop() async {
if (_usesExternalCastPlayback) {
await _casting.stopPlayback();
return;
}
await _audio.stop(); await _audio.stop();
} }
Future<void> _toggleCasting() async {
if (_casting.isConnected) {
await _casting.disconnect();
return;
}
final shouldAutoplay = !_audio.isStopped;
// Do NOT set _pendingRemotePlay when native handles autoplay.
// The native pendingCastRequest already handles autoplay via
// showDevicePicker(autoplay: true). Setting _pendingRemotePlay here
// causes a duplicate load from Flutter that races the native load and
// corrupts the cast status (especially in the Android Auto path).
// _pendingRemotePlay is only set by _play() when the user explicitly
// presses Play while the cast session is still connecting.
_pendingRemotePlay = false;
await _casting.showDevicePicker(
autoplay: shouldAutoplay,
streamUrl: shouldAutoplay ? _audio.streamUrl : null,
title: shouldAutoplay ? _audio.currentTitle : null,
subtitle: shouldAutoplay ? _audio.currentSubtitle : null,
);
}
String _castingLabel() {
final idleLabel = _isIosAirPlay ? 'AirPlay' : 'Cast';
final connectingLabel = _isIosAirPlay ? 'AirPlay...' : 'Casting...';
final errorLabel = _isIosAirPlay ? 'AirPlay Error' : 'Cast Error';
final unavailableLabel = _isIosAirPlay ? 'No AirPlay' : 'No Cast';
switch (_casting.status) {
case CastingStatus.idle:
return idleLabel;
case CastingStatus.connecting:
return connectingLabel;
case CastingStatus.connected:
final deviceName = _casting.connectedDeviceName;
if (deviceName != null && deviceName.isNotEmpty) {
return 'On $deviceName';
}
return _isIosAirPlay ? 'AirPlaying' : 'Casting';
case CastingStatus.error:
return errorLabel;
case CastingStatus.unavailable:
return unavailableLabel;
}
}
IconData _castingIcon() {
if (_isIosAirPlay) {
return Icons.airplay_rounded;
}
return _casting.isConnected
? Icons.cast_connected_rounded
: Icons.cast_rounded;
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_audio.removeListener(_handleAudioStateChanged); _audio.removeListener(_handleAudioStateChanged);
_casting.removeListener(_handleCastingStateChanged);
_liveInfo.removeListener(_handleLiveInfoChanged); _liveInfo.removeListener(_handleLiveInfoChanged);
_liveInfo.dispose(); _liveInfo.dispose();
super.dispose(); super.dispose();
@@ -166,12 +395,12 @@ class _MainPageState extends State<MainPage> {
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Text( child: Text(
'KRYZ Go!', AppConfig.appName,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith( style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 0.4, letterSpacing: 0.4,
color: themeColors.statusText, color: themeColors.headingColor,
), ),
), ),
), ),
@@ -219,20 +448,28 @@ class _MainPageState extends State<MainPage> {
runSpacing: 12, runSpacing: 12,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _audio.isStopped ? _play : null, onPressed: _isPlaybackStopped ? _play : null,
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'), label: const Text('Play'),
), ),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _audio.isStopped ? null : _stop, onPressed: _isPlaybackStopped ? null : _stop,
icon: const Icon(Icons.stop_rounded), icon: const Icon(Icons.stop_rounded),
label: const Text('Stop'), label: const Text('Stop'),
), ),
ElevatedButton.icon(
onPressed:
_casting.status == CastingStatus.connecting
? null
: _toggleCasting,
icon: Icon(_castingIcon()),
label: Text(_castingLabel()),
),
Text( Text(
_playbackStatusLabel(), _playbackStatusLabel(),
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: themeColors.statusText, color: themeColors.textOnDark,
), ),
), ),
], ],
+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),
);
},
),
),
),
],
);
}
}
+64
View File
@@ -0,0 +1,64 @@
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,
required this.cardSurface,
required this.cardBorder,
});
final String label;
final LiveInfoShowInfo? show;
final String? sourceTimezone;
final Color titleColor;
final Color cardSurface;
final Color cardBorder;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: cardSurface,
border: Border.all(color: cardBorder),
),
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,
color: titleColor,
),
),
),
const SizedBox(width: 8),
Text(
formatStartingTime(
show?.starts,
sourceTimezone: sourceTimezone,
),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: titleColor),
),
],
),
);
}
}
+116 -60
View File
@@ -1,48 +1,95 @@
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 'generated/app_config.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: '${AppConfig.appName} 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 = AppConfig.defaultTitle;
String _currentSubtitle = AppConfig.defaultArtist;
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 +105,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 ? AppConfig.defaultTitle : item.title;
final subtitle =
(item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : 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,
);
}
+80
View File
@@ -0,0 +1,80 @@
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,
required this.cardSurface,
required this.cardBorder,
});
final String label;
final LiveInfoTrackInfo? track;
final Color titleColor;
final Color cardSurface;
final Color cardBorder;
@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: cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: cardBorder),
),
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,
color: titleColor,
),
),
const SizedBox(height: 6),
Text(
subtitle.isEmpty ? 'No metadata provided' : subtitle,
maxLines: 6,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: titleColor),
),
const Spacer(),
Text(
window,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: titleColor,
),
),
],
),
);
}
}
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More