Adds app whitelabling feature from kmountainflower site dumps

This commit is contained in:
Your Name
2026-07-07 10:00:16 +00:00
parent b8d624343b
commit ea1bc88a70
54 changed files with 585 additions and 71 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ ENV PATH="$PATH:/opt/flutter/bin"
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\ wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils python3 pip pipx lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Become non-root # Become non-root
+7 -2
View File
@@ -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,5 +14,10 @@
] ]
} }
}, },
"postCreateCommand": "flutter doctor -v" "postCreateCommand": "flutter pub get && python3 -m venv .venv && . .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && curl -fsSL https://claude.ai/install.sh | bash",
"containerEnv": {
"ANTHROPIC_BASE_URL": "http://192.168.1.108:1234",
"ANTHROPIC_API_KEY": "lmstudio",
"ANTHROPIC_BASE_MODEL": "qwen/qwen3.6-27b"
}
} }
+9 -1
View File
@@ -1,3 +1,6 @@
# Generated whitelabel config — rebuilt by build_whitelabel.py
lib/generated/
# ---> Dart # ---> Dart
# See https://www.dartlang.org/guides/libraries/private-files # See https://www.dartlang.org/guides/libraries/private-files
@@ -69,7 +72,7 @@ google-services.json
.LSOverride .LSOverride
# Icon must end with two \r # Icon must end with two \r
Icon Icon
# Thumbnails # Thumbnails
._* ._*
@@ -90,3 +93,8 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
# Python venv
.venv/
# Whitelabel app theming
theme.json
+1 -1
View File
@@ -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="KRYZ Go!" android:label="Fire FM"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -38,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://kryz.out.airtime.pro/kryz_a" private const val STREAM_URL = "http://www.stream.org/stream"
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
} }
@@ -166,8 +166,8 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher") 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("KRYZ Live Stream") .setTitle("KMTN Live Stream")
.setSubtitle("KRYZ Radio") .setSubtitle("KMountain Radio")
.setIconUri(iconUri) .setIconUri(iconUri)
.setMediaUri(Uri.parse(STREAM_URL)) .setMediaUri(Uri.parse(STREAM_URL))
.build() .build()
@@ -296,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,
"KRYZ Auto Playback", "KMountain Flower Radio Auto Playback",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "KRYZ Radio Android Auto playback" description = "KMountain Flower Radio Android Auto playback"
setShowBadge(false) setShowBadge(false)
} }
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -314,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("KRYZ Radio") .setContentTitle("KMountain Radio")
.setContentText(statusText) .setContentText(statusText)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(isPlaying || isBuffering) .setOngoing(isPlaying || isBuffering)
@@ -333,8 +333,8 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art") 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 ?: "KRYZ Live Stream") .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KMTN Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio") .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KMountain 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)
@@ -347,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 = "KRYZ Live Stream", title = "KMTN Live Stream",
subtitle = "KRYZ Radio", subtitle = "KMountain Radio",
album = "Live", album = "Live",
mediaUri = STREAM_URL, mediaUri = STREAM_URL,
) )
@@ -430,8 +430,8 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
private fun buildLiveStreamMetadata(): MediaMetadataCompat { private fun buildLiveStreamMetadata(): MediaMetadataCompat {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher") val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
return MediaMetadataCompat.Builder() return MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream") .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KMTN Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio") .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KMountain 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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 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">#E36A18</color> <color name="kryz_primary">#000000</color>
<color name="kryz_primary_dark">#B84E08</color> <color name="kryz_primary_dark">#0d0d0d</color>
<color name="kryz_accent">#1663C7</color> <color name="kryz_accent">#ff7a33</color>
</resources> </resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+504
View File
@@ -0,0 +1,504 @@
#!/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
light_props = [
("primarySeed", hex_to_rgba(c["primary"])),
("secondarySeed", 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)),
("accent", hex_to_rgba(c["accent_dark"])),
("webViewBorder", hex_to_rgba(c["medium"], 0.15)),
("webViewBackground", hex_to_rgba(c["white"])),
("controlsGradientStart", hex_to_rgba(c["primary_muted"])),
("controlsGradientEnd", hex_to_rgba(c["accent_muted"])),
("controlsShadow", hex_to_rgba(c["black"], 0.14)),
("statusText", hex_to_rgba(c["text"])),
]
# Dark mode
dark_props = [
("primarySeed", hex_to_rgba(c["primary_light"])),
("secondarySeed", hex_to_rgba(c["accent_light"])),
("scaffoldGradientStart", hex_to_rgba(c["black"])),
("scaffoldGradientEnd", darken(c["primary_dark"], 0.30)),
("shellSurface", hex_to_rgba(c["black"], 0.85)),
("shellBorder", hex_to_rgba(c["accent_light"], 0.20)),
("accent", hex_to_rgba(c["primary_light"])),
("webViewBorder", hex_to_rgba(c["accent_light"], 0.20)),
("webViewBackground", hex_to_rgba(c["black"])),
("controlsGradientStart", hex_to_rgba(c["primary_dark"])),
("controlsGradientEnd", hex_to_rgba(c["accent_dark"])),
("controlsShadow", hex_to_rgba(c["black"], 0.27)),
("statusText", hex_to_rgba(c["light"])),
]
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")
# ---------------------------------------------------------------------------
# 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)
# 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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+2 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>KRYZ Go!</string> <string>Fire FM</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>KRYZ Go!</string> <string>Fire FM</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+4 -31
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'generated/theme_colors.dart';
class AppThemeColors { class AppThemeColors {
const AppThemeColors({ const AppThemeColors({
required this.primarySeed, required this.primarySeed,
@@ -33,37 +35,8 @@ class AppThemeColors {
} }
class AppThemeBusinessObject { class AppThemeBusinessObject {
static const AppThemeColors lightColors = AppThemeColors( static const AppThemeColors lightColors = GeneratedThemeColors.lightColors;
primarySeed: Color(0xFFE36A18), static const AppThemeColors darkColors = GeneratedThemeColors.darkColors;
secondarySeed: Color(0xFF1663C7),
scaffoldGradientStart: Color(0xFFFFF3E3),
scaffoldGradientEnd: Color(0xFFE6F2FF),
shellSurface: Color(0xF7FFFFFF),
shellBorder: Color(0x1A1A4D86),
accent: Color(0xFF0F5DB6),
webViewBorder: Color(0x261A4D86),
webViewBackground: Color(0xFFFFFFFF),
controlsGradientStart: Color(0xFFFFB465),
controlsGradientEnd: Color(0xFF69ADFF),
controlsShadow: Color(0x24114B7A),
statusText: Color(0xFF13243D),
);
static const AppThemeColors darkColors = AppThemeColors(
primarySeed: Color(0xFFFF9D59),
secondarySeed: Color(0xFF76B8FF),
scaffoldGradientStart: Color(0xFF0F1C30),
scaffoldGradientEnd: Color(0xFF31190C),
shellSurface: Color(0xD9162438),
shellBorder: Color(0x3366AAFF),
accent: Color(0xFFFFBC7E),
webViewBorder: Color(0x3378B7FF),
webViewBackground: Color(0xFF0C1420),
controlsGradientStart: Color(0xFF275792),
controlsGradientEnd: Color(0xFF9A4F1F),
controlsShadow: Color(0x44000000),
statusText: Color(0xFFF4F7FF),
);
static bool isDarkMode(BuildContext context) { static bool isDarkMode(BuildContext context) {
return MediaQuery.platformBrightnessOf(context) == Brightness.dark; return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
+6 -5
View File
@@ -6,6 +6,7 @@ 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 {
@@ -16,16 +17,16 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
); );
} }
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a'; static const String streamUrl = AppConfig.streamUrl;
static const String mediaId = 'kryz-live-stream'; static const String mediaId = 'kryz-live-stream';
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info'; static const String _liveInfoEndpoint = AppConfig.metadataUrl ?? '';
static const Duration _liveInfoTimeout = Duration(seconds: 8); static const Duration _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: 'KRYZ Live Stream', title: AppConfig.defaultTitle,
artist: 'KRYZ Radio', artist: AppConfig.defaultArtist,
album: 'Live', album: 'Live',
extras: <String, dynamic>{ extras: <String, dynamic>{
'isLive': true, 'isLive': true,
@@ -52,7 +53,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
MediaItem( MediaItem(
id: mediaId, id: mediaId,
title: current.displayTitle, title: current.displayTitle,
artist: subtitle.isEmpty ? 'KRYZ Radio' : subtitle, artist: subtitle.isEmpty ? AppConfig.defaultArtist : subtitle,
album: 'Live', album: 'Live',
extras: <String, dynamic>{ extras: <String, dynamic>{
'isLive': true, 'isLive': true,
+24 -6
View File
@@ -4,17 +4,31 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'generated/app_config.dart';
class LiveInfoBusinessObject extends ChangeNotifier { class LiveInfoBusinessObject extends ChangeNotifier {
LiveInfoBusinessObject({ LiveInfoBusinessObject({
this.endpoint = const String.fromEnvironment( String? endpoint,
'KRYZ_LIVE_INFO_URL',
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),
}) : _refreshIntervalSeconds = refreshIntervalSeconds; }) : _endpointOverride = endpoint,
_refreshIntervalSeconds = refreshIntervalSeconds;
final String endpoint; /// Explicit endpoint passed at construction, if any.
final String? _endpointOverride;
/// The effective polling endpoint: construction override > env var > AppConfig.
String get endpoint {
final override = _endpointOverride;
if (override != null && override.isNotEmpty) {
return override;
}
const envUrl = String.fromEnvironment('KRYZ_LIVE_INFO_URL', defaultValue: '');
if (envUrl.isNotEmpty) {
return envUrl;
}
return AppConfig.metadataUrl ?? '';
}
final Duration requestTimeout; final Duration requestTimeout;
Timer? _pollTimer; Timer? _pollTimer;
@@ -34,6 +48,10 @@ class LiveInfoBusinessObject extends ChangeNotifier {
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed); List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
Future<void> start() async { Future<void> start() async {
// Skip polling if no endpoint is configured
if (endpoint.isEmpty && (AppConfig.metadataUrl ?? '').isEmpty) {
return;
}
await refresh(); await refresh();
_restartTimer(); _restartTimer();
} }
+3 -2
View File
@@ -4,6 +4,7 @@ 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 'casting_business_object.dart'; import 'casting_business_object.dart';
import 'live_info_business_object.dart'; import 'live_info_business_object.dart';
@@ -53,7 +54,7 @@ class _MyAppState extends State<MyApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'KRYZ Go!', title: AppConfig.appName,
theme: AppThemeBusinessObject.lightTheme(), theme: AppThemeBusinessObject.lightTheme(),
darkTheme: AppThemeBusinessObject.darkTheme(), darkTheme: AppThemeBusinessObject.darkTheme(),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
@@ -394,7 +395,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Text( child: Text(
'KRYZ Go!', AppConfig.appName,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith( style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
+6 -5
View File
@@ -3,6 +3,7 @@ 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';
@@ -38,7 +39,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: 'KRYZ Audio Playback', androidNotificationChannelName: '${AppConfig.appName} Audio Playback',
androidNotificationOngoing: true, androidNotificationOngoing: true,
androidStopForegroundOnPause: true, androidStopForegroundOnPause: true,
), ),
@@ -60,8 +61,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 = 'KRYZ Live Stream'; String _currentTitle = AppConfig.defaultTitle;
String _currentSubtitle = 'KRYZ Radio'; String _currentSubtitle = AppConfig.defaultArtist;
StreamingPlaybackStatus get playbackStatus => _playbackStatus; StreamingPlaybackStatus get playbackStatus => _playbackStatus;
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing; bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
@@ -145,9 +146,9 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
return; return;
} }
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title; final title = item.title.trim().isEmpty ? AppConfig.defaultTitle : item.title;
final subtitle = final subtitle =
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : item.artist!.trim(); (item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : item.artist!.trim();
if (title == _currentTitle && subtitle == _currentSubtitle) { if (title == _currentTitle && subtitle == _currentSubtitle) {
return; return;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

+3
View File
@@ -0,0 +1,3 @@
# build_whitelabel.py dependencies
Pillow>=9.0.0
cairosvg>=2.7.0
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB