Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ea55f359 | ||
|
|
f069fcdbfb | ||
|
|
9706cc9a6d | ||
|
|
ea1bc88a70 | ||
|
|
b8d624343b | ||
|
|
4c4113f66f | ||
|
|
5f3801e65f | ||
|
|
a2b5da3669 | ||
|
|
1c7e27b83d | ||
|
|
df2338e5e9 | ||
|
|
85740b399b | ||
|
|
5ea7442c7c | ||
|
|
533a3d74f3 | ||
|
|
065d3c8e47 | ||
|
|
83dd1a28b8 | ||
|
|
52f5ec577b | ||
|
|
5827d129f6 | ||
|
|
da0779cafb | ||
|
|
d32720a2e0 | ||
|
|
cbdc3f7710 | ||
|
|
283a918b81 | ||
|
|
f7979a70e6 | ||
|
|
c9ef69c8b5 | ||
|
|
3bb2630e1a | ||
|
|
65e5db2d35 | ||
|
|
318bb095a1 | ||
|
|
f1fd59c199 | ||
|
|
0c6442e260 | ||
|
|
a5e1e36ebf | ||
|
|
de0a243388 | ||
|
|
6893758a16 | ||
|
|
42a34712a2 | ||
|
|
e3656a64a8 | ||
|
|
15fc7e3d6f | ||
|
|
d7d094d70a | ||
|
|
deaa4db158 | ||
|
|
af7eb3b7f8 |
@@ -4,7 +4,7 @@ ENV PATH="$PATH:/opt/flutter/bin"
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget curl unzip git xz-utils zip libglu1-mesa openjdk-17-jdk\
|
||||
wget curl unzip git xz-utils zip cmake clang pkg-config mesa-utils python3 pip pipx lld libgtk-3-dev ninja-build libglu1-mesa openjdk-17-jdk\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Become non-root
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"name": "kryz-go",
|
||||
"mounts": [
|
||||
// "source=/dev/dri,target=/dev/dri,type=bind"
|
||||
],
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
@@ -11,5 +14,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "flutter doctor -v"
|
||||
"postCreateCommand": "flutter pub get && python3 -m venv .venv && . .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && curl -fsSL https://claude.ai/install.sh | bash",
|
||||
"postStartCommand": ".venv/bin/activate",
|
||||
"containerEnv": {
|
||||
"ANTHROPIC_BASE_URL": "http://192.168.1.108:1234",
|
||||
"ANTHROPIC_API_KEY": "lmstudio",
|
||||
"ANTHROPIC_BASE_MODEL": "qwen/qwen3.6-27b"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
# Generated whitelabel config — rebuilt by build_whitelabel.py
|
||||
lib/generated/
|
||||
|
||||
# ---> Dart
|
||||
# See https://www.dartlang.org/guides/libraries/private-files
|
||||
|
||||
@@ -90,3 +93,8 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Python venv
|
||||
.venv/
|
||||
|
||||
# Whitelabel app theming
|
||||
theme.json
|
||||
@@ -5,6 +5,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.google.android.gms:play-services-cast-framework:22.1.0")
|
||||
implementation("androidx.mediarouter:mediarouter:1.7.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.media:media:1.7.0")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.kryz_go_flutter"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||
|
||||
<application
|
||||
android:label="KRYZ Go!"
|
||||
android:label="KMountain Flower Radio"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
@@ -37,6 +38,14 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.example.kryz_go_flutter.CastOptionsProvider" />
|
||||
|
||||
<!-- Declares this app as an Android Auto media app. -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
@@ -44,6 +53,20 @@
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="Instantiatable">
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Dedicated MediaBrowserService for Android Auto. Android Auto connects here
|
||||
to browse content and send transport controls. Styled with KryzAutoTheme
|
||||
so Auto accents its media UI with the KRYZ brand orange (#E36A18).
|
||||
-->
|
||||
<service
|
||||
android:name="com.example.kryz_go_flutter.KryzAutoMediaBrowserService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:theme="@style/KryzAutoTheme"
|
||||
tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.kryz_go_flutter
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.cast.LaunchOptions
|
||||
import com.google.android.gms.cast.framework.CastOptions
|
||||
import com.google.android.gms.cast.framework.OptionsProvider
|
||||
import com.google.android.gms.cast.framework.SessionProvider
|
||||
|
||||
class CastOptionsProvider : OptionsProvider {
|
||||
override fun getCastOptions(context: Context): CastOptions {
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(context.getString(R.string.cast_receiver_app_id))
|
||||
.setLaunchOptions(
|
||||
LaunchOptions.Builder()
|
||||
.setAndroidReceiverCompatible(true)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getAdditionalSessionProviders(context: Context): MutableList<SessionProvider>? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.example.kryz_go_flutter
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import com.ryanheise.audioservice.AudioService
|
||||
|
||||
/**
|
||||
* MediaBrowserService for Android Auto.
|
||||
*
|
||||
* Android Auto connects to this service to browse content and send transport
|
||||
* controls. Playback execution is delegated to Flutter's audio_service session.
|
||||
*
|
||||
* Brand color (#E36A18) is declared on this service via KryzAutoTheme in styles.xml
|
||||
* so Android Auto accents its media UI with the KRYZ orange.
|
||||
*/
|
||||
class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KryzAutoService"
|
||||
private const val MEDIA_ID_ROOT = "kryz_root"
|
||||
private const val MEDIA_ID_LIVE = "kryz_live_stream"
|
||||
private const val STREAM_URL = "https://libretime.westerntechnologies.duckdns.org/main"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private var audioServiceBrowser: MediaBrowserCompat? = null
|
||||
private var audioServiceController: MediaControllerCompat? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var pendingPlayRequest: Boolean = false
|
||||
private var pendingStopRequest: Boolean = false
|
||||
private var reconnectScheduled: Boolean = false
|
||||
|
||||
/**
|
||||
* Album-art bitmap loaded from the drawable resource.
|
||||
* Embedded directly into MediaMetadata so Android Auto cannot serve a stale URI cache.
|
||||
*/
|
||||
private val albumArtBitmap: Bitmap by lazy {
|
||||
BitmapFactory.decodeResource(resources, R.drawable.kryz_auto_art)
|
||||
}
|
||||
|
||||
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
|
||||
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
|
||||
applyPlaybackStateFromFlutter(state)
|
||||
}
|
||||
|
||||
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
|
||||
applyNowPlayingMetadataFromFlutter(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
private val browserConnectionCallback = object : MediaBrowserCompat.ConnectionCallback() {
|
||||
override fun onConnected() {
|
||||
val browser = audioServiceBrowser ?: return
|
||||
try {
|
||||
val controller = MediaControllerCompat(this@KryzAutoMediaBrowserService, browser.sessionToken)
|
||||
audioServiceController?.unregisterCallback(mediaControllerCallback)
|
||||
audioServiceController = controller
|
||||
controller.registerCallback(mediaControllerCallback)
|
||||
applyPlaybackStateFromFlutter(controller.playbackState)
|
||||
applyNowPlayingMetadataFromFlutter(controller.metadata)
|
||||
consumePendingTransportCommands()
|
||||
Log.d(TAG, "Connected to Flutter audio_service session")
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Unable to bind media controller: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionSuspended() {
|
||||
Log.w(TAG, "audio_service browser connection suspended")
|
||||
handleControllerDisconnected()
|
||||
scheduleBrowserReconnect()
|
||||
}
|
||||
|
||||
override fun onConnectionFailed() {
|
||||
Log.e(TAG, "audio_service browser connection failed")
|
||||
handleControllerDisconnected()
|
||||
scheduleBrowserReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() {
|
||||
Log.d(TAG, "onPlay()")
|
||||
triggerPlay()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Log.d(TAG, "onStop()")
|
||||
dispatchStopCommand()
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
Log.d(TAG, "onPlayFromMediaId: $mediaId")
|
||||
if (mediaId == MEDIA_ID_LIVE) triggerPlay()
|
||||
}
|
||||
|
||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||
Log.d(TAG, "onPlayFromSearch: $query")
|
||||
triggerPlay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerPlay() {
|
||||
dispatchPlayCommand()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an android.resource Uri for the Auto album-art drawable.
|
||||
* Uses R.drawable.kryz_auto_art so the resource shrinker sees the reference
|
||||
* and keeps the image in release APKs.
|
||||
*/
|
||||
private fun getAutoArtUri(): Uri {
|
||||
return Uri.Builder()
|
||||
.scheme("android.resource")
|
||||
.authority(packageName)
|
||||
.appendPath(resources.getResourceTypeName(R.drawable.kryz_auto_art))
|
||||
.appendPath(resources.getResourceEntryName(R.drawable.kryz_auto_art))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "onCreate()")
|
||||
createNotificationChannel()
|
||||
|
||||
mediaSession = MediaSessionCompat(this, TAG).apply {
|
||||
setCallback(mediaSessionCallback)
|
||||
@Suppress("DEPRECATION")
|
||||
setFlags(
|
||||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
|
||||
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
|
||||
)
|
||||
setPlaybackState(
|
||||
PlaybackStateCompat.Builder()
|
||||
.setActions(
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_STOP or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||
)
|
||||
.setState(PlaybackStateCompat.STATE_STOPPED, 0, 1f)
|
||||
.build()
|
||||
)
|
||||
setMetadata(buildLiveStreamMetadata())
|
||||
isActive = true
|
||||
}
|
||||
sessionToken = mediaSession.sessionToken
|
||||
Log.d(TAG, "MediaSession created, sessionToken set")
|
||||
connectToFlutterAudioService()
|
||||
}
|
||||
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot {
|
||||
Log.d(TAG, "onGetRoot() from $clientPackageName")
|
||||
return BrowserRoot(MEDIA_ID_ROOT, null)
|
||||
}
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<List<MediaItem>>
|
||||
) {
|
||||
Log.d(TAG, "onLoadChildren($parentId)")
|
||||
if (parentId == MEDIA_ID_ROOT) {
|
||||
val iconUri = getAutoArtUri()
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(MEDIA_ID_LIVE)
|
||||
.setTitle("KMTN Live Stream")
|
||||
.setSubtitle("KMountain Radio")
|
||||
.setIconUri(iconUri)
|
||||
.setMediaUri(Uri.parse(STREAM_URL))
|
||||
.build()
|
||||
result.sendResult(listOf(MediaItem(description, MediaItem.FLAG_PLAYABLE)))
|
||||
} else {
|
||||
result.sendResult(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "onDestroy()")
|
||||
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||
disconnectFromFlutterAudioService()
|
||||
if (::mediaSession.isInitialized) mediaSession.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.d(TAG, "onTaskRemoved()")
|
||||
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
Log.d(TAG, "onUnbind()")
|
||||
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
private fun stopFlutterPlaybackForHeadUnitDisconnect() {
|
||||
pendingPlayRequest = false
|
||||
pendingStopRequest = false
|
||||
|
||||
try {
|
||||
audioServiceController?.transportControls?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Unable to stop Flutter audio on head unit disconnect: ${error.message}")
|
||||
}
|
||||
|
||||
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||
}
|
||||
|
||||
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
|
||||
if (!::mediaSession.isInitialized) return
|
||||
val state = when {
|
||||
isPlaying -> PlaybackStateCompat.STATE_PLAYING
|
||||
isBuffering -> PlaybackStateCompat.STATE_BUFFERING
|
||||
else -> PlaybackStateCompat.STATE_STOPPED
|
||||
}
|
||||
val actions = if (isPlaying || isBuffering) {
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
} else {
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||
}
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackStateCompat.Builder()
|
||||
.setActions(actions)
|
||||
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
|
||||
.build()
|
||||
)
|
||||
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = isPlaying, isBuffering = isBuffering))
|
||||
Log.d(TAG, "applyPlaybackState: playing=$isPlaying buffering=$isBuffering")
|
||||
}
|
||||
|
||||
private fun dispatchPlayCommand() {
|
||||
val controller = audioServiceController
|
||||
if (controller != null) {
|
||||
pendingPlayRequest = false
|
||||
pendingStopRequest = false
|
||||
applyPlaybackState(isPlaying = false, isBuffering = true)
|
||||
controller.transportControls.play()
|
||||
return
|
||||
}
|
||||
|
||||
pendingStopRequest = false
|
||||
pendingPlayRequest = true
|
||||
applyPlaybackState(isPlaying = false, isBuffering = true)
|
||||
connectToFlutterAudioService()
|
||||
}
|
||||
|
||||
private fun dispatchStopCommand() {
|
||||
val controller = audioServiceController
|
||||
if (controller != null) {
|
||||
pendingPlayRequest = false
|
||||
pendingStopRequest = false
|
||||
controller.transportControls.stop()
|
||||
return
|
||||
}
|
||||
|
||||
pendingPlayRequest = false
|
||||
pendingStopRequest = true
|
||||
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||
connectToFlutterAudioService()
|
||||
}
|
||||
|
||||
private fun consumePendingTransportCommands() {
|
||||
val controller = audioServiceController ?: return
|
||||
|
||||
if (pendingStopRequest) {
|
||||
pendingStopRequest = false
|
||||
controller.transportControls.stop()
|
||||
}
|
||||
|
||||
if (pendingPlayRequest) {
|
||||
pendingPlayRequest = false
|
||||
controller.transportControls.play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPlaybackStateFromFlutter(state: PlaybackStateCompat?) {
|
||||
if (state == null) {
|
||||
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||
return
|
||||
}
|
||||
|
||||
val isPlaying = state.state == PlaybackStateCompat.STATE_PLAYING
|
||||
val isBuffering = state.state == PlaybackStateCompat.STATE_BUFFERING ||
|
||||
state.state == PlaybackStateCompat.STATE_CONNECTING
|
||||
|
||||
applyPlaybackState(isPlaying = isPlaying, isBuffering = isBuffering)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"KMountain Flower Radio Auto Playback",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "KMountain Flower Radio Android Auto playback"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(isPlaying: Boolean, isBuffering: Boolean): Notification {
|
||||
val statusText = when {
|
||||
isPlaying -> "Playing from Flutter audio handler"
|
||||
isBuffering -> "Connecting via Flutter audio handler..."
|
||||
else -> "Stopped"
|
||||
}
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("KMountain Radio")
|
||||
.setContentText(statusText)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(isPlaying || isBuffering)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun applyNowPlayingMetadata(
|
||||
title: String?,
|
||||
subtitle: String?,
|
||||
album: String?,
|
||||
mediaUri: String?,
|
||||
) {
|
||||
if (!::mediaSession.isInitialized) return
|
||||
|
||||
val iconUri = getAutoArtUri()
|
||||
mediaSession.setMetadata(
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KMTN Live Stream")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KMountain Radio")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
|
||||
if (metadata == null) {
|
||||
applyNowPlayingMetadata(
|
||||
title = "KMTN Live Stream",
|
||||
subtitle = "KMountain Radio",
|
||||
album = "Live",
|
||||
mediaUri = STREAM_URL,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
applyNowPlayingMetadata(
|
||||
title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE),
|
||||
subtitle = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST),
|
||||
album = metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM),
|
||||
mediaUri = metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI),
|
||||
)
|
||||
}
|
||||
|
||||
private fun connectToFlutterAudioService() {
|
||||
if (audioServiceController != null) {
|
||||
return
|
||||
}
|
||||
|
||||
val existing = audioServiceBrowser
|
||||
if (existing?.isConnected == true) {
|
||||
return
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
try {
|
||||
existing.connect()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed reconnect attempt: ${error.message}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val browser = MediaBrowserCompat(
|
||||
this,
|
||||
ComponentName(this, AudioService::class.java),
|
||||
browserConnectionCallback,
|
||||
null,
|
||||
)
|
||||
|
||||
audioServiceBrowser = browser
|
||||
browser.connect()
|
||||
}
|
||||
|
||||
private fun disconnectFromFlutterAudioService() {
|
||||
reconnectScheduled = false
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
|
||||
handleControllerDisconnected()
|
||||
|
||||
audioServiceBrowser?.let { browser ->
|
||||
if (browser.isConnected) {
|
||||
browser.disconnect()
|
||||
}
|
||||
}
|
||||
audioServiceBrowser = null
|
||||
}
|
||||
|
||||
private fun handleControllerDisconnected() {
|
||||
audioServiceController?.unregisterCallback(mediaControllerCallback)
|
||||
audioServiceController = null
|
||||
}
|
||||
|
||||
private fun scheduleBrowserReconnect() {
|
||||
if (reconnectScheduled) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectScheduled = true
|
||||
mainHandler.postDelayed(
|
||||
{
|
||||
reconnectScheduled = false
|
||||
connectToFlutterAudioService()
|
||||
},
|
||||
1200,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KMTN Live Stream")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KMountain Radio")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, albumArtBitmap)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,623 @@
|
||||
package com.example.kryz_go_flutter
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.mediarouter.app.MediaRouteChooserDialog
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaLoadRequestData
|
||||
import com.google.android.gms.cast.MediaMetadata
|
||||
import com.google.android.gms.cast.MediaStatus
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.gms.cast.framework.SessionManagerListener
|
||||
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
||||
import com.google.android.gms.common.api.CommonStatusCodes
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : AudioServiceActivity()
|
||||
class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||
companion object {
|
||||
private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods"
|
||||
private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events"
|
||||
private const val MAX_PENDING_CAST_AUTOPLAY_RETRIES = 5
|
||||
private const val REMOTE_STATUS_POLL_INTERVAL_MS = 500L
|
||||
}
|
||||
|
||||
private data class PendingCastRequest(
|
||||
val streamUrl: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
)
|
||||
|
||||
private var castContext: CastContext? = null
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
private var chooserDialog: MediaRouteChooserDialog? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var activeRemoteMediaClient: RemoteMediaClient? = null
|
||||
private var pendingCastRequest: PendingCastRequest? = null
|
||||
private var pendingCastRequestInFlight: Boolean = false
|
||||
private var pendingCastAutoplayRetryCount: Int = 0
|
||||
private var remoteCastStatusPollingRunnable: Runnable? = null
|
||||
|
||||
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
|
||||
"status" to "idle",
|
||||
"playbackStatus" to "stopped",
|
||||
"platform" to "android",
|
||||
)
|
||||
|
||||
private val remoteMediaClientCallback = object : RemoteMediaClient.Callback() {
|
||||
override fun onStatusUpdated() {
|
||||
emitRemotePlaybackStatus()
|
||||
}
|
||||
|
||||
override fun onMetadataUpdated() {
|
||||
emitRemotePlaybackStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private val castSessionManagerListener = object : SessionManagerListener<CastSession> {
|
||||
override fun onSessionStarting(session: CastSession) {
|
||||
emitStatus("connecting")
|
||||
}
|
||||
|
||||
override fun onSessionStarted(session: CastSession, sessionId: String) {
|
||||
attachRemoteClientCallback(session)
|
||||
startRemoteStatusPolling()
|
||||
emitStatus("connected", session.castDevice?.friendlyName)
|
||||
startPendingCastRequest(session)
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(session: CastSession, error: Int) {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("error", message = "Cast session failed to start ($error)")
|
||||
}
|
||||
|
||||
override fun onSessionEnding(session: CastSession) {
|
||||
detachRemoteClientCallback()
|
||||
// The Cast framework reports the final state in onSessionEnded.
|
||||
}
|
||||
|
||||
override fun onSessionEnded(session: CastSession, error: Int) {
|
||||
stopRemoteStatusPolling()
|
||||
clearPendingCastRequest()
|
||||
// If app was launched from Android Auto, restart audio service on cast disconnect
|
||||
restartAudioServiceSession()
|
||||
emitStatus("idle")
|
||||
}
|
||||
|
||||
override fun onSessionResuming(session: CastSession, sessionId: String) {
|
||||
emitStatus("connecting")
|
||||
}
|
||||
|
||||
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
|
||||
attachRemoteClientCallback(session)
|
||||
startRemoteStatusPolling()
|
||||
emitStatus("connected", session.castDevice?.friendlyName)
|
||||
startPendingCastRequest(session)
|
||||
}
|
||||
|
||||
override fun onSessionResumeFailed(session: CastSession, error: Int) {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("error", message = "Cast session failed to resume ($error)")
|
||||
}
|
||||
|
||||
override fun onSessionSuspended(session: CastSession, reason: Int) {
|
||||
stopRemoteStatusPolling()
|
||||
detachRemoteClientCallback()
|
||||
emitStatus("idle")
|
||||
}
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||
.apply { setMethodCallHandler(this@MainActivity) }
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
||||
.setStreamHandler(this)
|
||||
|
||||
initializeCastContext()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getStatus" -> result.success(statusPayload)
|
||||
"showDevicePicker" -> {
|
||||
showDevicePicker(call)
|
||||
result.success(null)
|
||||
}
|
||||
"startCastingLiveStream" -> {
|
||||
startCastingLiveStream(call, result)
|
||||
}
|
||||
"disconnect" -> {
|
||||
clearPendingCastRequest()
|
||||
castContext?.sessionManager?.endCurrentSession(true)
|
||||
emitStatus("idle")
|
||||
result.success(null)
|
||||
}
|
||||
"stopCastingPlayback" -> {
|
||||
stopCastingPlayback(result)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
eventSink = events
|
||||
events.success(statusPayload)
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
chooserDialog?.dismiss()
|
||||
chooserDialog = null
|
||||
|
||||
castContext?.sessionManager?.removeSessionManagerListener(
|
||||
castSessionManagerListener,
|
||||
CastSession::class.java,
|
||||
)
|
||||
detachRemoteClientCallback()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun initializeCastContext() {
|
||||
try {
|
||||
castContext = CastContext.getSharedInstance(this)
|
||||
castContext?.sessionManager?.addSessionManagerListener(
|
||||
castSessionManagerListener,
|
||||
CastSession::class.java,
|
||||
)
|
||||
|
||||
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||
if (currentSession != null && currentSession.isConnected) {
|
||||
attachRemoteClientCallback(currentSession)
|
||||
emitStatus("connected", currentSession.castDevice?.friendlyName)
|
||||
} else {
|
||||
emitStatus("idle")
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
emitStatus("unavailable", message = error.message ?: "Cast unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDevicePicker(call: MethodCall) {
|
||||
if (castContext == null) {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("unavailable", message = "Cast services unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
setPendingCastRequest(parsePendingCastRequest(call.arguments))
|
||||
|
||||
val receiverAppId = getString(R.string.cast_receiver_app_id)
|
||||
val routeSelector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverAppId))
|
||||
.build()
|
||||
|
||||
try {
|
||||
val themedContext = ContextThemeWrapper(this, androidx.appcompat.R.style.Theme_AppCompat_DayNight)
|
||||
|
||||
chooserDialog?.dismiss()
|
||||
chooserDialog = MediaRouteChooserDialog(themedContext)
|
||||
chooserDialog?.routeSelector = routeSelector
|
||||
chooserDialog?.setCanceledOnTouchOutside(true)
|
||||
chooserDialog?.setOnDismissListener {
|
||||
handlePickerDismissed()
|
||||
}
|
||||
chooserDialog?.show()
|
||||
|
||||
emitStatus("connecting")
|
||||
} catch (error: IllegalArgumentException) {
|
||||
emitStatus("error", message = "Unable to open cast picker: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePickerDismissed() {
|
||||
chooserDialog = null
|
||||
|
||||
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||
val currentStatus = statusPayload["status"] as? String
|
||||
|
||||
if (currentSession == null && currentStatus == "connecting") {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("idle")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCastingLiveStream(call: MethodCall, result: MethodChannel.Result) {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
if (arguments == null) {
|
||||
result.error("invalid_arguments", "Missing cast media payload", null)
|
||||
return
|
||||
}
|
||||
|
||||
val streamUrl = arguments["streamUrl"] as? String
|
||||
val title = "KRYZ 98.5 LPFM Mariposa Community Radio"
|
||||
val subtitle = "Live Stream"
|
||||
|
||||
if (streamUrl.isNullOrBlank()) {
|
||||
result.error("invalid_stream_url", "streamUrl is required", null)
|
||||
return
|
||||
}
|
||||
|
||||
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||
if (currentSession == null || !currentSession.isConnected) {
|
||||
result.error("no_cast_session", "Connect to a cast device first", null)
|
||||
return
|
||||
}
|
||||
|
||||
val loadRequestData = buildLoadRequestData(
|
||||
streamUrl = streamUrl,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
loadMediaWithRetry(
|
||||
session = currentSession,
|
||||
loadRequestData = loadRequestData,
|
||||
attempt = 1,
|
||||
result = result,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPendingCastRequest(request: PendingCastRequest?) {
|
||||
pendingCastRequest = request
|
||||
pendingCastRequestInFlight = false
|
||||
pendingCastAutoplayRetryCount = 0
|
||||
}
|
||||
|
||||
private fun clearPendingCastRequest() {
|
||||
setPendingCastRequest(null)
|
||||
}
|
||||
|
||||
private fun parsePendingCastRequest(arguments: Any?): PendingCastRequest? {
|
||||
val map = arguments as? Map<*, *> ?: return null
|
||||
val shouldAutoplay = map["autoplay"] as? Boolean ?: false
|
||||
if (!shouldAutoplay) {
|
||||
return null
|
||||
}
|
||||
|
||||
val streamUrl = map["streamUrl"] as? String
|
||||
if (streamUrl.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return PendingCastRequest(
|
||||
streamUrl = streamUrl,
|
||||
title = map["title"] as? String ?: "Live Stream",
|
||||
subtitle = map["subtitle"] as? String ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
private fun startPendingCastRequest(session: CastSession) {
|
||||
val request = pendingCastRequest ?: return
|
||||
if (pendingCastRequestInFlight) {
|
||||
return
|
||||
}
|
||||
pendingCastRequestInFlight = true
|
||||
|
||||
loadMediaWithRetry(
|
||||
session = session,
|
||||
loadRequestData = buildLoadRequestData(
|
||||
streamUrl = request.streamUrl,
|
||||
title = request.title,
|
||||
subtitle = request.subtitle,
|
||||
),
|
||||
attempt = 1,
|
||||
result = object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
clearPendingCastRequest()
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
pendingCastRequestInFlight = false
|
||||
schedulePendingCastRequestRetry(errorMessage ?: errorCode)
|
||||
}
|
||||
|
||||
override fun notImplemented() {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun schedulePendingCastRequestRetry(message: String) {
|
||||
val request = pendingCastRequest
|
||||
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||
if (request == null || currentSession == null || !currentSession.isConnected) {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("error", message = message)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingCastAutoplayRetryCount >= MAX_PENDING_CAST_AUTOPLAY_RETRIES) {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("error", message = message)
|
||||
return
|
||||
}
|
||||
|
||||
pendingCastAutoplayRetryCount += 1
|
||||
mainHandler.postDelayed(
|
||||
{
|
||||
val resumedSession = castContext?.sessionManager?.currentCastSession
|
||||
if (resumedSession != null && resumedSession.isConnected) {
|
||||
startPendingCastRequest(resumedSession)
|
||||
} else {
|
||||
clearPendingCastRequest()
|
||||
}
|
||||
},
|
||||
1000,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildLoadRequestData(
|
||||
streamUrl: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
): MediaLoadRequestData {
|
||||
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
|
||||
putString(MediaMetadata.KEY_TITLE, title)
|
||||
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
|
||||
}
|
||||
|
||||
val mediaInfo = MediaInfo.Builder(streamUrl)
|
||||
.setStreamType(MediaInfo.STREAM_TYPE_LIVE)
|
||||
.setContentType("audio/mpeg")
|
||||
.setMetadata(metadata)
|
||||
.build()
|
||||
|
||||
return MediaLoadRequestData.Builder()
|
||||
.setMediaInfo(mediaInfo)
|
||||
.setAutoplay(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun loadMediaWithRetry(
|
||||
session: CastSession,
|
||||
loadRequestData: MediaLoadRequestData,
|
||||
attempt: Int,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val remoteMediaClient = session.remoteMediaClient
|
||||
if (remoteMediaClient == null) {
|
||||
if (attempt >= 8) {
|
||||
val message = "Cast client not ready after session connection"
|
||||
emitStatus("error", message = message)
|
||||
result.error("no_remote_media_client", message, null)
|
||||
return
|
||||
}
|
||||
|
||||
emitStatus("connecting", session.castDevice?.friendlyName, playbackStatus = "buffering")
|
||||
mainHandler.postDelayed(
|
||||
{
|
||||
loadMediaWithRetry(
|
||||
session = session,
|
||||
loadRequestData = loadRequestData,
|
||||
attempt = attempt + 1,
|
||||
result = result,
|
||||
)
|
||||
},
|
||||
350,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
|
||||
if (mediaChannelResult.status.isSuccess) {
|
||||
// Stop local playback immediately when remote load succeeds
|
||||
// This MUST be done in Kotlin (not Flutter) because the audio service
|
||||
// session may be active from Android Auto and won't respond to Flutter calls
|
||||
stopAudioServiceSession()
|
||||
|
||||
emitStatus("connected", session.castDevice?.friendlyName)
|
||||
// Force initial remote status update and continue polling
|
||||
emitRemotePlaybackStatus()
|
||||
result.success(null)
|
||||
return@setResultCallback
|
||||
}
|
||||
|
||||
val statusCode = mediaChannelResult.status.statusCode
|
||||
val statusText = CommonStatusCodes.getStatusCodeString(statusCode)
|
||||
val message = "Cast load failed: $statusText ($statusCode)"
|
||||
emitStatus("error", message = message)
|
||||
result.error("cast_load_failed", message, null)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
result.error("cast_load_failed", error.message, JSONObject.NULL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCastingPlayback(result: MethodChannel.Result) {
|
||||
val session = castContext?.sessionManager?.currentCastSession
|
||||
if (session == null || !session.isConnected) {
|
||||
result.error("no_cast_session", "Connect to a cast device first", null)
|
||||
return
|
||||
}
|
||||
|
||||
val remoteMediaClient = session.remoteMediaClient
|
||||
if (remoteMediaClient == null) {
|
||||
result.error("no_remote_media_client", "Unable to control cast media", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
remoteMediaClient.stop().setResultCallback { stopResult ->
|
||||
if (stopResult.status.isSuccess) {
|
||||
emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped")
|
||||
result.success(null)
|
||||
return@setResultCallback
|
||||
}
|
||||
|
||||
val code = stopResult.status.statusCode
|
||||
val statusText = CommonStatusCodes.getStatusCodeString(code)
|
||||
result.error("cast_stop_failed", "Cast stop failed: $statusText ($code)", null)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
result.error("cast_stop_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachRemoteClientCallback(session: CastSession) {
|
||||
val remoteMediaClient = session.remoteMediaClient
|
||||
if (remoteMediaClient == null) {
|
||||
detachRemoteClientCallback()
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRemoteMediaClient === remoteMediaClient) {
|
||||
emitRemotePlaybackStatus()
|
||||
return
|
||||
}
|
||||
|
||||
detachRemoteClientCallback()
|
||||
activeRemoteMediaClient = remoteMediaClient
|
||||
remoteMediaClient.registerCallback(remoteMediaClientCallback)
|
||||
startRemoteStatusPolling()
|
||||
if (pendingCastRequest != null) {
|
||||
startPendingCastRequest(session)
|
||||
}
|
||||
emitRemotePlaybackStatus()
|
||||
}
|
||||
|
||||
private fun detachRemoteClientCallback() {
|
||||
stopRemoteStatusPolling()
|
||||
activeRemoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
|
||||
activeRemoteMediaClient = null
|
||||
}
|
||||
|
||||
private fun startRemoteStatusPolling() {
|
||||
if (remoteCastStatusPollingRunnable != null) {
|
||||
return
|
||||
}
|
||||
|
||||
val pollRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
try {
|
||||
if (activeRemoteMediaClient != null) {
|
||||
emitRemotePlaybackStatus()
|
||||
// Use mainHandler to keep polling alive across lifecycle changes
|
||||
mainHandler.postDelayed(this, REMOTE_STATUS_POLL_INTERVAL_MS)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Safely handle any errors to keep polling thread alive
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remoteCastStatusPollingRunnable = pollRunnable
|
||||
mainHandler.post(pollRunnable)
|
||||
}
|
||||
|
||||
private fun stopRemoteStatusPolling() {
|
||||
val runnable = remoteCastStatusPollingRunnable ?: return
|
||||
mainHandler.removeCallbacks(runnable)
|
||||
remoteCastStatusPollingRunnable = null
|
||||
}
|
||||
|
||||
private fun emitRemotePlaybackStatus() {
|
||||
val remoteMediaClient = activeRemoteMediaClient
|
||||
if (remoteMediaClient == null) return
|
||||
|
||||
val mediaStatus = remoteMediaClient.mediaStatus
|
||||
val playbackStatus = when (mediaStatus?.playerState) {
|
||||
MediaStatus.PLAYER_STATE_PLAYING -> "playing"
|
||||
MediaStatus.PLAYER_STATE_BUFFERING,
|
||||
MediaStatus.PLAYER_STATE_LOADING -> "buffering"
|
||||
MediaStatus.PLAYER_STATE_PAUSED -> "stopped"
|
||||
MediaStatus.PLAYER_STATE_IDLE,
|
||||
MediaStatus.PLAYER_STATE_UNKNOWN,
|
||||
null -> "stopped"
|
||||
else -> "stopped"
|
||||
}
|
||||
|
||||
val currentSession = castContext?.sessionManager?.currentCastSession
|
||||
if (currentSession != null && currentSession.isConnected) {
|
||||
emitStatus("connected", currentSession.castDevice?.friendlyName, playbackStatus = playbackStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalAudioPlayback() {
|
||||
try {
|
||||
// Tell Flutter side to stop local audio via method channel
|
||||
// This prevents audio from playing on both mobile and cast device
|
||||
methodChannel?.invokeMethod("stopLocalAudio", null)
|
||||
} catch (e: Exception) {
|
||||
// Silently handle if Flutter side doesn't implement this method
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAudioServiceSession() {
|
||||
try {
|
||||
// Stop the audio service session directly (works even if app is in background via Android Auto)
|
||||
// This ensures the shared audio handler doesn't keep playing when cast device takes over
|
||||
// Access the audio service through the session token if available
|
||||
methodChannel?.invokeMethod("stopAudioService", null)
|
||||
} catch (e: Exception) {
|
||||
// Audio service might not be available, silently continue
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartAudioServiceSession() {
|
||||
try {
|
||||
// When cast session ends, allow the audio service to resume management
|
||||
// This re-enables local playback for the Flutter side
|
||||
methodChannel?.invokeMethod("resumeLocalAudio", null)
|
||||
} catch (e: Exception) {
|
||||
// If Flutter handler isn't ready, that's ok - audio service will be managed externally
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitStatus(
|
||||
status: String,
|
||||
deviceName: String? = null,
|
||||
message: String? = null,
|
||||
playbackStatus: String? = null,
|
||||
) {
|
||||
val resolvedPlaybackStatus = playbackStatus
|
||||
?: statusPayload["playbackStatus"] as? String
|
||||
?: "stopped"
|
||||
|
||||
if (status == "idle" || status == "unavailable") {
|
||||
statusPayload["playbackStatus"] = "stopped"
|
||||
}
|
||||
|
||||
val payload = mutableMapOf<String, Any?>(
|
||||
"status" to status,
|
||||
"playbackStatus" to if (status == "idle" || status == "unavailable") "stopped" else resolvedPlaybackStatus,
|
||||
"platform" to "android",
|
||||
)
|
||||
|
||||
if (!deviceName.isNullOrBlank()) {
|
||||
payload["deviceName"] = deviceName
|
||||
}
|
||||
|
||||
if (!message.isNullOrBlank()) {
|
||||
payload["message"] = message
|
||||
}
|
||||
|
||||
statusPayload = payload
|
||||
eventSink?.success(payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 31 KiB |
@@ -12,7 +12,8 @@
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Brand colors exposed to Android Auto. Android Auto's media UI reads colorPrimary
|
||||
from the app theme to accent its browsing and now-playing screens.
|
||||
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
|
||||
-->
|
||||
<resources>
|
||||
<color name="kryz_primary">#1a3a5c</color>
|
||||
<color name="kryz_primary_dark">#0e2440</color>
|
||||
<color name="kryz_accent">#e87a2e</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cast_receiver_app_id">CC1AD845</string>
|
||||
</resources>
|
||||
@@ -12,7 +12,18 @@
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Theme used by KryzAutoMediaBrowserService. Android Auto reads colorPrimary
|
||||
from the service's theme to accent its media browsing UI with the KRYZ brand orange.
|
||||
-->
|
||||
<style name="KryzAutoTheme" parent="Theme.AppCompat">
|
||||
<item name="colorPrimary">@color/kryz_primary</item>
|
||||
<item name="colorPrimaryDark">@color/kryz_primary_dark</item>
|
||||
<item name="colorAccent">@color/kryz_accent</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media"/>
|
||||
</automotiveApp>
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 422 KiB |
@@ -1,171 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1000"
|
||||
height="1000"
|
||||
viewBox="0 0 1000 1000"
|
||||
version="1.1"
|
||||
id="svg7"
|
||||
sodipodi:docname="icon_2.svg"
|
||||
inkscape:version="1.4.3 (fcd0343856, 2026-01-01)"
|
||||
inkscape:export-filename="app_icon.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs7" />
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.44427667"
|
||||
inkscape:cx="707.89222"
|
||||
inkscape:cy="284.73248"
|
||||
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="505.82516"
|
||||
id="text8"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan7"
|
||||
x="22.209259"
|
||||
y="505.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="677.90143"
|
||||
id="text10"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan10"
|
||||
x="25.539324"
|
||||
y="677.90143"
|
||||
style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="25.539324"
|
||||
y="813.70941"
|
||||
id="tspan11"
|
||||
style="line-height:0.8;stroke-width:2.80256">Public</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="25.539324"
|
||||
y="949.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"></tspan></text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,585 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Whitelabel build script for KRYZ Go Flutter.
|
||||
|
||||
Reads theme.json and generates:
|
||||
- lib/generated/app_config.dart (compile-time constants)
|
||||
- lib/generated/theme_colors.dart (light + dark AppThemeColors)
|
||||
- assets/icon/app_icon.png (from embedded logo SVG)
|
||||
Patches native platform files (AndroidManifest, Info.plist, automotive_colors.xml, Kotlin).
|
||||
Invokes flutter_launcher_icons and flutter_name_manager.
|
||||
|
||||
Usage:
|
||||
python3 build_whitelabel.py [--theme theme.json] [--no-tools]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def hex_to_rgba(hex_color: str, alpha: float = 1.0) -> str:
|
||||
"""Convert '#RRGGBB' hex to '0xAARRGGBB' Dart Color format."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
assert len(hex_color) == 6, f"Expected 6 hex digits, got {len(hex_color)}"
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
a = int(round(alpha * 255))
|
||||
return f"Color(0x{a:02X}{r:02X}{g:02X}{b:02X})"
|
||||
|
||||
|
||||
def darken(hex_color: str, factor: float) -> str:
|
||||
"""Darken a hex color by multiplying RGB channels by factor (0-1)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
r = int(min(r * factor, 255))
|
||||
g = int(min(g * factor, 255))
|
||||
b = int(min(b * factor, 255))
|
||||
return f"Color(0xFF{r:02X}{g:02X}{b:02X})"
|
||||
|
||||
|
||||
def dart_escape(value: str) -> str:
|
||||
"""Escape a string for use in a Dart const string literal."""
|
||||
return value.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||
|
||||
|
||||
def read_theme(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_app_config(theme: dict) -> str:
|
||||
station = theme["station"]
|
||||
streams = theme["streams"]
|
||||
|
||||
name_primary = station.get("name_primary", "")
|
||||
name_secondary = station.get("name_secondary", "")
|
||||
callsign = station.get("callsign", "")
|
||||
tagline = station.get("tagline", "")
|
||||
frequency = station.get("frequency", "")
|
||||
stream_url = streams.get("url", "")
|
||||
metadata_url = streams.get("metadata_url")
|
||||
|
||||
app_name = f"{name_primary} {name_secondary}".strip()
|
||||
default_title = f"{callsign or name_primary} Live Stream"
|
||||
default_artist = f"{name_primary} Radio"
|
||||
|
||||
metadata_url_line = ""
|
||||
if metadata_url:
|
||||
metadata_url_line = f" static const String metadataUrl = '{dart_escape(metadata_url)}';\n"
|
||||
else:
|
||||
metadata_url_line = " static const String? metadataUrl = null;\n"
|
||||
|
||||
return f'''// GENERATED FILE — DO NOT EDIT.
|
||||
// Run `python3 build_whitelabel.py` to regenerate.
|
||||
|
||||
class AppConfig {{
|
||||
static const String callsign = '{dart_escape(callsign)}';
|
||||
static const String appName = '{dart_escape(app_name)}';
|
||||
static const String tagline = '{dart_escape(tagline)}';
|
||||
static const String frequency = '{dart_escape(frequency)}';
|
||||
static const String streamUrl = '{dart_escape(stream_url)}';
|
||||
{metadata_url_line.rstrip()}
|
||||
static const String defaultTitle = '{dart_escape(default_title)}';
|
||||
static const String defaultArtist = '{dart_escape(default_artist)}';
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme colors generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_theme_colors(theme: dict) -> str:
|
||||
c = theme["colors"]
|
||||
|
||||
# Light mode — mapped to match companion website color usage
|
||||
light_props = [
|
||||
("headingColor", hex_to_rgba(c["primary"])),
|
||||
("accentColor", hex_to_rgba(c["accent"])),
|
||||
("scaffoldGradientStart", hex_to_rgba(c["background"])),
|
||||
("scaffoldGradientEnd", hex_to_rgba(c["light"])),
|
||||
("shellSurface", hex_to_rgba(c["white"], 0.97)),
|
||||
("shellBorder", hex_to_rgba(c["medium"], 0.10)),
|
||||
("mutedText", hex_to_rgba(c["medium"])),
|
||||
("textOnDark", hex_to_rgba(c["white"])),
|
||||
("textOnDarkSecondary", hex_to_rgba(c["light"])),
|
||||
("controlsGradientStart", hex_to_rgba(c["primary"])),
|
||||
("controlsGradientEnd", hex_to_rgba(c["primary_light"])),
|
||||
("controlsShadow", hex_to_rgba(c["black"], 0.14)),
|
||||
("bodyText", hex_to_rgba(c["text"])),
|
||||
("cardSurface", hex_to_rgba(c["white"], 0.08)),
|
||||
("cardBorder", hex_to_rgba(c["white"], 0.12)),
|
||||
("successColor", hex_to_rgba(c["success"])),
|
||||
("dangerColor", hex_to_rgba(c["danger"])),
|
||||
]
|
||||
|
||||
# Dark mode — mapped to match companion website color usage
|
||||
dark_props = [
|
||||
("headingColor", hex_to_rgba(c["primary_light"])),
|
||||
("accentColor", hex_to_rgba(c["accent_light"])),
|
||||
("scaffoldGradientStart", hex_to_rgba(c["black"])),
|
||||
("scaffoldGradientEnd", darken(c["primary_dark"], 0.30)),
|
||||
("shellSurface", hex_to_rgba(c["primary_dark"], 0.85)),
|
||||
("shellBorder", hex_to_rgba(c["medium"], 0.20)),
|
||||
("mutedText", hex_to_rgba(c["medium"])),
|
||||
("textOnDark", hex_to_rgba(c["white"])),
|
||||
("textOnDarkSecondary", hex_to_rgba(c["light"])),
|
||||
("controlsGradientStart", hex_to_rgba(c["primary_dark"])),
|
||||
("controlsGradientEnd", hex_to_rgba(c["primary"])),
|
||||
("controlsShadow", hex_to_rgba(c["black"], 0.27)),
|
||||
("bodyText", hex_to_rgba(c["light"])),
|
||||
("cardSurface", hex_to_rgba(c["white"], 0.08)),
|
||||
("cardBorder", hex_to_rgba(c["white"], 0.12)),
|
||||
("successColor", hex_to_rgba(c["success"])),
|
||||
("dangerColor", hex_to_rgba(c["danger"])),
|
||||
]
|
||||
|
||||
def props_block(props, indent: str = " ") -> str:
|
||||
lines = []
|
||||
for name, color_expr in props:
|
||||
lines.append(f"{indent}{name}: {color_expr},")
|
||||
return "\n".join(lines)
|
||||
|
||||
return f'''// GENERATED FILE — DO NOT EDIT.
|
||||
// Run `python3 build_whitelabel.py` to regenerate.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_theme_business_object.dart';
|
||||
|
||||
class GeneratedThemeColors {{
|
||||
static const AppThemeColors lightColors = AppThemeColors(
|
||||
{props_block(light_props)}
|
||||
);
|
||||
|
||||
static const AppThemeColors darkColors = AppThemeColors(
|
||||
{props_block(dark_props)}
|
||||
);
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icon generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_icon(theme: dict, dry_run: bool = False) -> None:
|
||||
"""Decode embedded logo (JPEG, PNG, or SVG) and write app_icon.png."""
|
||||
embedded = theme.get("images_embedded", {})
|
||||
logo = embedded.get("logo", {})
|
||||
data_b64 = logo.get("data", "")
|
||||
content_type = logo.get("content_type", "")
|
||||
if not data_b64:
|
||||
print(" [icon] No embedded logo data — skipping icon generation")
|
||||
return
|
||||
|
||||
raw_bytes = base64.b64decode(data_b64)
|
||||
icon_path = os.path.join(PROJECT_ROOT, "assets", "icon", "app_icon.png")
|
||||
|
||||
if dry_run:
|
||||
print(f" [icon] Would generate PNG from embedded {content_type or 'image'} at assets/icon/app_icon.png")
|
||||
return
|
||||
|
||||
# --- JPEG / PNG: convert to PNG via Pillow, or write as-is ---
|
||||
if content_type in ("image/jpeg", "image/png"):
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")
|
||||
img = img.resize((1024, 1024), Image.LANCZOS)
|
||||
img.save(icon_path, "PNG")
|
||||
print(f" [icon] Converted {content_type} → PNG (Pillow, 1024×1024)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [icon] Pillow conversion failed: {e} — writing raw bytes")
|
||||
|
||||
# Fallback: write raw bytes directly (flutter_launcher_icons accepts JPEG)
|
||||
if content_type == "image/jpeg":
|
||||
fallback = os.path.join(PROJECT_ROOT, "assets", "icon", "app_icon.jpg")
|
||||
else:
|
||||
fallback = icon_path
|
||||
with open(fallback, "wb") as f:
|
||||
f.write(raw_bytes)
|
||||
print(f" [icon] Wrote embedded {content_type} (no Pillow — using raw bytes)")
|
||||
# If we wrote a JPEG, rename the pubspec image_path is still .png,
|
||||
# so try to at least rename it
|
||||
if fallback != icon_path:
|
||||
os.replace(fallback, icon_path)
|
||||
return
|
||||
|
||||
# --- SVG: render to PNG via cairosvg or rsvg-convert ---
|
||||
try:
|
||||
import cairosvg
|
||||
cairosvg.svg2png(bytestring=raw_bytes, write_to=icon_path, output_width=1024, output_height=1024)
|
||||
print(" [icon] Rendered SVG → PNG (cairosvg)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [icon] cairosvg failed: {e} — trying rsvg-convert")
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
|
||||
tmp.write(raw_bytes)
|
||||
tmp_path = tmp.name
|
||||
subprocess.run(
|
||||
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", icon_path, tmp_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
os.unlink(tmp_path)
|
||||
print(" [icon] Rendered SVG → PNG (rsvg-convert)")
|
||||
except Exception as e:
|
||||
print(f" [icon] WARNING: Could not render icon: {e}")
|
||||
print(" [icon] Install cairosvg (`pip install cairosvg`) or rsvg-convert")
|
||||
|
||||
|
||||
def generate_android_auto_art(theme: dict, dry_run: bool = False) -> None:
|
||||
"""Decode embedded hero_icon (JPEG, PNG, or SVG) and write kryz_auto_art.png."""
|
||||
embedded = theme["images_embedded"]
|
||||
hero_data = embedded["hero_icon"]["data"]
|
||||
hero_content_type = embedded["hero_icon"].get("content_type", "")
|
||||
print(" [android-auto-art] Using 'hero_icon' from images_embedded")
|
||||
|
||||
raw_bytes = base64.b64decode(hero_data)
|
||||
icon_path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "drawable-nodpi", "kryz_auto_art.png")
|
||||
|
||||
if dry_run:
|
||||
print(f" [android-auto-art] Would generate PNG from embedded {hero_content_type or 'image'} at android/app/src/main/res/drawable-nodpi/kryz_auto_art.png")
|
||||
return
|
||||
|
||||
# --- JPEG / PNG: convert to PNG via Pillow, or write as-is ---
|
||||
if hero_content_type in ("image/jpeg", "image/png"):
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(raw_bytes)).convert("RGBA")
|
||||
img = img.resize((1024, 1024), Image.LANCZOS)
|
||||
img.save(icon_path, "PNG")
|
||||
print(f" [android-auto-art] Converted {hero_content_type} → PNG (Pillow, 1024×1024)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [android-auto-art] Pillow conversion failed: {e} — writing raw bytes")
|
||||
|
||||
# Fallback: write raw bytes directly
|
||||
if hero_content_type == "image/jpeg":
|
||||
fallback = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "drawable-nodpi", "kryz_auto_art.jpg")
|
||||
else:
|
||||
fallback = icon_path
|
||||
with open(fallback, "wb") as f:
|
||||
f.write(raw_bytes)
|
||||
print(f" [android-auto-art] Wrote embedded {hero_content_type} (no Pillow — using raw bytes)")
|
||||
if fallback != icon_path:
|
||||
os.replace(fallback, icon_path)
|
||||
return
|
||||
|
||||
# --- SVG: render to PNG via cairosvg or rsvg-convert ---
|
||||
try:
|
||||
import cairosvg
|
||||
cairosvg.svg2png(bytestring=raw_bytes, write_to=icon_path, output_width=1024, output_height=1024)
|
||||
print(" [android-auto-art] Rendered SVG → PNG (cairosvg)")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [android-auto-art] cairosvg failed: {e} — trying rsvg-convert")
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
|
||||
tmp.write(raw_bytes)
|
||||
tmp_path = tmp.name
|
||||
subprocess.run(
|
||||
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", icon_path, tmp_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
os.unlink(tmp_path)
|
||||
print(" [android-auto-art] Rendered SVG → PNG (rsvg-convert)")
|
||||
except Exception as e:
|
||||
print(f" [android-auto-art] WARNING: Could not render icon: {e}")
|
||||
print(" [android-auto-art] Install cairosvg (`pip install cairosvg`) or rsvg-convert")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native file patching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_android_manifest(theme: dict, dry_run: bool = False) -> None:
|
||||
path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "AndroidManifest.xml")
|
||||
if not os.path.exists(path):
|
||||
print(" [android] AndroidManifest.xml not found — skipping")
|
||||
return
|
||||
|
||||
station = theme["station"]
|
||||
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
new_content = re.sub(
|
||||
r'android:label="[^"]*"',
|
||||
f'android:label="{app_name}"',
|
||||
content,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print(f" [android] Would patch AndroidManifest.xml label → '{app_name}'")
|
||||
return
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
print(f" [android] Patched AndroidManifest.xml label → '{app_name}'")
|
||||
|
||||
|
||||
def patch_info_plist(theme: dict, dry_run: bool = False) -> None:
|
||||
path = os.path.join(PROJECT_ROOT, "ios", "Runner", "Info.plist")
|
||||
if not os.path.exists(path):
|
||||
print(" [ios] Info.plist not found — skipping")
|
||||
return
|
||||
|
||||
station = theme["station"]
|
||||
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace CFBundleDisplayName value
|
||||
content = re.sub(
|
||||
r'(<key>CFBundleDisplayName</key>\s*<string>)[^<]*(</string>)',
|
||||
rf'\1{app_name}\2',
|
||||
content,
|
||||
)
|
||||
# Replace CFBundleName value
|
||||
content = re.sub(
|
||||
r'(<key>CFBundleName</key>\s*<string>)[^<]*(</string>)',
|
||||
rf'\1{app_name}\2',
|
||||
content,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print(f" [ios] Would patch Info.plist CFBundleDisplayName/CFBundleName → '{app_name}'")
|
||||
return
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f" [ios] Patched Info.plist → '{app_name}'")
|
||||
|
||||
|
||||
def patch_automotive_colors(theme: dict, dry_run: bool = False) -> None:
|
||||
path = os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "res", "values", "automotive_colors.xml")
|
||||
if not os.path.exists(path):
|
||||
print(" [android-auto] automotive_colors.xml not found — skipping")
|
||||
return
|
||||
|
||||
c = theme["colors"]
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace hex values in color tags
|
||||
content = re.sub(
|
||||
r'(<color name="kryz_primary">)#[^<]*</color>',
|
||||
rf'\1{c["primary"]}</color>',
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r'(<color name="kryz_primary_dark">)#[^<]*</color>',
|
||||
rf'\1{c["primary_dark"]}</color>',
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r'(<color name="kryz_accent">)#[^<]*</color>',
|
||||
rf'\1{c["accent"]}</color>',
|
||||
content,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print(" [android-auto] Would patch automotive_colors.xml")
|
||||
return
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(" [android-auto] Patched automotive_colors.xml")
|
||||
|
||||
|
||||
def patch_kotlin_service(theme: dict, dry_run: bool = False) -> None:
|
||||
kts = list(
|
||||
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter", f)
|
||||
for f in os.listdir(
|
||||
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter")
|
||||
)
|
||||
) if os.path.isdir(
|
||||
os.path.join(PROJECT_ROOT, "android", "app", "src", "main", "kotlin", "com", "example", "kryz_go_flutter")
|
||||
) else []
|
||||
|
||||
if not kts:
|
||||
print(" [kotlin] No Kotlin files found — skipping")
|
||||
return
|
||||
|
||||
station = theme["station"]
|
||||
streams = theme["streams"]
|
||||
name_primary = station.get("name_primary", "KRYZ")
|
||||
callsign = station.get("callsign", name_primary)
|
||||
app_name = f"{name_primary} {station.get('name_secondary','')}".strip()
|
||||
default_title = f"{callsign} Live Stream"
|
||||
default_artist = f"{name_primary} Radio"
|
||||
stream_url = streams.get("url", "")
|
||||
|
||||
replacements = [
|
||||
# STREAM_URL constant
|
||||
(r'(private const val STREAM_URL = ")[^"]*(")', rf'\g<1>{stream_url}\2'),
|
||||
# "KRYZ Live Stream" → default title
|
||||
(r'"KRYZ Live Stream"', f'"{default_title}"'),
|
||||
# "KRYZ Radio" → default artist
|
||||
(r'"KRYZ Radio"', f'"{default_artist}"'),
|
||||
# "KRYZ Auto Playback" → app name + " Auto Playback"
|
||||
(r'"KRYZ Auto Playback"', f'"{app_name} Auto Playback"'),
|
||||
# "KRYZ Radio Android Auto playback" → descriptive text
|
||||
(r'"KRYZ Radio Android Auto playback"', f'"{app_name} Android Auto playback"'),
|
||||
]
|
||||
|
||||
for kfile in kts:
|
||||
if not kfile.endswith(".kt"):
|
||||
continue
|
||||
with open(kfile, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
original = content
|
||||
for pattern, replacement in replacements:
|
||||
content = re.sub(pattern, replacement, content)
|
||||
|
||||
if content == original:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" [kotlin] Would patch {os.path.basename(kfile)}")
|
||||
return
|
||||
|
||||
with open(kfile, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f" [kotlin] Patched {os.path.basename(kfile)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Whitelabel build script")
|
||||
parser.add_argument("--theme", default=os.path.join(PROJECT_ROOT, "theme.json"), help="Path to theme.json")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without writing files")
|
||||
parser.add_argument("--no-tools", action="store_true", help="Skip invoking flutter_launcher_icons and flutter_name_manager")
|
||||
args = parser.parse_args()
|
||||
|
||||
theme_path = args.theme
|
||||
if not os.path.exists(theme_path):
|
||||
print(f"Error: theme.json not found at {theme_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {theme_path} ...")
|
||||
theme = read_theme(theme_path)
|
||||
|
||||
station = theme.get("station", {})
|
||||
if not station.get("name_primary"):
|
||||
print("Error: theme.json is missing station.name_primary")
|
||||
sys.exit(1)
|
||||
|
||||
app_name = f"{station.get('name_primary','')} {station.get('name_secondary','')}".strip()
|
||||
print(f"Whitelabel: {app_name}")
|
||||
print()
|
||||
|
||||
# Generate Dart files
|
||||
gen_dir = os.path.join(PROJECT_ROOT, "lib", "generated")
|
||||
os.makedirs(gen_dir, exist_ok=True)
|
||||
|
||||
app_config_path = os.path.join(gen_dir, "app_config.dart")
|
||||
theme_colors_path = os.path.join(gen_dir, "theme_colors.dart")
|
||||
|
||||
if args.dry_run:
|
||||
print(" [gen] Would generate lib/generated/app_config.dart")
|
||||
print(" [gen] Would generate lib/generated/theme_colors.dart")
|
||||
else:
|
||||
with open(app_config_path, "w", encoding="utf-8") as f:
|
||||
f.write(generate_app_config(theme))
|
||||
print(" [gen] Generated lib/generated/app_config.dart")
|
||||
|
||||
with open(theme_colors_path, "w", encoding="utf-8") as f:
|
||||
f.write(generate_theme_colors(theme))
|
||||
print(" [gen] Generated lib/generated/theme_colors.dart")
|
||||
|
||||
# Generate icon
|
||||
print()
|
||||
generate_icon(theme, dry_run=args.dry_run)
|
||||
|
||||
# Generate Android Auto album art
|
||||
print()
|
||||
generate_android_auto_art(theme, dry_run=args.dry_run)
|
||||
|
||||
# Patch native files
|
||||
print()
|
||||
patch_android_manifest(theme, dry_run=args.dry_run)
|
||||
patch_info_plist(theme, dry_run=args.dry_run)
|
||||
patch_automotive_colors(theme, dry_run=args.dry_run)
|
||||
patch_kotlin_service(theme, dry_run=args.dry_run)
|
||||
|
||||
# Invoke downstream tools
|
||||
if not args.dry_run and not args.no_tools:
|
||||
print()
|
||||
print("Running flutter_launcher_icons ...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["dart", "run", "flutter_launcher_icons"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
print(" flutter_launcher_icons succeeded")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" WARNING: flutter_launcher_icons failed: {e}")
|
||||
except FileNotFoundError:
|
||||
print(" WARNING: 'dart' not found — run `flutter pub get` first")
|
||||
|
||||
print()
|
||||
print("Running flutter_name_manager ...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["dart", "run", "flutter_name_manager", "--app-name", app_name],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
print(" flutter_name_manager succeeded")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" WARNING: flutter_name_manager failed: {e}")
|
||||
except FileNotFoundError:
|
||||
print(" WARNING: 'dart' not found — run `flutter pub get` first")
|
||||
|
||||
print()
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,6 +14,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -56,6 +57,8 @@
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -158,10 +161,12 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -382,6 +387,7 @@
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -473,6 +479,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -656,6 +663,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -679,6 +687,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
|
||||
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
|
||||
private let castingEventChannelName = "kryz_go_flutter/casting/events"
|
||||
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
|
||||
|
||||
private var castingEventSink: FlutterEventSink?
|
||||
private var castingStatus: [String: Any] = [
|
||||
"status": "idle",
|
||||
"platform": "ios"
|
||||
]
|
||||
private var playbackMethodChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
@@ -17,10 +29,152 @@ import AVFoundation
|
||||
NSLog("Failed to configure AVAudioSession: \(error)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAudioRouteChange),
|
||||
name: AVAudioSession.routeChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
updateCastingStatusForCurrentRoute()
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
|
||||
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "CastingBridge") else {
|
||||
return
|
||||
}
|
||||
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: castingMethodChannelName,
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: castingEventChannelName,
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
|
||||
eventChannel.setStreamHandler(self)
|
||||
|
||||
methodChannel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handleCastingMethod(call: call, result: result)
|
||||
}
|
||||
|
||||
playbackMethodChannel = FlutterMethodChannel(
|
||||
name: playbackMethodChannelName,
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
}
|
||||
|
||||
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||
func invokePlay() {
|
||||
playbackMethodChannel?.invokeMethod("play", arguments: nil)
|
||||
}
|
||||
|
||||
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||
func invokeStop() {
|
||||
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
|
||||
}
|
||||
|
||||
@objc private func handleAudioRouteChange() {
|
||||
updateCastingStatusForCurrentRoute()
|
||||
}
|
||||
|
||||
private func handleCastingMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "getStatus":
|
||||
updateCastingStatusForCurrentRoute()
|
||||
result(castingStatus)
|
||||
case "showDevicePicker":
|
||||
showAirPlayPicker()
|
||||
result(nil)
|
||||
case "startCastingLiveStream":
|
||||
// AirPlay route selection controls where iOS outputs audio. No explicit load call needed.
|
||||
result(nil)
|
||||
case "disconnect":
|
||||
// iOS does not expose direct route disconnect. Users select route via picker.
|
||||
showAirPlayPicker()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func showAirPlayPicker() {
|
||||
DispatchQueue.main.async {
|
||||
guard let rootViewController = self.activeRootViewController() else {
|
||||
self.emitCastingEvent(status: "error", message: "Unable to open AirPlay picker")
|
||||
return
|
||||
}
|
||||
|
||||
let routePickerView = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
||||
routePickerView.alpha = 0.01
|
||||
rootViewController.view.addSubview(routePickerView)
|
||||
|
||||
for subview in routePickerView.subviews {
|
||||
if let button = subview as? UIButton {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
routePickerView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activeRootViewController() -> UIViewController? {
|
||||
let activeScene = UIApplication.shared.connectedScenes
|
||||
.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
let keyWindow = activeScene?.windows.first { $0.isKeyWindow }
|
||||
return keyWindow?.rootViewController
|
||||
}
|
||||
|
||||
private func updateCastingStatusForCurrentRoute() {
|
||||
let currentOutputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
if let airPlayOutput = currentOutputs.first(where: { $0.portType == .airPlay }) {
|
||||
castingStatus = [
|
||||
"status": "connected",
|
||||
"platform": "ios",
|
||||
"deviceName": airPlayOutput.portName
|
||||
]
|
||||
} else {
|
||||
castingStatus = [
|
||||
"status": "idle",
|
||||
"platform": "ios"
|
||||
]
|
||||
}
|
||||
|
||||
castingEventSink?(castingStatus)
|
||||
}
|
||||
|
||||
private func emitCastingEvent(status: String, message: String) {
|
||||
let event: [String: Any] = [
|
||||
"status": status,
|
||||
"platform": "ios",
|
||||
"message": message
|
||||
]
|
||||
|
||||
castingStatus = event
|
||||
castingEventSink?(event)
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
castingEventSink = events
|
||||
events(castingStatus)
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
castingEventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 25 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 |
@@ -0,0 +1,86 @@
|
||||
import CarPlay
|
||||
import UIKit
|
||||
|
||||
/**
|
||||
* CarPlay scene delegate for KRYZ Go!
|
||||
*
|
||||
* Shows a CPListTemplate with the KRYZ Live Stream item. Tapping it triggers
|
||||
* playback via AppDelegate.invokePlay(), then presents CPNowPlayingTemplate.
|
||||
*
|
||||
* CarPlay enforces its own design system for safety — colors cannot be customised.
|
||||
* The app icon (which carries the KRYZ brand orange) is used as item artwork to
|
||||
* provide visual continuity with the phone app.
|
||||
*
|
||||
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
|
||||
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
|
||||
*/
|
||||
@available(iOS 14.0, *)
|
||||
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
|
||||
private var carPlayInterfaceController: CPInterfaceController?
|
||||
private var listTemplate: CPListTemplate?
|
||||
|
||||
// MARK: - CPTemplateApplicationSceneDelegate
|
||||
|
||||
func templateApplicationScene(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didConnect interfaceController: CPInterfaceController
|
||||
) {
|
||||
carPlayInterfaceController = interfaceController
|
||||
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil)
|
||||
}
|
||||
|
||||
func templateApplicationScene(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||
) {
|
||||
carPlayInterfaceController = nil
|
||||
listTemplate = nil
|
||||
appDelegate?.invokeStop()
|
||||
}
|
||||
|
||||
// MARK: - Template construction
|
||||
|
||||
private func makeListTemplate() -> CPListTemplate {
|
||||
let artwork = CPListItem.maximumImageSize
|
||||
let icon = UIImage(named: "AppIcon")?
|
||||
.withRenderingMode(.alwaysOriginal)
|
||||
|
||||
let item = CPListItem(
|
||||
text: "KRYZ Live Stream",
|
||||
detailText: "KRYZ Radio",
|
||||
image: icon
|
||||
)
|
||||
item.handler = { [weak self] _, completion in
|
||||
self?.handleStreamItemTapped()
|
||||
completion()
|
||||
}
|
||||
|
||||
let section = CPListSection(items: [item])
|
||||
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
|
||||
template.tabTitle = "KRYZ Go!"
|
||||
template.tabImage = icon
|
||||
|
||||
listTemplate = template
|
||||
// Silence unused-variable warning
|
||||
_ = artwork
|
||||
return template
|
||||
}
|
||||
|
||||
// MARK: - Playback
|
||||
|
||||
private func handleStreamItemTapped() {
|
||||
appDelegate?.invokePlay()
|
||||
carPlayInterfaceController?.pushTemplate(
|
||||
CPNowPlayingTemplate.shared,
|
||||
animated: true,
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var appDelegate: AppDelegate? {
|
||||
UIApplication.shared.delegate as? AppDelegate
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>KRYZ Go!</string>
|
||||
<string>KMountain Flower Radio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>KRYZ Go!</string>
|
||||
<string>KMountain Flower Radio</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -49,6 +49,17 @@
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>CPTemplateApplicationScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>CarPlay</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
CarPlay audio entitlement. This must also be enabled on the Apple Developer Portal
|
||||
(Certificates, Identifiers & Profiles → your App ID → Capabilities → CarPlay)
|
||||
and added to the target in Xcode: Signing & Capabilities → + Capability → CarPlay.
|
||||
-->
|
||||
<key>com.apple.developer.carplay-audio</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,45 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'generated/theme_colors.dart';
|
||||
|
||||
class AppThemeColors {
|
||||
const AppThemeColors({
|
||||
required this.primarySeed,
|
||||
required this.webViewBorder,
|
||||
required this.webViewBackground,
|
||||
required this.headingColor,
|
||||
required this.accentColor,
|
||||
required this.scaffoldGradientStart,
|
||||
required this.scaffoldGradientEnd,
|
||||
required this.shellSurface,
|
||||
required this.shellBorder,
|
||||
required this.mutedText,
|
||||
required this.textOnDark,
|
||||
required this.textOnDarkSecondary,
|
||||
required this.controlsGradientStart,
|
||||
required this.controlsGradientEnd,
|
||||
required this.controlsShadow,
|
||||
required this.statusText,
|
||||
required this.bodyText,
|
||||
required this.cardSurface,
|
||||
required this.cardBorder,
|
||||
required this.successColor,
|
||||
required this.dangerColor,
|
||||
});
|
||||
|
||||
final Color primarySeed;
|
||||
final Color webViewBorder;
|
||||
final Color webViewBackground;
|
||||
/// Website: `primary` → headings (h1–h6)
|
||||
final Color headingColor;
|
||||
/// Website: `accent` → links & accent highlights
|
||||
final Color accentColor;
|
||||
/// Page background gradient start — website: `background` (cream)
|
||||
final Color scaffoldGradientStart;
|
||||
/// Page background gradient end — website: `light`
|
||||
final Color scaffoldGradientEnd;
|
||||
/// Top header card surface
|
||||
final Color shellSurface;
|
||||
/// Top header card border
|
||||
final Color shellBorder;
|
||||
/// Website: `medium` → muted / secondary text
|
||||
final Color mutedText;
|
||||
/// Website: `white` → text on dark surfaces
|
||||
final Color textOnDark;
|
||||
/// Website: `light` → secondary text on dark surfaces
|
||||
final Color textOnDarkSecondary;
|
||||
/// Controls bar / card surface gradient start — website: `primary_dark`
|
||||
final Color controlsGradientStart;
|
||||
/// Controls bar / card surface gradient end — website: `primary_dark`
|
||||
final Color controlsGradientEnd;
|
||||
/// Shadow color for elevated surfaces
|
||||
final Color controlsShadow;
|
||||
final Color statusText;
|
||||
/// Website: `text` → body text
|
||||
final Color bodyText;
|
||||
/// Inner card background overlay — derived from `primary_dark` / `primary_light`
|
||||
final Color cardSurface;
|
||||
/// Inner card border — derived from `primary_dark` / `primary_light`
|
||||
final Color cardBorder;
|
||||
/// Website: `success` → live indicator, success states
|
||||
final Color successColor;
|
||||
/// Website: `danger` → error states
|
||||
final Color dangerColor;
|
||||
}
|
||||
|
||||
class AppThemeBusinessObject {
|
||||
static const AppThemeColors lightColors = AppThemeColors(
|
||||
primarySeed: Color(0xFF607D8B),
|
||||
webViewBorder: Color(0x42000000),
|
||||
webViewBackground: Color(0xFFFFFFFF),
|
||||
controlsGradientStart: Color(0xFFE1F5FE),
|
||||
controlsGradientEnd: Color(0xFFB3E5FC),
|
||||
controlsShadow: Color(0x22000000),
|
||||
statusText: Color(0xDD000000),
|
||||
);
|
||||
|
||||
static const AppThemeColors darkColors = AppThemeColors(
|
||||
primarySeed: Color(0xFF37474F),
|
||||
webViewBorder: Color(0x66FFFFFF),
|
||||
webViewBackground: Color(0xFF0F1417),
|
||||
controlsGradientStart: Color(0xFF1B2731),
|
||||
controlsGradientEnd: Color(0xFF233744),
|
||||
controlsShadow: Color(0x44000000),
|
||||
statusText: Color(0xE6FFFFFF),
|
||||
);
|
||||
static const AppThemeColors lightColors = GeneratedThemeColors.lightColors;
|
||||
static const AppThemeColors darkColors = GeneratedThemeColors.darkColors;
|
||||
|
||||
static bool isDarkMode(BuildContext context) {
|
||||
return MediaQuery.platformBrightnessOf(context) == Brightness.dark;
|
||||
@@ -50,19 +72,62 @@ class AppThemeBusinessObject {
|
||||
}
|
||||
|
||||
static ThemeData lightTheme() {
|
||||
final ColorScheme scheme = ColorScheme.light(
|
||||
brightness: Brightness.light,
|
||||
primary: lightColors.headingColor,
|
||||
onPrimary: lightColors.textOnDark,
|
||||
secondary: lightColors.accentColor,
|
||||
onSecondary: lightColors.textOnDark,
|
||||
surface: lightColors.shellSurface,
|
||||
onSurface: lightColors.bodyText,
|
||||
error: lightColors.dangerColor,
|
||||
onError: lightColors.textOnDark,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: lightColors.primarySeed),
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData darkTheme() {
|
||||
return ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: darkColors.primarySeed,
|
||||
final ColorScheme scheme = ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
primary: darkColors.headingColor,
|
||||
onPrimary: darkColors.textOnDark,
|
||||
secondary: darkColors.accentColor,
|
||||
onSecondary: darkColors.textOnDark,
|
||||
surface: darkColors.shellSurface,
|
||||
onSurface: darkColors.bodyText,
|
||||
error: darkColors.dangerColor,
|
||||
onError: darkColors.textOnDark,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
enum CastingStatus { unavailable, idle, connecting, connected, error }
|
||||
|
||||
enum CastingPlaybackStatus { unknown, stopped, buffering, playing }
|
||||
|
||||
class CastingBusinessObject extends ChangeNotifier {
|
||||
static const MethodChannel _methodChannel = MethodChannel(
|
||||
'kryz_go_flutter/casting/methods',
|
||||
);
|
||||
static const EventChannel _eventChannel = EventChannel(
|
||||
'kryz_go_flutter/casting/events',
|
||||
);
|
||||
|
||||
StreamSubscription<dynamic>? _eventSubscription;
|
||||
CastingStatus _status = CastingStatus.unavailable;
|
||||
CastingPlaybackStatus _playbackStatus = CastingPlaybackStatus.unknown;
|
||||
String? _connectedDeviceName;
|
||||
String? _lastError;
|
||||
|
||||
/// Callback for when native side requests local audio to stop
|
||||
VoidCallback? onStopLocalAudioRequested;
|
||||
|
||||
CastingStatus get status => _status;
|
||||
CastingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||
String? get connectedDeviceName => _connectedDeviceName;
|
||||
String? get lastError => _lastError;
|
||||
|
||||
bool get isSupportedOnThisPlatform {
|
||||
if (kIsWeb) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.android;
|
||||
}
|
||||
|
||||
bool get isConnected => _status == CastingStatus.connected;
|
||||
bool get isPlaying => _playbackStatus == CastingPlaybackStatus.playing;
|
||||
bool get isBuffering => _playbackStatus == CastingPlaybackStatus.buffering;
|
||||
bool get isStopped => _playbackStatus == CastingPlaybackStatus.stopped;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!isSupportedOnThisPlatform) {
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
_eventSubscription?.cancel();
|
||||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
_handleEvent,
|
||||
onError: (Object error) {
|
||||
_lastError = error.toString();
|
||||
_setStatus(CastingStatus.error);
|
||||
},
|
||||
);
|
||||
|
||||
// Set up handler for native method calls (e.g., stopLocalAudio)
|
||||
_methodChannel.setMethodCallHandler(_handleMethodCall);
|
||||
|
||||
await refreshStatus();
|
||||
}
|
||||
|
||||
Future<void> refreshStatus() async {
|
||||
if (!isSupportedOnThisPlatform) {
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final dynamic status = await _methodChannel.invokeMethod<dynamic>(
|
||||
'getStatus',
|
||||
);
|
||||
_handleEvent(status);
|
||||
} on PlatformException catch (error) {
|
||||
_lastError = error.message ?? error.code;
|
||||
_setStatus(CastingStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showDevicePicker({
|
||||
bool autoplay = false,
|
||||
String? streamUrl,
|
||||
String? title,
|
||||
String? subtitle,
|
||||
}) async {
|
||||
if (!isSupportedOnThisPlatform) {
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
_setStatus(CastingStatus.connecting);
|
||||
}
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod<void>('showDevicePicker', {
|
||||
'autoplay': autoplay,
|
||||
'streamUrl': streamUrl,
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
});
|
||||
} on PlatformException catch (error) {
|
||||
_lastError = error.message ?? error.code;
|
||||
_setStatus(CastingStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startCastingLiveStream({
|
||||
required String streamUrl,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
}) async {
|
||||
if (!isSupportedOnThisPlatform) {
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod<void>('startCastingLiveStream', {
|
||||
'streamUrl': streamUrl,
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
});
|
||||
} on PlatformException catch (error) {
|
||||
_lastError = error.message ?? error.code;
|
||||
// Do NOT set status to error here — native emits its own status events.
|
||||
// Overriding status from Dart corrupts the UI when native considers the
|
||||
// session still connected (e.g. during a retry after a race condition).
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!isSupportedOnThisPlatform) {
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod<void>('disconnect');
|
||||
} on PlatformException catch (error) {
|
||||
_lastError = error.message ?? error.code;
|
||||
_setStatus(CastingStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPlayback() async {
|
||||
if (!isSupportedOnThisPlatform) {
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod<void>('stopCastingPlayback');
|
||||
} on PlatformException catch (error) {
|
||||
_lastError = error.message ?? error.code;
|
||||
_setStatus(CastingStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(dynamic event) {
|
||||
if (event is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String? status = event['status'] as String?;
|
||||
final String? playbackStatus = event['playbackStatus'] as String?;
|
||||
final String? deviceName = event['deviceName'] as String?;
|
||||
final String? message = event['message'] as String?;
|
||||
|
||||
if (deviceName != null && deviceName.isNotEmpty) {
|
||||
_connectedDeviceName = deviceName;
|
||||
} else if (status != 'connected') {
|
||||
_connectedDeviceName = null;
|
||||
}
|
||||
|
||||
if (message != null && message.isNotEmpty) {
|
||||
_lastError = message;
|
||||
}
|
||||
|
||||
_setPlaybackStatus(_parsePlaybackStatus(playbackStatus));
|
||||
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
_setStatus(CastingStatus.idle);
|
||||
break;
|
||||
case 'connecting':
|
||||
_setStatus(CastingStatus.connecting);
|
||||
break;
|
||||
case 'connected':
|
||||
_setStatus(CastingStatus.connected);
|
||||
break;
|
||||
case 'error':
|
||||
_setStatus(CastingStatus.error);
|
||||
break;
|
||||
case 'unavailable':
|
||||
default:
|
||||
_setStatus(CastingStatus.unavailable);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _setStatus(CastingStatus status) {
|
||||
if (_status == status) {
|
||||
return;
|
||||
}
|
||||
|
||||
_status = status;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
CastingPlaybackStatus _parsePlaybackStatus(String? playbackStatus) {
|
||||
switch (playbackStatus) {
|
||||
case 'playing':
|
||||
return CastingPlaybackStatus.playing;
|
||||
case 'buffering':
|
||||
return CastingPlaybackStatus.buffering;
|
||||
case 'stopped':
|
||||
return CastingPlaybackStatus.stopped;
|
||||
default:
|
||||
return CastingPlaybackStatus.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
void _setPlaybackStatus(CastingPlaybackStatus status) {
|
||||
if (_playbackStatus == status) {
|
||||
return;
|
||||
}
|
||||
|
||||
_playbackStatus = status;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Handles method calls from native code
|
||||
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'stopLocalAudio':
|
||||
onStopLocalAudioRequested?.call();
|
||||
return null;
|
||||
case 'stopAudioService':
|
||||
// Stop the audio service directly (called when transferring to cast)
|
||||
onStopLocalAudioRequested?.call();
|
||||
return null;
|
||||
case 'resumeLocalAudio':
|
||||
// Audio service is resuming after cast disconnect; no action needed on Flutter side
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import 'generated/app_config.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
|
||||
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
KryzAudioHandler() {
|
||||
mediaItem.add(_defaultMediaItem);
|
||||
_playerStateSubscription = _player.playerStateStream.listen(
|
||||
_onPlayerStateChanged,
|
||||
);
|
||||
}
|
||||
|
||||
static const String streamUrl = AppConfig.streamUrl;
|
||||
static const String mediaId = 'kryz-live-stream';
|
||||
static const String _liveInfoEndpoint = AppConfig.metadataUrl ?? '';
|
||||
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
||||
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
||||
|
||||
static const MediaItem _defaultMediaItem = MediaItem(
|
||||
id: mediaId,
|
||||
title: AppConfig.defaultTitle,
|
||||
artist: AppConfig.defaultArtist,
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
'seekable': false,
|
||||
},
|
||||
);
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
late final StreamSubscription<PlayerState> _playerStateSubscription;
|
||||
Timer? _metadataRefreshTimer;
|
||||
bool _isAudioSessionConfigured = false;
|
||||
bool _isSourceLoaded = false;
|
||||
bool _playRequested = false;
|
||||
|
||||
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
|
||||
final current = snapshot?.current;
|
||||
if (current == null || current.type != 'track') {
|
||||
mediaItem.add(_defaultMediaItem);
|
||||
return;
|
||||
}
|
||||
|
||||
final String subtitle = current.displaySubtitle.trim();
|
||||
mediaItem.add(
|
||||
MediaItem(
|
||||
id: mediaId,
|
||||
title: current.displayTitle,
|
||||
artist: subtitle.isEmpty ? AppConfig.defaultArtist : subtitle,
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
'seekable': false,
|
||||
'starts': current.starts,
|
||||
'ends': current.ends,
|
||||
'schedulerTime': snapshot?.schedulerTime,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_playRequested = true;
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: const <MediaControl>[MediaControl.stop],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: AudioProcessingState.buffering,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
|
||||
await _configureAudioSessionIfNeeded();
|
||||
|
||||
try {
|
||||
if (!_isSourceLoaded) {
|
||||
await _player.setAudioSource(
|
||||
AudioSource.uri(
|
||||
Uri.parse(streamUrl),
|
||||
tag: mediaItem.value,
|
||||
),
|
||||
);
|
||||
_isSourceLoaded = true;
|
||||
}
|
||||
|
||||
final session = await AudioSession.instance;
|
||||
await session.setActive(true);
|
||||
await _player.play();
|
||||
await _refreshMetadataFromApi();
|
||||
_startMetadataRefreshTimer();
|
||||
} catch (_) {
|
||||
_playRequested = false;
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: const <MediaControl>[MediaControl.play],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: AudioProcessingState.idle,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_playRequested = false;
|
||||
_isSourceLoaded = false;
|
||||
_metadataRefreshTimer?.cancel();
|
||||
_metadataRefreshTimer = null;
|
||||
await _player.stop();
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: const <MediaControl>[MediaControl.play],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: AudioProcessingState.idle,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
// Live stream is intentionally non-seekable.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) {
|
||||
return play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playFromSearch(String query, [Map<String, dynamic>? extras]) {
|
||||
return play();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_metadataRefreshTimer?.cancel();
|
||||
await _playerStateSubscription.cancel();
|
||||
await _player.dispose();
|
||||
}
|
||||
|
||||
Future<void> _configureAudioSessionIfNeeded() async {
|
||||
if (_isAudioSessionConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration.music());
|
||||
_isAudioSessionConfigured = true;
|
||||
}
|
||||
|
||||
void _onPlayerStateChanged(PlayerState state) {
|
||||
final AudioProcessingState processingState = switch (state.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||
ProcessingState.ready => AudioProcessingState.ready,
|
||||
ProcessingState.completed => AudioProcessingState.completed,
|
||||
};
|
||||
|
||||
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
|
||||
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
|
||||
(_playRequested && !state.playing && state.processingState != ProcessingState.completed);
|
||||
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: <MediaControl>[
|
||||
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
||||
],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
|
||||
playing: isPlaying,
|
||||
),
|
||||
);
|
||||
|
||||
if (isPlaying || isBuffering) {
|
||||
_startMetadataRefreshTimer();
|
||||
} else {
|
||||
_metadataRefreshTimer?.cancel();
|
||||
_metadataRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _startMetadataRefreshTimer() {
|
||||
if (_metadataRefreshTimer != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_metadataRefreshTimer = Timer.periodic(_metadataRefreshInterval, (_) {
|
||||
_refreshMetadataFromApi();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshMetadataFromApi() async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(_liveInfoEndpoint))
|
||||
.timeout(_liveInfoTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return;
|
||||
}
|
||||
|
||||
final dynamic decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return;
|
||||
}
|
||||
|
||||
final snapshot = LiveInfoSnapshot.fromJson(decoded);
|
||||
await updateMetadataFromLiveInfo(snapshot);
|
||||
} catch (_) {
|
||||
// Keep existing metadata if live-info fetch fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'generated/app_config.dart';
|
||||
|
||||
class LiveInfoBusinessObject extends ChangeNotifier {
|
||||
LiveInfoBusinessObject({
|
||||
String? endpoint,
|
||||
int refreshIntervalSeconds = 20,
|
||||
this.requestTimeout = const Duration(seconds: 8),
|
||||
}) : _endpointOverride = endpoint,
|
||||
_refreshIntervalSeconds = refreshIntervalSeconds;
|
||||
|
||||
/// Explicit endpoint passed at construction, if any.
|
||||
final String? _endpointOverride;
|
||||
|
||||
/// The effective polling endpoint: construction override > env var > AppConfig.
|
||||
String get endpoint {
|
||||
final override = _endpointOverride;
|
||||
if (override != null && override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
const envUrl = String.fromEnvironment('KRYZ_LIVE_INFO_URL', defaultValue: '');
|
||||
if (envUrl.isNotEmpty) {
|
||||
return envUrl;
|
||||
}
|
||||
return AppConfig.metadataUrl ?? '';
|
||||
}
|
||||
final Duration requestTimeout;
|
||||
|
||||
Timer? _pollTimer;
|
||||
LiveInfoSnapshot? _snapshot;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
DateTime? _lastUpdatedAt;
|
||||
int _refreshIntervalSeconds;
|
||||
final List<RecentPlayedItem> _recentPlayed = <RecentPlayedItem>[];
|
||||
final Set<String> _recentKeys = <String>{};
|
||||
|
||||
LiveInfoSnapshot? get snapshot => _snapshot;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
DateTime? get lastUpdatedAt => _lastUpdatedAt;
|
||||
int get refreshIntervalSeconds => _refreshIntervalSeconds;
|
||||
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
|
||||
|
||||
Future<void> start() async {
|
||||
// Skip polling if no endpoint is configured
|
||||
if (endpoint.isEmpty && (AppConfig.metadataUrl ?? '').isEmpty) {
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
_restartTimer();
|
||||
}
|
||||
|
||||
void setRefreshIntervalSeconds(int seconds) {
|
||||
if (seconds <= 0 || seconds == _refreshIntervalSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshIntervalSeconds = seconds;
|
||||
_restartTimer();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(endpoint))
|
||||
.timeout(requestTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw StateError('HTTP ${response.statusCode} from live-info API');
|
||||
}
|
||||
|
||||
final dynamic decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw StateError('Unexpected live-info payload shape');
|
||||
}
|
||||
|
||||
final LiveInfoSnapshot next = LiveInfoSnapshot.fromJson(decoded);
|
||||
_snapshot = next;
|
||||
_lastUpdatedAt = DateTime.now();
|
||||
_errorMessage = null;
|
||||
_updateRecentPlayed(next);
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _restartTimer() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(
|
||||
Duration(seconds: _refreshIntervalSeconds),
|
||||
(_) => refresh(),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateRecentPlayed(LiveInfoSnapshot snapshot) {
|
||||
void addIfTrack(LiveInfoTrackInfo? track, {required String source}) {
|
||||
if (track == null || track.type != 'track') {
|
||||
return;
|
||||
}
|
||||
|
||||
final String key =
|
||||
'$source:${track.metadata.id ?? 'none'}:${track.starts ?? 'unknown'}';
|
||||
if (_recentKeys.contains(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_recentKeys.add(key);
|
||||
_recentPlayed.insert(
|
||||
0,
|
||||
RecentPlayedItem(
|
||||
title: track.displayTitle,
|
||||
subtitle: track.displaySubtitle,
|
||||
starts: track.starts,
|
||||
ends: track.ends,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
addIfTrack(snapshot.current, source: 'current');
|
||||
addIfTrack(snapshot.previous, source: 'previous');
|
||||
|
||||
const int maxItems = 40;
|
||||
if (_recentPlayed.length > maxItems) {
|
||||
final List<RecentPlayedItem> removed = _recentPlayed
|
||||
.sublist(maxItems)
|
||||
.toList(growable: false);
|
||||
_recentPlayed.removeRange(maxItems, _recentPlayed.length);
|
||||
for (final RecentPlayedItem item in removed) {
|
||||
_recentKeys.remove(item.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class LiveInfoSnapshot {
|
||||
LiveInfoSnapshot({
|
||||
required this.env,
|
||||
required this.schedulerTime,
|
||||
required this.previous,
|
||||
required this.current,
|
||||
required this.next,
|
||||
required this.currentShow,
|
||||
required this.nextShow,
|
||||
required this.timezone,
|
||||
required this.timezoneOffset,
|
||||
required this.sourceEnabled,
|
||||
});
|
||||
|
||||
final String? env;
|
||||
final String? schedulerTime;
|
||||
final LiveInfoTrackInfo? previous;
|
||||
final LiveInfoTrackInfo? current;
|
||||
final LiveInfoTrackInfo? next;
|
||||
final List<LiveInfoShowInfo> currentShow;
|
||||
final List<LiveInfoShowInfo> nextShow;
|
||||
final String? timezone;
|
||||
final String? timezoneOffset;
|
||||
final String? sourceEnabled;
|
||||
|
||||
factory LiveInfoSnapshot.fromJson(Map<String, dynamic> json) {
|
||||
List<LiveInfoShowInfo> parseShows(dynamic value) {
|
||||
if (value is! List) {
|
||||
return const <LiveInfoShowInfo>[];
|
||||
}
|
||||
|
||||
return value
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LiveInfoShowInfo.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
return LiveInfoSnapshot(
|
||||
env: json['env'] as String?,
|
||||
schedulerTime: json['schedulerTime'] as String?,
|
||||
previous: LiveInfoTrackInfo.fromUnknown(json['previous']),
|
||||
current: LiveInfoTrackInfo.fromUnknown(json['current']),
|
||||
next: LiveInfoTrackInfo.fromUnknown(json['next']),
|
||||
currentShow: parseShows(json['currentShow']),
|
||||
nextShow: parseShows(json['nextShow']),
|
||||
timezone: json['timezone'] as String?,
|
||||
timezoneOffset: json['timezoneOffset']?.toString(),
|
||||
sourceEnabled: json['source_enabled'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LiveInfoTrackInfo {
|
||||
LiveInfoTrackInfo({
|
||||
required this.starts,
|
||||
required this.ends,
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.metadata,
|
||||
});
|
||||
|
||||
final String? starts;
|
||||
final String? ends;
|
||||
final String? type;
|
||||
final String? name;
|
||||
final LiveInfoTrackMetadata metadata;
|
||||
|
||||
String get displayTitle {
|
||||
final String candidate = _decodeHtml(
|
||||
metadata.trackTitle ??
|
||||
metadata.filepath ??
|
||||
metadata.name ??
|
||||
name ??
|
||||
'Unknown Track',
|
||||
);
|
||||
return candidate.trim().isEmpty ? 'Unknown Track' : candidate;
|
||||
}
|
||||
|
||||
String get displaySubtitle {
|
||||
final String artist = _decodeHtml(metadata.artistName ?? '').trim();
|
||||
final String album = _decodeHtml(metadata.albumTitle ?? '').trim();
|
||||
|
||||
if (artist.isNotEmpty && album.isNotEmpty) {
|
||||
return '$artist • $album';
|
||||
}
|
||||
if (artist.isNotEmpty) {
|
||||
return artist;
|
||||
}
|
||||
if (album.isNotEmpty) {
|
||||
return album;
|
||||
}
|
||||
return _decodeHtml(name ?? '').trim();
|
||||
}
|
||||
|
||||
factory LiveInfoTrackInfo.fromUnknown(dynamic value) {
|
||||
if (value is! Map<String, dynamic>) {
|
||||
return LiveInfoTrackInfo(
|
||||
starts: null,
|
||||
ends: null,
|
||||
type: null,
|
||||
name: null,
|
||||
metadata: LiveInfoTrackMetadata.empty(),
|
||||
);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> metadataMap =
|
||||
value['metadata'] is Map<String, dynamic>
|
||||
? value['metadata'] as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
|
||||
return LiveInfoTrackInfo(
|
||||
starts: value['starts']?.toString(),
|
||||
ends: value['ends']?.toString(),
|
||||
type: value['type']?.toString(),
|
||||
name: value['name']?.toString(),
|
||||
metadata: LiveInfoTrackMetadata.fromJson(metadataMap),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LiveInfoTrackMetadata {
|
||||
LiveInfoTrackMetadata({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.filepath,
|
||||
required this.trackTitle,
|
||||
required this.artistName,
|
||||
required this.albumTitle,
|
||||
required this.genre,
|
||||
required this.year,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String? name;
|
||||
final String? filepath;
|
||||
final String? trackTitle;
|
||||
final String? artistName;
|
||||
final String? albumTitle;
|
||||
final String? genre;
|
||||
final String? year;
|
||||
|
||||
factory LiveInfoTrackMetadata.fromJson(Map<String, dynamic> json) {
|
||||
return LiveInfoTrackMetadata(
|
||||
id: json['id'] as int?,
|
||||
name: json['name']?.toString(),
|
||||
filepath: json['filepath']?.toString(),
|
||||
trackTitle: json['track_title']?.toString(),
|
||||
artistName: json['artist_name']?.toString(),
|
||||
albumTitle: json['album_title']?.toString(),
|
||||
genre: json['genre']?.toString(),
|
||||
year: json['year']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
factory LiveInfoTrackMetadata.empty() {
|
||||
return LiveInfoTrackMetadata(
|
||||
id: null,
|
||||
name: null,
|
||||
filepath: null,
|
||||
trackTitle: null,
|
||||
artistName: null,
|
||||
albumTitle: null,
|
||||
genre: null,
|
||||
year: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LiveInfoShowInfo {
|
||||
LiveInfoShowInfo({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.starts,
|
||||
required this.ends,
|
||||
});
|
||||
|
||||
final String? name;
|
||||
final String? description;
|
||||
final String? starts;
|
||||
final String? ends;
|
||||
|
||||
factory LiveInfoShowInfo.fromJson(Map<String, dynamic> json) {
|
||||
return LiveInfoShowInfo(
|
||||
name: json['name']?.toString(),
|
||||
description: json['description']?.toString(),
|
||||
starts: json['starts']?.toString(),
|
||||
ends: json['ends']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecentPlayedItem {
|
||||
RecentPlayedItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.starts,
|
||||
required this.ends,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? starts;
|
||||
final String? ends;
|
||||
final String source;
|
||||
|
||||
String get key => '$source:$starts:$title';
|
||||
}
|
||||
|
||||
String _decodeHtml(String raw) {
|
||||
if (raw.isEmpty) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'app_theme_business_object.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
import 'show_card.dart';
|
||||
import 'track_card.dart';
|
||||
|
||||
class LiveInfoPanel extends StatelessWidget {
|
||||
const LiveInfoPanel({
|
||||
super.key,
|
||||
required this.liveInfo,
|
||||
required this.themeColors,
|
||||
required this.intervalOptions,
|
||||
});
|
||||
|
||||
final LiveInfoBusinessObject liveInfo;
|
||||
final AppThemeColors themeColors;
|
||||
final List<int> intervalOptions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final LiveInfoSnapshot? snapshot = liveInfo.snapshot;
|
||||
final LiveInfoTrackInfo? current = snapshot?.current;
|
||||
final LiveInfoTrackInfo? next = snapshot?.next;
|
||||
final LiveInfoShowInfo? currentShow = snapshot?.currentShow.isEmpty ?? true
|
||||
? null
|
||||
: snapshot!.currentShow.first;
|
||||
final LiveInfoShowInfo? nextShow = snapshot?.nextShow.isEmpty ?? true
|
||||
? null
|
||||
: snapshot!.nextShow.first;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
themeColors.controlsGradientStart.withValues(alpha: 0.9),
|
||||
themeColors.controlsGradientEnd.withValues(alpha: 0.95),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: themeColors.controlsShadow,
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
color: themeColors.textOnDarkSecondary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Live Studio Feed',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: themeColors.textOnDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (liveInfo.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Live info warning: ${liveInfo.errorMessage}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TrackCard(
|
||||
label: 'Now Playing',
|
||||
track: current,
|
||||
titleColor: themeColors.textOnDark,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TrackCard(
|
||||
label: 'Coming Next',
|
||||
track: next,
|
||||
titleColor: themeColors.textOnDark,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShowCard(
|
||||
label: 'On Air Show',
|
||||
show: currentShow,
|
||||
sourceTimezone: snapshot?.timezone,
|
||||
titleColor: themeColors.textOnDarkSecondary,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ShowCard(
|
||||
label: 'Up Next Show',
|
||||
show: nextShow,
|
||||
sourceTimezone: snapshot?.timezone,
|
||||
titleColor: themeColors.textOnDarkSecondary,
|
||||
cardSurface: themeColors.cardSurface,
|
||||
cardBorder: themeColors.cardBorder,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
liveInfo.lastUpdatedAt == null
|
||||
? 'Waiting for API response...'
|
||||
: 'Last update (Local): ${DateFormat.yMd().format(liveInfo.lastUpdatedAt!.toLocal())} ${DateFormat.jm().format(liveInfo.lastUpdatedAt!.toLocal())}',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
import 'generated/app_config.dart';
|
||||
import 'app_theme_business_object.dart';
|
||||
import 'casting_business_object.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
import 'live_info_panel.dart';
|
||||
import 'streaming_audio_business_object.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await JustAudioBackground.init(
|
||||
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
||||
androidNotificationChannelName: 'KRYZ Audio Playback',
|
||||
androidNotificationOngoing: true,
|
||||
);
|
||||
|
||||
tz.initializeTimeZones();
|
||||
runApp(const MyApp());
|
||||
|
||||
// Keep startup non-blocking so UI can render even if background media
|
||||
// services are initializing after an Android Auto cold start.
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
@@ -25,9 +35,18 @@ class MyApp extends StatefulWidget {
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
||||
final CastingBusinessObject _casting = CastingBusinessObject();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_audio.ensureInitialized());
|
||||
_casting.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_casting.dispose();
|
||||
_audio.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -35,47 +54,121 @@ class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Split Screen Player',
|
||||
title: AppConfig.appName,
|
||||
theme: AppThemeBusinessObject.lightTheme(),
|
||||
darkTheme: AppThemeBusinessObject.darkTheme(),
|
||||
themeMode: ThemeMode.system,
|
||||
home: MainPage(audio: _audio),
|
||||
home: MainPage(audio: _audio, casting: _casting),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MainPage extends StatefulWidget {
|
||||
const MainPage({super.key, required this.audio});
|
||||
const MainPage({super.key, required this.audio, required this.casting});
|
||||
|
||||
final StreamingAudioBusinessObject audio;
|
||||
final CastingBusinessObject casting;
|
||||
|
||||
@override
|
||||
State<MainPage> createState() => _MainPageState();
|
||||
}
|
||||
|
||||
class _MainPageState extends State<MainPage> {
|
||||
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||
StreamingAudioBusinessObject get _audio => widget.audio;
|
||||
late final WebViewController _webViewController;
|
||||
CastingBusinessObject get _casting => widget.casting;
|
||||
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
||||
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
|
||||
bool _isStartingCastStream = false;
|
||||
bool _pendingRemotePlay = false;
|
||||
|
||||
bool get _usesExternalCastPlayback =>
|
||||
_casting.isConnected && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
bool get _isIosAirPlay => defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
bool get _isUsingRemotePlayback => _usesExternalCastPlayback;
|
||||
|
||||
bool get _isPlaybackStopped {
|
||||
if (_isUsingRemotePlayback) {
|
||||
return _casting.isStopped ||
|
||||
_casting.playbackStatus == CastingPlaybackStatus.unknown;
|
||||
}
|
||||
|
||||
return _audio.isStopped;
|
||||
}
|
||||
|
||||
bool get _isPlaybackPlaying {
|
||||
if (_isUsingRemotePlayback) {
|
||||
return _casting.isPlaying;
|
||||
}
|
||||
|
||||
return _audio.isPlaying;
|
||||
}
|
||||
|
||||
bool get _isPlaybackBuffering {
|
||||
if (_isUsingRemotePlayback) {
|
||||
return _casting.isBuffering;
|
||||
}
|
||||
|
||||
return _audio.isBuffering;
|
||||
}
|
||||
|
||||
String _playbackStatusLabel() {
|
||||
switch (_audio.playbackStatus) {
|
||||
case StreamingPlaybackStatus.stopped:
|
||||
return 'Stopped';
|
||||
case StreamingPlaybackStatus.buffering:
|
||||
if (_isPlaybackBuffering) {
|
||||
return 'Buffering...';
|
||||
case StreamingPlaybackStatus.playing:
|
||||
}
|
||||
|
||||
if (_isPlaybackPlaying) {
|
||||
return 'Playing';
|
||||
}
|
||||
|
||||
return 'Stopped';
|
||||
}
|
||||
|
||||
static const _playbackChannel = MethodChannel(
|
||||
'kryz_go_flutter/playback/methods',
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_webViewController = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..loadRequest(Uri.parse('https://kryzradio.org'));
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_audio.addListener(_handleAudioStateChanged);
|
||||
_casting.addListener(_handleCastingStateChanged);
|
||||
_liveInfo.addListener(_handleLiveInfoChanged);
|
||||
_liveInfo.start();
|
||||
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
|
||||
|
||||
// When native code requests local audio to stop (cast media loaded)
|
||||
_casting.onStopLocalAudioRequested = () {
|
||||
unawaited(_audio.stop());
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Re-initialize (not just refresh) to re-establish the EventChannel
|
||||
// subscription. In a cold-start Android Auto scenario the Flutter engine
|
||||
// boots headlessly before MainActivity.configureFlutterEngine() runs, so
|
||||
// the native EventChannel StreamHandler is not yet registered when
|
||||
// initialize() first fires. The "listen" message is dropped, eventSink
|
||||
// stays null, and all subsequent emitStatus() calls are silently lost.
|
||||
// Calling initialize() here cancels that dead subscription and creates a
|
||||
// fresh one now that the handler is registered; onListen immediately
|
||||
// re-emits statusPayload so the Dart state is fully re-synced.
|
||||
unawaited(_casting.initialize());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'play':
|
||||
await _play();
|
||||
case 'stop':
|
||||
await _stop();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAudioStateChanged() {
|
||||
@@ -88,102 +181,250 @@ class _MainPageState extends State<MainPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _handleLiveInfoChanged() {
|
||||
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
// Rebuild when live metadata updates from the polling API.
|
||||
});
|
||||
}
|
||||
|
||||
void _handleCastingStateChanged() {
|
||||
final currentStatus = _casting.status;
|
||||
|
||||
if (_casting.isPlaying || _casting.isBuffering) {
|
||||
_pendingRemotePlay = false;
|
||||
}
|
||||
|
||||
if (currentStatus != CastingStatus.connected) {
|
||||
_isStartingCastStream = false;
|
||||
}
|
||||
|
||||
if (_usesExternalCastPlayback &&
|
||||
_pendingRemotePlay &&
|
||||
!_isStartingCastStream) {
|
||||
// Only load when _pendingRemotePlay was set by _play() while connecting.
|
||||
// Do NOT trigger on justConnected — native pendingCastRequest handles
|
||||
// all autoplay from showDevicePicker(autoplay: true).
|
||||
_startCastingLiveStream();
|
||||
}
|
||||
|
||||
if (!_usesExternalCastPlayback &&
|
||||
currentStatus != CastingStatus.connecting) {
|
||||
_pendingRemotePlay = false;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
// Rebuild when cast state changes from the native platforms.
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startCastingLiveStream() async {
|
||||
_isStartingCastStream = true;
|
||||
|
||||
try {
|
||||
await _casting.startCastingLiveStream(
|
||||
streamUrl: _audio.streamUrl,
|
||||
title: _audio.currentTitle,
|
||||
subtitle: _audio.currentSubtitle,
|
||||
);
|
||||
|
||||
_pendingRemotePlay = false;
|
||||
|
||||
if (_usesExternalCastPlayback && !_audio.isStopped) {
|
||||
await _audio.stop();
|
||||
}
|
||||
} catch (_) {
|
||||
_pendingRemotePlay = !_audio.isStopped;
|
||||
} finally {
|
||||
_isStartingCastStream = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _play() async {
|
||||
if (_casting.status == CastingStatus.connecting) {
|
||||
_pendingRemotePlay = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_usesExternalCastPlayback) {
|
||||
if (_casting.isPlaying || _isStartingCastStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _startCastingLiveStream();
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePlay = false;
|
||||
await _audio.play();
|
||||
}
|
||||
|
||||
Future<void> _stop() async {
|
||||
if (_usesExternalCastPlayback) {
|
||||
await _casting.stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
await _audio.stop();
|
||||
}
|
||||
|
||||
Future<void> _toggleCasting() async {
|
||||
if (_casting.isConnected) {
|
||||
await _casting.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
final shouldAutoplay = !_audio.isStopped;
|
||||
// Do NOT set _pendingRemotePlay when native handles autoplay.
|
||||
// The native pendingCastRequest already handles autoplay via
|
||||
// showDevicePicker(autoplay: true). Setting _pendingRemotePlay here
|
||||
// causes a duplicate load from Flutter that races the native load and
|
||||
// corrupts the cast status (especially in the Android Auto path).
|
||||
// _pendingRemotePlay is only set by _play() when the user explicitly
|
||||
// presses Play while the cast session is still connecting.
|
||||
_pendingRemotePlay = false;
|
||||
await _casting.showDevicePicker(
|
||||
autoplay: shouldAutoplay,
|
||||
streamUrl: shouldAutoplay ? _audio.streamUrl : null,
|
||||
title: shouldAutoplay ? _audio.currentTitle : null,
|
||||
subtitle: shouldAutoplay ? _audio.currentSubtitle : null,
|
||||
);
|
||||
}
|
||||
|
||||
String _castingLabel() {
|
||||
final idleLabel = _isIosAirPlay ? 'AirPlay' : 'Cast';
|
||||
final connectingLabel = _isIosAirPlay ? 'AirPlay...' : 'Casting...';
|
||||
final errorLabel = _isIosAirPlay ? 'AirPlay Error' : 'Cast Error';
|
||||
final unavailableLabel = _isIosAirPlay ? 'No AirPlay' : 'No Cast';
|
||||
|
||||
switch (_casting.status) {
|
||||
case CastingStatus.idle:
|
||||
return idleLabel;
|
||||
case CastingStatus.connecting:
|
||||
return connectingLabel;
|
||||
case CastingStatus.connected:
|
||||
final deviceName = _casting.connectedDeviceName;
|
||||
if (deviceName != null && deviceName.isNotEmpty) {
|
||||
return 'On $deviceName';
|
||||
}
|
||||
return _isIosAirPlay ? 'AirPlaying' : 'Casting';
|
||||
case CastingStatus.error:
|
||||
return errorLabel;
|
||||
case CastingStatus.unavailable:
|
||||
return unavailableLabel;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _castingIcon() {
|
||||
if (_isIosAirPlay) {
|
||||
return Icons.airplay_rounded;
|
||||
}
|
||||
|
||||
return _casting.isConnected
|
||||
? Icons.cast_connected_rounded
|
||||
: Icons.cast_rounded;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_audio.removeListener(_handleAudioStateChanged);
|
||||
_casting.removeListener(_handleCastingStateChanged);
|
||||
_liveInfo.removeListener(_handleLiveInfoChanged);
|
||||
_liveInfo.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeColors = AppThemeBusinessObject.colorsFor(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 9,
|
||||
body: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
themeColors.scaffoldGradientStart,
|
||||
themeColors.scaffoldGradientEnd,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: themeColors.webViewBorder,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: themeColors.webViewBackground,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: themeColors.webViewBorder,
|
||||
color: themeColors.shellSurface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: themeColors.shellBorder),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: themeColors.controlsShadow,
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Station Information',
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: themeColors.statusText,
|
||||
fontWeight: FontWeight.w700,
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon.png',
|
||||
width: 72,
|
||||
height: 72,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'https://kryzradio.org',
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Colors.blue,
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppConfig.appName,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.4,
|
||||
color: themeColors.headingColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 86),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: WebViewWidget(controller: _webViewController),
|
||||
),
|
||||
],
|
||||
child: LiveInfoPanel(
|
||||
liveInfo: _liveInfo,
|
||||
themeColors: themeColors,
|
||||
intervalOptions: _intervalOptions,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Container(
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -196,35 +437,49 @@ class _MainPageState extends State<MainPage> {
|
||||
BoxShadow(
|
||||
color: themeColors.controlsShadow,
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 6),
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _audio.isStopped ? _play : null,
|
||||
child: const Text('▶️'),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isPlaybackStopped ? _play : null,
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: const Text('Play'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _audio.isStopped ? null : _stop,
|
||||
child: const Text('⏹️'),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isPlaybackStopped ? null : _stop,
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
label: const Text('Stop'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed:
|
||||
_casting.status == CastingStatus.connecting
|
||||
? null
|
||||
: _toggleCasting,
|
||||
icon: Icon(_castingIcon()),
|
||||
label: Text(_castingLabel()),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
_playbackStatusLabel(),
|
||||
style: TextStyle(color: themeColors.statusText),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: themeColors.textOnDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'live_info_business_object.dart';
|
||||
import 'time_formatter.dart';
|
||||
|
||||
class RecentlyPlayedList extends StatelessWidget {
|
||||
const RecentlyPlayedList({
|
||||
super.key,
|
||||
required this.recentPlayed,
|
||||
required this.isLoading,
|
||||
});
|
||||
|
||||
final List<RecentPlayedItem> recentPlayed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Recently Played (runtime)',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: recentPlayed.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
isLoading
|
||||
? 'Loading recent items...'
|
||||
: 'No recent tracks captured yet',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: recentPlayed.length,
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final RecentPlayedItem item = recentPlayed[index];
|
||||
final String window = formatTimeWindow(
|
||||
item.starts,
|
||||
item.ends,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 0,
|
||||
),
|
||||
title: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
item.subtitle.isEmpty
|
||||
? window
|
||||
: '${item.subtitle} | $window',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
leading: const Icon(Icons.queue_music_rounded),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'live_info_business_object.dart';
|
||||
import 'time_formatter.dart';
|
||||
|
||||
class ShowCard extends StatelessWidget {
|
||||
const ShowCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.show,
|
||||
required this.sourceTimezone,
|
||||
required this.titleColor,
|
||||
required this.cardSurface,
|
||||
required this.cardBorder,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final LiveInfoShowInfo? show;
|
||||
final String? sourceTimezone;
|
||||
final Color titleColor;
|
||||
final Color cardSurface;
|
||||
final Color cardBorder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: cardSurface,
|
||||
border: Border.all(color: cardBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.mic_external_on_rounded, color: titleColor, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$label: ${show?.name ?? 'No scheduled show'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formatStartingTime(
|
||||
show?.starts,
|
||||
sourceTimezone: sourceTimezone,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: titleColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,95 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import 'generated/app_config.dart';
|
||||
import 'kryz_audio_handler.dart';
|
||||
import 'live_info_business_object.dart';
|
||||
|
||||
enum StreamingPlaybackStatus { stopped, buffering, playing }
|
||||
|
||||
class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||
StreamingAudioBusinessObject({
|
||||
this.streamUrl = 'https://kryz.out.airtime.pro/kryz_a',
|
||||
}) {
|
||||
_playerStateSubscription = _player.playerStateStream.listen((state) {
|
||||
_setPlaybackStatus(_mapPlayerStateToStatus(state));
|
||||
StreamingAudioBusinessObject() {
|
||||
final initFuture = _audioHandlerInitFuture;
|
||||
if (initFuture != null) {
|
||||
initFuture.then((handler) {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
_attachHandler(handler);
|
||||
});
|
||||
}
|
||||
|
||||
final String streamUrl;
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
late final StreamSubscription<PlayerState> _playerStateSubscription;
|
||||
bool _isSourceLoaded = false;
|
||||
final handler = _audioHandler;
|
||||
if (handler != null) {
|
||||
_attachHandler(handler);
|
||||
}
|
||||
}
|
||||
|
||||
static AudioHandler? _audioHandler;
|
||||
static Future<AudioHandler>? _audioHandlerInitFuture;
|
||||
|
||||
static Future<void> initializeBackgroundAudio() async {
|
||||
if (_audioHandler != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_audioHandlerInitFuture ??= AudioService.init(
|
||||
builder: () => KryzAudioHandler(),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
|
||||
androidNotificationChannelName: '${AppConfig.appName} Audio Playback',
|
||||
androidNotificationOngoing: true,
|
||||
androidStopForegroundOnPause: true,
|
||||
),
|
||||
).then((handler) {
|
||||
_audioHandler = handler;
|
||||
return handler;
|
||||
}).catchError((error) {
|
||||
_audioHandlerInitFuture = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
await _audioHandlerInitFuture;
|
||||
}
|
||||
|
||||
String get streamUrl => KryzAudioHandler.streamUrl;
|
||||
|
||||
StreamSubscription<PlaybackState>? _playbackStateSubscription;
|
||||
StreamSubscription<MediaItem?>? _mediaItemSubscription;
|
||||
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
|
||||
bool _isAudioSessionConfigured = false;
|
||||
bool _playRequested = false;
|
||||
bool _isDisposed = false;
|
||||
bool _isAttached = false;
|
||||
String _currentTitle = AppConfig.defaultTitle;
|
||||
String _currentSubtitle = AppConfig.defaultArtist;
|
||||
|
||||
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
|
||||
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
|
||||
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
|
||||
String get currentTitle => _currentTitle;
|
||||
String get currentSubtitle => _currentSubtitle;
|
||||
|
||||
StreamingPlaybackStatus _mapPlayerStateToStatus(PlayerState state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
Future<void> ensureInitialized() async {
|
||||
await initializeBackgroundAudio();
|
||||
}
|
||||
|
||||
StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) {
|
||||
if (state.processingState == AudioProcessingState.completed) {
|
||||
return StreamingPlaybackStatus.stopped;
|
||||
}
|
||||
|
||||
if (state.playing) {
|
||||
if (state.processingState == ProcessingState.ready) {
|
||||
if (state.processingState == AudioProcessingState.ready) {
|
||||
return StreamingPlaybackStatus.playing;
|
||||
}
|
||||
|
||||
return StreamingPlaybackStatus.buffering;
|
||||
}
|
||||
|
||||
if (_playRequested && state.processingState != ProcessingState.completed) {
|
||||
if (state.processingState == AudioProcessingState.buffering ||
|
||||
state.processingState == AudioProcessingState.loading) {
|
||||
return StreamingPlaybackStatus.buffering;
|
||||
}
|
||||
|
||||
@@ -58,57 +105,66 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _configureAudioSessionIfNeeded() async {
|
||||
if (_isAudioSessionConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration.music());
|
||||
_isAudioSessionConfigured = true;
|
||||
}
|
||||
|
||||
Future<void> play() async {
|
||||
_playRequested = true;
|
||||
await _configureAudioSessionIfNeeded();
|
||||
_setPlaybackStatus(StreamingPlaybackStatus.buffering);
|
||||
|
||||
try {
|
||||
if (!_isSourceLoaded) {
|
||||
await _player.setAudioSource(
|
||||
AudioSource.uri(
|
||||
Uri.parse(streamUrl),
|
||||
tag: const MediaItem(
|
||||
id: 'kryz-live-stream',
|
||||
title: 'KRYZ Live Stream',
|
||||
artist: 'KRYZ Radio',
|
||||
album: 'Live',
|
||||
),
|
||||
),
|
||||
);
|
||||
_isSourceLoaded = true;
|
||||
}
|
||||
|
||||
final session = await AudioSession.instance;
|
||||
await session.setActive(true);
|
||||
await _player.play();
|
||||
} catch (_) {
|
||||
_playRequested = false;
|
||||
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
||||
rethrow;
|
||||
}
|
||||
final handler = await _requireHandler();
|
||||
await handler.play();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_playRequested = false;
|
||||
final handler = await _requireHandler();
|
||||
await handler.stop();
|
||||
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
|
||||
await _player.stop();
|
||||
}
|
||||
|
||||
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
|
||||
final handler = await _requireHandler();
|
||||
await handler.updateMetadataFromLiveInfo(snapshot);
|
||||
}
|
||||
|
||||
Future<KryzAudioHandler> _requireHandler() async {
|
||||
await initializeBackgroundAudio();
|
||||
final handler = _audioHandler;
|
||||
if (handler is! KryzAudioHandler) {
|
||||
throw StateError('KryzAudioHandler is not initialized');
|
||||
}
|
||||
_attachHandler(handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
void _attachHandler(AudioHandler handler) {
|
||||
if (_isAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isAttached = true;
|
||||
_playbackStateSubscription = handler.playbackState.listen((state) {
|
||||
_setPlaybackStatus(_mapPlaybackStateToStatus(state));
|
||||
});
|
||||
_mediaItemSubscription = handler.mediaItem.listen((item) {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final title = item.title.trim().isEmpty ? AppConfig.defaultTitle : item.title;
|
||||
final subtitle =
|
||||
(item.artist ?? '').trim().isEmpty ? AppConfig.defaultArtist : item.artist!.trim();
|
||||
|
||||
if (title == _currentTitle && subtitle == _currentSubtitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentTitle = title;
|
||||
_currentSubtitle = subtitle;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerStateSubscription.cancel();
|
||||
_player.dispose();
|
||||
_isDisposed = true;
|
||||
_playbackStateSubscription?.cancel();
|
||||
_mediaItemSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
// Source - https://stackoverflow.com/a/54775297
|
||||
// Posted by diegoveloper, modified by community. See post 'Timeline' for change history
|
||||
// Retrieved 2026-05-09, License - CC BY-SA 4.0
|
||||
|
||||
String _printDuration(Duration duration) {
|
||||
String negativeSign = duration.isNegative ? '-' : '';
|
||||
String twoDigits(int n) => n.toString().padLeft(2, "0");
|
||||
String twoDigitMinutes = twoDigits(duration.inMinutes.abs());
|
||||
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
|
||||
return "$negativeSign$twoDigitMinutes:$twoDigitSeconds";
|
||||
}
|
||||
|
||||
String formatTimeWindow(String? starts, String? ends) {
|
||||
if (starts != null && ends != null) {
|
||||
DateTime? startDt = _tryParseServerDateTime(starts);
|
||||
DateTime? endDt = _tryParseServerDateTime(ends);
|
||||
if (startDt == null || endDt == null) {
|
||||
return '--:--';
|
||||
}
|
||||
|
||||
Duration songLength = endDt.difference(startDt);
|
||||
|
||||
return _printDuration(songLength);
|
||||
}
|
||||
|
||||
return "--:--";
|
||||
}
|
||||
|
||||
String formatStartingTime(String? startingTime, {String? sourceTimezone}) {
|
||||
if (startingTime != null) {
|
||||
final DateTime? localStart = _serverTimeToLocal(
|
||||
startingTime,
|
||||
sourceTimezone: sourceTimezone,
|
||||
);
|
||||
if (localStart == null) {
|
||||
return '--:--';
|
||||
}
|
||||
return DateFormat('jm').format(localStart);
|
||||
}
|
||||
return "--:--";
|
||||
}
|
||||
|
||||
DateTime? _serverTimeToLocal(String rawTime, {String? sourceTimezone}) {
|
||||
final DateTime? parsed = _tryParseServerDateTime(rawTime);
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_hasExplicitTimezone(rawTime)) {
|
||||
return parsed.toLocal();
|
||||
}
|
||||
|
||||
// API timestamps are wall-clock values in the API timezone field.
|
||||
if (sourceTimezone == null || sourceTimezone.trim().isEmpty) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
final tz.Location sourceLocation = tz.getLocation(sourceTimezone.trim());
|
||||
final tz.TZDateTime sourceTime = tz.TZDateTime(
|
||||
sourceLocation,
|
||||
parsed.year,
|
||||
parsed.month,
|
||||
parsed.day,
|
||||
parsed.hour,
|
||||
parsed.minute,
|
||||
parsed.second,
|
||||
parsed.millisecond,
|
||||
parsed.microsecond,
|
||||
);
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
sourceTime.millisecondsSinceEpoch,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
} catch (_) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasExplicitTimezone(String value) {
|
||||
final String trimmed = value.trim();
|
||||
|
||||
if (trimmed.endsWith('Z') || trimmed.endsWith('z')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final RegExp offsetPattern = RegExp(r'[+-]\d{2}:?\d{2}$');
|
||||
return offsetPattern.hasMatch(trimmed);
|
||||
}
|
||||
|
||||
DateTime? _tryParseServerDateTime(String value) {
|
||||
final String trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final DateTime? isoAttempt = DateTime.tryParse(trimmed);
|
||||
if (isoAttempt != null) {
|
||||
return isoAttempt;
|
||||
}
|
||||
|
||||
final RegExp serverDateTimePattern = RegExp(
|
||||
r'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$',
|
||||
);
|
||||
final RegExpMatch? match = serverDateTimePattern.firstMatch(trimmed);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int parsePart(int group) => int.parse(match.group(group)!);
|
||||
|
||||
final int year = parsePart(1);
|
||||
final int month = parsePart(2);
|
||||
final int day = parsePart(3);
|
||||
final int hour = parsePart(4);
|
||||
final int minute = parsePart(5);
|
||||
final int second = parsePart(6);
|
||||
|
||||
final String fraction = (match.group(7) ?? '').padRight(6, '0');
|
||||
final int microseconds =
|
||||
fraction.isEmpty ? 0 : int.parse(fraction.substring(0, 6));
|
||||
|
||||
return DateTime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
microseconds ~/ 1000,
|
||||
microseconds % 1000,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'live_info_business_object.dart';
|
||||
import 'time_formatter.dart';
|
||||
|
||||
class TrackCard extends StatelessWidget {
|
||||
const TrackCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.track,
|
||||
required this.titleColor,
|
||||
required this.cardSurface,
|
||||
required this.cardBorder,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final LiveInfoTrackInfo? track;
|
||||
final Color titleColor;
|
||||
final Color cardSurface;
|
||||
final Color cardBorder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String title = track?.displayTitle ?? 'Unavailable';
|
||||
final String subtitle = (track?.displaySubtitle ?? '').trim();
|
||||
final String window = formatTimeWindow(track?.starts, track?.ends);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cardSurface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: cardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle.isEmpty ? 'No metadata provided' : subtitle,
|
||||
maxLines: 6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: titleColor),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
window,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(KRYZ Go! LANGUAGES CXX)
|
||||
project(KRYZ_Go! LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
|
||||