From 91a1030ca0ef548f421b5a22266a405a25620a14 Mon Sep 17 00:00:00 2001 From: Luis Vervaet Date: Tue, 21 Jul 2026 20:56:02 -0400 Subject: [PATCH 1/8] feat: Chromecast support --- app/build.gradle.kts | 4 + app/proguard-rules.pro | 4 + app/src/main/AndroidManifest.xml | 15 + .../classes/models/CastManager.kt | 364 +++++++++ .../singletons/CastConnectivity.kt | 23 + .../hiltModules/singletons/CastManager.kt | 35 + .../session/MusicPlayerService.kt | 22 +- .../session/cast/CastConnectivity.kt | 131 ++++ .../session/cast/CastOptionsProvider.kt | 34 + .../session/cast/CastRadioPlayer.kt | 101 +++ .../session/cast/CastRemotePlaybackState.kt | 28 + .../ui/mainActivity/MainActivity.kt | 10 +- .../ui/mainActivity/MainActivityViewModel.kt | 2 + .../ui/nowplaying/NowPlayingContent.kt | 29 +- .../ui/nowplaying/NowPlayingPane.kt | 28 +- .../ui/nowplaying/NowPlayingViewModel.kt | 6 +- .../ui/nowplaying/cast/CastDeviceSheet.kt | 700 ++++++++++++++++++ .../nowplaying/components/NowPlayingTopBar.kt | 49 +- .../azuracastplayer/utils/RoundedStarShape.kt | 45 ++ docs/CASTING.md | 526 +++++++++++++ gradle/libs.versions.toml | 4 + 21 files changed, 2147 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/larvey/azuracastplayer/classes/models/CastManager.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastConnectivity.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/hiltModules/singletons/CastManager.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/session/cast/CastConnectivity.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/session/cast/CastOptionsProvider.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRadioPlayer.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRemotePlaybackState.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/cast/CastDeviceSheet.kt create mode 100644 app/src/main/java/com/larvey/azuracastplayer/utils/RoundedStarShape.kt create mode 100644 docs/CASTING.md 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 + + /** Guards the local↔receiver sync from feeding back on itself. */ + private var isApplyingSync = false + + /** 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 --- + + 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 + castRadioPlayer?.loadStation(info) + } + } + } + } + + private fun loadCurrentStation() { + val info = buildStationInfoOrNull() ?: return + lastLoadedKey = stationKey(info) + castRadioPlayer?.loadStation(info) + } + + 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 || isApplyingSync) return + if (playWhenReady) castRadioPlayer?.play() 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 + isApplyingSync = true + if (shouldPlay) player.play() else player.pause() + isApplyingSync = false + } + + // --- 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 + } + } + + fun startDiscovery() { + mediaRouter.addCallback( + castSelector, + mediaRouterCallback, + MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY + ) + syncFromRouter(mediaRouter) + } + + private var refreshJob: kotlinx.coroutines.Job? = null + + /** A short active-scan burst for the device sheet's "refresh" affordance. */ + fun refreshRoutes() { + refreshJob?.cancel() + refreshJob = applicationScope.launch { + isRefreshingRoutes.value = true + mediaRouter.removeCallback(mediaRouterCallback) + mediaRouter.addCallback( + castSelector, + mediaRouterCallback, + MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN + ) + syncFromRouter(mediaRouter) + delay(5000) + mediaRouter.removeCallback(mediaRouterCallback) + mediaRouter.addCallback( + castSelector, + mediaRouterCallback, + MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY + ) + syncFromRouter(mediaRouter) + isRefreshingRoutes.value = false + } + } + + fun selectRoute(route: MediaRouter.RouteInfo) { + isConnecting.value = true + mediaRouter.selectRoute(route) + } + + /** Disconnect: return to the default route. Audio returns to the phone. */ + fun disconnect() { + mediaRouter.selectRoute(mediaRouter.defaultRoute) + syncFromRouter(mediaRouter) + } + + 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/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..e903a5a 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) ) 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..cbef93f --- /dev/null +++ b/app/src/main/java/com/larvey/azuracastplayer/session/cast/CastRadioPlayer.kt @@ -0,0 +1,101 @@ +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?.takeIf { it.isNotBlank() }?.let { + addImage(WebImage(Uri.parse(it.fixHttps()))) + } + } + + 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..c557d5d 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,15 @@ fun NowPlayingContent( navController.navigateUp() } + if (showCastSheet) { + CastDeviceSheet( + castManager = castManager, + castConnectivity = nowPlayingViewModel.castConnectivity, + palette = palette?.value, + onDismiss = { showCastSheet = false } + ) + } + Box(modifier = Modifier.fillMaxSize()) { if (drawBackground) { NowPlayingBackground( @@ -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..fd27f8a 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,17 @@ fun NowPlayingPane( val nowPlayingViewModel: NowPlayingViewModel = viewModel() + val castManager = nowPlayingViewModel.castManager + var showCastSheet by remember { mutableStateOf(false) } + + if (showCastSheet) { + CastDeviceSheet( + castManager = castManager, + castConnectivity = nowPlayingViewModel.castConnectivity, + palette = palette?.value, + onDismiss = { showCastSheet = false } + ) + } AnimatedVisibility( visible = nowPlayingViewModel.sharedMediaController.playerState.value?.currentMediaItem != null, @@ -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..4a473f5 --- /dev/null +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/cast/CastDeviceSheet.kt @@ -0,0 +1,700 @@ +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.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.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.Row +import androidx.compose.foundation.layout.Spacer +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.size +import androidx.compose.foundation.layout.width +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.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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +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 androidx.palette.graphics.Palette +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.albumColors +import com.larvey.azuracastplayer.utils.getRoundedCornerRadius +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. Themed from + * the current station's palette via [albumColors]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CastDeviceSheet( + castManager: CastManager, + castConnectivity: CastConnectivity, + palette: Palette?, + 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() + castManager.refreshRoutes() + } + } + + 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) } + } + + 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 + ) { + 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, + palette = palette, + 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)) + } +} + +@Composable +private fun CastSheetBody( + castManager: CastManager, + castConnectivity: CastConnectivity, + palette: Palette?, + onDisconnect: () -> Unit +) { + val context = LocalContext.current + val colors = albumColors(palette) + + 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 pagerState = rememberPagerState( + initialPage = if (isCasting || isConnecting) 0 else 1, + pageCount = { 2 } + ) + val scope = rememberCoroutineScope() + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Connect device", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(horizontal = 24.dp) + ) + Spacer(Modifier.height(8.dp)) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth() + ) { page -> + when (page) { + 0 -> CastControlsTab( + colors = colors, + 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 = { + castManager.refreshRoutes() + castConnectivity.refresh() + } + ) + 1 -> CastDevicesTab( + colors = colors, + routes = routes, + selectedRouteId = selectedRoute?.id, + isCasting = isCasting, + isRefreshing = isRefreshing, + btDevices = btDevices, + onSelect = { route -> castManager.selectRoute(route) }, + onDisconnect = onDisconnect, + onOpenBluetooth = { context.openSettings(Settings.ACTION_BLUETOOTH_SETTINGS) } + ) + } + } + + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text("CONTROLS") }, + icon = { Icon(Icons.Rounded.Speaker, contentDescription = null) } + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text("DEVICES") }, + icon = { Icon(Icons.Rounded.Devices, contentDescription = null) } + ) + } + } +} + +@Composable +private fun CastControlsTab( + colors: com.larvey.azuracastplayer.utils.AlbumColors, + 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 + ) { + Text( + text = "Connectivity", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onRefresh) { + Icon(Icons.Rounded.Refresh, contentDescription = "Refresh") + } + } + 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( + colors: com.larvey.azuracastplayer.utils.AlbumColors, + routes: List, + selectedRouteId: String?, + isCasting: Boolean, + isRefreshing: Boolean, + btDevices: List, + onSelect: (MediaRouter.RouteInfo) -> Unit, + onDisconnect: () -> Unit, + onOpenBluetooth: () -> Unit +) { + val context = LocalContext.current + Column(modifier = Modifier.fillMaxWidth()) { + if (isRefreshing) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(50)) + ) + Spacer(Modifier.height(8.dp)) + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (routes.isEmpty() && btDevices.isEmpty()) { + item { + 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 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..ea8a7c8 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 @@ -29,18 +32,24 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** - * 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. 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 inner (left) edge, fully rounded + * outer (right) edge; the Cast button mirrors it (rounded outer-left, squared inner-right) so the + * two read as a split pair. * * [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 + onClose: (() -> Unit)? = null, + onCast: (() -> Unit)? = null, + isCasting: Boolean = false, + isConnecting: Boolean = false ) { Row( modifier = modifier @@ -67,6 +76,36 @@ fun NowPlayingTopBar( 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 = 22.dp, + bottomStart = 22.dp, + topEnd = 6.dp, + bottomEnd = 6.dp + ), + width = 42.dp + ) { + when { + isConnecting -> CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = Color.White + ) + else -> Icon( + imageVector = if (isCasting) Icons.Rounded.CastConnected else Icons.Rounded.Cast, + contentDescription = "Cast", + modifier = Modifier.size(22.dp), + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + } + GlassButton( onClick = onToggleHistory, shape = RoundedCornerShape( 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 `