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 `