diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2adc813..00a4b06 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -115,6 +115,10 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.hls)
+ // Google Cast (Chromecast) support.
+ implementation(libs.androidx.mediarouter)
+ implementation(libs.play.services.cast.framework)
+
implementation(libs.androidx.palette)
implementation(libs.androidx.activity)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 3ce8f25..ef24c4d 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -39,6 +39,10 @@
-keep class com.larvey.azuracastplayer.classes.models.* { *; }
-keep class com.larvey.azuracastplayer.classes.data.* { *; }
+
+# Cast: the OptionsProvider is instantiated reflectively from the manifest meta-data.
+-keep class com.larvey.azuracastplayer.session.cast.CastOptionsProvider { *; }
+-keep class * implements com.google.android.gms.cast.framework.OptionsProvider
-keep class com.google.gson.reflect.TypeToken
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 07f963b..90b04b9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,17 @@
+
+
+
+
+
+
+
+
+
+
+
>(emptyList())
+ val selectedRoute = mutableStateOf(null)
+ val routeVolume = mutableStateOf(0)
+ val isRefreshingRoutes = mutableStateOf(false)
+
+ // --- Internals ---
+
+ private var initialized = false
+ private var castContext: CastContext? = null
+ private var sessionManager: SessionManager? = null
+ private val mediaRouter: MediaRouter by lazy { MediaRouter.getInstance(context) }
+
+ private var castSession: CastSession? = null
+ private var castRadioPlayer: CastRadioPlayer? = null
+
+ /**
+ * When we push a receiver-driven play/pause onto the local player, suppress the
+ * local→receiver mirror for a moment so the two-way sync doesn't feed back on
+ * itself (Media3 listener callbacks are delivered asynchronously, so a boolean
+ * flag set/reset around the call wouldn't cover them).
+ */
+ private var suppressLocalMirrorUntilMs = 0L
+
+ /** Identity of what is currently loaded on the receiver, to skip song-change reloads. */
+ private var lastLoadedKey: String? = null
+
+ private val castSelector: MediaRouteSelector by lazy {
+ MediaRouteSelector.Builder()
+ .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
+ .addControlCategory(
+ CastMediaControlIntent.categoryForCast(
+ CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
+ )
+ )
+ .build()
+ }
+
+ private fun MediaRouter.RouteInfo.isCastRoute(): Boolean =
+ supportsControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) ||
+ supportsControlCategory(
+ CastMediaControlIntent.categoryForCast(
+ CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
+ )
+ )
+
+ private fun localPlayer(): Player? = sharedMediaController.mediaSession.value?.player
+
+ // --- Lifecycle ---
+
+ /**
+ * Called once from [com.larvey.azuracastplayer.session.MusicPlayerService.onCreate]
+ * after the media session is published. Registers the Cast session listener,
+ * starts passive route discovery, and begins observing the now-playing station
+ * so the receiver reloads when the station changes.
+ */
+ fun initialize() {
+ if (initialized) return
+ initialized = true
+
+ val manager = try {
+ CastContext.getSharedInstance(context).sessionManager
+ } catch (e: Exception) {
+ Log.i(TAG, "Cast unavailable (no Play Services?): ${e.message}")
+ isCastAvailable.value = false
+ null
+ }
+ if (manager == null) return
+
+ castContext = try {
+ CastContext.getSharedInstance(context)
+ } catch (e: Exception) {
+ null
+ }
+ sessionManager = manager
+ isCastAvailable.value = true
+
+ manager.addSessionManagerListener(sessionManagerListener, CastSession::class.java)
+ // Reattach if a session already exists (e.g. the service restarted while casting).
+ manager.currentCastSession?.let { onCastConnected(it) }
+
+ startDiscovery()
+ observeStationForReload()
+ }
+
+ private val sessionManagerListener = object : SessionManagerListener {
+ override fun onSessionStarting(session: CastSession) {
+ isConnecting.value = true
+ }
+
+ override fun onSessionStarted(session: CastSession, sessionId: String) {
+ onCastConnected(session)
+ }
+
+ override fun onSessionResuming(session: CastSession, sessionId: String) {
+ isConnecting.value = true
+ }
+
+ override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
+ onCastConnected(session)
+ }
+
+ override fun onSessionStartFailed(session: CastSession, error: Int) {
+ isConnecting.value = false
+ isCasting.value = false
+ }
+
+ override fun onSessionResumeFailed(session: CastSession, error: Int) {
+ isConnecting.value = false
+ isCasting.value = false
+ }
+
+ override fun onSessionSuspended(session: CastSession, reason: Int) {
+ isConnecting.value = true
+ }
+
+ override fun onSessionEnding(session: CastSession) {}
+
+ override fun onSessionEnded(session: CastSession, error: Int) {
+ onCastDisconnected()
+ }
+ }
+
+ private fun onCastConnected(session: CastSession) {
+ castSession = session
+ castRadioPlayer = CastRadioPlayer(session)
+ session.remoteMediaClient?.registerCallback(remoteCallback)
+ localPlayer()?.addListener(localPlayerListener)
+
+ // Silence local audio; the receiver plays the sound. The (muted) local
+ // player stays the app's source of truth for the UI.
+ localPlayer()?.volume = 0f
+
+ isCasting.value = true
+ isConnecting.value = false
+ lastLoadedKey = null
+ loadCurrentStation()
+ session.remoteMediaClient?.requestStatus()
+ }
+
+ private fun onCastDisconnected() {
+ castSession?.remoteMediaClient?.unregisterCallback(remoteCallback)
+ localPlayer()?.removeListener(localPlayerListener)
+
+ // Return audio to the phone.
+ localPlayer()?.volume = 1f
+
+ castRadioPlayer = null
+ castSession = null
+ lastLoadedKey = null
+ isCasting.value = false
+ isConnecting.value = false
+ }
+
+ /** Stops casting completely: ends the session (receiver stops) and stops the local player. */
+ fun stopCasting() {
+ localPlayer()?.stop()
+ sessionManager?.endCurrentSession(true)
+ }
+
+ // --- Station load / reload ---
+
+ // The receiver artwork is downscaled on the phone and sent as a small data: URI
+ // (the Default Media Receiver spins forever on oversized/redirecting art URLs).
+ // Cached per station so a resume/reload is instant.
+ private var cachedArtKey: String? = null
+ private var cachedArtDataUri: String? = null
+
+ private fun observeStationForReload() {
+ applicationScope.launch {
+ snapshotFlow { buildStationInfoOrNull() }.collect { info ->
+ if (!isCasting.value || info == null) return@collect
+ val key = stationKey(info)
+ if (key != lastLoadedKey) {
+ lastLoadedKey = key
+ launchLoad(info)
+ }
+ }
+ }
+ }
+
+ private fun loadCurrentStation() {
+ val info = buildStationInfoOrNull() ?: return
+ lastLoadedKey = stationKey(info)
+ launchLoad(info)
+ }
+
+ /** Downscale the art (using the pre-warmed cache if available), then load the station. */
+ private fun launchLoad(info: CastStationInfo) {
+ val key = stationKey(info)
+ applicationScope.launch {
+ val artDataUri = resolveArtDataUri(info.artUrl, key)
+ if (isCasting.value) {
+ castRadioPlayer?.loadStation(info.copy(artUrl = artDataUri))
+ }
+ }
+ }
+
+ /**
+ * Kick off the (cached) art downscale early — during the connect handshake — so
+ * that by the time we load the station on the receiver the small data URI is
+ * ready and audio isn't delayed.
+ */
+ private fun prewarmArt() {
+ val info = buildStationInfoOrNull() ?: return
+ applicationScope.launch { resolveArtDataUri(info.artUrl, stationKey(info)) }
+ }
+
+ private suspend fun resolveArtDataUri(artUrl: String?, key: String): String? {
+ val url = artUrl?.takeIf { it.isNotBlank() } ?: return null
+ if (key == cachedArtKey) return cachedArtDataUri
+ val dataUri = withTimeoutOrNull(4000L) { withContext(Dispatchers.IO) { encodeArt(url) } }
+ cachedArtKey = key
+ cachedArtDataUri = dataUri
+ return dataUri
+ }
+
+ private suspend fun encodeArt(url: String): String? {
+ return try {
+ val request = ImageRequest.Builder(context)
+ .allowHardware(false)
+ .size(RECEIVER_ART_SIZE, RECEIVER_ART_SIZE)
+ .data(url.fixHttps())
+ .build()
+ val result = ImageLoader(context).execute(request)
+ if (result !is SuccessResult) return null
+ val bitmap = result.image.toBitmap()
+ val out = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
+ val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
+ // Keep the load message well under the Cast channel size limit.
+ if (base64.length > 90_000) null else "data:image/jpeg;base64,$base64"
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to prepare Cast artwork: ${e.message}")
+ null
+ }
+ }
+
+ private fun stationKey(info: CastStationInfo): String =
+ "${info.streamUrl}|${info.title}|${info.artist}|${info.artUrl}"
+
+ private fun buildStationInfoOrNull(): CastStationInfo? {
+ val mount = nowPlayingData.nowPlayingMount.value
+ val url = nowPlayingData.nowPlayingURL.value
+ val shortCode = nowPlayingData.nowPlayingShortCode.value
+ if (mount.isEmpty() || url.isEmpty() || shortCode.isEmpty()) return null
+
+ val station = nowPlayingData.staticData.value?.station
+ val stationName = station?.name?.takeIf { it.isNotBlank() } ?: shortCode
+ val currentMount = station?.mounts?.find { it.url.fixHttps() == mount }
+ val formatBitrate = when {
+ mount.lowercase(Locale.ROOT).substringBefore('?').endsWith("m3u8") -> "HLS"
+ currentMount?.format != null && currentMount.bitrate != null ->
+ "${currentMount.format.uppercase(Locale.ROOT)} ${currentMount.bitrate}kbps"
+ else -> ""
+ }
+ val artUrl = "https://$url/api/station/$shortCode/art/-1"
+
+ return CastStationInfo(
+ streamUrl = mount,
+ title = stationName,
+ artist = formatBitrate,
+ artUrl = artUrl
+ )
+ }
+
+ // --- Two-way play/pause sync ---
+
+ private val localPlayerListener = object : Player.Listener {
+ override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
+ if (!isCasting.value) return
+ if (SystemClock.elapsedRealtime() < suppressLocalMirrorUntilMs) return
+ if (playWhenReady) {
+ // Live radio: resuming must rejoin the live edge, not resume from the
+ // stale point where the receiver paused — so reload the stream rather
+ // than plain play().
+ loadCurrentStation()
+ } else {
+ castRadioPlayer?.pause()
+ }
+ }
+ }
+
+ private val remoteCallback = object : RemoteMediaClient.Callback() {
+ override fun onStatusUpdated() {
+ mirrorRemoteToLocal()
+ }
+ }
+
+ private fun mirrorRemoteToLocal() {
+ val client = castSession?.remoteMediaClient ?: return
+ val shouldPlay = CastRemotePlaybackState.shouldLocalBePlaying(client.mediaStatus) ?: return
+ val player = localPlayer() ?: return
+ if (player.playWhenReady == shouldPlay) return
+ // Applying a remote-driven change to the local player; don't let the local
+ // listener echo it back to the receiver.
+ suppressLocalMirrorUntilMs = SystemClock.elapsedRealtime() + 1000
+ if (shouldPlay) player.play() else player.pause()
+ }
+
+ // --- Route discovery / selection / volume (used by the cast sheet) ---
+
+ private val mediaRouterCallback = object : MediaRouter.Callback() {
+ override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = syncFromRouter(router)
+ override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) = syncFromRouter(router)
+ override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) = syncFromRouter(router)
+ override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) = syncFromRouter(router)
+ override fun onRouteUnselected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) = syncFromRouter(router)
+ override fun onRouteVolumeChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
+ if (route.id == selectedRoute.value?.id) routeVolume.value = route.volume
+ }
+ }
+
+ /** Passive, low-battery discovery kept running app-wide so the button state is fresh. */
+ fun startDiscovery() {
+ mediaRouter.addCallback(
+ castSelector,
+ mediaRouterCallback,
+ MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY
+ )
+ syncFromRouter(mediaRouter)
+ }
+
+ private var refreshJob: kotlinx.coroutines.Job? = null
+
+ /**
+ * Active discovery while the device sheet is open. This must stay on for the
+ * whole time the sheet is visible: once a session connects, the Cast provider
+ * stops advertising the *other* devices under passive discovery, so they'd
+ * vanish from the list. Active scanning keeps them visible.
+ */
+ fun beginActiveDiscovery() {
+ refreshJob?.cancel()
+ mediaRouter.removeCallback(mediaRouterCallback)
+ mediaRouter.addCallback(
+ castSelector,
+ mediaRouterCallback,
+ MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN
+ )
+ syncFromRouter(mediaRouter)
+ }
+
+ /** Sheet closed: drop back to passive discovery to save battery. */
+ fun endActiveDiscovery() {
+ refreshJob?.cancel()
+ isRefreshingRoutes.value = false
+ mediaRouter.removeCallback(mediaRouterCallback)
+ mediaRouter.addCallback(
+ castSelector,
+ mediaRouterCallback,
+ MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY
+ )
+ syncFromRouter(mediaRouter)
+ }
+
+ /** The sheet's "refresh" affordance: re-arm the active scan and show a brief spinner. */
+ fun refreshRoutes() {
+ refreshJob?.cancel()
+ refreshJob = applicationScope.launch {
+ isRefreshingRoutes.value = true
+ // Re-arm active scanning (already on while the sheet is open) to force a fresh sweep.
+ mediaRouter.removeCallback(mediaRouterCallback)
+ mediaRouter.addCallback(
+ castSelector,
+ mediaRouterCallback,
+ MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN
+ )
+ syncFromRouter(mediaRouter)
+ delay(4000)
+ syncFromRouter(mediaRouter)
+ isRefreshingRoutes.value = false
+ }
+ }
+
+ fun selectRoute(route: MediaRouter.RouteInfo) {
+ isConnecting.value = true
+ // Downscale the artwork now, during the connect handshake, so it's ready by
+ // the time we load the station (audio isn't delayed waiting on the image).
+ prewarmArt()
+ mediaRouter.selectRoute(route)
+ }
+
+ /**
+ * Disconnect: stop the receiver and return audio to the phone. We end the
+ * session with `stopReceiverApplication = true` so playback actually stops on
+ * the Cast device (selecting the default route alone leaves the receiver
+ * playing, since the framework default is to leave the receiver running).
+ */
+ fun disconnect() {
+ val manager = sessionManager
+ if (manager?.currentCastSession != null) {
+ manager.endCurrentSession(true)
+ } else {
+ mediaRouter.selectRoute(mediaRouter.defaultRoute)
+ syncFromRouter(mediaRouter)
+ }
+ }
+
+ /** Ends the session (stopping the receiver) on app teardown. */
+ fun endSessionForShutdown() {
+ runCatching {
+ if (sessionManager?.currentCastSession != null) {
+ sessionManager?.endCurrentSession(true)
+ }
+ }
+ }
+
+ fun setRouteVolume(volume: Int) {
+ routeVolume.value = volume
+ selectedRoute.value?.requestSetVolume(volume)
+ }
+
+ private fun syncFromRouter(router: MediaRouter) {
+ castRoutes.value = router.routes.filter { it.isCastRoute() }.distinctBy { it.id }
+ val selected = router.selectedRoute
+ selectedRoute.value = selected
+ routeVolume.value = selected.volume
+ }
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/classes/models/NowPlayingData.kt b/app/src/main/java/com/larvey/azuracastplayer/classes/models/NowPlayingData.kt
index 2b0755e..8530198 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/classes/models/NowPlayingData.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/classes/models/NowPlayingData.kt
@@ -151,13 +151,18 @@ class NowPlayingData(
shortCode
)] = data
+ // Some stations occasionally send an empty title/artist for the current song; fall back to
+ // explicit placeholders so the UI/notification/Android Auto never show a blank (or "null").
+ val songTitle = data.nowPlaying.song.title.ifBlank { "No title" }
+ val songArtist = data.nowPlaying.song.artist.ifBlank { "No artist" }
+
val metaData = MediaMetadata.Builder()
.setMediaType(MEDIA_TYPE_MUSIC) // Hint for session consumers (e.g. Android Auto content styling).
- .setDisplayTitle(data.nowPlaying.song.title)
- .setSubtitle(data.nowPlaying.song.artist) // Android Auto renders the subtitle as the artist line.
- .setArtist(data.nowPlaying.song.artist)
+ .setDisplayTitle(songTitle)
+ .setSubtitle(songArtist) // Android Auto renders the subtitle as the artist line.
+ .setArtist(songArtist)
.setAlbumTitle(data.nowPlaying.song.album)
- .setAlbumArtist(data.nowPlaying.song.artist)
+ .setAlbumArtist(songArtist)
.setDescription(data.nowPlaying.song.album) // Android Auto renders the description as the album line.
.setGenre(data.nowPlaying.song.genre)
.setArtworkUri(data.nowPlaying.song.art.fixHttps().toUri())
diff --git a/app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastConnectivity.kt b/app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastConnectivity.kt
new file mode 100644
index 0000000..888a739
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastConnectivity.kt
@@ -0,0 +1,23 @@
+package com.larvey.azuracastplayer.hiltModules.singletons
+
+import android.content.Context
+import com.larvey.azuracastplayer.session.cast.CastConnectivity
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object CastConnectivityModule {
+
+ @Provides
+ @Singleton
+ fun provideCastConnectivity(
+ @ApplicationContext context: Context
+ ): CastConnectivity {
+ return CastConnectivity(context)
+ }
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastManager.kt b/app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastManager.kt
new file mode 100644
index 0000000..64a0c78
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastManager.kt
@@ -0,0 +1,35 @@
+package com.larvey.azuracastplayer.hiltModules.singletons
+
+import android.content.Context
+import com.larvey.azuracastplayer.classes.models.CastManager
+import com.larvey.azuracastplayer.classes.models.NowPlayingData
+import com.larvey.azuracastplayer.classes.models.SharedMediaController
+import com.larvey.azuracastplayer.hiltModules.ApplicationScope
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object CastManagerModule {
+
+ @Provides
+ @Singleton
+ fun provideCastManager(
+ @ApplicationContext context: Context,
+ @ApplicationScope applicationScope: CoroutineScope,
+ sharedMediaController: SharedMediaController,
+ nowPlayingData: NowPlayingData
+ ): CastManager {
+ return CastManager(
+ context = context,
+ applicationScope = applicationScope,
+ sharedMediaController = sharedMediaController,
+ nowPlayingData = nowPlayingData
+ )
+ }
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/session/MusicPlayerService.kt b/app/src/main/java/com/larvey/azuracastplayer/session/MusicPlayerService.kt
index 46cf741..8643d6d 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/session/MusicPlayerService.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/session/MusicPlayerService.kt
@@ -39,6 +39,7 @@ import com.larvey.azuracastplayer.AppSetup
import com.larvey.azuracastplayer.R
import com.larvey.azuracastplayer.classes.data.DiscoveryJSON
import com.larvey.azuracastplayer.classes.data.DiscoveryStation
+import com.larvey.azuracastplayer.classes.models.CastManager
import com.larvey.azuracastplayer.classes.models.NowPlayingData
import com.larvey.azuracastplayer.classes.models.SavedStationsDB
import com.larvey.azuracastplayer.classes.models.SharedMediaController
@@ -106,6 +107,9 @@ class MusicPlayerService : MediaLibraryService() {
@Inject
lateinit var discoveryJSON: MutableState
+ @Inject
+ lateinit var castManager: CastManager
+
lateinit var savedChildrenStyle: AndroidAutoLayouts
@@ -117,7 +121,11 @@ class MusicPlayerService : MediaLibraryService() {
TAG,
"Sleep timer fired; stopping playback"
)
- mediaSession?.player?.stop()
+ if (castManager.isCasting.value) {
+ castManager.stopCasting()
+ } else {
+ mediaSession?.player?.stop()
+ }
}
}
@@ -264,6 +272,10 @@ class MusicPlayerService : MediaLibraryService() {
mediaSession?.let {
sharedMediaController.mediaSession.value = mediaSession
}
+
+ // Cast: register the session listener + route discovery now that the local
+ // player/session exist. Safe no-op when Play Services / a Cast device is absent.
+ castManager.initialize()
}
@UnstableApi
@@ -330,7 +342,13 @@ class MusicPlayerService : MediaLibraryService() {
args: Bundle
): ListenableFuture {
if (customCommand.customAction == "STOP_RADIO") {
- session.player.stop()
+ // While casting, Stop must end the cast session entirely (which also
+ // stops the local player); otherwise stop the local player as usual.
+ if (castManager.isCasting.value) {
+ castManager.stopCasting()
+ } else {
+ session.player.stop()
+ }
return Futures.immediateFuture(
SessionResult(SessionResult.RESULT_SUCCESS)
)
@@ -883,6 +901,8 @@ class MusicPlayerService : MediaLibraryService() {
// The user dismissed the app from the recent tasks
override fun onTaskRemoved(rootIntent: Intent?) {
+ // Stop the Cast receiver too when the app is swiped away.
+ castManager.endSessionForShutdown()
val player = mediaSession?.player!!
player.stop()
player.release()
@@ -891,6 +911,8 @@ class MusicPlayerService : MediaLibraryService() {
// Remember to release the player and media session in onDestroy
override fun onDestroy() {
+ // Stop the Cast receiver on shutdown so it doesn't keep playing after the app dies.
+ castManager.endSessionForShutdown()
mediaSession?.run {
player.stop()
player.release()
diff --git a/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastConnectivity.kt b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastConnectivity.kt
new file mode 100644
index 0000000..d8d1580
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastConnectivity.kt
@@ -0,0 +1,131 @@
+package com.larvey.azuracastplayer.session.cast
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothA2dp
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothClass
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.wifi.WifiManager
+import android.os.Build
+import androidx.compose.runtime.mutableStateOf
+import androidx.core.content.ContextCompat
+
+/** A Bluetooth audio (A2DP) device shown in the cast sheet. */
+data class BluetoothAudioDevice(
+ val name: String,
+ val address: String,
+ val isConnected: Boolean
+)
+
+/**
+ * Lightweight Wi-Fi + Bluetooth state for the cast device sheet's connectivity
+ * tiles and Bluetooth device list. All reads are best-effort and permission-safe
+ * (wrapped in try/catch) — the sheet gates the relevant runtime permissions
+ * before showing devices, but this must never crash if they're missing.
+ *
+ * SSID is intentionally not read on Android 12+ (it would imply location access);
+ * we show a generic "Wi-Fi Connected" label there instead.
+ */
+class CastConnectivity(private val context: Context) {
+
+ val isWifiEnabled = mutableStateOf(false)
+ val wifiName = mutableStateOf(null)
+ val isBluetoothEnabled = mutableStateOf(false)
+ val bluetoothDevices = mutableStateOf>(emptyList())
+
+ private val wifiManager: WifiManager? =
+ context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
+
+ private val bluetoothAdapter: BluetoothAdapter? =
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+
+ private var a2dpProxy: BluetoothA2dp? = null
+
+ init {
+ // Cache the A2DP profile proxy so we can report which bonded audio devices
+ // are actually connected.
+ runCatching {
+ bluetoothAdapter?.getProfileProxy(
+ context,
+ object : BluetoothProfile.ServiceListener {
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+ if (profile == BluetoothProfile.A2DP) {
+ a2dpProxy = proxy as? BluetoothA2dp
+ refresh()
+ }
+ }
+
+ override fun onServiceDisconnected(profile: Int) {
+ if (profile == BluetoothProfile.A2DP) a2dpProxy = null
+ }
+ },
+ BluetoothProfile.A2DP
+ )
+ }
+ }
+
+ /** Recompute all state. Called when the sheet opens and on its refresh button. */
+ fun refresh() {
+ isWifiEnabled.value = wifiManager?.isWifiEnabled == true
+ wifiName.value = resolveWifiName()
+ isBluetoothEnabled.value = bluetoothAdapter?.isEnabled == true
+ bluetoothDevices.value = resolveBluetoothDevices()
+ }
+
+ private fun resolveWifiName(): String? {
+ if (wifiManager?.isWifiEnabled != true) return null
+ // Reading the SSID requires location on ≤ Android 11; on 12+ we avoid it.
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
+ @Suppress("DEPRECATION")
+ val ssid = runCatching { wifiManager?.connectionInfo?.ssid }.getOrNull()
+ if (!ssid.isNullOrBlank() && ssid != "") {
+ return ssid.trim('"')
+ }
+ }
+ return "Wi-Fi Connected"
+ }
+
+ // Every Bluetooth read below is guarded by an explicit BLUETOOTH_CONNECT check
+ // and wrapped in runCatching; lint can't trace the helper, so suppress here.
+ @SuppressLint("MissingPermission")
+ private fun resolveBluetoothDevices(): List {
+ val adapter = bluetoothAdapter ?: return emptyList()
+ if (!adapter.isEnabled) return emptyList()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ !hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ ) {
+ return emptyList()
+ }
+ val connected = runCatching { a2dpProxy?.connectedDevices?.map { it.address }?.toSet() }
+ .getOrNull() ?: emptySet()
+ return runCatching {
+ adapter.bondedDevices
+ .filter { it.isAudioDevice() }
+ .map { device ->
+ BluetoothAudioDevice(
+ name = device.safeName() ?: device.address,
+ address = device.address,
+ isConnected = device.address in connected
+ )
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun BluetoothDevice.isAudioDevice(): Boolean {
+ val major = runCatching { bluetoothClass?.majorDeviceClass }.getOrNull()
+ return major == BluetoothClass.Device.Major.AUDIO_VIDEO
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun BluetoothDevice.safeName(): String? =
+ runCatching { name }.getOrNull()?.takeIf { it.isNotBlank() }
+
+ private fun hasPermission(permission: String): Boolean =
+ ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastOptionsProvider.kt b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastOptionsProvider.kt
new file mode 100644
index 0000000..5da2dca
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastOptionsProvider.kt
@@ -0,0 +1,34 @@
+package com.larvey.azuracastplayer.session.cast
+
+import android.content.Context
+import com.google.android.gms.cast.CastMediaControlIntent
+import com.google.android.gms.cast.framework.CastOptions
+import com.google.android.gms.cast.framework.OptionsProvider
+import com.google.android.gms.cast.framework.SessionProvider
+
+/**
+ * Bootstraps the Google Cast SDK. Declared in the manifest via the
+ * `com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME` meta-data
+ * and instantiated reflectively by [com.google.android.gms.cast.framework.CastContext]
+ * (hence the ProGuard keep rule).
+ *
+ * We target the stock Google **Default Media Receiver** — it needs no console
+ * registration and plays any standard audio stream, which is all a radio station
+ * needs.
+ *
+ * We deliberately do **not** set a `CastMediaOptions`. That tells the SDK not to
+ * create its own media session or notification: the app keeps its single Media3
+ * notification (driven by the local player, which stays running muted while
+ * casting), and that notification's controls drive the receiver through
+ * [CastManager]'s sync. Setting `CastMediaOptions` would spawn a competing cast
+ * notification.
+ */
+class CastOptionsProvider : OptionsProvider {
+ override fun getCastOptions(context: Context): CastOptions {
+ return CastOptions.Builder()
+ .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
+ .build()
+ }
+
+ override fun getAdditionalSessionProviders(context: Context): List? = null
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRadioPlayer.kt b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRadioPlayer.kt
new file mode 100644
index 0000000..82607e5
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRadioPlayer.kt
@@ -0,0 +1,103 @@
+package com.larvey.azuracastplayer.session.cast
+
+import android.net.Uri
+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.framework.CastSession
+import com.google.android.gms.cast.framework.media.RemoteMediaClient
+import com.google.android.gms.common.images.WebImage
+import com.larvey.azuracastplayer.utils.fixHttps
+import java.util.Locale
+
+/**
+ * The one static piece of metadata a radio station shows on the Cast receiver.
+ * The receiver deliberately never sees the per-song metadata (see [CastManager]):
+ * [title] is the station name, [artist] is the stream's format + bitrate, and
+ * [artUrl] is the station art.
+ */
+data class CastStationInfo(
+ val streamUrl: String,
+ val title: String,
+ val artist: String,
+ val artUrl: String?
+)
+
+/**
+ * Thin wrapper over a Cast session's [RemoteMediaClient] that plays a single
+ * **live** radio stream. Unlike a normal media caster, it loads the stream as
+ * [MediaInfo.STREAM_TYPE_LIVE] with **no duration**, so the receiver renders it
+ * with no progress bar or timestamp, and it carries only the fixed station
+ * metadata — a song change never touches the receiver; only a station change
+ * triggers a fresh [loadStation].
+ *
+ * All [RemoteMediaClient] calls must run on the main thread (the caller,
+ * [CastManager], already lives on the app's main-immediate scope).
+ */
+class CastRadioPlayer(castSession: CastSession) {
+
+ private val remoteMediaClient: RemoteMediaClient? = castSession.remoteMediaClient
+
+ val hasClient: Boolean get() = remoteMediaClient != null
+
+ /** True if the receiver is currently playing or buffering our stream. */
+ val isPlaying: Boolean get() = remoteMediaClient?.isPlaying == true
+
+ fun loadStation(info: CastStationInfo) {
+ val client = remoteMediaClient ?: return
+
+ val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
+ putString(MediaMetadata.KEY_TITLE, info.title)
+ putString(MediaMetadata.KEY_ARTIST, info.artist)
+ // [info.artUrl] is a phone-downscaled `data:` image URI (or null) prepared by
+ // CastManager, so the receiver never fetches an oversized remote image.
+ info.artUrl?.takeIf { it.isNotBlank() }?.let {
+ addImage(WebImage(Uri.parse(it)))
+ }
+ }
+
+ val streamUrl = info.streamUrl.fixHttps()
+ val mediaInfo = MediaInfo.Builder(streamUrl)
+ .setStreamType(MediaInfo.STREAM_TYPE_LIVE) // live → receiver shows no progress/timestamp
+ .setContentType(contentTypeFor(streamUrl))
+ .setMetadata(metadata)
+ // No setStreamDuration: live streams have unknown duration.
+ .build()
+
+ client.load(
+ MediaLoadRequestData.Builder()
+ .setMediaInfo(mediaInfo)
+ .setAutoplay(true)
+ .build()
+ )
+ }
+
+ fun play() {
+ remoteMediaClient?.play()
+ }
+
+ fun pause() {
+ remoteMediaClient?.pause()
+ }
+
+ fun stop() {
+ remoteMediaClient?.stop()
+ }
+
+ companion object {
+ /**
+ * Best-effort MIME for the receiver based on the stream URL extension. The
+ * Default Media Receiver plays MP3/AAC/OGG progressive streams and HLS
+ * (HLS additionally requires CORS headers on the station host).
+ */
+ private fun contentTypeFor(streamUrl: String): String {
+ val path = streamUrl.substringBefore('?').lowercase(Locale.ROOT)
+ return when {
+ path.endsWith(".m3u8") -> "application/x-mpegurl"
+ path.endsWith(".aac") || path.endsWith(".aacp") -> "audio/aac"
+ path.endsWith(".ogg") || path.endsWith(".opus") -> "audio/ogg"
+ else -> "audio/mpeg" // mp3 and the common default for AzuraCast mounts
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRemotePlaybackState.kt b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRemotePlaybackState.kt
new file mode 100644
index 0000000..273693a
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRemotePlaybackState.kt
@@ -0,0 +1,28 @@
+package com.larvey.azuracastplayer.session.cast
+
+import com.google.android.gms.cast.MediaStatus
+
+/**
+ * Projects a Cast [MediaStatus] into "should the (muted) local player be
+ * playing?" for the receiver → app half of the two-way sync in [CastManager].
+ *
+ * Returns:
+ * - `true` → the receiver is playing/buffering; mirror local to playing.
+ * - `false` → the receiver is paused; mirror local to paused.
+ * - `null` → indeterminate (unknown/idle/loading); leave the local player as-is
+ * so a transient state doesn't fight the app or a pending reload.
+ *
+ * Live radio has no meaningful seek/finish, so we keep this intentionally small:
+ * BUFFERING counts as playing (it's trying to), and IDLE/UNKNOWN/LOADING are
+ * left indeterminate rather than forcing a pause.
+ */
+internal object CastRemotePlaybackState {
+ fun shouldLocalBePlaying(mediaStatus: MediaStatus?): Boolean? {
+ return when (mediaStatus?.playerState) {
+ MediaStatus.PLAYER_STATE_PLAYING,
+ MediaStatus.PLAYER_STATE_BUFFERING -> true
+ MediaStatus.PLAYER_STATE_PAUSED -> false
+ else -> null
+ }
+ }
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt
index 62cd077..8199914 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt
@@ -856,7 +856,15 @@ class MainActivity : ComponentActivity() {
nowPlaying = { mainActivityViewModel?.nowPlayingData?.staticData?.value?.nowPlaying },
play = { mediaController?.play() },
pause = { mediaController?.pause() },
- stop = { mainActivityViewModel?.sharedMediaController?.mediaSession?.value?.player?.stop() },
+ stop = {
+ // While casting, Stop must end the cast session entirely; otherwise
+ // just stop the local player.
+ if (mainActivityViewModel?.castManager?.isCasting?.value == true) {
+ mainActivityViewModel?.castManager?.stopCasting()
+ } else {
+ mainActivityViewModel?.sharedMediaController?.mediaSession?.value?.player?.stop()
+ }
+ },
onDismissProgress = { miniDismissProgress = it },
// Portrait tablet keeps the side rail (no floating bottom nav bar), so the mini bar
// rests just above the system nav inset instead of reserving the nav bar footprint,
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivityViewModel.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivityViewModel.kt
index 3a8378b..ef7ab1a 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivityViewModel.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivityViewModel.kt
@@ -18,6 +18,7 @@ import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
import com.larvey.azuracastplayer.classes.data.SavedStation
+import com.larvey.azuracastplayer.classes.models.CastManager
import com.larvey.azuracastplayer.classes.models.NowPlayingData
import com.larvey.azuracastplayer.classes.models.SavedStationsDB
import com.larvey.azuracastplayer.classes.models.SharedMediaController
@@ -39,6 +40,7 @@ class MainActivityViewModel @Inject constructor(
val nowPlayingData: NowPlayingData,
val savedStationsDB: SavedStationsDB,
val sharedMediaController: SharedMediaController,
+ val castManager: CastManager,
private val application: Application
) : ViewModel() {
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt
index 024f6c1..6cfc815 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingContent.kt
@@ -27,6 +27,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -46,6 +49,7 @@ import com.larvey.azuracastplayer.ui.nowplaying.components.NowPlayingBottomBar
import com.larvey.azuracastplayer.ui.nowplaying.components.NowPlayingHistory
import com.larvey.azuracastplayer.ui.nowplaying.components.NowPlayingTopBar
import com.larvey.azuracastplayer.ui.nowplaying.components.SongAndArtist
+import com.larvey.azuracastplayer.ui.nowplaying.cast.CastDeviceSheet
/**
* The full-screen Now Playing body, decoupled from any sheet/container. It is rendered as the
@@ -77,6 +81,9 @@ fun NowPlayingContent(
val playerState = nowPlayingViewModel.sharedMediaController.playerState.value ?: return
+ val castManager = nowPlayingViewModel.castManager
+ var showCastSheet by remember { mutableStateOf(false) }
+
val navController = rememberNavController()
val scrollState = rememberLazyListState()
@@ -93,6 +100,14 @@ fun NowPlayingContent(
navController.navigateUp()
}
+ if (showCastSheet) {
+ CastDeviceSheet(
+ castManager = castManager,
+ castConnectivity = nowPlayingViewModel.castConnectivity,
+ onDismiss = { showCastSheet = false }
+ )
+ }
+
Box(modifier = Modifier.fillMaxSize()) {
if (drawBackground) {
NowPlayingBackground(
@@ -108,6 +123,7 @@ fun NowPlayingContent(
// its own status-bar inset.
Box(modifier = Modifier.statusBarsPadding()) {
NowPlayingTopBar(
+ palette = palette?.value,
onClose = onCollapse,
onToggleHistory = {
if (navController.currentDestination?.route == "nowPlaying") {
@@ -118,14 +134,23 @@ fun NowPlayingContent(
inclusive = false
)
}
- }
+ },
+ onCast = if (castManager.isCastAvailable.value) {
+ { showCastSheet = true }
+ } else null,
+ isCasting = castManager.isCasting.value,
+ isConnecting = castManager.isConnecting.value
)
}
},
bottomBar = {
NowPlayingBottomBar(
stop = onStop ?: {
- nowPlayingViewModel.sharedMediaController.mediaSession.value?.player?.stop()
+ if (castManager.isCasting.value) {
+ castManager.stopCasting()
+ } else {
+ nowPlayingViewModel.sharedMediaController.mediaSession.value?.player?.stop()
+ }
Unit
},
pause = {
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingPane.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingPane.kt
index 5fe4a9a..011170a 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingPane.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingPane.kt
@@ -28,6 +28,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -47,6 +50,7 @@ import com.larvey.azuracastplayer.ui.nowplaying.components.NowPlayingBottomBar
import com.larvey.azuracastplayer.ui.nowplaying.components.NowPlayingHistory
import com.larvey.azuracastplayer.ui.nowplaying.components.NowPlayingTopBar
import com.larvey.azuracastplayer.ui.nowplaying.components.SongAndArtist
+import com.larvey.azuracastplayer.ui.nowplaying.cast.CastDeviceSheet
@OptIn(
ExperimentalMaterial3Api::class,
@@ -62,6 +66,16 @@ fun NowPlayingPane(
val nowPlayingViewModel: NowPlayingViewModel = viewModel()
+ val castManager = nowPlayingViewModel.castManager
+ var showCastSheet by remember { mutableStateOf(false) }
+
+ if (showCastSheet) {
+ CastDeviceSheet(
+ castManager = castManager,
+ castConnectivity = nowPlayingViewModel.castConnectivity,
+ onDismiss = { showCastSheet = false }
+ )
+ }
AnimatedVisibility(
visible = nowPlayingViewModel.sharedMediaController.playerState.value?.currentMediaItem != null,
@@ -99,6 +113,7 @@ fun NowPlayingPane(
containerColor = Color.Transparent,
topBar = {
NowPlayingTopBar(
+ palette = palette?.value,
onToggleHistory = {
if (navController.currentDestination?.route == "nowPlaying") {
navController.navigate("queue")
@@ -108,13 +123,22 @@ fun NowPlayingPane(
inclusive = false
)
}
- }
+ },
+ onCast = if (castManager.isCastAvailable.value) {
+ { showCastSheet = true }
+ } else null,
+ isCasting = castManager.isCasting.value,
+ isConnecting = castManager.isConnecting.value
)
},
bottomBar = {
NowPlayingBottomBar(
stop = {
- nowPlayingViewModel.sharedMediaController.mediaSession.value?.player?.stop()
+ if (castManager.isCasting.value) {
+ castManager.stopCasting()
+ } else {
+ nowPlayingViewModel.sharedMediaController.mediaSession.value?.player?.stop()
+ }
},
pause = {
nowPlayingViewModel.sharedMediaController.mediaSession.value?.player?.pause()
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingViewModel.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingViewModel.kt
index ee8429f..b40012f 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingViewModel.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/NowPlayingViewModel.kt
@@ -1,9 +1,11 @@
package com.larvey.azuracastplayer.ui.nowplaying
import androidx.lifecycle.ViewModel
+import com.larvey.azuracastplayer.classes.models.CastManager
import com.larvey.azuracastplayer.classes.models.NowPlayingData
import com.larvey.azuracastplayer.classes.models.SavedStationsDB
import com.larvey.azuracastplayer.classes.models.SharedMediaController
+import com.larvey.azuracastplayer.session.cast.CastConnectivity
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@@ -11,5 +13,7 @@ import javax.inject.Inject
class NowPlayingViewModel @Inject constructor(
val nowPlayingData: NowPlayingData,
val savedStationsDB: SavedStationsDB,
- val sharedMediaController: SharedMediaController
+ val sharedMediaController: SharedMediaController,
+ val castManager: CastManager,
+ val castConnectivity: CastConnectivity
) : ViewModel()
\ No newline at end of file
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/cast/CastDeviceSheet.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/cast/CastDeviceSheet.kt
new file mode 100644
index 0000000..d519337
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/cast/CastDeviceSheet.kt
@@ -0,0 +1,944 @@
+package com.larvey.azuracastplayer.ui.nowplaying.cast
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Bluetooth
+import androidx.compose.material.icons.rounded.Cast
+import androidx.compose.material.icons.rounded.Devices
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.Speaker
+import androidx.compose.material.icons.rounded.Tv
+import androidx.compose.material.icons.rounded.Wifi
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.mediarouter.media.MediaRouter
+import com.larvey.azuracastplayer.classes.models.CastManager
+import com.larvey.azuracastplayer.session.cast.CastConnectivity
+import com.larvey.azuracastplayer.utils.RoundedStarShape
+import com.larvey.azuracastplayer.utils.getRoundedCornerRadius
+import kotlin.math.abs
+import kotlinx.coroutines.launch
+
+/**
+ * The Cast device sheet. Opened from the Now Playing top-bar cast button. Shows
+ * nearby Cast devices, the connected device + volume + disconnect, plus Wi-Fi and
+ * Bluetooth connectivity tiles, across a Controls / Devices tab pair. The tab pair
+ * animates on switch (scale bounce + neighbour nudge, see [CastTab]) and the sheet
+ * height animates between the two tabs' content sizes.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CastDeviceSheet(
+ castManager: CastManager,
+ castConnectivity: CastConnectivity,
+ onDismiss: () -> Unit
+) {
+ val context = LocalContext.current
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+
+ val requiredPermissions = remember {
+ buildList {
+ // We only read already-paired / connected Bluetooth audio devices (needs
+ // BLUETOOTH_CONNECT), and never scan for new ones — so no BLUETOOTH_SCAN.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ add(Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(Manifest.permission.NEARBY_WIFI_DEVICES)
+ }
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ add(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ }
+ }
+ var missingPermissions by remember { mutableStateOf(missingPermissions(context, requiredPermissions)) }
+ var requestedOnce by remember { mutableStateOf(false) }
+
+ fun recheck() {
+ missingPermissions = missingPermissions(context, requiredPermissions)
+ if (missingPermissions.isEmpty()) {
+ castConnectivity.refresh()
+ }
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { recheck() }
+
+ LaunchedEffect(Unit) { recheck() }
+
+ // Re-check when returning to the app (e.g. after granting from app settings).
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) recheck()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ // Keep active device discovery running for the whole time the sheet is open, so
+ // other Cast devices stay visible even after connecting to one. Drop back to
+ // passive discovery when the sheet closes.
+ DisposableEffect(missingPermissions.isEmpty()) {
+ if (missingPermissions.isEmpty()) {
+ castManager.beginActiveDiscovery()
+ }
+ onDispose { castManager.endActiveDiscovery() }
+ }
+
+ fun animatedDismiss() {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) onDismiss()
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ dragHandle = { BottomSheetDefaults.DragHandle() },
+ shape = RoundedCornerShape(
+ topStart = getRoundedCornerRadius(),
+ topEnd = getRoundedCornerRadius()
+ ),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ tonalElevation = 12.dp
+ ) {
+ if (missingPermissions.isNotEmpty()) {
+ CastPermissionStep(
+ onRequest = {
+ requestedOnce = true
+ permissionLauncher.launch(missingPermissions.toTypedArray())
+ },
+ // If a request already happened and permission is still missing, it may be
+ // permanently denied — offer the app settings as an escape hatch.
+ showOpenSettings = requestedOnce,
+ onOpenSettings = {
+ context.startActivity(
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.packageName, null)
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+ )
+ } else {
+ CastSheetBody(
+ castManager = castManager,
+ castConnectivity = castConnectivity,
+ onDisconnect = {
+ castManager.disconnect()
+ animatedDismiss()
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun CastPermissionStep(
+ onRequest: () -> Unit,
+ showOpenSettings: Boolean,
+ onOpenSettings: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Get ready to connect",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "Allow nearby-device access so AzuraCast can find Cast devices on your network.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Button(
+ onClick = onRequest,
+ shape = RoundedCornerShape(50),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Allow access")
+ }
+ if (showOpenSettings) {
+ Text(
+ text = "Still blocked? Enable nearby-device access in app settings.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Button(
+ onClick = onOpenSettings,
+ shape = RoundedCornerShape(50),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Open app settings")
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CastSheetBody(
+ castManager: CastManager,
+ castConnectivity: CastConnectivity,
+ onDisconnect: () -> Unit
+) {
+ val context = LocalContext.current
+
+ val isCasting = castManager.isCasting.value
+ val isConnecting = castManager.isConnecting.value
+ val routes = castManager.castRoutes.value.filterNot { it.isDefault }
+ val selectedRoute = castManager.selectedRoute.value?.takeUnless { it.isDefault }
+ val routeVolume = castManager.routeVolume.value
+ val isRefreshing = castManager.isRefreshingRoutes.value
+ val btDevices = castConnectivity.bluetoothDevices.value
+
+ val refresh: () -> Unit = {
+ castManager.refreshRoutes()
+ castConnectivity.refresh()
+ }
+
+ val pagerState = rememberPagerState(
+ initialPage = if (isCasting || isConnecting) 0 else 1,
+ pageCount = { 2 }
+ )
+ val scope = rememberCoroutineScope()
+
+ // Bound the pager so a long device list can't push the sheet past the screen; the
+ // sheet animates between the two tabs' content heights within this cap.
+ val configuration = LocalConfiguration.current
+ val safeInsets = WindowInsets.safeDrawing.asPaddingValues()
+ val maxPagerHeight = (
+ configuration.screenHeightDp.dp -
+ safeInsets.calculateTopPadding() -
+ safeInsets.calculateBottomPadding() -
+ 212.dp
+ ).coerceAtLeast(280.dp)
+
+ // Skip the height animation for the first couple of frames so the sheet opens at its
+ // natural size instead of growing from zero; animate every switch after that.
+ var heightAnimationEnabled by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ withFrameNanos { }
+ withFrameNanos { }
+ heightAnimationEnabled = true
+ }
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ shape = CircleShape
+ )
+ ) {
+ Text(
+ text = "Connect device",
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold)
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = maxPagerHeight)
+ .animateContentSize(
+ animationSpec = if (heightAnimationEnabled) {
+ tween(durationMillis = 280, easing = FastOutSlowInEasing)
+ } else {
+ snap()
+ },
+ alignment = Alignment.TopCenter
+ )
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .wrapContentHeight()
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.Top
+ ) { page ->
+ when (page) {
+ 0 -> CastControlsTab(
+ isCasting = isCasting,
+ isConnecting = isConnecting,
+ deviceName = selectedRoute?.name,
+ volume = routeVolume,
+ volumeMax = (selectedRoute?.volumeMax ?: 20).coerceAtLeast(1),
+ onVolumeChange = { castManager.setRouteVolume(it) },
+ onDisconnect = onDisconnect,
+ wifiConnected = castConnectivity.isWifiEnabled.value,
+ wifiName = castConnectivity.wifiName.value,
+ bluetoothConnected = castConnectivity.isBluetoothEnabled.value,
+ onOpenWifi = { context.openSettings(Settings.ACTION_WIFI_SETTINGS) },
+ onOpenBluetooth = { context.openSettings(Settings.ACTION_BLUETOOTH_SETTINGS) },
+ onRefresh = refresh
+ )
+ 1 -> CastDevicesTab(
+ routes = routes,
+ selectedRouteId = selectedRoute?.id,
+ isCasting = isCasting,
+ isRefreshing = isRefreshing,
+ btDevices = btDevices,
+ maxContentHeight = maxPagerHeight,
+ onSelect = { route -> castManager.selectRoute(route) },
+ onDisconnect = onDisconnect,
+ onOpenBluetooth = { context.openSettings(Settings.ACTION_BLUETOOTH_SETTINGS) },
+ onRefresh = refresh
+ )
+ }
+ }
+ }
+
+ PrimaryTabRow(
+ selectedTabIndex = pagerState.currentPage,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .padding(5.dp),
+ containerColor = Color.Transparent,
+ divider = {},
+ indicator = {}
+ ) {
+ CastTab(
+ index = 0,
+ selectedIndex = pagerState.currentPage,
+ transformOrigin = TransformOrigin(0f, 0.5f),
+ onClick = { scope.launch { pagerState.animateScrollToPage(0) } }
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Rounded.Speaker,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(horizontal = 4.dp)
+ .size(18.dp)
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Controls", fontWeight = FontWeight.Bold)
+ }
+ }
+ CastTab(
+ index = 1,
+ selectedIndex = pagerState.currentPage,
+ transformOrigin = TransformOrigin(1f, 0.5f),
+ onClick = { scope.launch { pagerState.animateScrollToPage(1) } }
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Rounded.Devices,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(horizontal = 4.dp)
+ .size(18.dp)
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Devices", fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CastControlsTab(
+ isCasting: Boolean,
+ isConnecting: Boolean,
+ deviceName: String?,
+ volume: Int,
+ volumeMax: Int,
+ onVolumeChange: (Int) -> Unit,
+ onDisconnect: () -> Unit,
+ wifiConnected: Boolean,
+ wifiName: String?,
+ bluetoothConnected: Boolean,
+ onOpenWifi: () -> Unit,
+ onOpenBluetooth: () -> Unit,
+ onRefresh: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Card(
+ shape = RoundedCornerShape(28.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
+ elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isConnecting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(30.dp),
+ strokeWidth = 3.dp,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Rounded.Cast,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = deviceName ?: "This phone",
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = when {
+ isConnecting -> "Connecting…"
+ isCasting -> "Casting"
+ else -> "Local playback"
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ maxLines = 1
+ )
+ }
+ }
+
+ if (isCasting || isConnecting) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = "Device volume",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ var sliderValue by remember(volume) { mutableFloatStateOf(volume.toFloat()) }
+ Slider(
+ value = sliderValue.coerceIn(0f, volumeMax.toFloat()),
+ onValueChange = {
+ sliderValue = it
+ onVolumeChange(it.toInt())
+ },
+ valueRange = 0f..volumeMax.toFloat()
+ )
+ }
+ Button(
+ onClick = onDisconnect,
+ shape = RoundedCornerShape(50),
+ enabled = !isConnecting,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ )
+ ) {
+ Text("Disconnect")
+ }
+ } else {
+ Text(
+ text = "Pick a device on the Devices tab to start casting.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = "Connectivity",
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
+ )
+ Text(
+ text = if (!wifiConnected && !bluetoothConnected) {
+ "Turn on Wi-Fi or Bluetooth"
+ } else {
+ "Manage active radios and rescan"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ FilledRefreshButton(onClick = onRefresh)
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ QuickTile(
+ modifier = Modifier.weight(1f),
+ icon = Icons.Rounded.Wifi,
+ label = if (wifiConnected) (wifiName ?: "Wi-Fi") else "Wi-Fi",
+ subtitle = if (wifiConnected) "Connected" else "Off",
+ active = wifiConnected,
+ onClick = onOpenWifi
+ )
+ QuickTile(
+ modifier = Modifier.weight(1f),
+ icon = Icons.Rounded.Bluetooth,
+ label = "Bluetooth",
+ subtitle = if (bluetoothConnected) "On" else "Off",
+ active = bluetoothConnected,
+ onClick = onOpenBluetooth
+ )
+ }
+ Spacer(Modifier.height(20.dp))
+ }
+}
+
+@Composable
+private fun QuickTile(
+ modifier: Modifier = Modifier,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ subtitle: String,
+ active: Boolean,
+ onClick: () -> Unit
+) {
+ val bg = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh
+ val fg = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ Row(
+ modifier = modifier
+ .height(64.dp)
+ .clip(RoundedCornerShape(18.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .clickable { onClick() }
+ .padding(horizontal = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(38.dp)
+ .clip(CircleShape)
+ .background(bg),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(20.dp))
+ }
+ Column {
+ Text(label, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ }
+ }
+}
+
+@Composable
+private fun CastDevicesTab(
+ routes: List,
+ selectedRouteId: String?,
+ isCasting: Boolean,
+ isRefreshing: Boolean,
+ btDevices: List,
+ maxContentHeight: androidx.compose.ui.unit.Dp,
+ onSelect: (MediaRouter.RouteInfo) -> Unit,
+ onDisconnect: () -> Unit,
+ onOpenBluetooth: () -> Unit,
+ onRefresh: () -> Unit
+) {
+ val hasDevices = routes.isNotEmpty() || btDevices.isNotEmpty()
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = maxContentHeight),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item(key = "nearbyHeader") {
+ DeviceSectionHeader(hasDevices = hasDevices, onRefresh = onRefresh)
+ }
+ if (isRefreshing) {
+ item(key = "refreshIndicator") {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(50))
+ )
+ }
+ }
+ if (!hasDevices) {
+ item(key = "empty") {
+ Text(
+ text = if (isRefreshing) "Searching for devices…" else "No devices found",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 24.dp)
+ )
+ }
+ }
+ items(routes, key = { it.id }) { route ->
+ val isSelected = route.id == selectedRouteId
+ CastDeviceRow(
+ name = route.name,
+ description = route.description,
+ isTv = route.deviceType == MediaRouter.RouteInfo.DEVICE_TYPE_TV,
+ isBluetooth = false,
+ isSelected = isSelected && isCasting,
+ isConnecting = isSelected && !isCasting,
+ onClick = {
+ if (isSelected && isCasting) onDisconnect() else onSelect(route)
+ }
+ )
+ }
+ items(btDevices, key = { "bt_" + it.address }) { device ->
+ CastDeviceRow(
+ name = device.name,
+ description = if (device.isConnected) "Connected" else "Bluetooth",
+ isTv = false,
+ isBluetooth = true,
+ isSelected = false,
+ isConnecting = false,
+ onClick = onOpenBluetooth
+ )
+ }
+ }
+}
+
+@Composable
+private fun DeviceSectionHeader(
+ hasDevices: Boolean,
+ onRefresh: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.padding(start = 4.dp, end = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = "Nearby devices",
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
+ )
+ Text(
+ text = if (hasDevices) "Tap to connect" else "No devices yet",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ FilledRefreshButton(onClick = onRefresh)
+ }
+}
+
+/** Material3 filled icon button (tonal surface background) used by both refresh affordances. */
+@Composable
+private fun FilledRefreshButton(onClick: () -> Unit) {
+ IconButton(
+ onClick = onClick,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ modifier = Modifier.clip(RoundedCornerShape(16.dp))
+ ) {
+ Icon(Icons.Rounded.Refresh, contentDescription = "Refresh")
+ }
+}
+
+/**
+ * A single pill tab in the Controls / Devices switch. On selection change it does a brief
+ * scale bounce (1 → 1.05 → 1) and nudges its direct neighbour aside (±12px), then settles —
+ * so switching tabs feels springy rather than instant. The first composition does not animate.
+ */
+@Composable
+private fun CastTab(
+ index: Int,
+ selectedIndex: Int,
+ onClick: () -> Unit,
+ transformOrigin: TransformOrigin,
+ content: @Composable () -> Unit
+) {
+ val haptics = LocalHapticFeedback.current
+ val isSelected = index == selectedIndex
+
+ val selectedColor = MaterialTheme.colorScheme.primary
+ val unselectedColor = MaterialTheme.colorScheme.surface
+ val onSelectedColor = MaterialTheme.colorScheme.onPrimary
+ val onUnselectedColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f)
+
+ val animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
+ val scale = remember { Animatable(1f) }
+ val offsetX = remember { Animatable(0f) }
+ var hasAnimatedSelectionChange by remember { mutableStateOf(false) }
+
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isSelected) selectedColor else unselectedColor,
+ animationSpec = tween(durationMillis = 200),
+ label = "castTabBackground"
+ )
+ val contentColor by animateColorAsState(
+ targetValue = if (isSelected) onSelectedColor else onUnselectedColor,
+ animationSpec = tween(durationMillis = 200),
+ label = "castTabContent"
+ )
+
+ // Animate only on actual selection changes, not on first composition.
+ LaunchedEffect(selectedIndex) {
+ if (!hasAnimatedSelectionChange) {
+ hasAnimatedSelectionChange = true
+ scale.snapTo(1f)
+ offsetX.snapTo(0f)
+ return@LaunchedEffect
+ }
+ if (isSelected) {
+ launch {
+ scale.animateTo(1.05f, animationSpec = animationSpec)
+ scale.animateTo(1f, animationSpec = animationSpec)
+ }
+ offsetX.snapTo(0f)
+ } else {
+ scale.snapTo(1f)
+ val distance = index - selectedIndex
+ if (abs(distance) == 1) {
+ val direction = if (distance > 0) 1 else -1
+ launch {
+ offsetX.animateTo(12f * direction, animationSpec = animationSpec)
+ offsetX.animateTo(0f, animationSpec = animationSpec)
+ }
+ } else {
+ offsetX.snapTo(0f)
+ }
+ }
+ }
+
+ Tab(
+ modifier = Modifier
+ .padding(all = 5.dp)
+ .graphicsLayer {
+ scaleX = scale.value
+ translationX = offsetX.value
+ this.transformOrigin = transformOrigin
+ }
+ .clip(CircleShape)
+ .background(
+ color = backgroundColor,
+ shape = RoundedCornerShape(50)
+ ),
+ selected = isSelected,
+ text = content,
+ onClick = {
+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ onClick()
+ },
+ selectedContentColor = contentColor,
+ unselectedContentColor = contentColor
+ )
+}
+
+@Composable
+private fun CastDeviceRow(
+ name: String,
+ description: CharSequence?,
+ isTv: Boolean,
+ isBluetooth: Boolean,
+ isSelected: Boolean,
+ isConnecting: Boolean,
+ onClick: () -> Unit
+) {
+ val container = when {
+ isSelected -> MaterialTheme.colorScheme.primaryContainer
+ isBluetooth -> MaterialTheme.colorScheme.secondaryContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ val onContainer = when {
+ isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
+ isBluetooth -> MaterialTheme.colorScheme.onSecondaryContainer
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ val icon = when {
+ isBluetooth -> Icons.Rounded.Bluetooth
+ isTv -> Icons.Rounded.Tv
+ else -> Icons.Rounded.Speaker
+ }
+
+ // Slow rotation on the active device's scalloped icon backdrop.
+ val rotation by rememberInfiniteTransition(label = "castRowRotation").animateFloat(
+ initialValue = 0f,
+ targetValue = 360f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 9000, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "castRowRotationValue"
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(CircleShape)
+ .background(container)
+ .clickable { onClick() }
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Box(
+ modifier = Modifier.size(48.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .graphicsLayer { if (isSelected) rotationZ = rotation }
+ .background(
+ color = onContainer.copy(alpha = 0.12f),
+ shape = if (isSelected) RoundedStarShape(sides = 8, curve = 0.10) else CircleShape
+ )
+ )
+ if (isConnecting) {
+ CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp, color = onContainer)
+ } else {
+ Icon(icon, contentDescription = null, tint = onContainer, modifier = Modifier.size(24.dp))
+ }
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = onContainer,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ val status = when {
+ isSelected -> "Connected"
+ isConnecting -> "Connecting…"
+ else -> description?.toString() ?: "Available"
+ }
+ Text(status, style = MaterialTheme.typography.bodySmall, color = onContainer.copy(alpha = 0.8f), maxLines = 1)
+ }
+ }
+}
+
+private fun missingPermissions(context: Context, permissions: List): List =
+ permissions.filter {
+ ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
+ }
+
+private fun Context.openSettings(action: String) {
+ runCatching {
+ startActivity(Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+}
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingTopBar.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingTopBar.kt
index 0e18ab2..700f4d9 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingTopBar.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/NowPlayingTopBar.kt
@@ -15,7 +15,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
+import androidx.compose.material.icons.rounded.Cast
+import androidx.compose.material.icons.rounded.CastConnected
import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
@@ -27,26 +30,42 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.palette.graphics.Palette
+import com.larvey.azuracastplayer.utils.albumColors
/**
- * The Now Playing top bar: an optional collapse button on the left and a song-history button on
- * the right. Styled as neutral translucent "glass" so it reads on the dark backdrop without
- * competing with the album-tinted controls. The history button is shaped like the right half of a
- * split button — squared-off inner (left) edge, fully rounded outer (right) edge.
+ * The Now Playing top bar: an optional collapse button on the left, and (right-aligned) an
+ * optional Cast button followed by the song-history button. The Cast/history pair reads as a split
+ * button — the history button is the right half (squared inner/left edge, fully-rounded outer/right
+ * edge) and the Cast button mirrors it (rounded outer/left edge, squared inner/right edge). The
+ * collapse button is a plain circle.
+ *
+ * All three buttons are tinted from the current album art: a pale [AlbumColors.lightChip] fill with
+ * a deep album-hued [AlbumColors.onLightChip] icon — the same pair the play/pause chip uses — so the
+ * bar coheres with the rest of the album-tinted controls instead of reading as neutral glass.
*
* [onClose] is nullable so the side-pane layout (which has nothing to collapse) can omit it.
+ * [onCast] is nullable so the button is omitted when casting is unavailable.
*/
@Composable
fun NowPlayingTopBar(
onToggleHistory: () -> Unit,
modifier: Modifier = Modifier,
- onClose: (() -> Unit)? = null
+ palette: Palette? = null,
+ onClose: (() -> Unit)? = null,
+ onCast: (() -> Unit)? = null,
+ isCasting: Boolean = false,
+ isConnecting: Boolean = false
) {
+ val colors = albumColors(palette)
+ val containerColor = colors.lightChip
+ val contentColor = colors.onLightChip
+
Row(
modifier = modifier
.fillMaxWidth()
.padding(
- horizontal = 12.dp,
+ horizontal = 14.dp,
vertical = 6.dp
),
verticalAlignment = Alignment.CenterVertically
@@ -54,34 +73,63 @@ fun NowPlayingTopBar(
if (onClose != null) {
GlassButton(
onClick = onClose,
- shape = CircleShape
+ shape = CircleShape,
+ containerColor = containerColor,
+ width = 42.dp
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = "Close",
- modifier = Modifier.size(26.dp),
- tint = Color.White
+ tint = contentColor
)
}
}
Spacer(modifier = Modifier.weight(1f))
+ if (onCast != null) {
+ GlassButton(
+ onClick = onCast,
+ // Mirror of the history button: rounded outer (left) edge, squared inner (right) edge.
+ shape = RoundedCornerShape(
+ topStart = 50.dp,
+ bottomStart = 50.dp,
+ topEnd = 6.dp,
+ bottomEnd = 6.dp
+ ),
+ containerColor = containerColor
+ ) {
+ when {
+ isConnecting -> CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = contentColor
+ )
+ else -> Icon(
+ imageVector = if (isCasting) Icons.Rounded.CastConnected else Icons.Rounded.Cast,
+ contentDescription = "Cast",
+ tint = contentColor
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(6.dp))
+ }
+
GlassButton(
onClick = onToggleHistory,
shape = RoundedCornerShape(
topStart = 6.dp,
bottomStart = 6.dp,
- topEnd = 22.dp,
- bottomEnd = 22.dp
+ topEnd = 50.dp,
+ bottomEnd = 50.dp
),
- width = 42.dp
+ containerColor = containerColor
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.QueueMusic,
contentDescription = "Song history",
- modifier = Modifier.size(22.dp),
- tint = Color.White
+ tint = contentColor
)
}
}
@@ -91,16 +139,17 @@ fun NowPlayingTopBar(
private fun GlassButton(
onClick: () -> Unit,
shape: Shape,
+ containerColor: Color,
modifier: Modifier = Modifier,
- width: Dp = 44.dp,
+ width: Dp = 50.dp,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
- .height(44.dp)
+ .height(42.dp)
.width(width)
.clip(shape)
- .background(Color.White.copy(alpha = 0.15f))
+ .background(containerColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple()
diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/ProgressBar.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/ProgressBar.kt
index a780a54..940d20a 100644
--- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/ProgressBar.kt
+++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/ProgressBar.kt
@@ -3,18 +3,22 @@ package com.larvey.azuracastplayer.ui.nowplaying.components
import androidx.annotation.OptIn
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -62,32 +66,35 @@ fun ProgressBar(
Spacer(modifier = Modifier.size(8.dp))
- // Progress Timestamps
- Row(
+ // Progress Timestamps. Laid out as a Box (not a weighted Row) so the centre quality chip
+ // stays dead-centre regardless of what flanks it — the left elapsed timestamp is dropped
+ // entirely in the Live case, which would otherwise pull a weighted layout off-centre.
+ val durationMs = playerState.mediaMetadata.durationMs
+ // Some stations report a current song with no length; there is nothing meaningful to
+ // count toward, so we drop the elapsed/total timestamps and just flag the stream as Live.
+ val isLive = durationMs == null || durationMs <= 0L
+
+ val position = currentPosition.toDuration(DurationUnit.MILLISECONDS)
+ val duration = (durationMs ?: 0L).toDuration(DurationUnit.MILLISECONDS)
+
+ Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
- val duration = playerState.mediaMetadata.durationMs?.toDuration(DurationUnit.MILLISECONDS)
- ?: 0.toDuration(DurationUnit.MILLISECONDS)
-
- val position = currentPosition.toDuration(DurationUnit.MILLISECONDS)
-
- val durationString = formatPlaybackTimestamp(
- value = duration,
- forceHours = position.inWholeHours > 0
- )
- val positionString = formatPlaybackTimestamp(
- value = position,
- forceHours = duration.inWholeHours > 0
- )
- Text(
- positionString,
- maxLines = 1,
- style = MaterialTheme.typography.labelMedium,
- color = Color.White
- )
- Spacer(modifier = Modifier.weight(1f))
+ if (!isLive) {
+ val positionString = formatPlaybackTimestamp(
+ value = position,
+ forceHours = duration.inWholeHours > 0
+ )
+ Text(
+ positionString,
+ maxLines = 1,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ modifier = Modifier.align(Alignment.CenterStart)
+ )
+ }
if ((currentMount?.bitrate != null && currentMount.format != null) || playerState.currentMediaItem?.mediaId?.endsWith("m3u8") == true) {
val qualityLabel = if (playerState.currentMediaItem?.mediaId?.endsWith("m3u8") == true) {
"HLS"
@@ -100,6 +107,7 @@ fun ProgressBar(
maxLines = 1,
color = colors.onLightChip,
modifier = Modifier
+ .align(Alignment.Center)
.clip(RoundedCornerShape(percent = 50))
.background(colors.lightChip)
.padding(
@@ -108,13 +116,61 @@ fun ProgressBar(
)
)
}
- Spacer(modifier = Modifier.weight(1f))
- Text(
- durationString,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- color = Color.White
- )
+ if (isLive) {
+ LiveBadge(
+ containerColor = colors.lightChip,
+ contentColor = colors.onLightChip,
+ modifier = Modifier.align(Alignment.CenterEnd)
+ )
+ } else {
+ val durationString = formatPlaybackTimestamp(
+ value = duration,
+ forceHours = position.inWholeHours > 0
+ )
+ Text(
+ durationString,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ color = Color.White,
+ modifier = Modifier.align(Alignment.CenterEnd)
+ )
+ }
}
}
+}
+
+/**
+ * A small "Live" pill shown on the right of the progress row when the current song has no length.
+ * Matches the quality chip's pill shape and album-tinted colors, with a leading dot as a live cue.
+ */
+@Composable
+private fun LiveBadge(
+ containerColor: Color,
+ contentColor: Color,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = modifier
+ .clip(RoundedCornerShape(percent = 50))
+ .background(containerColor)
+ .padding(
+ horizontal = 12.dp,
+ vertical = 4.dp
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(contentColor)
+ )
+ Text(
+ text = "Live",
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ color = contentColor
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/larvey/azuracastplayer/utils/RoundedStarShape.kt b/app/src/main/java/com/larvey/azuracastplayer/utils/RoundedStarShape.kt
new file mode 100644
index 0000000..616cd51
--- /dev/null
+++ b/app/src/main/java/com/larvey/azuracastplayer/utils/RoundedStarShape.kt
@@ -0,0 +1,45 @@
+package com.larvey.azuracastplayer.utils
+
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.min
+import kotlin.math.sin
+
+/**
+ * A soft scalloped/"flower" shape used behind the active Cast device's icon. The
+ * outline is a smooth polar curve `r = base * (1 + curve * cos(sides * t))`, so
+ * with e.g. `sides = 8` it reads as an 8-lobed rounded star.
+ */
+class RoundedStarShape(
+ private val sides: Int = 8,
+ private val curve: Double = 0.10,
+ private val rotation: Float = 0f
+) : Shape {
+ override fun createOutline(
+ size: androidx.compose.ui.geometry.Size,
+ layoutDirection: LayoutDirection,
+ density: Density
+ ): Outline {
+ val cx = size.width / 2f
+ val cy = size.height / 2f
+ val baseRadius = min(size.width, size.height) * 0.5f / (1f + curve.toFloat())
+ val steps = 360
+ val rotationRad = Math.toRadians(rotation.toDouble())
+
+ val path = Path()
+ for (i in 0..steps) {
+ val t = (i.toDouble() / steps) * 2.0 * PI
+ val r = baseRadius * (1.0 + curve * cos(sides * t))
+ val x = cx + (r * cos(t + rotationRad)).toFloat()
+ val y = cy + (r * sin(t + rotationRad)).toFloat()
+ if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
+ }
+ path.close()
+ return Outline.Generic(path)
+ }
+}
diff --git a/docs/CASTING.md b/docs/CASTING.md
new file mode 100644
index 0000000..9120043
--- /dev/null
+++ b/docs/CASTING.md
@@ -0,0 +1,526 @@
+# How PixelPlayer implements Google Cast (Chromecast)
+
+Research document. This describes, in detail, **how the open-source [PixelPlayer](https://github.com/PixelPlayerHQ/PixelPlayer) Android app (`com.theveloper.pixelplay`) implements casting** — both its **backend** (session handling, device delivery, volume) and its **UI** (the cast button, the device sheet, every style and animation). It was produced by reading PixelPlayer's actual source directly (a fresh clone), cross-checked against Google's official Cast Android-sender documentation.
+
+Every file path below is under `app/src/main/java/com/theveloper/pixelplay/` in the PixelPlayer repo unless stated otherwise. Line numbers are from the clone used for this research and are approximate.
+
+> One framing fact that explains a large part of the design: **PixelPlayer plays local device files.** A Chromecast can only fetch media over HTTP(S), so PixelPlayer runs an **HTTP server on the phone** to expose each local file to the receiver. Much of the backend exists to serve, secure, and transcode those local files. This is called out where relevant.
+
+---
+
+## Table of contents
+
+**Backend**
+1. [Architecture at a glance](#1-architecture-at-a-glance)
+2. [Dependencies, manifest, permissions](#2-dependencies-manifest-permissions)
+3. [Bootstrapping the Cast SDK — CastOptionsProvider](#3-bootstrapping-the-cast-sdk--castoptionsprovider)
+4. [Device discovery & routes — CastStateHolder](#4-device-discovery--routes--caststateholder)
+5. [Selecting a device & starting a session](#5-selecting-a-device--starting-a-session)
+6. [How it casts to a device, part 1: the LAN HTTP server](#6-how-it-casts-to-a-device-part-1-the-lan-http-server)
+7. [How it casts to a device, part 2: the CastPlayer wrapper](#7-how-it-casts-to-a-device-part-2-the-castplayer-wrapper)
+8. [Local → remote transfer (transferPlayback)](#8-local--remote-transfer-transferplayback)
+9. [Remote status → app state (the reconciler)](#9-remote-status--app-state-the-reconciler)
+10. [Remote → local transfer (disconnect)](#10-remote--local-transfer-disconnect)
+11. [Volume — end to end](#11-volume--end-to-end)
+12. [Command dispatch while casting](#12-command-dispatch-while-casting)
+13. [Notification, foreground, and the service-side mirror](#13-notification-foreground-and-the-service-side-mirror)
+14. [Error handling & watchdogs](#14-error-handling--watchdogs)
+
+**UI**
+15. [The cast button](#15-the-cast-button)
+16. [Opening the sheet & album-color theming](#16-opening-the-sheet--album-color-theming)
+17. [The sheet container](#17-the-sheet-container)
+18. [Permission gate](#18-permission-gate)
+19. [Layout — the two-tab pager](#19-layout--the-two-tab-pager)
+20. [Controls tab — hero card + volume slider](#20-controls-tab--hero-card--volume-slider)
+21. [Devices tab — rows, states, the scallop animation](#21-devices-tab--rows-states-the-scallop-animation)
+22. [Complete animation reference](#22-complete-animation-reference)
+
+**Appendix**
+- [A. Google Cast platform facts used above](#a-google-cast-platform-facts-used-above)
+
+---
+
+# BACKEND
+
+## 1. Architecture at a glance
+
+PixelPlayer does **not** use Media3's `androidx.media3:media3-cast` `CastPlayer`. It talks to the Google Cast SDK's `RemoteMediaClient` directly, through a **hand-written class also named `CastPlayer`** (`data/service/player/CastPlayer.kt`), and it treats casting as a **parallel remote-control plane at the ViewModel layer**. The Media3 `MediaSession` in `MusicService` stays bound to the **local** ExoPlayer for the whole cast session (the local player is *paused*, not swapped out); every transport command re-checks "am I casting?" and branches local-vs-remote.
+
+The moving parts:
+
+| Class | Scope | Role |
+|---|---|---|
+| `CastOptionsProvider` (`data/service/cast/`) | manifest | Cast SDK bootstrap: receiver app id + notification actions |
+| `CastStateHolder` (`presentation/viewmodel/`) | `@Singleton` | State bus (`castSession`, `castPlayer`, `isRemotePlaybackActive`, `isCastConnecting`, `remotePosition`) + owns all `MediaRouter` discovery, `castRoutes`, `selectedRoute`, `routeVolume` |
+| `CastRouteStateHolder` (`presentation/viewmodel/`) | `@ViewModelScoped` | Thin route select / disconnect / volume façade |
+| `CastTransferStateHolder` (`presentation/viewmodel/`) | `@Singleton` (~1,600 lines) | The brain: `SessionManagerListener`, local→remote transfer, remote-status reconciler, remote→local transfer, watchdogs, HTTP-server lifecycle |
+| `CastPlayer` (`data/service/player/`) | plain class, per session | Wraps `CastSession.remoteMediaClient`: `loadQueue`, `play/pause/seek/next/previous/jumpToItem/setRepeatMode`, serialized command queue |
+| `CastRemotePlaybackState` (`data/service/cast/`) | object | Pure projection `MediaStatus` → `{isPlaying, playWhenReady, isBuffering}` |
+| `CastSyncCoordinator` (`data/service/`) | manual, in `MusicService` | **Service-side** observer: mirrors remote playback into listening-stats + widget/Wear (does not control playback) |
+| `MediaFileHttpServerService` (`data/service/http/`) | foreground `Service` (~2,900 lines) | The Ktor HTTP server that serves local files to the receiver |
+| `CastSessionSecurity`, `CastAudioMimeUtils`, `IsoBmffAudioCodecDetector` (`data/service/…`) | — | Per-session auth tokens; MIME normalization; MP4 codec sniffing |
+| `CastBottomSheet` (`presentation/components/`) | Composable (~2,000 lines) | The device picker + controls sheet |
+
+`CastContext` is fetched lazily, wrapped in `try/catch`, at three independent sites (`CastStateHolder:39`, `CastTransferStateHolder:86`, `CastSyncCoordinator:75`) — each uses the deprecated synchronous `CastContext.getSharedInstance(context)` overload. If Play Services is unavailable it resolves to `null` and cast silently does nothing; there is **no** `GoogleApiAvailability` check.
+
+## 2. Dependencies, manifest, permissions
+
+**Gradle** (`gradle/libs.versions.toml`, `app/build.gradle.kts:295,323`):
+
+```kotlin
+implementation("androidx.mediarouter:mediarouter:1.8.1")
+implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
+// there is NO androidx.media3:media3-cast dependency
+```
+
+**Manifest** (`AndroidManifest.xml`):
+
+```xml
+
+
+
+
+
+```
+
+Cast-relevant permissions: `INTERNET`, `ACCESS_NETWORK_STATE`, `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES` (Android 13+), `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION` (`maxSdkVersion=30`, legacy Wi-Fi/BT discovery), `WAKE_LOCK`, `FOREGROUND_SERVICE_MEDIA_PLAYBACK`. **`CHANGE_WIFI_MULTICAST_STATE` is *not* declared** — Cast discovery goes through the Play Services route provider, which holds its own multicast lock. **`MediaTransferReceiver` is *not* declared**, so the Android-13 system Output Switcher is not integrated.
+
+The app sets `android:usesCleartextTraffic="false"` but overrides it with `android:networkSecurityConfig="@xml/network_security_config"`, whose `` re-enables cleartext app-wide (plus explicit `127.0.0.1`/`localhost` domain configs) — needed because the phone talks to its own `http://:` server.
+
+**ProGuard** (`proguard-rules.pro:66-68`) — needed because the OptionsProvider is instantiated reflectively from the manifest string:
+
+```proguard
+-keep class com.theveloper.pixelplay.data.service.cast.CastOptionsProvider { *; }
+-keep class * implements com.google.android.gms.cast.framework.OptionsProvider
+```
+
+There is **no Hilt module** for casting; the cast singletons are plain constructor-injected classes.
+
+## 3. Bootstrapping the Cast SDK — CastOptionsProvider
+
+`data/service/cast/CastOptionsProvider.kt` (the entire file):
+
+```kotlin
+class CastOptionsProvider : OptionsProvider {
+ override fun getCastOptions(context: Context): CastOptions {
+ val notificationOptions = NotificationOptions.Builder()
+ .setActions(
+ listOf(
+ MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK,
+ MediaIntentReceiver.ACTION_STOP_CASTING
+ ), intArrayOf(0, 1) // both shown in compact view
+ )
+ .build()
+ val mediaOptions = CastMediaOptions.Builder()
+ .setNotificationOptions(notificationOptions)
+ .build()
+ return CastOptions.Builder()
+ .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
+ .setCastMediaOptions(mediaOptions)
+ .build()
+ }
+ override fun getAdditionalSessionProviders(context: Context): List? = null
+}
+```
+
+- **Receiver:** the **Default Media Receiver** (`DEFAULT_MEDIA_RECEIVER_APPLICATION_ID`, Google-hosted, no console registration, no UI styling). There is no custom receiver anywhere.
+- **Notification:** because `CastMediaOptions` is set, the **Cast SDK posts its own media notification** during a session (a play/pause toggle + a stop-casting action). This is separate from the Media3 notification — see [§13](#13-notification-foreground-and-the-service-side-mirror).
+- Every other `CastOptions` flag keeps its framework default (`resumeSavedSession=true`, `enableReconnectionService=true`, `stopReceiverApplicationWhenEndingSession=false`, `remoteToLocalEnabled` unset).
+
+## 4. Device discovery & routes — CastStateHolder
+
+Discovery uses **androidx `MediaRouter` directly** (not `CastButtonFactory`). In `CastStateHolder.kt`:
+
+```kotlin
+val mediaRouter = MediaRouter.getInstance(context) // main-thread only
+
+private val castControlCategory =
+ CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
+
+fun MediaRouter.RouteInfo.isCastRoute() =
+ supportsControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) ||
+ supportsControlCategory(castControlCategory)
+
+fun buildCastRouteSelector() = MediaRouteSelector.Builder()
+ .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
+ .addControlCategory(castControlCategory)
+ .build()
+```
+
+Two scan modes:
+
+- **Passive** (`startDiscovery()`, `CastStateHolder:257`): `addCallback(selector, cb, CALLBACK_FLAG_REQUEST_DISCOVERY)`.
+- **Active burst** (`refreshRoutes()`, `CastStateHolder:233`): swap to `CALLBACK_FLAG_REQUEST_DISCOVERY or CALLBACK_FLAG_PERFORM_ACTIVE_SCAN`, `delay(1800)`, then drop back to passive. Toggles `isRefreshingRoutes` around it. The `init` block deliberately does nothing ("battery drain").
+
+A single `MediaRouter.Callback` funnels `onRouteAdded/Removed/Changed/Selected/Unselected` into `updateRoutes()` (filter `isCastRoute()`, `distinctBy { it.id }`, **no sorting**) + `syncSelectedRouteFromRouter()`. `onRouteVolumeChanged` updates `routeVolume` only for the selected route (`CastStateHolder:207`).
+
+Exposed as `StateFlow`s: `castRoutes`, `selectedRoute`, `routeVolume`, `isRefreshingRoutes`, plus session state (`castSession`, `isRemotePlaybackActive`, `isCastConnecting`, `remotePosition`, `isRemotelySeeking`).
+
+`disconnect()` selects `mediaRouter.defaultRoute` — it does **not** call `endCurrentSession`; the framework tears the session down in response, which comes back as `onSessionEnded`.
+
+## 5. Selecting a device & starting a session
+
+`CastRouteStateHolder.selectRoute()` (`CastRouteStateHolder:20`):
+
+1. If it's a cast route but `sessionManager == null` → surface "Cast is unavailable right now…" and bail.
+2. Detect **switching between two remotes** (already active/connecting, different route) and **retrying a failed same route**; in those cases set `pendingCastRouteId = route.id`, `castConnecting = true`, and `sessionManager.endCurrentSession(true)` (or `disconnect()` for retry) first.
+3. If it's a cast route, `castTransferStateHolder.primeHttpServerStart()` — pre-warm the HTTP server in parallel with the handshake.
+4. `castStateHolder.selectRoute(route)` → `mediaRouter.selectRoute(route)` → the Cast framework starts a `CastSession`.
+
+Session detection is entirely a `SessionManagerListener` registered in `CastTransferStateHolder.setupListeners()` (`:201`). There is **no** `CastStateListener` and **no** `MediaRouteButton`. The callbacks:
+
+| Callback | Action |
+|---|---|
+| `onSessionStarting` / `onSessionResuming` | `castConnecting = true` |
+| `onSessionStarted` / `onSessionResumed` | `transferPlayback(session)` ([§8](#8-local--remote-transfer-transferplayback)) |
+| `onSessionEnded` | `stopServerAndTransferBack()` ([§10](#10-remote--local-transfer-disconnect)) |
+| `onSessionSuspended(reason)` | `castConnecting = true` + 12 s recovery timer; if still no `remoteMediaClient`, transfer back |
+| `onSessionStartFailed` / `onSessionResumeFailed` | clear pending route, `castConnecting=false`, toast |
+
+On `setupListeners`, if a `currentCastSession` already exists (process re-created while casting) it registers the callbacks and requests status immediately.
+
+## 6. How it casts to a device, part 1: the LAN HTTP server
+
+Because the songs are local files, PixelPlayer streams them to the receiver from a phone-hosted HTTP server. **This whole section is specific to local-file playback.**
+
+`data/service/http/MediaFileHttpServerService.kt` — a foreground `Service` running a **Ktor CIO** server:
+
+```kotlin
+server = embeddedServer(CIO, port = serverPort, host = "0.0.0.0") { routing { … } }
+```
+
+**Lifecycle** (`onCreate`/`onStartCommand`, `:264`): started via `ACTION_START_SERVER` (with an optional `EXTRA_CAST_DEVICE_IP`), returns `START_STICKY`, posts a foreground notification on channel `"pixelplay_cast_server"`. `onDestroy` (`:2902`) resets state, deletes transcode temp files, and stops Ktor on a background thread (`stop(100, 2000)`). `onBind` returns `null`.
+
+**Published state** — `@Volatile` companion fields the ViewModel layer reads directly (`:155`): `isServerRunning`, `isServerStarting`, `serverAddress` (`"http://:"`), `serverHostAddress`, `serverPrefixLength`, `lastFailureReason`/`lastFailureMessage` (`FailureReason { NO_NETWORK_ADDRESS, FOREGROUND_START_EXCEPTION, START_EXCEPTION }`).
+
+**Port** (`resolveServerPort`, `:843`): prefers **8080**, scans 8080..8100, else an ephemeral `ServerSocket(0)`.
+
+**LAN IP** (`selectIpAddress`, `:882`): walks `ConnectivityManager.allNetworks`, keeps non-loopback non-link-local IPv4 addresses on Wi-Fi/Ethernet, and **prefers an address on the Cast device's subnet** when a hint is given (active > validated > has-internet > longest prefix). IPv6 is never used.
+
+**Endpoints** (`:385`): `GET/HEAD /health` (loopback-only), `GET/HEAD /song/{songId}`, `GET/HEAD /art/{songId}`.
+
+**Security** (`CastSessionSecurity.kt` + `ensureAuthorizedCastMediaRequest`, `:995`): a per-session **auth token** (16 `SecureRandom` bytes, hex, in the `?auth=` query param), a **song-ID allowlist**, and a soft **client-IP allowlist** (cast device IP + loopback + the server's own LAN IP). The real boundary is token + allowlisted songId — a valid pair is accepted even from a non-allowlisted IP (only logged); an invalid token → `401`, a non-allowlisted song → `404`. The policy is rebuilt on every queue load via `configureCastSessionAccess(allowedSongIds, castDeviceIpHint)`; the token is reused for the server's lifetime. Everything is plain HTTP (no TLS, no CORS headers — the Default Receiver's `