feat: wire album art to UI and handle Android 13+ notification permission

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 03:11:09 -04:00
parent 89b58477c9
commit c143483f33
4 changed files with 87 additions and 6 deletions

View File

@@ -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>(Screen.StationList) }

View File

@@ -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)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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<String?>(null)
val artworkUrl: StateFlow<String?> = _artworkUrl.asStateFlow()
val sessionElapsed: StateFlow<Long>
val connectionElapsed: StateFlow<Long>
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() {