Compare commits

8 Commits
164 changed files with 1166 additions and 348 deletions
Regular → Executable
+1 -1
View File
@@ -4,7 +4,7 @@ ENV PATH="$PATH:/opt/flutter/bin"
# Install system dependencies
RUN apt-get update && apt-get install -y \
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils lld libgtk-3-dev ninja-build 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/*
# Become non-root
Regular → Executable
+8 -2
View File
@@ -1,7 +1,7 @@
{
"name": "kryz-go",
"mounts": [
"source=/dev/dri,target=/dev/dri,type=bind"
// "source=/dev/dri,target=/dev/dri,type=bind"
],
"build": {
"dockerfile": "Dockerfile"
@@ -14,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
+8
View File
@@ -1,3 +1,6 @@
# Generated whitelabel config — rebuilt by build_whitelabel.py
lib/generated/
# ---> Dart
# See https://www.dartlang.org/guides/libraries/private-files
@@ -90,3 +93,8 @@ Network Trash Folder
Temporary Items
.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
View File
View File
+1 -1
View File
@@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<application
android:label="KRYZ Go!"
android:label="KMountain Flower Radio"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -5,6 +5,9 @@ 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
@@ -37,7 +40,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
private const val TAG = "KryzAutoService"
private const val MEDIA_ID_ROOT = "kryz_root"
private const val MEDIA_ID_LIVE = "kryz_live_stream"
private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a"
private const val STREAM_URL = "https://libretime.westerntechnologies.duckdns.org/main"
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
private const val NOTIFICATION_ID = 1001
}
@@ -50,6 +53,14 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
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)
@@ -116,6 +127,20 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
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()")
@@ -162,11 +187,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
) {
Log.d(TAG, "onLoadChildren($parentId)")
if (parentId == MEDIA_ID_ROOT) {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
val iconUri = getAutoArtUri()
val description = MediaDescriptionCompat.Builder()
.setMediaId(MEDIA_ID_LIVE)
.setTitle("KRYZ Live Stream")
.setSubtitle("KRYZ Radio")
.setTitle("KMTN Live Stream")
.setSubtitle("KMountain Radio")
.setIconUri(iconUri)
.setMediaUri(Uri.parse(STREAM_URL))
.build()
@@ -178,11 +203,37 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
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 {
@@ -269,10 +320,10 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"KRYZ Auto Playback",
"KMountain Flower Radio Auto Playback",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "KRYZ Radio Android Auto playback"
description = "KMountain Flower Radio Android Auto playback"
setShowBadge(false)
}
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -287,7 +338,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
else -> "Stopped"
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("KRYZ Radio")
.setContentTitle("KMountain Radio")
.setContentText(statusText)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(isPlaying || isBuffering)
@@ -303,16 +354,16 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
) {
if (!::mediaSession.isInitialized) return
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art")
val iconUri = getAutoArtUri()
mediaSession.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio")
.putString(MediaMetadataCompat.METADATA_KEY_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)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
.build()
)
}
@@ -320,8 +371,8 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
if (metadata == null) {
applyNowPlayingMetadata(
title = "KRYZ Live Stream",
subtitle = "KRYZ Radio",
title = "KMTN Live Stream",
subtitle = "KMountain Radio",
album = "Live",
mediaUri = STREAM_URL,
)
@@ -401,15 +452,14 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
}
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
return MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio")
.putString(MediaMetadataCompat.METADATA_KEY_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)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
.build()
}
}
+222 -27
View File
@@ -27,13 +27,26 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
companion object {
private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods"
private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events"
private const val 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",
@@ -58,10 +71,13 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
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)")
}
@@ -71,6 +87,10 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
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")
}
@@ -80,14 +100,18 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
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")
}
@@ -96,8 +120,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(this)
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.apply { setMethodCallHandler(this@MainActivity) }
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(this)
@@ -114,13 +138,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
when (call.method) {
"getStatus" -> result.success(statusPayload)
"showDevicePicker" -> {
showDevicePicker()
showDevicePicker(call)
result.success(null)
}
"startCastingLiveStream" -> {
startCastingLiveStream(call, result)
}
"disconnect" -> {
clearPendingCastRequest()
castContext?.sessionManager?.endCurrentSession(true)
emitStatus("idle")
result.success(null)
@@ -174,12 +199,15 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
}
private fun showDevicePicker() {
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))
@@ -210,6 +238,7 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
val currentStatus = statusPayload["status"] as? String
if (currentSession == null && currentStatus == "connecting") {
clearPendingCastRequest()
emitStatus("idle")
}
}
@@ -222,8 +251,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
val streamUrl = arguments["streamUrl"] as? String
val title = arguments["title"] as? String ?: "Live Stream"
val subtitle = arguments["subtitle"] 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)
@@ -236,6 +265,113 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
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)
@@ -247,17 +383,10 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
.setMetadata(metadata)
.build()
val loadRequestData = MediaLoadRequestData.Builder()
return MediaLoadRequestData.Builder()
.setMediaInfo(mediaInfo)
.setAutoplay(true)
.build()
loadMediaWithRetry(
session = currentSession,
loadRequestData = loadRequestData,
attempt = 1,
result = result,
)
}
private fun loadMediaWithRetry(
@@ -292,14 +421,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
try {
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
if (mediaChannelResult == null) {
emitStatus("error", message = "Cast load returned no result")
result.error("cast_load_failed", "Cast load returned no result", null)
return@setResultCallback
}
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
@@ -331,11 +460,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
try {
remoteMediaClient.stop().setResultCallback { stopResult ->
if (stopResult == null) {
result.error("cast_stop_failed", "Cast stop returned no result", null)
return@setResultCallback
}
if (stopResult.status.isSuccess) {
emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped")
result.success(null)
@@ -366,22 +490,59 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
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
val mediaStatus = remoteMediaClient?.mediaStatus
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,
MediaStatus.PLAYER_STATE_PAUSED -> "stopped"
MediaStatus.PLAYER_STATE_IDLE,
MediaStatus.PLAYER_STATE_UNKNOWN,
null -> "stopped"
@@ -394,6 +555,40 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
}
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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

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

View File
+3 -3
View File
@@ -5,7 +5,7 @@
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
-->
<resources>
<color name="kryz_primary">#E36A18</color>
<color name="kryz_primary_dark">#B84E08</color>
<color name="kryz_accent">#1663C7</color>
<color name="kryz_primary">#1a3a5c</color>
<color name="kryz_primary_dark">#0e2440</color>
<color name="kryz_accent">#e87a2e</color>
</resources>
View File
View File
View File
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
View File
View File
View File
Generated Regular → Executable
View File
View File
View File
Regular → Executable
View File
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
+1
View File
@@ -14,6 +14,7 @@ import UIKit
* 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?
Regular → Executable
+2 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>KRYZ Go!</string>
<string>KMountain Flower Radio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>KRYZ Go!</string>
<string>KMountain Flower Radio</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
View File
Regular → Executable
View File
Regular → Executable
View File
View File
+63 -65
View File
@@ -1,69 +1,67 @@
import 'package:flutter/material.dart';
import 'generated/theme_colors.dart';
class AppThemeColors {
const AppThemeColors({
required this.primarySeed,
required this.secondarySeed,
required this.headingColor,
required this.accentColor,
required this.scaffoldGradientStart,
required this.scaffoldGradientEnd,
required this.shellSurface,
required this.shellBorder,
required this.accent,
required this.webViewBorder,
required this.webViewBackground,
required this.mutedText,
required this.textOnDark,
required this.textOnDarkSecondary,
required this.controlsGradientStart,
required this.controlsGradientEnd,
required this.controlsShadow,
required this.statusText,
required this.bodyText,
required this.cardSurface,
required this.cardBorder,
required this.successColor,
required this.dangerColor,
});
final Color primarySeed;
final Color secondarySeed;
/// Website: `primary` → headings (h1h6)
final Color headingColor;
/// Website: `accent` → links & accent highlights
final Color accentColor;
/// Page background gradient start — website: `background` (cream)
final Color scaffoldGradientStart;
/// Page background gradient end — website: `light`
final Color scaffoldGradientEnd;
/// Top header card surface
final Color shellSurface;
/// Top header card border
final Color shellBorder;
final Color accent;
final Color webViewBorder;
final Color webViewBackground;
/// Website: `medium` → muted / secondary text
final Color mutedText;
/// 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;
/// Controls bar / card surface gradient end — website: `primary_dark`
final Color controlsGradientEnd;
/// Shadow color for elevated surfaces
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 {
static const AppThemeColors lightColors = AppThemeColors(
primarySeed: Color(0xFFE36A18),
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 const AppThemeColors lightColors = GeneratedThemeColors.lightColors;
static const AppThemeColors darkColors = GeneratedThemeColors.darkColors;
static bool isDarkMode(BuildContext context) {
return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
@@ -74,17 +72,17 @@ class AppThemeBusinessObject {
}
static ThemeData lightTheme() {
final ColorScheme scheme =
ColorScheme.fromSeed(
seedColor: lightColors.primarySeed,
brightness: Brightness.light,
).copyWith(
primary: lightColors.primarySeed,
secondary: lightColors.secondarySeed,
tertiary: const Color(0xFF2C85E1),
surface: const Color(0xFFFFFBF7),
onSurface: lightColors.statusText,
);
final ColorScheme scheme = ColorScheme.light(
brightness: Brightness.light,
primary: lightColors.headingColor,
onPrimary: lightColors.textOnDark,
secondary: lightColors.accentColor,
onSecondary: lightColors.textOnDark,
surface: lightColors.shellSurface,
onSurface: lightColors.bodyText,
error: lightColors.dangerColor,
onError: lightColors.textOnDark,
);
return ThemeData(
useMaterial3: true,
@@ -104,17 +102,17 @@ class AppThemeBusinessObject {
}
static ThemeData darkTheme() {
final ColorScheme scheme =
ColorScheme.fromSeed(
seedColor: darkColors.primarySeed,
brightness: Brightness.dark,
).copyWith(
primary: darkColors.primarySeed,
secondary: darkColors.secondarySeed,
tertiary: const Color(0xFF4E97E8),
surface: const Color(0xFF111A27),
onSurface: darkColors.statusText,
);
final ColorScheme scheme = ColorScheme.dark(
brightness: Brightness.dark,
primary: darkColors.headingColor,
onPrimary: darkColors.textOnDark,
secondary: darkColors.accentColor,
onSecondary: darkColors.textOnDark,
surface: darkColors.shellSurface,
onSurface: darkColors.bodyText,
error: darkColors.dangerColor,
onError: darkColors.textOnDark,
);
return ThemeData(
useMaterial3: true,
Regular → Executable
+51 -3
View File
@@ -21,6 +21,9 @@ class CastingBusinessObject extends ChangeNotifier {
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;
@@ -55,6 +58,18 @@ class CastingBusinessObject extends ChangeNotifier {
},
);
// 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',
@@ -66,7 +81,12 @@ class CastingBusinessObject extends ChangeNotifier {
}
}
Future<void> showDevicePicker() async {
Future<void> showDevicePicker({
bool autoplay = false,
String? streamUrl,
String? title,
String? subtitle,
}) async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
@@ -77,7 +97,12 @@ class CastingBusinessObject extends ChangeNotifier {
}
try {
await _methodChannel.invokeMethod<void>('showDevicePicker');
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);
@@ -102,7 +127,10 @@ class CastingBusinessObject extends ChangeNotifier {
});
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
// 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;
}
}
@@ -146,6 +174,8 @@ class CastingBusinessObject extends ChangeNotifier {
if (deviceName != null && deviceName.isNotEmpty) {
_connectedDeviceName = deviceName;
} else if (status != 'connected') {
_connectedDeviceName = null;
}
if (message != null && message.isNotEmpty) {
@@ -210,4 +240,22 @@ class CastingBusinessObject extends ChangeNotifier {
_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;
}
}
}
Regular → Executable
+6 -5
View File
@@ -6,6 +6,7 @@ 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 {
@@ -16,16 +17,16 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
);
}
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
static const String streamUrl = AppConfig.streamUrl;
static const String mediaId = 'kryz-live-stream';
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info';
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: 'KRYZ Live Stream',
artist: 'KRYZ Radio',
title: AppConfig.defaultTitle,
artist: AppConfig.defaultArtist,
album: 'Live',
extras: <String, dynamic>{
'isLive': true,
@@ -52,7 +53,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
MediaItem(
id: mediaId,
title: current.displayTitle,
artist: subtitle.isEmpty ? 'KRYZ Radio' : subtitle,
artist: subtitle.isEmpty ? AppConfig.defaultArtist : subtitle,
album: 'Live',
extras: <String, dynamic>{
'isLive': true,
+24 -6
View File
@@ -4,17 +4,31 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'generated/app_config.dart';
class LiveInfoBusinessObject extends ChangeNotifier {
LiveInfoBusinessObject({
this.endpoint = const String.fromEnvironment(
'KRYZ_LIVE_INFO_URL',
defaultValue: 'http://kryz.airtime.pro/api/live-info',
),
String? endpoint,
int refreshIntervalSeconds = 20,
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;
Timer? _pollTimer;
@@ -34,6 +48,10 @@ class LiveInfoBusinessObject extends ChangeNotifier {
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
Future<void> start() async {
// Skip polling if no endpoint is configured
if (endpoint.isEmpty && (AppConfig.metadataUrl ?? '').isEmpty) {
return;
}
await refresh();
_restartTimer();
}
Regular → Executable
+14 -6
View File
@@ -58,7 +58,7 @@ class LiveInfoPanel extends StatelessWidget {
children: [
Icon(
Icons.graphic_eq_rounded,
color: themeColors.statusText,
color: themeColors.textOnDarkSecondary,
size: 20,
),
const SizedBox(width: 8),
@@ -67,7 +67,7 @@ class LiveInfoPanel extends StatelessWidget {
'Live Studio Feed',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: themeColors.statusText,
color: themeColors.textOnDark,
),
),
),
@@ -94,7 +94,9 @@ class LiveInfoPanel extends StatelessWidget {
child: TrackCard(
label: 'Now Playing',
track: current,
titleColor: themeColors.statusText,
titleColor: themeColors.textOnDark,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
),
),
const SizedBox(width: 8),
@@ -102,7 +104,9 @@ class LiveInfoPanel extends StatelessWidget {
child: TrackCard(
label: 'Coming Next',
track: next,
titleColor: themeColors.statusText,
titleColor: themeColors.textOnDark,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
),
),
],
@@ -113,14 +117,18 @@ class LiveInfoPanel extends StatelessWidget {
label: 'On Air Show',
show: currentShow,
sourceTimezone: snapshot?.timezone,
titleColor: themeColors.statusText,
titleColor: themeColors.textOnDarkSecondary,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
),
const SizedBox(height: 6),
ShowCard(
label: 'Up Next Show',
show: nextShow,
sourceTimezone: snapshot?.timezone,
titleColor: themeColors.statusText,
titleColor: themeColors.textOnDarkSecondary,
cardSurface: themeColors.cardSurface,
cardBorder: themeColors.cardBorder,
),
const SizedBox(height: 6),
Text(
Regular → Executable
+68 -22
View File
@@ -4,6 +4,7 @@ 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 'casting_business_object.dart';
import 'live_info_business_object.dart';
@@ -53,7 +54,7 @@ class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'KRYZ Go!',
title: AppConfig.appName,
theme: AppThemeBusinessObject.lightTheme(),
darkTheme: AppThemeBusinessObject.darkTheme(),
themeMode: ThemeMode.system,
@@ -73,12 +74,11 @@ class MainPage extends StatefulWidget {
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
StreamingAudioBusinessObject get _audio => widget.audio;
CastingBusinessObject get _casting => widget.casting;
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
bool _castStreamStartedForActiveSession = false;
bool _isStartingCastStream = false;
bool _pendingRemotePlay = false;
@@ -133,11 +133,33 @@ class _MainPageState extends State<MainPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_audio.addListener(_handleAudioStateChanged);
_casting.addListener(_handleCastingStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged);
_liveInfo.start();
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
// When native code requests local audio to stop (cast media loaded)
_casting.onStopLocalAudioRequested = () {
unawaited(_audio.stop());
};
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Re-initialize (not just refresh) to re-establish the EventChannel
// subscription. In a cold-start Android Auto scenario the Flutter engine
// boots headlessly before MainActivity.configureFlutterEngine() runs, so
// the native EventChannel StreamHandler is not yet registered when
// initialize() first fires. The "listen" message is dropped, eventSink
// stays null, and all subsequent emitStatus() calls are silently lost.
// Calling initialize() here cancels that dead subscription and creates a
// fresh one now that the handler is registered; onListen immediately
// re-emits statusPayload so the Dart state is fully re-synced.
unawaited(_casting.initialize());
}
}
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
@@ -172,15 +194,27 @@ 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 &&
(!_castStreamStartedForActiveSession || _pendingRemotePlay) &&
_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) {
_castStreamStartedForActiveSession = false;
_isStartingCastStream = false;
if (!_usesExternalCastPlayback &&
currentStatus != CastingStatus.connecting) {
_pendingRemotePlay = false;
}
@@ -203,21 +237,24 @@ class _MainPageState extends State<MainPage> {
subtitle: _audio.currentSubtitle,
);
_castStreamStartedForActiveSession = true;
_pendingRemotePlay = false;
if (_usesExternalCastPlayback && !_audio.isStopped) {
await _audio.stop();
}
} catch (_) {
_castStreamStartedForActiveSession = false;
// State is already updated by the casting business object.
_pendingRemotePlay = !_audio.isStopped;
} finally {
_isStartingCastStream = false;
}
}
Future<void> _play() async {
if (_casting.status == CastingStatus.connecting) {
_pendingRemotePlay = true;
return;
}
if (_usesExternalCastPlayback) {
if (_casting.isPlaying || _isStartingCastStream) {
return;
@@ -227,12 +264,7 @@ class _MainPageState extends State<MainPage> {
return;
}
if (_usesExternalCastPlayback &&
_casting.status == CastingStatus.connecting) {
_pendingRemotePlay = true;
return;
}
_pendingRemotePlay = false;
await _audio.play();
}
@@ -251,7 +283,21 @@ class _MainPageState extends State<MainPage> {
return;
}
await _casting.showDevicePicker();
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() {
@@ -290,6 +336,7 @@ class _MainPageState extends State<MainPage> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_audio.removeListener(_handleAudioStateChanged);
_casting.removeListener(_handleCastingStateChanged);
_liveInfo.removeListener(_handleLiveInfoChanged);
@@ -348,12 +395,12 @@ class _MainPageState extends State<MainPage> {
const SizedBox(width: 14),
Expanded(
child: Text(
'KRYZ Go!',
AppConfig.appName,
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0.4,
color: themeColors.statusText,
color: themeColors.headingColor,
),
),
),
@@ -412,8 +459,7 @@ class _MainPageState extends State<MainPage> {
),
ElevatedButton.icon(
onPressed:
_casting.status == CastingStatus.connecting ||
_casting.status == CastingStatus.unavailable
_casting.status == CastingStatus.connecting
? null
: _toggleCasting,
icon: Icon(_castingIcon()),
@@ -423,7 +469,7 @@ class _MainPageState extends State<MainPage> {
_playbackStatusLabel(),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: themeColors.statusText,
color: themeColors.textOnDark,
),
),
],
Regular → Executable
View File
Regular → Executable
+13 -4
View File
@@ -10,12 +10,16 @@ class ShowCard extends StatelessWidget {
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) {
@@ -23,8 +27,8 @@ class ShowCard extends StatelessWidget {
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)),
color: cardSurface,
border: Border.all(color: cardBorder),
),
child: Row(
children: [
@@ -37,7 +41,10 @@ class ShowCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: titleColor,
),
),
),
const SizedBox(width: 8),
@@ -46,7 +53,9 @@ class ShowCard extends StatelessWidget {
show?.starts,
sourceTimezone: sourceTimezone,
),
style: Theme.of(context).textTheme.labelSmall,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: titleColor),
),
],
),
+6 -5
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'generated/app_config.dart';
import 'kryz_audio_handler.dart';
import 'live_info_business_object.dart';
@@ -38,7 +39,7 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
builder: () => KryzAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: 'KRYZ Audio Playback',
androidNotificationChannelName: '${AppConfig.appName} Audio Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
@@ -60,8 +61,8 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
bool _isDisposed = false;
bool _isAttached = false;
String _currentTitle = 'KRYZ Live Stream';
String _currentSubtitle = 'KRYZ Radio';
String _currentTitle = AppConfig.defaultTitle;
String _currentSubtitle = AppConfig.defaultArtist;
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
@@ -145,9 +146,9 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
return;
}
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title;
final title = item.title.trim().isEmpty ? AppConfig.defaultTitle : item.title;
final subtitle =
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : item.artist!.trim();
(item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : item.artist!.trim();
if (title == _currentTitle && subtitle == _currentSubtitle) {
return;
Regular → Executable
View File
Regular → Executable
+17 -5
View File
@@ -9,11 +9,15 @@ class TrackCard extends StatelessWidget {
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) {
@@ -24,9 +28,9 @@ class TrackCard extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.08),
color: cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.black.withValues(alpha: 0.12)),
border: Border.all(color: cardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -45,21 +49,29 @@ class TrackCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
).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,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: titleColor),
),
const Spacer(),
Text(
window,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
).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