From c143483f33af851d7d316ec246bf983475cacabb Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 03:11:09 -0400 Subject: [PATCH] feat: wire album art to UI and handle Android 13+ notification permission Made-with: Cursor --- .../xyz/cottongin/radio247/MainActivity.kt | 19 +++++++++ .../cottongin/radio247/RadioApplication.kt | 10 +++++ .../ui/screens/nowplaying/NowPlayingScreen.kt | 25 +++++++++--- .../screens/nowplaying/NowPlayingViewModel.kt | 39 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/xyz/cottongin/radio247/MainActivity.kt b/app/src/main/java/xyz/cottongin/radio247/MainActivity.kt index 46ef152..8d22427 100644 --- a/app/src/main/java/xyz/cottongin/radio247/MainActivity.kt +++ b/app/src/main/java/xyz/cottongin/radio247/MainActivity.kt @@ -1,13 +1,21 @@ package xyz.cottongin.radio247 +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import xyz.cottongin.radio247.ui.navigation.Screen import xyz.cottongin.radio247.ui.screens.nowplaying.NowPlayingScreen import xyz.cottongin.radio247.ui.screens.settings.SettingsScreen @@ -18,6 +26,17 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { + val requestNotificationPermission = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { _ -> } + val context = LocalContext.current + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } Radio247Theme { var currentScreen by remember { mutableStateOf(Screen.StationList) } diff --git a/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt b/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt index c5e713c..6296d0b 100644 --- a/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt +++ b/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt @@ -4,7 +4,9 @@ import android.app.Application import androidx.room.Room import xyz.cottongin.radio247.data.db.RadioDatabase import xyz.cottongin.radio247.data.prefs.RadioPreferences +import xyz.cottongin.radio247.metadata.AlbumArtResolver import xyz.cottongin.radio247.service.RadioController +import okhttp3.OkHttpClient class RadioApplication : Application() { val database: RadioDatabase by lazy { @@ -19,4 +21,12 @@ class RadioApplication : Application() { val controller: RadioController by lazy { RadioController(this) } + + val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder().build() + } + + val albumArtResolver: AlbumArtResolver by lazy { + AlbumArtResolver(okHttpClient) + } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt index b55e80b..f82177c 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt @@ -1,6 +1,7 @@ package xyz.cottongin.radio247.ui.screens.nowplaying import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -38,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage import xyz.cottongin.radio247.R import xyz.cottongin.radio247.audio.IcyMetadata import xyz.cottongin.radio247.service.PlaybackState @@ -54,6 +56,7 @@ fun NowPlayingScreen( ) ) { val playbackState by viewModel.playbackState.collectAsState() + val artworkUrl by viewModel.artworkUrl.collectAsState() val sessionElapsed by viewModel.sessionElapsed.collectAsState() val connectionElapsed by viewModel.connectionElapsed.collectAsState() val estimatedLatencyMs by viewModel.estimatedLatencyMs.collectAsState() @@ -110,14 +113,24 @@ fun NowPlayingScreen( shape = MaterialTheme.shapes.medium ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { - Image( - painter = painterResource(R.drawable.ic_radio_placeholder), - contentDescription = null, - modifier = Modifier.size(120.dp) - ) + if (artworkUrl != null) { + AsyncImage( + model = artworkUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } else { + Image( + painter = painterResource(R.drawable.ic_radio_placeholder), + contentDescription = null, + modifier = Modifier.size(120.dp) + ) + } } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt index 288ddbb..7dd27bb 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt @@ -5,9 +5,12 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import xyz.cottongin.radio247.RadioApplication import xyz.cottongin.radio247.service.PlaybackState +import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn @@ -29,9 +32,14 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati 0 ) + private val _artworkUrl = MutableStateFlow(null) + val artworkUrl: StateFlow = _artworkUrl.asStateFlow() + val sessionElapsed: StateFlow val connectionElapsed: StateFlow + private var artworkResolveJob: Job? = null + init { val ticker = flow { while (true) { @@ -54,6 +62,37 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati else -> 0L } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L) + + viewModelScope.launch { + playbackState.collect { state -> + val (station, metadata) = when (state) { + is PlaybackState.Playing -> state.station to state.metadata + is PlaybackState.Reconnecting -> state.station to state.metadata + PlaybackState.Idle -> null to null + } + when (state) { + is PlaybackState.Playing, + is PlaybackState.Reconnecting -> { + artworkResolveJob?.cancel() + artworkResolveJob = viewModelScope.launch { + val url = station?.let { s -> + app.albumArtResolver.resolve( + artist = metadata?.artist, + title = metadata?.title, + icyStreamUrl = metadata?.streamUrl, + stationArtworkUrl = s.defaultArtworkUrl + ) + } + _artworkUrl.value = url + } + } + PlaybackState.Idle -> { + artworkResolveJob?.cancel() + _artworkUrl.value = null + } + } + } + } } fun stop() {