Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c523d0121f | ||
|
|
b8e86263d9 | ||
|
|
e3288e044a |
@@ -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 cmake clang pkg-config mesa-utils python3 pip pipx lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
|
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Become non-root
|
# Become non-root
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kryz-go",
|
"name": "kryz-go",
|
||||||
"mounts": [
|
"mounts": [
|
||||||
// "source=/dev/dri,target=/dev/dri,type=bind"
|
"source=/dev/dri,target=/dev/dri,type=bind"
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"dockerfile": "Dockerfile"
|
"dockerfile": "Dockerfile"
|
||||||
@@ -14,11 +14,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
"postCreateCommand": "flutter doctor -v"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -93,8 +90,3 @@ Network Trash Folder
|
|||||||
Network Trash Folder
|
Network Trash Folder
|
||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
# Python venv
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# Whitelabel app theming
|
|
||||||
theme.json
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"flutter": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="KMountain Flower Radio"
|
android:label="KRYZ Go!"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import android.app.NotificationManager
|
|||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
@@ -40,7 +38,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
private const val TAG = "KryzAutoService"
|
private const val TAG = "KryzAutoService"
|
||||||
private const val MEDIA_ID_ROOT = "kryz_root"
|
private const val MEDIA_ID_ROOT = "kryz_root"
|
||||||
private const val MEDIA_ID_LIVE = "kryz_live_stream"
|
private const val MEDIA_ID_LIVE = "kryz_live_stream"
|
||||||
private const val STREAM_URL = "https://libretime.westerntechnologies.duckdns.org/main"
|
private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a"
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
|
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
|
||||||
private const val NOTIFICATION_ID = 1001
|
private const val NOTIFICATION_ID = 1001
|
||||||
}
|
}
|
||||||
@@ -53,14 +51,6 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
private var pendingStopRequest: Boolean = false
|
private var pendingStopRequest: Boolean = false
|
||||||
private var reconnectScheduled: 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() {
|
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
|
||||||
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
||||||
applyPlaybackStateFromFlutter(state)
|
applyPlaybackStateFromFlutter(state)
|
||||||
@@ -127,20 +117,6 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
dispatchPlayCommand()
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d(TAG, "onCreate()")
|
Log.d(TAG, "onCreate()")
|
||||||
@@ -187,11 +163,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
) {
|
) {
|
||||||
Log.d(TAG, "onLoadChildren($parentId)")
|
Log.d(TAG, "onLoadChildren($parentId)")
|
||||||
if (parentId == MEDIA_ID_ROOT) {
|
if (parentId == MEDIA_ID_ROOT) {
|
||||||
val iconUri = getAutoArtUri()
|
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||||
val description = MediaDescriptionCompat.Builder()
|
val description = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(MEDIA_ID_LIVE)
|
.setMediaId(MEDIA_ID_LIVE)
|
||||||
.setTitle("KMTN Live Stream")
|
.setTitle("KRYZ Live Stream")
|
||||||
.setSubtitle("KMountain Radio")
|
.setSubtitle("KRYZ Radio")
|
||||||
.setIconUri(iconUri)
|
.setIconUri(iconUri)
|
||||||
.setMediaUri(Uri.parse(STREAM_URL))
|
.setMediaUri(Uri.parse(STREAM_URL))
|
||||||
.build()
|
.build()
|
||||||
@@ -320,10 +296,10 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
NOTIFICATION_CHANNEL_ID,
|
NOTIFICATION_CHANNEL_ID,
|
||||||
"KMountain Flower Radio Auto Playback",
|
"KRYZ Auto Playback",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
).apply {
|
||||||
description = "KMountain Flower Radio Android Auto playback"
|
description = "KRYZ Radio Android Auto playback"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -338,7 +314,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
else -> "Stopped"
|
else -> "Stopped"
|
||||||
}
|
}
|
||||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle("KMountain Radio")
|
.setContentTitle("KRYZ Radio")
|
||||||
.setContentText(statusText)
|
.setContentText(statusText)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setOngoing(isPlaying || isBuffering)
|
.setOngoing(isPlaying || isBuffering)
|
||||||
@@ -354,16 +330,16 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
) {
|
) {
|
||||||
if (!::mediaSession.isInitialized) return
|
if (!::mediaSession.isInitialized) return
|
||||||
|
|
||||||
val iconUri = getAutoArtUri()
|
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art")
|
||||||
mediaSession.setMetadata(
|
mediaSession.setMetadata(
|
||||||
MediaMetadataCompat.Builder()
|
MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KMTN Live Stream")
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KMountain Radio")
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio")
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
|
||||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -371,8 +347,8 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
|
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
|
||||||
if (metadata == null) {
|
if (metadata == null) {
|
||||||
applyNowPlayingMetadata(
|
applyNowPlayingMetadata(
|
||||||
title = "KMTN Live Stream",
|
title = "KRYZ Live Stream",
|
||||||
subtitle = "KMountain Radio",
|
subtitle = "KRYZ Radio",
|
||||||
album = "Live",
|
album = "Live",
|
||||||
mediaUri = STREAM_URL,
|
mediaUri = STREAM_URL,
|
||||||
)
|
)
|
||||||
@@ -452,14 +428,15 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
||||||
|
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
||||||
return MediaMetadataCompat.Builder()
|
return MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KMTN Live Stream")
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream")
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KMountain Radio")
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio")
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
|
||||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
|
||||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 16 KiB |
@@ -5,7 +5,7 @@
|
|||||||
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
|
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
|
||||||
-->
|
-->
|
||||||
<resources>
|
<resources>
|
||||||
<color name="kryz_primary">#1a3a5c</color>
|
<color name="kryz_primary">#E36A18</color>
|
||||||
<color name="kryz_primary_dark">#0e2440</color>
|
<color name="kryz_primary_dark">#B84E08</color>
|
||||||
<color name="kryz_accent">#e87a2e</color>
|
<color name="kryz_accent">#1663C7</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,171 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -1,585 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -2,12 +2,76 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import AVKit
|
import AVKit
|
||||||
|
import MediaPlayer
|
||||||
|
|
||||||
|
private struct CarPlayPlaybackSnapshot {
|
||||||
|
var playbackStatus: String
|
||||||
|
var title: String
|
||||||
|
var subtitle: String
|
||||||
|
var album: String
|
||||||
|
var isLive: Bool
|
||||||
|
var seekable: Bool
|
||||||
|
|
||||||
|
static let `default` = CarPlayPlaybackSnapshot(
|
||||||
|
playbackStatus: "stopped",
|
||||||
|
title: "KRYZ Live Stream",
|
||||||
|
subtitle: "KRYZ Radio",
|
||||||
|
album: "Live",
|
||||||
|
isLive: true,
|
||||||
|
seekable: false
|
||||||
|
)
|
||||||
|
|
||||||
|
init(
|
||||||
|
playbackStatus: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
album: String,
|
||||||
|
isLive: Bool,
|
||||||
|
seekable: Bool
|
||||||
|
) {
|
||||||
|
self.playbackStatus = playbackStatus
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.album = album
|
||||||
|
self.isLive = isLive
|
||||||
|
self.seekable = seekable
|
||||||
|
}
|
||||||
|
|
||||||
|
init(payload: [String: Any], fallback: CarPlayPlaybackSnapshot) {
|
||||||
|
let status = (payload["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let title = (payload["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let subtitle = (payload["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let album = (payload["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
playbackStatus = (status?.isEmpty == false) ? status! : fallback.playbackStatus
|
||||||
|
self.title = (title?.isEmpty == false) ? title! : fallback.title
|
||||||
|
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : fallback.subtitle
|
||||||
|
self.album = (album?.isEmpty == false) ? album! : fallback.album
|
||||||
|
isLive = payload["isLive"] as? Bool ?? fallback.isLive
|
||||||
|
seekable = payload["seekable"] as? Bool ?? fallback.seekable
|
||||||
|
}
|
||||||
|
|
||||||
|
func asDictionary() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"playbackStatus": playbackStatus,
|
||||||
|
"title": title,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"album": album,
|
||||||
|
"isLive": isLive,
|
||||||
|
"seekable": seekable
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
|
||||||
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
|
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
|
||||||
private let castingEventChannelName = "kryz_go_flutter/casting/events"
|
private let castingEventChannelName = "kryz_go_flutter/casting/events"
|
||||||
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
|
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
|
||||||
|
private let carPlayMethodChannelName = "kryz_go_flutter/carplay/methods"
|
||||||
|
|
||||||
|
static let carPlaySnapshotDidChangeNotification = Notification.Name("KRYZCarPlaySnapshotDidChange")
|
||||||
|
static let carPlaySnapshotUserInfoKey = "snapshot"
|
||||||
|
|
||||||
private var castingEventSink: FlutterEventSink?
|
private var castingEventSink: FlutterEventSink?
|
||||||
private var castingStatus: [String: Any] = [
|
private var castingStatus: [String: Any] = [
|
||||||
@@ -15,6 +79,13 @@ import AVKit
|
|||||||
"platform": "ios"
|
"platform": "ios"
|
||||||
]
|
]
|
||||||
private var playbackMethodChannel: FlutterMethodChannel?
|
private var playbackMethodChannel: FlutterMethodChannel?
|
||||||
|
private var carPlayMethodChannel: FlutterMethodChannel?
|
||||||
|
private var carPlaySnapshot = CarPlayPlaybackSnapshot.default
|
||||||
|
private var headlessFlutterEngine: FlutterEngine?
|
||||||
|
private var playCommandTarget: Any?
|
||||||
|
private var togglePlayPauseCommandTarget: Any?
|
||||||
|
private var pauseCommandTarget: Any?
|
||||||
|
private var stopCommandTarget: Any?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -37,14 +108,67 @@ import AVKit
|
|||||||
)
|
)
|
||||||
|
|
||||||
updateCastingStatusForCurrentRoute()
|
updateCastingStatusForCurrentRoute()
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
configureChannels(with: engineBridge.pluginRegistry)
|
||||||
|
|
||||||
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "CastingBridge") else {
|
if let engine = headlessFlutterEngine {
|
||||||
|
engine.destroyContext()
|
||||||
|
headlessFlutterEngine = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||||
|
func invokePlay() {
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
playbackMethodChannel?.invokeMethod("play", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||||
|
func invokeStop() {
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentCarPlaySnapshot() -> [String: Any] {
|
||||||
|
carPlaySnapshot.asDictionary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCarPlaySnapshot() {
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
carPlayMethodChannel?.invokeMethod("getStateSnapshot", arguments: nil) { [weak self] response in
|
||||||
|
guard let payload = response as? [String: Any] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.applyCarPlaySnapshot(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureFlutterBridgeReadyForCarPlay() {
|
||||||
|
if playbackMethodChannel != nil, carPlayMethodChannel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let engine = headlessFlutterEngine {
|
||||||
|
configureChannels(with: engine)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = FlutterEngine(name: "kryz_carplay_headless_engine", project: nil, allowHeadlessExecution: true)
|
||||||
|
headlessFlutterEngine = engine
|
||||||
|
engine.run()
|
||||||
|
GeneratedPluginRegistrant.register(with: engine)
|
||||||
|
configureChannels(with: engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureChannels(with registry: FlutterPluginRegistry) {
|
||||||
|
guard let registrar = registry.registrar(forPlugin: "CastingBridge") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,16 +191,17 @@ import AVKit
|
|||||||
name: playbackMethodChannelName,
|
name: playbackMethodChannelName,
|
||||||
binaryMessenger: registrar.messenger()
|
binaryMessenger: registrar.messenger()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
carPlayMethodChannel = FlutterMethodChannel(
|
||||||
|
name: carPlayMethodChannelName,
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
carPlayMethodChannel?.setMethodCallHandler { [weak self] call, result in
|
||||||
|
self?.handleCarPlayMethod(call: call, result: result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
configureRemoteCommandCenterIfNeeded()
|
||||||
func invokePlay() {
|
requestCarPlaySnapshot()
|
||||||
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() {
|
@objc private func handleAudioRouteChange() {
|
||||||
@@ -103,6 +228,132 @@ import AVKit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleCarPlayMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
case "syncState":
|
||||||
|
guard let payload = call.arguments as? [String: Any] else {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "invalid_payload",
|
||||||
|
message: "Expected map payload for syncState",
|
||||||
|
details: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCarPlaySnapshot(payload)
|
||||||
|
result(nil)
|
||||||
|
case "getCachedState":
|
||||||
|
result(carPlaySnapshot.asDictionary())
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCarPlaySnapshot(_ payload: [String: Any]) {
|
||||||
|
carPlaySnapshot = CarPlayPlaybackSnapshot(payload: payload, fallback: carPlaySnapshot)
|
||||||
|
updateNowPlayingInfoCenter(using: carPlaySnapshot)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: Self.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: self,
|
||||||
|
userInfo: [Self.carPlaySnapshotUserInfoKey: carPlaySnapshot.asDictionary()]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNowPlayingInfoCenter(using snapshot: CarPlayPlaybackSnapshot) {
|
||||||
|
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyTitle] = snapshot.title
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtist] = snapshot.subtitle
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = snapshot.album
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = snapshot.isLive
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = snapshot.playbackStatus == "playing" ? 1.0 : 0.0
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
let playbackState: MPNowPlayingPlaybackState = switch snapshot.playbackStatus {
|
||||||
|
case "playing":
|
||||||
|
.playing
|
||||||
|
case "buffering":
|
||||||
|
.playing
|
||||||
|
default:
|
||||||
|
.stopped
|
||||||
|
}
|
||||||
|
MPNowPlayingInfoCenter.default().playbackState = playbackState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureRemoteCommandCenterIfNeeded() {
|
||||||
|
if playCommandTarget != nil ||
|
||||||
|
togglePlayPauseCommandTarget != nil ||
|
||||||
|
pauseCommandTarget != nil ||
|
||||||
|
stopCommandTarget != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
||||||
|
commandCenter.seekForwardCommand.isEnabled = false
|
||||||
|
commandCenter.seekBackwardCommand.isEnabled = false
|
||||||
|
commandCenter.nextTrackCommand.isEnabled = false
|
||||||
|
commandCenter.previousTrackCommand.isEnabled = false
|
||||||
|
|
||||||
|
commandCenter.playCommand.isEnabled = true
|
||||||
|
playCommandTarget = commandCenter.playCommand.addTarget { [weak self] _ in
|
||||||
|
self?.invokePlay()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||||
|
togglePlayPauseCommandTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.carPlaySnapshot.playbackStatus {
|
||||||
|
case "playing", "buffering":
|
||||||
|
self.invokeStop()
|
||||||
|
default:
|
||||||
|
self.invokePlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.pauseCommand.isEnabled = true
|
||||||
|
pauseCommandTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
self?.invokeStop()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.stopCommand.isEnabled = true
|
||||||
|
stopCommandTarget = commandCenter.stopCommand.addTarget { [weak self] _ in
|
||||||
|
self?.invokeStop()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardownRemoteCommandCenter() {
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
if let target = playCommandTarget {
|
||||||
|
commandCenter.playCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
if let target = togglePlayPauseCommandTarget {
|
||||||
|
commandCenter.togglePlayPauseCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
if let target = pauseCommandTarget {
|
||||||
|
commandCenter.pauseCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
if let target = stopCommandTarget {
|
||||||
|
commandCenter.stopCommand.removeTarget(target)
|
||||||
|
}
|
||||||
|
playCommandTarget = nil
|
||||||
|
togglePlayPauseCommandTarget = nil
|
||||||
|
pauseCommandTarget = nil
|
||||||
|
stopCommandTarget = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func showAirPlayPicker() {
|
private func showAirPlayPicker() {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let rootViewController = self.activeRootViewController() else {
|
guard let rootViewController = self.activeRootViewController() else {
|
||||||
@@ -176,5 +427,6 @@ import AVKit
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
teardownRemoteCommandCenter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 936 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
@@ -14,11 +14,46 @@ import UIKit
|
|||||||
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
|
* 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.
|
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
|
||||||
*/
|
*/
|
||||||
@available(iOS 14.0, *)
|
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPListTemplateDelegate {
|
||||||
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|
||||||
|
private struct Snapshot {
|
||||||
|
var playbackStatus: String
|
||||||
|
var title: String
|
||||||
|
var subtitle: String
|
||||||
|
var album: String
|
||||||
|
|
||||||
|
init(playbackStatus: String, title: String, subtitle: String, album: String) {
|
||||||
|
self.playbackStatus = playbackStatus
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.album = album
|
||||||
|
}
|
||||||
|
|
||||||
|
static let `default` = Snapshot(
|
||||||
|
playbackStatus: "stopped",
|
||||||
|
title: "KRYZ Live Stream",
|
||||||
|
subtitle: "KRYZ Radio",
|
||||||
|
album: "Live"
|
||||||
|
)
|
||||||
|
|
||||||
|
init(payload: [String: Any]?) {
|
||||||
|
let base = Snapshot.default
|
||||||
|
let status = (payload?["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let title = (payload?["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let subtitle = (payload?["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let album = (payload?["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
playbackStatus = (status?.isEmpty == false) ? status! : base.playbackStatus
|
||||||
|
self.title = (title?.isEmpty == false) ? title! : base.title
|
||||||
|
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : base.subtitle
|
||||||
|
self.album = (album?.isEmpty == false) ? album! : base.album
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var carPlayInterfaceController: CPInterfaceController?
|
private var carPlayInterfaceController: CPInterfaceController?
|
||||||
private var listTemplate: CPListTemplate?
|
private var listTemplate: CPListTemplate?
|
||||||
|
private var streamItem: CPListItem?
|
||||||
|
private var snapshot: Snapshot = .default
|
||||||
|
|
||||||
// MARK: - CPTemplateApplicationSceneDelegate
|
// MARK: - CPTemplateApplicationSceneDelegate
|
||||||
|
|
||||||
@@ -26,14 +61,36 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|||||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||||
didConnect interfaceController: CPInterfaceController
|
didConnect interfaceController: CPInterfaceController
|
||||||
) {
|
) {
|
||||||
|
appDelegate?.ensureFlutterBridgeReadyForCarPlay()
|
||||||
carPlayInterfaceController = interfaceController
|
carPlayInterfaceController = interfaceController
|
||||||
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil)
|
snapshot = Snapshot(payload: appDelegate?.currentCarPlaySnapshot())
|
||||||
|
|
||||||
|
let rootTemplate = makeListTemplate()
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil)
|
||||||
|
} else {
|
||||||
|
// iOS 13 fallback: use API variant available before iOS 14.
|
||||||
|
interfaceController.setRootTemplate(rootTemplate, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleCarPlaySnapshotChanged(_:)),
|
||||||
|
name: AppDelegate.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
appDelegate?.requestCarPlaySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
func templateApplicationScene(
|
func templateApplicationScene(
|
||||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||||
) {
|
) {
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: AppDelegate.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
carPlayInterfaceController = nil
|
carPlayInterfaceController = nil
|
||||||
listTemplate = nil
|
listTemplate = nil
|
||||||
appDelegate?.invokeStop()
|
appDelegate?.invokeStop()
|
||||||
@@ -42,40 +99,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|||||||
// MARK: - Template construction
|
// MARK: - Template construction
|
||||||
|
|
||||||
private func makeListTemplate() -> CPListTemplate {
|
private func makeListTemplate() -> CPListTemplate {
|
||||||
let artwork = CPListItem.maximumImageSize
|
|
||||||
let icon = UIImage(named: "AppIcon")?
|
let icon = UIImage(named: "AppIcon")?
|
||||||
.withRenderingMode(.alwaysOriginal)
|
.withRenderingMode(.alwaysOriginal)
|
||||||
|
|
||||||
let item = CPListItem(
|
let item = CPListItem(
|
||||||
text: "KRYZ Live Stream",
|
text: snapshot.title,
|
||||||
detailText: "KRYZ Radio",
|
detailText: detailText(for: snapshot),
|
||||||
image: icon
|
image: icon
|
||||||
)
|
)
|
||||||
|
streamItem = item
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
item.handler = { [weak self] _, completion in
|
item.handler = { [weak self] _, completion in
|
||||||
self?.handleStreamItemTapped()
|
self?.handleStreamItemTapped()
|
||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let section = CPListSection(items: [item])
|
let section = CPListSection(items: [item])
|
||||||
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
|
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
|
||||||
|
|
||||||
|
if #unavailable(iOS 14.0) {
|
||||||
|
// iOS 13 fallback: use list delegate selection callback.
|
||||||
|
template.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
template.tabTitle = "KRYZ Go!"
|
template.tabTitle = "KRYZ Go!"
|
||||||
template.tabImage = icon
|
template.tabImage = icon
|
||||||
|
}
|
||||||
|
|
||||||
listTemplate = template
|
listTemplate = template
|
||||||
// Silence unused-variable warning
|
|
||||||
_ = artwork
|
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func detailText(for snapshot: Snapshot) -> String {
|
||||||
|
let statusText: String = switch snapshot.playbackStatus {
|
||||||
|
case "playing":
|
||||||
|
"Playing"
|
||||||
|
case "buffering":
|
||||||
|
"Buffering"
|
||||||
|
default:
|
||||||
|
"Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return statusText
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(snapshot.subtitle) • \(statusText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleCarPlaySnapshotChanged(_ notification: Notification) {
|
||||||
|
guard
|
||||||
|
let payload = notification.userInfo?[AppDelegate.carPlaySnapshotUserInfoKey] as? [String: Any]
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = Snapshot(payload: payload)
|
||||||
|
refreshTemplatesForSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshTemplatesForSnapshot() {
|
||||||
|
guard let interfaceController = carPlayInterfaceController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *), interfaceController.topTemplate is CPNowPlayingTemplate {
|
||||||
|
// Preserve Now Playing navigation stack while metadata updates are flowing.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedTemplate = makeListTemplate()
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
interfaceController.setRootTemplate(updatedTemplate, animated: false, completion: nil)
|
||||||
|
} else {
|
||||||
|
interfaceController.setRootTemplate(updatedTemplate, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Playback
|
// MARK: - Playback
|
||||||
|
|
||||||
private func handleStreamItemTapped() {
|
private func handleStreamItemTapped() {
|
||||||
appDelegate?.invokePlay()
|
appDelegate?.invokePlay()
|
||||||
carPlayInterfaceController?.pushTemplate(
|
appDelegate?.requestCarPlaySnapshot()
|
||||||
|
guard let interfaceController = carPlayInterfaceController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
interfaceController.pushTemplate(
|
||||||
CPNowPlayingTemplate.shared,
|
CPNowPlayingTemplate.shared,
|
||||||
animated: true,
|
animated: true,
|
||||||
completion: nil
|
completion: nil
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// iOS 13 fallback: no CPNowPlayingTemplate.shared API.
|
||||||
|
// Keep playback running and remain on the list template.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -83,4 +205,20 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|||||||
private var appDelegate: AppDelegate? {
|
private var appDelegate: AppDelegate? {
|
||||||
UIApplication.shared.delegate as? AppDelegate
|
UIApplication.shared.delegate as? AppDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CPListTemplateDelegate (iOS 13 fallback)
|
||||||
|
|
||||||
|
func listTemplate(
|
||||||
|
_ listTemplate: CPListTemplate,
|
||||||
|
didSelect item: CPListItem,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
defer { completionHandler() }
|
||||||
|
|
||||||
|
guard item == streamItem else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStreamItemTapped()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>KMountain Flower Radio</string>
|
<string>KRYZ Go!</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>KMountain Flower Radio</string>
|
<string>KRYZ Go!</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -1,67 +1,69 @@
|
|||||||
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.headingColor,
|
required this.primarySeed,
|
||||||
required this.accentColor,
|
required this.secondarySeed,
|
||||||
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.mutedText,
|
required this.accent,
|
||||||
required this.textOnDark,
|
required this.webViewBorder,
|
||||||
required this.textOnDarkSecondary,
|
required this.webViewBackground,
|
||||||
required this.controlsGradientStart,
|
required this.controlsGradientStart,
|
||||||
required this.controlsGradientEnd,
|
required this.controlsGradientEnd,
|
||||||
required this.controlsShadow,
|
required this.controlsShadow,
|
||||||
required this.bodyText,
|
required this.statusText,
|
||||||
required this.cardSurface,
|
|
||||||
required this.cardBorder,
|
|
||||||
required this.successColor,
|
|
||||||
required this.dangerColor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Website: `primary` → headings (h1–h6)
|
final Color primarySeed;
|
||||||
final Color headingColor;
|
final Color secondarySeed;
|
||||||
/// 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;
|
||||||
/// Website: `medium` → muted / secondary text
|
final Color accent;
|
||||||
final Color mutedText;
|
final Color webViewBorder;
|
||||||
/// Website: `white` → text on dark surfaces
|
final Color webViewBackground;
|
||||||
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;
|
||||||
/// Website: `text` → body text
|
final Color statusText;
|
||||||
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 = GeneratedThemeColors.lightColors;
|
static const AppThemeColors lightColors = AppThemeColors(
|
||||||
static const AppThemeColors darkColors = GeneratedThemeColors.darkColors;
|
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 bool isDarkMode(BuildContext context) {
|
static bool isDarkMode(BuildContext context) {
|
||||||
return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
|
return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
|
||||||
@@ -72,16 +74,16 @@ class AppThemeBusinessObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static ThemeData lightTheme() {
|
static ThemeData lightTheme() {
|
||||||
final ColorScheme scheme = ColorScheme.light(
|
final ColorScheme scheme =
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: lightColors.primarySeed,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: lightColors.headingColor,
|
).copyWith(
|
||||||
onPrimary: lightColors.textOnDark,
|
primary: lightColors.primarySeed,
|
||||||
secondary: lightColors.accentColor,
|
secondary: lightColors.secondarySeed,
|
||||||
onSecondary: lightColors.textOnDark,
|
tertiary: const Color(0xFF2C85E1),
|
||||||
surface: lightColors.shellSurface,
|
surface: const Color(0xFFFFFBF7),
|
||||||
onSurface: lightColors.bodyText,
|
onSurface: lightColors.statusText,
|
||||||
error: lightColors.dangerColor,
|
|
||||||
onError: lightColors.textOnDark,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -102,16 +104,16 @@ class AppThemeBusinessObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static ThemeData darkTheme() {
|
static ThemeData darkTheme() {
|
||||||
final ColorScheme scheme = ColorScheme.dark(
|
final ColorScheme scheme =
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: darkColors.primarySeed,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: darkColors.headingColor,
|
).copyWith(
|
||||||
onPrimary: darkColors.textOnDark,
|
primary: darkColors.primarySeed,
|
||||||
secondary: darkColors.accentColor,
|
secondary: darkColors.secondarySeed,
|
||||||
onSecondary: darkColors.textOnDark,
|
tertiary: const Color(0xFF4E97E8),
|
||||||
surface: darkColors.shellSurface,
|
surface: const Color(0xFF111A27),
|
||||||
onSurface: darkColors.bodyText,
|
onSurface: darkColors.statusText,
|
||||||
error: darkColors.dangerColor,
|
|
||||||
onError: darkColors.textOnDark,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'streaming_audio_business_object.dart';
|
||||||
|
|
||||||
|
class CarPlayBridge {
|
||||||
|
CarPlayBridge({required StreamingAudioBusinessObject audio}) : _audio = audio;
|
||||||
|
|
||||||
|
static const MethodChannel _channel = MethodChannel(
|
||||||
|
'kryz_go_flutter/carplay/methods',
|
||||||
|
);
|
||||||
|
|
||||||
|
final StreamingAudioBusinessObject _audio;
|
||||||
|
bool _isStarted = false;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isStarted = true;
|
||||||
|
_channel.setMethodCallHandler(_handleNativeMethodCall);
|
||||||
|
_audio.addListener(_onAudioChanged);
|
||||||
|
await _pushSnapshotToNative();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (!_isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isStarted = false;
|
||||||
|
_audio.removeListener(_onAudioChanged);
|
||||||
|
_channel.setMethodCallHandler(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _snapshot() {
|
||||||
|
final String playbackStatus = switch (_audio.playbackStatus) {
|
||||||
|
StreamingPlaybackStatus.playing => 'playing',
|
||||||
|
StreamingPlaybackStatus.buffering => 'buffering',
|
||||||
|
StreamingPlaybackStatus.stopped => 'stopped',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'playbackStatus': playbackStatus,
|
||||||
|
'isPlaying': _audio.isPlaying,
|
||||||
|
'isBuffering': _audio.isBuffering,
|
||||||
|
'isLive': true,
|
||||||
|
'seekable': false,
|
||||||
|
'title': _audio.currentTitle,
|
||||||
|
'subtitle': _audio.currentSubtitle,
|
||||||
|
'album': 'Live',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _handleNativeMethodCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'getStateSnapshot':
|
||||||
|
return _snapshot();
|
||||||
|
default:
|
||||||
|
throw MissingPluginException(
|
||||||
|
'Unknown CarPlay bridge method: ${call.method}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAudioChanged() {
|
||||||
|
_pushSnapshotToNative();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushSnapshotToNative() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('syncState', _snapshot());
|
||||||
|
} on MissingPluginException {
|
||||||
|
// CarPlay bridge only exists on iOS native builds.
|
||||||
|
} on PlatformException {
|
||||||
|
// Keep playback working even if native sync temporarily fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import 'package:audio_session/audio_session.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
import 'generated/app_config.dart';
|
|
||||||
import 'live_info_business_object.dart';
|
import 'live_info_business_object.dart';
|
||||||
|
|
||||||
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||||
@@ -17,21 +16,19 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const String streamUrl = AppConfig.streamUrl;
|
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
||||||
static const String mediaId = 'kryz-live-stream';
|
static const String mediaId = 'kryz-live-stream';
|
||||||
static const String _liveInfoEndpoint = AppConfig.metadataUrl ?? '';
|
static const String _liveInfoEndpoint =
|
||||||
|
'http://kryz.airtime.pro/api/live-info';
|
||||||
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
||||||
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
||||||
|
|
||||||
static const MediaItem _defaultMediaItem = MediaItem(
|
static const MediaItem _defaultMediaItem = MediaItem(
|
||||||
id: mediaId,
|
id: mediaId,
|
||||||
title: AppConfig.defaultTitle,
|
title: 'KRYZ Live Stream',
|
||||||
artist: AppConfig.defaultArtist,
|
artist: 'KRYZ Radio',
|
||||||
album: 'Live',
|
album: 'Live',
|
||||||
extras: <String, dynamic>{
|
extras: <String, dynamic>{'isLive': true, 'seekable': false},
|
||||||
'isLive': true,
|
|
||||||
'seekable': false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -53,7 +50,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
MediaItem(
|
MediaItem(
|
||||||
id: mediaId,
|
id: mediaId,
|
||||||
title: current.displayTitle,
|
title: current.displayTitle,
|
||||||
artist: subtitle.isEmpty ? AppConfig.defaultArtist : subtitle,
|
artist: subtitle.isEmpty ? 'KRYZ Radio' : subtitle,
|
||||||
album: 'Live',
|
album: 'Live',
|
||||||
extras: <String, dynamic>{
|
extras: <String, dynamic>{
|
||||||
'isLive': true,
|
'isLive': true,
|
||||||
@@ -83,10 +80,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
try {
|
try {
|
||||||
if (!_isSourceLoaded) {
|
if (!_isSourceLoaded) {
|
||||||
await _player.setAudioSource(
|
await _player.setAudioSource(
|
||||||
AudioSource.uri(
|
AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value),
|
||||||
Uri.parse(streamUrl),
|
|
||||||
tag: mediaItem.value,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_isSourceLoaded = true;
|
_isSourceLoaded = true;
|
||||||
}
|
}
|
||||||
@@ -127,6 +121,13 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() async {
|
||||||
|
// CarPlay and lock-screen transport may issue pause for live streams.
|
||||||
|
// Treat pause as stop to keep behavior consistent across platforms.
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
// Live stream is intentionally non-seekable.
|
// Live stream is intentionally non-seekable.
|
||||||
@@ -159,7 +160,8 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onPlayerStateChanged(PlayerState state) {
|
void _onPlayerStateChanged(PlayerState state) {
|
||||||
final AudioProcessingState processingState = switch (state.processingState) {
|
final AudioProcessingState processingState =
|
||||||
|
switch (state.processingState) {
|
||||||
ProcessingState.idle => AudioProcessingState.idle,
|
ProcessingState.idle => AudioProcessingState.idle,
|
||||||
ProcessingState.loading => AudioProcessingState.loading,
|
ProcessingState.loading => AudioProcessingState.loading,
|
||||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||||
@@ -167,17 +169,26 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
ProcessingState.completed => AudioProcessingState.completed,
|
ProcessingState.completed => AudioProcessingState.completed,
|
||||||
};
|
};
|
||||||
|
|
||||||
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
|
final bool isPlaying =
|
||||||
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
|
state.playing && state.processingState == ProcessingState.ready;
|
||||||
(_playRequested && !state.playing && state.processingState != ProcessingState.completed);
|
final bool isBuffering =
|
||||||
|
(state.playing && state.processingState != ProcessingState.ready) ||
|
||||||
|
(_playRequested &&
|
||||||
|
!state.playing &&
|
||||||
|
state.processingState != ProcessingState.completed);
|
||||||
|
|
||||||
playbackState.add(
|
playbackState.add(
|
||||||
playbackState.value.copyWith(
|
playbackState.value.copyWith(
|
||||||
controls: <MediaControl>[
|
controls: <MediaControl>[
|
||||||
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
if (isPlaying || isBuffering)
|
||||||
|
MediaControl.stop
|
||||||
|
else
|
||||||
|
MediaControl.play,
|
||||||
],
|
],
|
||||||
systemActions: const <MediaAction>{},
|
systemActions: const <MediaAction>{},
|
||||||
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
|
processingState: isBuffering
|
||||||
|
? AudioProcessingState.buffering
|
||||||
|
: processingState,
|
||||||
playing: isPlaying,
|
playing: isPlaying,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,31 +4,17 @@ 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({
|
||||||
String? endpoint,
|
this.endpoint = const String.fromEnvironment(
|
||||||
|
'KRYZ_LIVE_INFO_URL',
|
||||||
|
defaultValue: 'http://kryz.airtime.pro/api/live-info',
|
||||||
|
),
|
||||||
int refreshIntervalSeconds = 20,
|
int refreshIntervalSeconds = 20,
|
||||||
this.requestTimeout = const Duration(seconds: 8),
|
this.requestTimeout = const Duration(seconds: 8),
|
||||||
}) : _endpointOverride = endpoint,
|
}) : _refreshIntervalSeconds = refreshIntervalSeconds;
|
||||||
_refreshIntervalSeconds = refreshIntervalSeconds;
|
|
||||||
|
|
||||||
/// Explicit endpoint passed at construction, if any.
|
final String endpoint;
|
||||||
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;
|
||||||
@@ -48,10 +34,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.graphic_eq_rounded,
|
Icons.graphic_eq_rounded,
|
||||||
color: themeColors.textOnDarkSecondary,
|
color: themeColors.statusText,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -67,7 +67,7 @@ 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.textOnDark,
|
color: themeColors.statusText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -94,9 +94,7 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
child: TrackCard(
|
child: TrackCard(
|
||||||
label: 'Now Playing',
|
label: 'Now Playing',
|
||||||
track: current,
|
track: current,
|
||||||
titleColor: themeColors.textOnDark,
|
titleColor: themeColors.statusText,
|
||||||
cardSurface: themeColors.cardSurface,
|
|
||||||
cardBorder: themeColors.cardBorder,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -104,9 +102,7 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
child: TrackCard(
|
child: TrackCard(
|
||||||
label: 'Coming Next',
|
label: 'Coming Next',
|
||||||
track: next,
|
track: next,
|
||||||
titleColor: themeColors.textOnDark,
|
titleColor: themeColors.statusText,
|
||||||
cardSurface: themeColors.cardSurface,
|
|
||||||
cardBorder: themeColors.cardBorder,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -117,18 +113,14 @@ class LiveInfoPanel extends StatelessWidget {
|
|||||||
label: 'On Air Show',
|
label: 'On Air Show',
|
||||||
show: currentShow,
|
show: currentShow,
|
||||||
sourceTimezone: snapshot?.timezone,
|
sourceTimezone: snapshot?.timezone,
|
||||||
titleColor: themeColors.textOnDarkSecondary,
|
titleColor: themeColors.statusText,
|
||||||
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,
|
||||||
sourceTimezone: snapshot?.timezone,
|
sourceTimezone: snapshot?.timezone,
|
||||||
titleColor: themeColors.textOnDarkSecondary,
|
titleColor: themeColors.statusText,
|
||||||
cardSurface: themeColors.cardSurface,
|
|
||||||
cardBorder: themeColors.cardBorder,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tz;
|
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 'carplay_bridge.dart';
|
||||||
import 'casting_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';
|
||||||
@@ -36,16 +36,20 @@ 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();
|
final CastingBusinessObject _casting = CastingBusinessObject();
|
||||||
|
late final CarPlayBridge _carPlayBridge;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_carPlayBridge = CarPlayBridge(audio: _audio);
|
||||||
unawaited(_audio.ensureInitialized());
|
unawaited(_audio.ensureInitialized());
|
||||||
|
unawaited(_carPlayBridge.start());
|
||||||
_casting.initialize();
|
_casting.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
unawaited(_carPlayBridge.dispose());
|
||||||
_casting.dispose();
|
_casting.dispose();
|
||||||
_audio.dispose();
|
_audio.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -54,7 +58,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: AppConfig.appName,
|
title: 'KRYZ Go!',
|
||||||
theme: AppThemeBusinessObject.lightTheme(),
|
theme: AppThemeBusinessObject.lightTheme(),
|
||||||
darkTheme: AppThemeBusinessObject.darkTheme(),
|
darkTheme: AppThemeBusinessObject.darkTheme(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
@@ -395,12 +399,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
AppConfig.appName,
|
'KRYZ Go!',
|
||||||
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.headingColor,
|
color: themeColors.statusText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -458,8 +462,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
label: const Text('Stop'),
|
label: const Text('Stop'),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed:
|
onPressed: _casting.status == CastingStatus.connecting
|
||||||
_casting.status == CastingStatus.connecting
|
|
||||||
? null
|
? null
|
||||||
: _toggleCasting,
|
: _toggleCasting,
|
||||||
icon: Icon(_castingIcon()),
|
icon: Icon(_castingIcon()),
|
||||||
@@ -469,7 +472,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
_playbackStatusLabel(),
|
_playbackStatusLabel(),
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: themeColors.textOnDark,
|
color: themeColors.statusText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ class ShowCard extends StatelessWidget {
|
|||||||
required this.show,
|
required this.show,
|
||||||
required this.sourceTimezone,
|
required this.sourceTimezone,
|
||||||
required this.titleColor,
|
required this.titleColor,
|
||||||
required this.cardSurface,
|
|
||||||
required this.cardBorder,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final LiveInfoShowInfo? show;
|
final LiveInfoShowInfo? show;
|
||||||
final String? sourceTimezone;
|
final String? sourceTimezone;
|
||||||
final Color titleColor;
|
final Color titleColor;
|
||||||
final Color cardSurface;
|
|
||||||
final Color cardBorder;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -27,8 +23,8 @@ class ShowCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: cardSurface,
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
border: Border.all(color: cardBorder),
|
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -41,10 +37,7 @@ class ShowCard extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyMedium?.copyWith(
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: titleColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -53,9 +46,7 @@ class ShowCard extends StatelessWidget {
|
|||||||
show?.starts,
|
show?.starts,
|
||||||
sourceTimezone: sourceTimezone,
|
sourceTimezone: sourceTimezone,
|
||||||
),
|
),
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
context,
|
|
||||||
).textTheme.labelSmall?.copyWith(color: titleColor),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'generated/app_config.dart';
|
|
||||||
import 'kryz_audio_handler.dart';
|
import 'kryz_audio_handler.dart';
|
||||||
import 'live_info_business_object.dart';
|
import 'live_info_business_object.dart';
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
|||||||
builder: () => KryzAudioHandler(),
|
builder: () => KryzAudioHandler(),
|
||||||
config: const AudioServiceConfig(
|
config: const AudioServiceConfig(
|
||||||
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
||||||
androidNotificationChannelName: '${AppConfig.appName} Audio Playback',
|
androidNotificationChannelName: 'KRYZ Audio Playback',
|
||||||
androidNotificationOngoing: true,
|
androidNotificationOngoing: true,
|
||||||
androidStopForegroundOnPause: true,
|
androidStopForegroundOnPause: true,
|
||||||
),
|
),
|
||||||
@@ -61,8 +60,8 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
|||||||
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
bool _isAttached = false;
|
bool _isAttached = false;
|
||||||
String _currentTitle = AppConfig.defaultTitle;
|
String _currentTitle = 'KRYZ Live Stream';
|
||||||
String _currentSubtitle = AppConfig.defaultArtist;
|
String _currentSubtitle = 'KRYZ Radio';
|
||||||
|
|
||||||
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||||
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
||||||
@@ -146,9 +145,9 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final title = item.title.trim().isEmpty ? AppConfig.defaultTitle : item.title;
|
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title;
|
||||||
final subtitle =
|
final subtitle =
|
||||||
(item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : item.artist!.trim();
|
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : item.artist!.trim();
|
||||||
|
|
||||||
if (title == _currentTitle && subtitle == _currentSubtitle) {
|
if (title == _currentTitle && subtitle == _currentSubtitle) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,15 +9,11 @@ class TrackCard extends StatelessWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.titleColor,
|
required this.titleColor,
|
||||||
required this.cardSurface,
|
|
||||||
required this.cardBorder,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final LiveInfoTrackInfo? track;
|
final LiveInfoTrackInfo? track;
|
||||||
final Color titleColor;
|
final Color titleColor;
|
||||||
final Color cardSurface;
|
|
||||||
final Color cardBorder;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -28,9 +24,9 @@ class TrackCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cardSurface,
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: cardBorder),
|
border: Border.all(color: Colors.black.withValues(alpha: 0.12)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -49,29 +45,21 @@ class TrackCard extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleMedium?.copyWith(
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: titleColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
subtitle.isEmpty ? 'No metadata provided' : subtitle,
|
subtitle.isEmpty ? 'No metadata provided' : subtitle,
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(color: titleColor),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
window,
|
window,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelMedium?.copyWith(
|
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: titleColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||