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 package xyz.cottongin.radio247
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity 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.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import xyz.cottongin.radio247.ui.navigation.Screen import xyz.cottongin.radio247.ui.navigation.Screen
import xyz.cottongin.radio247.ui.screens.nowplaying.NowPlayingScreen import xyz.cottongin.radio247.ui.screens.nowplaying.NowPlayingScreen
import xyz.cottongin.radio247.ui.screens.settings.SettingsScreen import xyz.cottongin.radio247.ui.screens.settings.SettingsScreen
@@ -18,6 +26,17 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { 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 { Radio247Theme {
var currentScreen by remember { mutableStateOf<Screen>(Screen.StationList) } var currentScreen by remember { mutableStateOf<Screen>(Screen.StationList) }

View File

@@ -4,7 +4,9 @@ import android.app.Application
import androidx.room.Room import androidx.room.Room
import xyz.cottongin.radio247.data.db.RadioDatabase import xyz.cottongin.radio247.data.db.RadioDatabase
import xyz.cottongin.radio247.data.prefs.RadioPreferences import xyz.cottongin.radio247.data.prefs.RadioPreferences
import xyz.cottongin.radio247.metadata.AlbumArtResolver
import xyz.cottongin.radio247.service.RadioController import xyz.cottongin.radio247.service.RadioController
import okhttp3.OkHttpClient
class RadioApplication : Application() { class RadioApplication : Application() {
val database: RadioDatabase by lazy { val database: RadioDatabase by lazy {
@@ -19,4 +21,12 @@ class RadioApplication : Application() {
val controller: RadioController by lazy { val controller: RadioController by lazy {
RadioController(this) 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 package xyz.cottongin.radio247.ui.screens.nowplaying
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row 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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.audio.IcyMetadata import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.service.PlaybackState import xyz.cottongin.radio247.service.PlaybackState
@@ -54,6 +56,7 @@ fun NowPlayingScreen(
) )
) { ) {
val playbackState by viewModel.playbackState.collectAsState() val playbackState by viewModel.playbackState.collectAsState()
val artworkUrl by viewModel.artworkUrl.collectAsState()
val sessionElapsed by viewModel.sessionElapsed.collectAsState() val sessionElapsed by viewModel.sessionElapsed.collectAsState()
val connectionElapsed by viewModel.connectionElapsed.collectAsState() val connectionElapsed by viewModel.connectionElapsed.collectAsState()
val estimatedLatencyMs by viewModel.estimatedLatencyMs.collectAsState() val estimatedLatencyMs by viewModel.estimatedLatencyMs.collectAsState()
@@ -110,14 +113,24 @@ fun NowPlayingScreen(
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Image( if (artworkUrl != null) {
painter = painterResource(R.drawable.ic_radio_placeholder), AsyncImage(
contentDescription = null, model = artworkUrl,
modifier = Modifier.size(120.dp) 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 androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.RadioApplication import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.service.PlaybackState import xyz.cottongin.radio247.service.PlaybackState
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -29,9 +32,14 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
0 0
) )
private val _artworkUrl = MutableStateFlow<String?>(null)
val artworkUrl: StateFlow<String?> = _artworkUrl.asStateFlow()
val sessionElapsed: StateFlow<Long> val sessionElapsed: StateFlow<Long>
val connectionElapsed: StateFlow<Long> val connectionElapsed: StateFlow<Long>
private var artworkResolveJob: Job? = null
init { init {
val ticker = flow { val ticker = flow {
while (true) { while (true) {
@@ -54,6 +62,37 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
else -> 0L else -> 0L
} }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 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() { fun stop() {