diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 3515f0d..143ecc0 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -11,12 +11,9 @@ plugins {
}
kotlin {
- compilerOptions {
- freeCompilerArgs.add("-Xexpect-actual-classes")
- }
androidTarget {
compilerOptions {
- jvmTarget.set(JvmTarget.JVM_22)
+ jvmTarget.set(JvmTarget.JVM_17)
}
}
@@ -101,10 +98,13 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_22
- targetCompatibility = JavaVersion.VERSION_22
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
+ buildFeatures {
+ compose = true
+ }
}
dependencies {
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index d3cf854..2e5123c 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
+
+
-
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/MainActivity.kt
index 0f3f8eb..8513dd9 100644
--- a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/MainActivity.kt
@@ -1,6 +1,7 @@
package org.onedroid.radiowave
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
@@ -12,10 +13,15 @@ import org.onedroid.radiowave.app.App
import org.onedroid.radiowave.app.utils.setActivityProvider
class MainActivity : ComponentActivity() {
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setActivityProvider { this }
installSplashScreen()
+
+ // Keep screen on while app is active (driver mode)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
setContent {
enableEdgeToEdge(
SystemBarStyle.dark(MaterialTheme.colorScheme.onSurface.toArgb()),
@@ -24,4 +30,14 @@ class MainActivity : ComponentActivity() {
App()
}
}
-}
\ No newline at end of file
+
+ /**
+ * Set screen brightness. Called from the Mixer panel.
+ * @param brightness 0.0f to 1.0f, or -1.0f for system default.
+ */
+ fun setScreenBrightness(brightness: Float) {
+ val params = window.attributes
+ params.screenBrightness = brightness.coerceIn(0.01f, 1.0f)
+ window.attributes = params
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/di/Modules.android.kt
index c3c558a..4b41828 100644
--- a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/di/Modules.android.kt
+++ b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/di/Modules.android.kt
@@ -6,6 +6,7 @@ import org.koin.android.ext.koin.androidApplication
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
+import org.onedroid.radiowave.app.player.AudioEffectsManager
import org.onedroid.radiowave.app.player.PlayerController
import org.onedroid.radiowave.data.database.DatabaseFactory
import org.onedroid.radiowave.data.datastore.dataStorePreferences
@@ -16,4 +17,5 @@ actual val platformModule: Module
single { OkHttp.create() }
singleOf(::PlayerController)
singleOf(::dataStorePreferences)
- }
\ No newline at end of file
+ singleOf(::AudioEffectsManager)
+ }
diff --git a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/AudioEffectsManager.kt b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/AudioEffectsManager.kt
new file mode 100644
index 0000000..92d4832
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/AudioEffectsManager.kt
@@ -0,0 +1,143 @@
+package org.onedroid.radiowave.app.player
+
+import android.media.audiofx.Equalizer
+import android.media.audiofx.LoudnessEnhancer
+
+/**
+ * Manages audio effects (EQ presets) for driving scenarios.
+ * Presets: Flat, Voice Clarity, Warm, Reduce Hiss.
+ */
+class AudioEffectsManager {
+
+ private var equalizer: Equalizer? = null
+ private var loudnessEnhancer: LoudnessEnhancer? = null
+ private var currentPreset: EqPreset = EqPreset.FLAT
+
+ enum class EqPreset(val label: String) {
+ FLAT("Flat"),
+ VOICE_CLARITY("Voice Clarity"),
+ WARM("Warm"),
+ REDUCE_HISS("Reduce Hiss")
+ }
+
+ fun attach(audioSessionId: Int) {
+ release()
+ if (audioSessionId == 0) return
+
+ try {
+ equalizer = Equalizer(0, audioSessionId).apply {
+ enabled = true
+ }
+ loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply {
+ enabled = false
+ }
+ applyPreset(currentPreset)
+ } catch (_: Exception) {
+ // Some devices don't support audio effects
+ }
+ }
+
+ fun applyPreset(preset: EqPreset) {
+ currentPreset = preset
+ val eq = equalizer ?: return
+ val bands = eq.numberOfBands.toInt()
+
+ loudnessEnhancer?.enabled = false
+
+ when (preset) {
+ EqPreset.FLAT -> {
+ for (i in 0 until bands) {
+ eq.setBandLevel(i.toShort(), 0)
+ }
+ }
+ EqPreset.VOICE_CLARITY -> {
+ // Boost mids (speech frequencies ~300Hz-3kHz), cut lows
+ val levels = generateVoiceLevels(bands, eq)
+ for (i in 0 until bands) {
+ eq.setBandLevel(i.toShort(), levels[i])
+ }
+ loudnessEnhancer?.apply {
+ setTargetGain(400) // Slight loudness boost
+ enabled = true
+ }
+ }
+ EqPreset.WARM -> {
+ // Boost lows and low-mids, slight high cut
+ val levels = generateWarmLevels(bands, eq)
+ for (i in 0 until bands) {
+ eq.setBandLevel(i.toShort(), levels[i])
+ }
+ }
+ EqPreset.REDUCE_HISS -> {
+ // Cut highs aggressively to reduce hiss/noise
+ val levels = generateReduceHissLevels(bands, eq)
+ for (i in 0 until bands) {
+ eq.setBandLevel(i.toShort(), levels[i])
+ }
+ }
+ }
+ }
+
+ fun getCurrentPreset(): EqPreset = currentPreset
+
+ fun release() {
+ try {
+ equalizer?.release()
+ loudnessEnhancer?.release()
+ } catch (_: Exception) {}
+ equalizer = null
+ loudnessEnhancer = null
+ }
+
+ private fun generateVoiceLevels(bands: Int, eq: Equalizer): ShortArray {
+ val range = eq.bandLevelRange
+ val minLevel = range[0]
+ val maxLevel = range[1]
+ val mid = (maxLevel * 0.6).toInt().toShort()
+ val low = (minLevel * 0.3).toInt().toShort()
+
+ return ShortArray(bands) { i ->
+ val ratio = i.toFloat() / (bands - 1)
+ when {
+ ratio < 0.2f -> low // Cut sub-bass
+ ratio < 0.4f -> 0 // Slight low cut
+ ratio < 0.7f -> mid // Boost mids
+ ratio < 0.85f -> (mid * 0.5f).toInt().toShort() // Upper mids
+ else -> 0 // Flat highs
+ }
+ }
+ }
+
+ private fun generateWarmLevels(bands: Int, eq: Equalizer): ShortArray {
+ val range = eq.bandLevelRange
+ val maxLevel = range[1]
+ val boost = (maxLevel * 0.5).toInt().toShort()
+ val cut = (range[0] * 0.2).toInt().toShort()
+
+ return ShortArray(bands) { i ->
+ val ratio = i.toFloat() / (bands - 1)
+ when {
+ ratio < 0.3f -> boost // Boost bass
+ ratio < 0.5f -> (boost * 0.6f).toInt().toShort()
+ ratio < 0.7f -> 0
+ else -> cut // Slight high cut
+ }
+ }
+ }
+
+ private fun generateReduceHissLevels(bands: Int, eq: Equalizer): ShortArray {
+ val range = eq.bandLevelRange
+ val minLevel = range[0]
+ val cut = (minLevel * 0.6).toInt().toShort()
+ val deepCut = (minLevel * 0.9).toInt().toShort()
+
+ return ShortArray(bands) { i ->
+ val ratio = i.toFloat() / (bands - 1)
+ when {
+ ratio < 0.5f -> 0 // Keep lows/mids flat
+ ratio < 0.7f -> cut // Cut upper mids slightly
+ else -> deepCut // Aggressively cut highs
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.android.kt b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.android.kt
index dba39b0..582d7c8 100644
--- a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.android.kt
+++ b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.android.kt
@@ -1,180 +1,65 @@
package org.onedroid.radiowave.app.player
-import android.annotation.SuppressLint
import android.content.Context
+import android.content.Intent
import android.media.AudioManager
-import android.os.Build
import android.os.VibrationEffect
-import android.os.Vibrator
import android.os.VibratorManager
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.expandHorizontally
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkHorizontally
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.basicMarquee
-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.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-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.draw.alpha
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
-import androidx.media3.common.MediaMetadata
-import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
-import androidx.media3.exoplayer.ExoPlayer
-import coil3.compose.rememberAsyncImagePainter
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import org.jetbrains.compose.resources.stringResource
-import org.onedroid.radiowave.R
-import org.onedroid.radiowave.app.theme.small
-import org.onedroid.radiowave.app.theme.thin
-import org.onedroid.radiowave.domain.Radio
-import org.onedroid.radiowave.presentation.home.components.AnimatedRadioCoverImage
-import org.onedroid.radiowave.presentation.home.components.PlayerFullWidthView
-import org.onedroid.radiowave.presentation.home.components.RadioActionButtons
-import org.onedroid.radiowave.presentation.home.components.RadioDetails
-import radiowave.composeapp.generated.resources.Res
-import radiowave.composeapp.generated.resources.broken_image_radio
-import radiowave.composeapp.generated.resources.detecting
-import radiowave.composeapp.generated.resources.now_playing
-import radiowave.composeapp.generated.resources.unable_to_detect
-actual class PlayerController(context: Context) : PlayerRepository {
+actual class PlayerController(
+ private val context: Context
+) : PlayerRepository {
- private val player = ExoPlayer.Builder(context).build()
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
- private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
- private val _metadataState = MutableStateFlow(MetadataState.Detecting)
- private val _isPlaying = MutableStateFlow(false)
- private val _currentPosition = MutableStateFlow(0L)
- private val metadataState = _metadataState.asStateFlow()
- private val isPlaying = _isPlaying.asStateFlow()
- private val currentPosition = _currentPosition.asStateFlow()
- private var metadataTimeoutJob: Job? = null
- private var positionUpdateJob: Job? = null
+ private val vibrator = (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager)
+ .defaultVibrator
- private val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- val vibratorManager =
- context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
- vibratorManager.defaultVibrator
- } else {
- @Suppress("DEPRECATION")
- context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
- }
+ private val _isPlayingLocal = MutableStateFlow(false)
+ private val _metadataLocal = MutableStateFlow(null)
+ private val _playbackStateLocal = MutableStateFlow(Player.STATE_IDLE)
- @Composable
- override fun PLayerUI(
- radio: Radio,
- isSaved: Boolean,
- onSaveClick: () -> Unit,
- onWebpageClick: () -> Unit,
- onShareClick: () -> Unit
- ) {
- val isPlayingState by isPlaying.collectAsState()
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(small)
- ) {
- item {
- radio.imgUrl?.let { imgUrl ->
- val imagePainter = rememberAsyncImagePainter(
- model = imgUrl,
- placeholder = org.jetbrains.compose.resources.painterResource(Res.drawable.broken_image_radio),
- error = org.jetbrains.compose.resources.painterResource(Res.drawable.broken_image_radio)
- )
- AnimatedRadioCoverImage(
- isPlaying = isPlayingState,
- painter = imagePainter,
- )
- }
- }
- item {
- RadioDetails(
- radio = radio,
- nowPlayingIndicator = {
- NowPlayingIndicator()
- }
- )
- }
- item {
- RadioActionButtons(
- isSaved = isSaved,
- onSaveClick = onSaveClick,
- onWebpageClick = onWebpageClick,
- onShareClick = onShareClick
- )
- }
+ override val isPlaying: StateFlow
+ get() = RadioPlaybackService.isPlaying
- item {
- PlayerFullWidthView(
- onPlayClick = { pauseResume() },
- onVolumeUpClick = { volumeUp() },
- onVolumeDownClick = { volumeDown() },
- isPlaying = isPlayingState,
- playerStatusIndicator = { PlayerStatusIndicator() }
- )
- }
- }
+ override val metadataText: StateFlow
+ get() = RadioPlaybackService.metadataText
+
+ override val playbackStateCode: StateFlow
+ get() = RadioPlaybackService.playbackState
+
+ private fun ensureService() {
+ val intent = Intent(context, RadioPlaybackService::class.java)
+ context.startForegroundService(intent)
}
override fun play(audioUrl: String) {
+ ensureService()
+ val player = RadioPlaybackService.player
+ if (player != null) {
+ playOnPlayer(player, audioUrl)
+ } else {
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ RadioPlaybackService.player?.let { playOnPlayer(it, audioUrl) }
+ }, 300)
+ }
+ }
+
+ private fun playOnPlayer(player: androidx.media3.exoplayer.ExoPlayer, audioUrl: String) {
player.clearMediaItems()
val mediaItem = MediaItem.fromUri(audioUrl)
player.setMediaItem(mediaItem)
- _metadataState.value = MetadataState.Detecting
player.prepare()
player.play()
}
- private fun pauseResume() {
+ override fun pauseResume() {
+ val player = RadioPlaybackService.player ?: return
if (player.isPlaying) {
player.pause()
} else {
@@ -183,304 +68,35 @@ actual class PlayerController(context: Context) : PlayerRepository {
vibrateDevice()
}
- private fun startPositionTracking() {
- positionUpdateJob?.cancel()
- positionUpdateJob = coroutineScope.launch {
- while (isActive) {
- if (player.isPlaying) {
- _currentPosition.value = player.currentPosition
- }
- delay(50L)
- }
- }
- }
-
- init {
- player.addListener(object : Player.Listener {
- override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
- metadataTimeoutJob?.cancel()
-
- val artist = mediaMetadata.artist?.toString() ?: ""
- val title = mediaMetadata.title?.toString() ?: ""
-
- if (title.isNotEmpty()) {
- val content = if (artist.isNotEmpty()) "$artist - $title" else title
- _metadataState.value = MetadataState.Available(content)
- }
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- when (playbackState) {
- Player.STATE_IDLE, Player.STATE_ENDED -> {
- metadataTimeoutJob?.cancel()
- _metadataState.value = MetadataState.NotAvailable
- }
-
- Player.STATE_READY -> {
- if (_metadataState.value is MetadataState.Detecting) {
- startMetadataTimeout()
- }
- }
-
- Player.STATE_BUFFERING -> {
- _metadataState.value = MetadataState.Detecting
- }
- }
- }
-
- override fun onIsPlayingChanged(isNowPlaying: Boolean) {
- _isPlaying.value = isNowPlaying
- if (isNowPlaying) {
- if (_metadataState.value is MetadataState.Detecting &&
- (metadataTimeoutJob == null || metadataTimeoutJob?.isCompleted == true)
- ) {
- startMetadataTimeout()
- }
-
- if (positionUpdateJob?.isActive != true) {
- startPositionTracking()
- }
- }
- }
- })
-
- startPositionTracking()
- }
-
- private fun startMetadataTimeout() {
- metadataTimeoutJob?.cancel()
- metadataTimeoutJob = coroutineScope.launch {
- delay(5000)
- if (_metadataState.value is MetadataState.Detecting) {
- _metadataState.value = MetadataState.NotAvailable
- }
- }
- }
-
- @Composable
- private fun NowPlayingIndicator() {
- val state by metadataState.collectAsState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = thin)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(thin)
- ) {
- Text(
- text = stringResource(Res.string.now_playing),
- style = MaterialTheme.typography.bodyMedium,
- )
- AnimatedVisibility(
- visible = true,
- enter = fadeIn() + expandHorizontally(),
- exit = fadeOut() + shrinkHorizontally()
- ) {
- Text(
- modifier = Modifier.basicMarquee(
- initialDelayMillis = 0,
- iterations = Int.MAX_VALUE,
- ),
- text = when (state) {
- is MetadataState.Detecting -> stringResource(Res.string.detecting)
- is MetadataState.NotAvailable -> stringResource(Res.string.unable_to_detect)
- is MetadataState.Available -> (state as MetadataState.Available).content
- },
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
- }
+ override fun stop() {
+ val player = RadioPlaybackService.player ?: return
+ player.stop()
+ player.clearMediaItems()
}
- private fun volumeUp() {
+ override fun volumeUp() {
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val newVolume = (currentVolume + 1).coerceAtMost(maxVolume)
- adjustVolumeWithUi(newVolume)
+ audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
vibrateDevice()
}
- private fun volumeDown() {
+ override fun volumeDown() {
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val newVolume = (currentVolume - 1).coerceAtLeast(0)
- adjustVolumeWithUi(newVolume)
+ audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
vibrateDevice()
}
- private fun adjustVolumeWithUi(newVolume: Int) {
- audioManager.setStreamVolume(
- AudioManager.STREAM_MUSIC,
- newVolume, 0
- )
+ override fun getAudioSessionId(): Int {
+ return RadioPlaybackService.player?.audioSessionId ?: 0
}
private fun vibrateDevice() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))
- } else {
- @Suppress("DEPRECATION")
- vibrator.vibrate(50)
- }
- }
-
- @SuppressLint("DefaultLocale")
- @Composable
- private fun PlayerStatusIndicator() {
-
- val playbackState = remember { mutableIntStateOf(player.playbackState) }
- val hasError = remember { mutableStateOf(false) }
- val currentVolumeState = remember { mutableIntStateOf(getCurrentVolume()) }
- val position by currentPosition.collectAsState()
- val isPlayingState by isPlaying.collectAsState()
-
- LaunchedEffect(Unit) {
- while (true) {
- currentVolumeState.intValue = getCurrentVolume()
- delay(1000) // reduced polling frequency
- }
- }
-
- DisposableEffect(player) {
- val listener = object : Player.Listener {
- override fun onPlaybackStateChanged(state: Int) {
- playbackState.intValue = state
- if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) {
- hasError.value = false
- }
- }
-
- override fun onPlayerError(error: PlaybackException) {
- hasError.value = true
- }
- }
-
- player.addListener(listener)
- onDispose { player.removeListener(listener) }
- }
-
- val animatedVolumePercent by animateFloatAsState(
- targetValue = currentVolumeState.intValue / maxVolume.toFloat(),
- animationSpec = tween(durationMillis = 300),
- label = "volumeAnim"
- )
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
-
- if (hasError.value || playbackState.intValue == Player.STATE_IDLE || playbackState.intValue == Player.STATE_ENDED) {
- OfflineIndicator(4.dp)
- } else {
- LiveIndicator(4.dp)
- }
-
- LinearProgressIndicator(
- progress = { animatedVolumePercent },
- modifier = Modifier
- .weight(1f)
- .padding(horizontal = 10.dp)
- .height(8.dp)
- .clip(RoundedCornerShape(3.dp)),
- color = Color.Green,
- trackColor = Color.DarkGray,
- )
-
- when {
- hasError.value -> Text("Failed to play", color = Color.Red)
- playbackState.intValue == Player.STATE_BUFFERING -> CircularProgressIndicator(
- modifier = Modifier.size(16.dp)
- )
-
- playbackState.intValue == Player.STATE_READY || isPlayingState -> {
- val totalSeconds = position / 1000
- val hours = totalSeconds / 3600
- val minutes = (totalSeconds % 3600) / 60
- val seconds = totalSeconds % 60
- Text(
- textAlign = TextAlign.End,
- modifier = Modifier.width(65.dp),
- text = String.format("%d:%02d:%02d", hours, minutes, seconds)
- )
- }
-
- else -> Text(stringResource(Res.string.now_playing))
- }
- }
- }
-
- @Composable
- fun LiveIndicator(extraSmall: Dp = 4.dp) {
- var visible by remember { mutableStateOf(true) }
-
- LaunchedEffect(Unit) {
- while (true) {
- visible = !visible
- delay(500)
- }
- }
-
- val alpha by animateFloatAsState(
- targetValue = if (visible) 1f else 0f,
- animationSpec = tween(durationMillis = 300),
- label = "liveIndicatorAlpha"
- )
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier
- .size(10.dp)
- .clip(RoundedCornerShape(100))
- .alpha(alpha),
- contentAlignment = Alignment.Center
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_circle),
- contentDescription = "Blinking Dot",
- modifier = Modifier.size(10.dp),
- colorFilter = ColorFilter.tint(Color.Red)
- )
- }
- Spacer(modifier = Modifier.width(extraSmall))
- Text(text = "Live")
- }
- }
-
- @Composable
- fun OfflineIndicator(extraSmall: Dp = 4.dp) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier
- .size(10.dp)
- .clip(RoundedCornerShape(100))
- .background(Color.Gray)
- )
- Spacer(modifier = Modifier.width(extraSmall))
- Text(text = "Offline", color = Color.Gray)
- }
- }
-
- private fun getCurrentVolume(): Int {
- return audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
+ vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
}
override fun onCleared() {
- metadataTimeoutJob?.cancel()
- positionUpdateJob?.cancel()
- coroutineScope.cancel()
- player.release()
+ // Don't stop service here - let it continue for background playback
}
}
-
-sealed class MetadataState {
- data object Detecting : MetadataState()
- data object NotAvailable : MetadataState()
- data class Available(val content: String) : MetadataState()
-}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/RadioPlaybackService.kt b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/RadioPlaybackService.kt
new file mode 100644
index 0000000..1ecb25d
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/RadioPlaybackService.kt
@@ -0,0 +1,108 @@
+package org.onedroid.radiowave.app.player
+
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.annotation.OptIn
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.onedroid.radiowave.MainActivity
+
+class RadioPlaybackService : MediaSessionService() {
+
+ private var mediaSession: MediaSession? = null
+
+ companion object {
+ private val _isPlaying = MutableStateFlow(false)
+ private val _metadataText = MutableStateFlow(null)
+ private val _playbackState = MutableStateFlow(Player.STATE_IDLE)
+ private var _player: ExoPlayer? = null
+
+ val isPlaying = _isPlaying.asStateFlow()
+ val metadataText = _metadataText.asStateFlow()
+ val playbackState = _playbackState.asStateFlow()
+
+ val player: ExoPlayer? get() = _player
+ }
+
+ @OptIn(UnstableApi::class)
+ override fun onCreate() {
+ super.onCreate()
+
+ val exoPlayer = ExoPlayer.Builder(this)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(C.USAGE_MEDIA)
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .build(),
+ true
+ )
+ .setHandleAudioBecomingNoisy(true)
+ .build()
+
+ _player = exoPlayer
+
+ exoPlayer.addListener(object : Player.Listener {
+ override fun onIsPlayingChanged(isNowPlaying: Boolean) {
+ _isPlaying.value = isNowPlaying
+ }
+
+ override fun onPlaybackStateChanged(state: Int) {
+ _playbackState.value = state
+ if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) {
+ _metadataText.value = null
+ }
+ }
+
+ override fun onMediaMetadataChanged(metadata: MediaMetadata) {
+ val title = metadata.title?.toString() ?: ""
+ val artist = metadata.artist?.toString() ?: ""
+ if (title.isNotEmpty()) {
+ _metadataText.value = if (artist.isNotEmpty()) "$artist - $title" else title
+ }
+ }
+ })
+
+ val intent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ mediaSession = MediaSession.Builder(this, exoPlayer)
+ .setSessionActivity(pendingIntent)
+ .build()
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+ return mediaSession
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ val player = mediaSession?.player ?: return
+ if (!player.playWhenReady || player.mediaItemCount == 0) {
+ stopSelf()
+ }
+ }
+
+ override fun onDestroy() {
+ mediaSession?.run {
+ player.release()
+ release()
+ }
+ mediaSession = null
+ _player = null
+ _isPlaying.value = false
+ _metadataText.value = null
+ _playbackState.value = Player.STATE_IDLE
+ super.onDestroy()
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.android.kt b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.android.kt
new file mode 100644
index 0000000..5b5b5a0
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.android.kt
@@ -0,0 +1,31 @@
+package org.onedroid.radiowave.data.network
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.onedroid.radiowave.app.utils.DNS_LOOKUP_HOST
+import java.net.InetAddress
+
+actual class RadioBrowserServerResolver actual constructor() {
+
+ actual suspend fun resolveServers(): List = withContext(Dispatchers.IO) {
+ try {
+ val addresses = InetAddress.getAllByName(DNS_LOOKUP_HOST)
+ val servers = addresses.mapNotNull { addr ->
+ try {
+ val hostname = addr.canonicalHostName
+ if (hostname != addr.hostAddress) {
+ "https://$hostname/json"
+ } else {
+ null
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }.distinct().shuffled()
+
+ servers.ifEmpty { FALLBACK_SERVERS.shuffled() }
+ } catch (_: Exception) {
+ FALLBACK_SERVERS.shuffled()
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/di/Modules.kt
index b0dbf58..f4c56be 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/di/Modules.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/di/Modules.kt
@@ -12,11 +12,15 @@ import org.onedroid.radiowave.app.utils.AppPreferences
import org.onedroid.radiowave.app.utils.HttpClientFactory
import org.onedroid.radiowave.data.database.DatabaseFactory
import org.onedroid.radiowave.data.database.RadioWaveDatabase
+import org.onedroid.radiowave.data.network.RadioBrowserServerResolver
import org.onedroid.radiowave.data.network.RemoteRadioDataSource
import org.onedroid.radiowave.data.network.RemoteRadioDataSourceImpl
import org.onedroid.radiowave.data.repository.RadioRepositoryImpl
import org.onedroid.radiowave.domain.RadioRepository
+import org.onedroid.radiowave.presentation.browse.BrowseViewModel
+import org.onedroid.radiowave.presentation.favorites.FavoritesViewModel
import org.onedroid.radiowave.presentation.home.HomeViewModel
+import org.onedroid.radiowave.presentation.nowplaying.NowPlayingViewModel
import org.onedroid.radiowave.presentation.settings.SettingViewModel
expect val platformModule: Module
@@ -25,10 +29,14 @@ val sharedModule = module {
single { HttpClientFactory.create(get()) }
single { get().create().setDriver(BundledSQLiteDriver()).build() }
single { get().radioWaveDao }
+ singleOf(::RadioBrowserServerResolver)
singleOf(::RemoteRadioDataSourceImpl).bind()
singleOf(::RadioRepositoryImpl).bind()
singleOf(::PlayerRepositoryImpl).bind()
singleOf(::AppPreferences)
viewModelOf(::HomeViewModel)
viewModelOf(::SettingViewModel)
-}
\ No newline at end of file
+ viewModelOf(::BrowseViewModel)
+ viewModelOf(::FavoritesViewModel)
+ viewModelOf(::NowPlayingViewModel)
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavGraphBuilder.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavGraphBuilder.kt
index e9db641..3119df9 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavGraphBuilder.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavGraphBuilder.kt
@@ -3,23 +3,62 @@ package org.onedroid.radiowave.app.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
+import org.onedroid.radiowave.domain.Radio
+import org.onedroid.radiowave.presentation.browse.BrowseScreen
+import org.onedroid.radiowave.presentation.browse.BrowseViewModel
+import org.onedroid.radiowave.presentation.favorites.FavoritesScreen
+import org.onedroid.radiowave.presentation.favorites.FavoritesViewModel
import org.onedroid.radiowave.presentation.home.HomeScreen
+import org.onedroid.radiowave.presentation.nowplaying.NowPlayingScreen
+import org.onedroid.radiowave.presentation.nowplaying.NowPlayingViewModel
import org.onedroid.radiowave.presentation.settings.SettingScreen
import org.onedroid.radiowave.presentation.settings.SettingViewModel
fun NavGraphBuilder.navGraphBuilder(
rootNavController: NavController,
- settingViewModel: SettingViewModel
+ settingViewModel: SettingViewModel,
+ browseViewModel: BrowseViewModel,
+ favoritesViewModel: FavoritesViewModel,
+ nowPlayingViewModel: NowPlayingViewModel,
+ onMixerClick: () -> Unit,
+ onStationSelected: (Radio?) -> Unit,
) {
composable {
HomeScreen(
rootNavController = rootNavController
)
}
+ composable {
+ BrowseScreen(
+ viewModel = browseViewModel,
+ onStationSelected = {
+ rootNavController.navigate(Route.NowPlaying) {
+ launchSingleTop = true
+ }
+ }
+ )
+ }
+ composable {
+ FavoritesScreen(
+ viewModel = favoritesViewModel,
+ onStationSelected = {
+ rootNavController.navigate(Route.NowPlaying) {
+ launchSingleTop = true
+ }
+ }
+ )
+ }
+ composable {
+ NowPlayingScreen(
+ viewModel = nowPlayingViewModel,
+ onMixerClick = onMixerClick,
+ onBackClick = { rootNavController.popBackStack() }
+ )
+ }
composable {
SettingScreen(
viewModel = settingViewModel,
rootNavController = rootNavController
)
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavigationScreen.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavigationScreen.kt
index 8546a3d..3ba1cd9 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavigationScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/NavigationScreen.kt
@@ -1,25 +1,159 @@
package org.onedroid.radiowave.app.navigation
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
+import org.koin.compose.viewmodel.koinViewModel
+import org.onedroid.radiowave.domain.Radio
+import org.onedroid.radiowave.presentation.browse.BrowseViewModel
+import org.onedroid.radiowave.presentation.favorites.FavoritesViewModel
+import org.onedroid.radiowave.presentation.mixer.MixerPanel
+import org.onedroid.radiowave.presentation.nowplaying.NowPlayingViewModel
import org.onedroid.radiowave.presentation.settings.SettingViewModel
+data class NavItem(
+ val label: String,
+ val icon: ImageVector,
+ val route: Route
+)
+
+val navItems = listOf(
+ NavItem("Favorites", Icons.Default.Favorite, Route.Favorites),
+ NavItem("Browse", Icons.Default.Search, Route.Browse),
+ NavItem("Playing", Icons.Default.PlayArrow, Route.NowPlaying),
+ NavItem("Settings", Icons.Default.Settings, Route.Settings),
+)
+
@Composable
fun NavigationScreenRoot(
settingViewModel: SettingViewModel
) {
val rootNavController = rememberNavController()
- Scaffold {
- NavHost(
- navController = rootNavController,
- startDestination = Route.Home,
+ val browseViewModel = koinViewModel()
+ val favoritesViewModel = koinViewModel()
+ val nowPlayingViewModel = koinViewModel()
+
+ var showMixer by remember { mutableStateOf(false) }
+ var brightness by remember { mutableFloatStateOf(0.5f) }
+ var currentEqPreset by rememberSaveable { mutableStateOf("FLAT") }
+
+ // Track current station for NowPlaying
+ var currentStation by remember { mutableStateOf(null) }
+
+ val navBackStackEntry by rootNavController.currentBackStackEntryAsState()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ bottomBar = {
+ NavigationBar(
+ modifier = Modifier.height(72.dp),
+ tonalElevation = 4.dp
+ ) {
+ navItems.forEach { item ->
+ val isSelected = navBackStackEntry?.destination?.route
+ ?.contains(item.route::class.simpleName ?: "") == true
+
+ NavigationBarItem(
+ selected = isSelected,
+ onClick = {
+ rootNavController.navigate(item.route) {
+ popUpTo(Route.Favorites) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ icon = {
+ Icon(
+ imageVector = item.icon,
+ contentDescription = item.label,
+ modifier = Modifier.size(28.dp)
+ )
+ },
+ label = {
+ Text(
+ text = item.label,
+ fontSize = 13.sp
+ )
+ }
+ )
+ }
+ }
+ }
+ ) { padding ->
+ NavHost(
+ navController = rootNavController,
+ startDestination = Route.Favorites,
+ modifier = Modifier.padding(padding)
+ ) {
+ navGraphBuilder(
+ rootNavController = rootNavController,
+ settingViewModel = settingViewModel,
+ browseViewModel = browseViewModel,
+ favoritesViewModel = favoritesViewModel,
+ nowPlayingViewModel = nowPlayingViewModel,
+ onMixerClick = { showMixer = true },
+ onStationSelected = { radio ->
+ currentStation = radio
+ }
+ )
+ }
+ }
+
+ // Mixer overlay
+ AnimatedVisibility(
+ visible = showMixer,
+ enter = slideInVertically(initialOffsetY = { it }),
+ exit = slideOutVertically(targetOffsetY = { it })
) {
- navGraphBuilder(
- rootNavController = rootNavController,
- settingViewModel = settingViewModel
+ MixerPanel(
+ currentPresetId = currentEqPreset,
+ brightness = brightness,
+ onPresetSelected = { preset ->
+ currentEqPreset = preset
+ },
+ onBrightnessChanged = { newBrightness ->
+ brightness = newBrightness
+ },
+ onClose = { showMixer = false }
)
}
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/Route.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/Route.kt
index ea24864..1ba03a3 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/Route.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/navigation/Route.kt
@@ -6,6 +6,15 @@ sealed interface Route {
@Serializable
data object Home : Route
+ @Serializable
+ data object Browse : Route
+
+ @Serializable
+ data object Favorites : Route
+
+ @Serializable
+ data object NowPlaying : Route
+
@Serializable
data object Settings : Route
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/MetadataState.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/MetadataState.kt
new file mode 100644
index 0000000..4f30caa
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/MetadataState.kt
@@ -0,0 +1,7 @@
+package org.onedroid.radiowave.app.player
+
+sealed class MetadataState {
+ data object Detecting : MetadataState()
+ data object NotAvailable : MetadataState()
+ data class Available(val content: String) : MetadataState()
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.kt
index f0972cc..39f70d7 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.kt
@@ -1,3 +1,3 @@
package org.onedroid.radiowave.app.player
-expect class PlayerController : PlayerRepository
\ No newline at end of file
+expect class PlayerController : PlayerRepository
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepository.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepository.kt
index d1b0dc9..42209b7 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepository.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepository.kt
@@ -1,17 +1,17 @@
package org.onedroid.radiowave.app.player
-import androidx.compose.runtime.Composable
-import org.onedroid.radiowave.domain.Radio
+import kotlinx.coroutines.flow.StateFlow
interface PlayerRepository {
+ val isPlaying: StateFlow
+ val metadataText: StateFlow
+ val playbackStateCode: StateFlow
+
fun play(audioUrl: String)
- @Composable
- fun PLayerUI(
- radio: Radio,
- isSaved: Boolean,
- onSaveClick: () -> Unit,
- onWebpageClick: () -> Unit,
- onShareClick: () -> Unit
- )
+ fun pauseResume()
+ fun stop()
+ fun volumeUp()
+ fun volumeDown()
+ fun getAudioSessionId(): Int
fun onCleared()
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepositoryImpl.kt
index 4fea609..aa30468 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepositoryImpl.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/PlayerRepositoryImpl.kt
@@ -1,33 +1,20 @@
package org.onedroid.radiowave.app.player
-import androidx.compose.runtime.Composable
-import org.onedroid.radiowave.domain.Radio
+import kotlinx.coroutines.flow.StateFlow
class PlayerRepositoryImpl(
private val playerController: PlayerController
) : PlayerRepository {
- override fun play(audioUrl: String) {
- playerController.play(audioUrl)
- }
- @Composable
- override fun PLayerUI(
- radio: Radio,
- isSaved: Boolean,
- onSaveClick: () -> Unit,
- onWebpageClick: () -> Unit,
- onShareClick: () -> Unit
- ) {
- playerController.PLayerUI(
- radio = radio,
- isSaved = isSaved,
- onSaveClick = onSaveClick,
- onWebpageClick = onWebpageClick,
- onShareClick = onShareClick
- )
- }
+ override val isPlaying: StateFlow get() = playerController.isPlaying
+ override val metadataText: StateFlow get() = playerController.metadataText
+ override val playbackStateCode: StateFlow get() = playerController.playbackStateCode
- override fun onCleared() {
- playerController.onCleared()
- }
-}
\ No newline at end of file
+ override fun play(audioUrl: String) = playerController.play(audioUrl)
+ override fun pauseResume() = playerController.pauseResume()
+ override fun stop() = playerController.stop()
+ override fun volumeUp() = playerController.volumeUp()
+ override fun volumeDown() = playerController.volumeDown()
+ override fun getAudioSessionId(): Int = playerController.getAudioSessionId()
+ override fun onCleared() = playerController.onCleared()
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.kt
new file mode 100644
index 0000000..918d442
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.kt
@@ -0,0 +1,22 @@
+package org.onedroid.radiowave.data.network
+
+/**
+ * Resolves available Radio Browser API servers using DNS lookup.
+ * Radio Browser is a distributed service with multiple mirrors.
+ * We discover servers via DNS and failover between them.
+ */
+expect class RadioBrowserServerResolver() {
+ /**
+ * Returns a shuffled list of base URLs for Radio Browser API servers.
+ * Uses DNS lookup on all.api.radio-browser.info to discover available mirrors.
+ * Falls back to hardcoded servers if DNS fails.
+ */
+ suspend fun resolveServers(): List
+}
+
+internal val FALLBACK_SERVERS = listOf(
+ "https://de2.api.radio-browser.info/json",
+ "https://fi1.api.radio-browser.info/json",
+ "https://at1.api.radio-browser.info/json",
+ "https://nl1.api.radio-browser.info/json"
+)
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSource.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSource.kt
index 4ca543e..97feffd 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSource.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSource.kt
@@ -14,4 +14,10 @@ interface RemoteRadioDataSource {
offset: Int,
limit: Int
): Result, DataError.Remote>
-}
\ No newline at end of file
+
+ /**
+ * Register a station click with Radio Browser.
+ * Should be called when the user selects a station to play.
+ */
+ suspend fun registerClick(stationuuid: String)
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSourceImpl.kt
index 10fab6e..09b59ac 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSourceImpl.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RemoteRadioDataSourceImpl.kt
@@ -5,9 +5,6 @@ import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import org.onedroid.radiowave.app.utils.DataError
-import org.onedroid.radiowave.app.utils.RADIO_BROWSER_BASE_URL_SERVER1
-import org.onedroid.radiowave.app.utils.RADIO_BROWSER_BASE_URL_SERVER2
-import org.onedroid.radiowave.app.utils.RADIO_BROWSER_BASE_URL_SERVER3
import org.onedroid.radiowave.app.utils.Result
import org.onedroid.radiowave.app.utils.USER_AGENT
import org.onedroid.radiowave.app.utils.safeCall
@@ -15,13 +12,14 @@ import org.onedroid.radiowave.data.dto.RadioSearchResponseDto
class RemoteRadioDataSourceImpl(
private val httpClient: HttpClient,
+ private val serverResolver: RadioBrowserServerResolver,
) : RemoteRadioDataSource {
- private val radioBrowserBaseUrls = listOf(
- RADIO_BROWSER_BASE_URL_SERVER1,
- RADIO_BROWSER_BASE_URL_SERVER2,
- RADIO_BROWSER_BASE_URL_SERVER3,
- )
+ private var cachedServers: List? = null
+
+ private suspend fun getServers(): List {
+ return cachedServers ?: serverResolver.resolveServers().also { cachedServers = it }
+ }
override suspend fun searchRadio(
query: String,
@@ -35,6 +33,7 @@ class RemoteRadioDataSourceImpl(
parameter("limit", resultLimit)
parameter("hidebroken", "true")
parameter("order", "clickcount")
+ parameter("reverse", "true")
}
}
}
@@ -58,17 +57,37 @@ class RemoteRadioDataSourceImpl(
}
}
+ override suspend fun registerClick(stationuuid: String) {
+ try {
+ val servers = getServers()
+ for (baseUrl in servers) {
+ try {
+ httpClient.get("$baseUrl/url/$stationuuid") {
+ header("User-Agent", USER_AGENT)
+ }
+ return
+ } catch (_: Exception) {
+ continue
+ }
+ }
+ } catch (_: Exception) {
+ // Click registration is best-effort, don't propagate errors
+ }
+ }
+
private suspend fun fetchFromMirrors(
attempt: suspend (String) -> Result
): Result {
+ val servers = getServers()
val errors = mutableListOf()
- for (baseUrl in radioBrowserBaseUrls) {
+ for (baseUrl in servers) {
when (val result = attempt(baseUrl)) {
is Result.Success -> return result
is Result.Error -> errors.add(result.error)
}
}
-
+ // Invalidate cache on total failure so next call re-resolves
+ cachedServers = null
return Result.Error(errors.lastOrNull() ?: DataError.Remote.UNKNOWN)
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/repository/RadioRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/repository/RadioRepositoryImpl.kt
index 52f2115..2b19351 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/repository/RadioRepositoryImpl.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/repository/RadioRepositoryImpl.kt
@@ -77,4 +77,8 @@ class RadioRepositoryImpl(
radioEntities.map { it.toRadio() }
}
}
-}
\ No newline at end of file
+
+ override suspend fun registerClick(stationuuid: String) {
+ remoteRadioDataSource.registerClick(stationuuid)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/domain/RadioRepository.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/domain/RadioRepository.kt
index 39dd0a5..49e959f 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/domain/RadioRepository.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/domain/RadioRepository.kt
@@ -14,4 +14,5 @@ interface RadioRepository {
suspend fun insertRecentlyUpdatedRadios(radios: List): EmptyResult
fun getRecentlyUpdatedRadios(): Flow>
fun getSavedRadios(): Flow>
-}
\ No newline at end of file
+ suspend fun registerClick(stationuuid: String)
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseScreen.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseScreen.kt
new file mode 100644
index 0000000..b4c95c3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseScreen.kt
@@ -0,0 +1,197 @@
+package org.onedroid.radiowave.presentation.browse
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.onedroid.radiowave.presentation.components.StationCard
+
+@Composable
+fun BrowseScreen(
+ viewModel: BrowseViewModel,
+ onStationSelected: () -> Unit
+) {
+ val radios by viewModel.radios.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val errorMsg by viewModel.errorMsg.collectAsState()
+ val searchQuery by viewModel.searchQuery.collectAsState()
+ val searchResults by viewModel.searchResults.collectAsState()
+ val isSearching by viewModel.isSearching.collectAsState()
+ val isSearchActive by viewModel.isSearchActive.collectAsState()
+ val savedRadios by viewModel.savedRadios.collectAsState()
+
+ val savedIds = remember(savedRadios) { savedRadios.map { it.id }.toSet() }
+ val displayList = if (isSearchActive && searchQuery.length >= 3) searchResults else radios
+ val listState = rememberLazyListState()
+
+ // Infinite scroll: load more when near end
+ val shouldLoadMore by remember {
+ derivedStateOf {
+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+ !isSearchActive && lastVisible >= (radios.size - 5) && !isLoading && radios.isNotEmpty()
+ }
+ }
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) viewModel.loadRadios()
+ }
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Search bar - large and driving-friendly
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSearchActive) {
+ IconButton(
+ onClick = { viewModel.toggleSearch() },
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+
+ TextField(
+ value = searchQuery,
+ onValueChange = {
+ if (!isSearchActive) viewModel.toggleSearch()
+ viewModel.setSearchQuery(it)
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ placeholder = {
+ Text(
+ "Search stations...",
+ fontSize = 18.sp
+ )
+ },
+ leadingIcon = {
+ Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(24.dp))
+ },
+ trailingIcon = {
+ if (searchQuery.isNotEmpty()) {
+ IconButton(onClick = { viewModel.setSearchQuery("") }) {
+ Icon(Icons.Default.Close, contentDescription = "Clear")
+ }
+ }
+ },
+ singleLine = true,
+ shape = RoundedCornerShape(28.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ )
+ }
+
+ if (!isSearchActive) {
+ Text(
+ text = "Popular Stations",
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 22.sp
+ ),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
+ )
+ }
+
+ // Station list
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ items(
+ items = displayList,
+ key = { it.id }
+ ) { radio ->
+ StationCard(
+ radio = radio,
+ onClick = {
+ viewModel.playStation(radio)
+ onStationSelected()
+ },
+ onFavoriteClick = { viewModel.toggleFavorite(radio) },
+ isFavorite = savedIds.contains(radio.id),
+ minHeight = 88.dp
+ )
+ }
+
+ if (isLoading || isSearching) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+
+ if (errorMsg != null && displayList.isEmpty()) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = errorMsg ?: "Something went wrong",
+ color = MaterialTheme.colorScheme.error,
+ fontSize = 16.sp
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseViewModel.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseViewModel.kt
new file mode 100644
index 0000000..e401fb7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseViewModel.kt
@@ -0,0 +1,128 @@
+package org.onedroid.radiowave.presentation.browse
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.onedroid.radiowave.app.player.PlayerRepository
+import org.onedroid.radiowave.app.utils.MAX_RADIO_TO_FETCH
+import org.onedroid.radiowave.app.utils.Result
+import org.onedroid.radiowave.app.utils.SEARCH_TRIGGER_CHAR
+import org.onedroid.radiowave.domain.Radio
+import org.onedroid.radiowave.domain.RadioRepository
+
+class BrowseViewModel(
+ private val radioRepository: RadioRepository,
+ val playerRepository: PlayerRepository
+) : ViewModel() {
+
+ // Browse feed
+ private val _radios = MutableStateFlow>(emptyList())
+ val radios: StateFlow> = _radios.asStateFlow()
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ private val _errorMsg = MutableStateFlow(null)
+ val errorMsg: StateFlow = _errorMsg.asStateFlow()
+
+ // Search
+ private val _searchQuery = MutableStateFlow("")
+ val searchQuery: StateFlow = _searchQuery.asStateFlow()
+
+ private val _searchResults = MutableStateFlow>(emptyList())
+ val searchResults: StateFlow> = _searchResults.asStateFlow()
+
+ private val _isSearching = MutableStateFlow(false)
+ val isSearching: StateFlow = _isSearching.asStateFlow()
+
+ private val _isSearchActive = MutableStateFlow(false)
+ val isSearchActive: StateFlow = _isSearchActive.asStateFlow()
+
+ // Saved station IDs for showing favorite state
+ val savedRadios: StateFlow> = radioRepository.getSavedRadios()
+ .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+
+ private var offset = 0
+
+ init {
+ loadRadios()
+ observeSearch()
+ }
+
+ fun loadRadios() {
+ if (_isLoading.value) return
+ viewModelScope.launch {
+ _isLoading.value = true
+ _errorMsg.value = null
+ when (val result = radioRepository.getRadios(offset, MAX_RADIO_TO_FETCH)) {
+ is Result.Success -> {
+ _radios.value = _radios.value + result.data
+ offset += result.data.size
+ }
+ is Result.Error -> {
+ _errorMsg.value = "Failed to load stations"
+ }
+ }
+ _isLoading.value = false
+ }
+ }
+
+ fun setSearchQuery(query: String) {
+ _searchQuery.value = query
+ }
+
+ fun toggleSearch() {
+ _isSearchActive.value = !_isSearchActive.value
+ if (!_isSearchActive.value) {
+ _searchQuery.value = ""
+ _searchResults.value = emptyList()
+ }
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun observeSearch() {
+ viewModelScope.launch {
+ _searchQuery
+ .debounce(400)
+ .collectLatest { query ->
+ if (query.length >= SEARCH_TRIGGER_CHAR) {
+ _isSearching.value = true
+ when (val result = radioRepository.searchRadios(query)) {
+ is Result.Success -> _searchResults.value = result.data
+ is Result.Error -> _searchResults.value = emptyList()
+ }
+ _isSearching.value = false
+ } else {
+ _searchResults.value = emptyList()
+ }
+ }
+ }
+ }
+
+ fun playStation(radio: Radio) {
+ val streamUrl = radio.urlResolved ?: radio.url
+ playerRepository.play(streamUrl)
+ viewModelScope.launch {
+ radioRepository.registerClick(radio.id)
+ }
+ }
+
+ fun toggleFavorite(radio: Radio) {
+ viewModelScope.launch {
+ val savedIds = savedRadios.value.map { it.id }.toSet()
+ if (savedIds.contains(radio.id)) {
+ radioRepository.deleteFromSaved(radio.id)
+ } else {
+ radioRepository.saveRadio(radio)
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/components/StationCard.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/components/StationCard.kt
new file mode 100644
index 0000000..fe5d504
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/components/StationCard.kt
@@ -0,0 +1,229 @@
+package org.onedroid.radiowave.presentation.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil3.compose.AsyncImage
+import org.onedroid.radiowave.domain.Radio
+
+/**
+ * Large driving-friendly station card for Browse/Search grid.
+ * Min touch target: 48dp, but we go much larger for driving safety.
+ */
+@Composable
+fun StationCard(
+ radio: Radio,
+ onClick: () -> Unit,
+ onFavoriteClick: (() -> Unit)? = null,
+ isFavorite: Boolean = false,
+ modifier: Modifier = Modifier,
+ minHeight: Dp = 100.dp,
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(minHeight)
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Station logo
+ Box(
+ modifier = Modifier
+ .size(minHeight - 24.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center
+ ) {
+ if (!radio.imgUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = radio.imgUrl,
+ contentDescription = radio.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Station info
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = radio.name,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ // Metadata line: genre/country/bitrate
+ val metaItems = buildList {
+ radio.tags?.firstOrNull()?.let { if (it.isNotBlank()) add(it) }
+ radio.country?.let { if (it.isNotBlank()) add(it) }
+ radio.bitrate?.let { if (it > 0) add("${it}kbps") }
+ }
+ if (metaItems.isNotEmpty()) {
+ Text(
+ text = metaItems.joinToString(" \u2022 "),
+ style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Favorite button
+ if (onFavoriteClick != null) {
+ IconButton(
+ onClick = onFavoriteClick,
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(
+ imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
+ contentDescription = if (isFavorite) "Remove favorite" else "Add favorite",
+ tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Huge tile for the Favorites driving pager.
+ * Shows station name prominently with logo. One-tap to play.
+ */
+@Composable
+fun FavoriteTile(
+ radio: Radio,
+ onClick: () -> Unit,
+ isPlaying: Boolean = false,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(20.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isPlaying)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surfaceVariant
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Station logo
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center
+ ) {
+ if (!radio.imgUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = radio.imgUrl,
+ contentDescription = radio.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = radio.name,
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ color = if (isPlaying)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onSurface
+ )
+
+ if (isPlaying) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Now Playing",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesScreen.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesScreen.kt
new file mode 100644
index 0000000..63f03e4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesScreen.kt
@@ -0,0 +1,143 @@
+package org.onedroid.radiowave.presentation.favorites
+
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.onedroid.radiowave.presentation.components.FavoriteTile
+
+private const val TILES_PER_PAGE = 6
+
+/**
+ * Driving-optimized favorites screen (landscape primary).
+ * Shows favorites in pages of 5-6 huge cards in a single row.
+ * Horizontal swipe to page between groups.
+ * One tap on a favorite starts playback.
+ */
+@Composable
+fun FavoritesScreen(
+ viewModel: FavoritesViewModel,
+ onStationSelected: () -> Unit
+) {
+ val favorites by viewModel.favorites.collectAsState()
+ val isPlaying by viewModel.playerRepository.isPlaying.collectAsState()
+
+ if (favorites.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "No Favorites Yet",
+ style = MaterialTheme.typography.headlineMedium.copy(
+ fontWeight = FontWeight.Bold
+ ),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Browse stations and tap the heart to add favorites",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ return
+ }
+
+ val pages = favorites.chunked(TILES_PER_PAGE)
+ val pagerState = rememberPagerState(pageCount = { pages.size })
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 8.dp)
+ ) {
+ // Page indicator
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Favorites",
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 22.sp
+ )
+ )
+ if (pages.size > 1) {
+ Text(
+ text = "${pagerState.currentPage + 1} / ${pages.size}",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
+ )
+ }
+ }
+
+ // Horizontal pager with huge tiles
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ pageSpacing = 12.dp
+ ) { pageIndex ->
+ val pageItems = pages[pageIndex]
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ pageItems.forEach { radio ->
+ FavoriteTile(
+ radio = radio,
+ onClick = {
+ viewModel.playStation(radio)
+ onStationSelected()
+ },
+ isPlaying = false, // Could check current station later
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(vertical = 8.dp)
+ )
+ }
+ // Fill remaining slots with spacers for consistent layout
+ repeat(TILES_PER_PAGE - pageItems.size) {
+ Spacer(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesViewModel.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesViewModel.kt
new file mode 100644
index 0000000..fd5c0f0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesViewModel.kt
@@ -0,0 +1,34 @@
+package org.onedroid.radiowave.presentation.favorites
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.onedroid.radiowave.app.player.PlayerRepository
+import org.onedroid.radiowave.domain.Radio
+import org.onedroid.radiowave.domain.RadioRepository
+
+class FavoritesViewModel(
+ private val radioRepository: RadioRepository,
+ val playerRepository: PlayerRepository
+) : ViewModel() {
+
+ val favorites: StateFlow> = radioRepository.getSavedRadios()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+ fun playStation(radio: Radio) {
+ val streamUrl = radio.urlResolved ?: radio.url
+ playerRepository.play(streamUrl)
+ viewModelScope.launch {
+ radioRepository.registerClick(radio.id)
+ }
+ }
+
+ fun removeFavorite(radio: Radio) {
+ viewModelScope.launch {
+ radioRepository.deleteFromSaved(radio.id)
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeScreen.kt
index 515a05b..8fbb4ca 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeScreen.kt
@@ -97,20 +97,22 @@ fun HomeScreen(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
scaffoldState = bottomSheetState,
sheetContent = {
- viewModel.selectedRadio?.let {
- viewModel.playerRepository.PLayerUI(
- radio = it,
- isSaved = viewModel.isSaved,
- onWebpageClick = {
- it.homepage?.let { it1 -> urlHandler.openUri(it1) }
- },
- onShareClick = {
- shareLink(it.url)
- },
- onSaveClick = {
- viewModel.saveRadio(it)
+ viewModel.selectedRadio?.let { radio ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = radio.name,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = { viewModel.togglePlayPause() }) {
+ Text(if (viewModel.isPlaying) "Pause" else "Play")
}
- )
+ }
}
},
sheetPeekHeight = if (viewModel.selectedRadio != null) BottomSheetDefaults.SheetPeekHeight else 0.dp,
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeViewModel.kt
index 6e92928..a0bdeed 100644
--- a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/home/HomeViewModel.kt
@@ -96,6 +96,7 @@ class HomeViewModel(
}
fun togglePlayPause() {
+ playerRepository.pauseResume()
isPlaying = !isPlaying
}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/mixer/MixerPanel.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/mixer/MixerPanel.kt
new file mode 100644
index 0000000..b7077b4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/mixer/MixerPanel.kt
@@ -0,0 +1,263 @@
+package org.onedroid.radiowave.presentation.mixer
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+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.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+data class EqPresetItem(
+ val id: String,
+ val label: String,
+ val description: String
+)
+
+val EQ_PRESETS = listOf(
+ EqPresetItem("FLAT", "Flat", "No EQ"),
+ EqPresetItem("VOICE_CLARITY", "Voice Clarity", "Boost speech"),
+ EqPresetItem("WARM", "Warm", "Rich bass"),
+ EqPresetItem("REDUCE_HISS", "Reduce Hiss", "Cut noise"),
+)
+
+/**
+ * Mixer panel for EQ presets and brightness control.
+ * Large touch targets for driving safety.
+ */
+@Composable
+fun MixerPanel(
+ currentPresetId: String,
+ brightness: Float,
+ onPresetSelected: (String) -> Unit,
+ onBrightnessChanged: (Float) -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.97f))
+ .padding(24.dp)
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Sound & Display",
+ style = MaterialTheme.typography.headlineSmall.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp
+ )
+ )
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Close",
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // EQ Presets - large buttons in a row
+ Text(
+ text = "Sound Quality",
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ EQ_PRESETS.forEach { preset ->
+ val isSelected = preset.id == currentPresetId
+ EqPresetButton(
+ preset = preset,
+ isSelected = isSelected,
+ onClick = { onPresetSelected(preset.id) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Brightness control
+ Text(
+ text = "Screen Brightness",
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "\u2600\uFE0F",
+ fontSize = 20.sp
+ )
+ Slider(
+ value = brightness,
+ onValueChange = onBrightnessChanged,
+ modifier = Modifier
+ .weight(1f)
+ .height(48.dp),
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary
+ )
+ )
+ Text(
+ text = "\u2600\uFE0F",
+ fontSize = 28.sp
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ QuickBrightnessButton("Dim", 0.1f, onBrightnessChanged)
+ QuickBrightnessButton("Low", 0.35f, onBrightnessChanged)
+ QuickBrightnessButton("Medium", 0.6f, onBrightnessChanged)
+ QuickBrightnessButton("Bright", 1.0f, onBrightnessChanged)
+ }
+ }
+ }
+}
+
+@Composable
+private fun EqPresetButton(
+ preset: EqPresetItem,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .height(80.dp)
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surfaceVariant
+ ),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (isSelected) 6.dp else 2.dp
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = preset.label,
+ style = MaterialTheme.typography.titleSmall.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 15.sp
+ ),
+ color = if (isSelected)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = preset.description,
+ style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
+ color = if (isSelected)
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ }
+ }
+}
+
+@Composable
+private fun QuickBrightnessButton(
+ label: String,
+ value: Float,
+ onBrightnessChanged: (Float) -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .height(48.dp)
+ .width(80.dp)
+ .clickable { onBrightnessChanged(value) },
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp
+ )
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingScreen.kt
new file mode 100644
index 0000000..ea441ff
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingScreen.kt
@@ -0,0 +1,322 @@
+package org.onedroid.radiowave.presentation.nowplaying
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.draw.alpha
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil3.compose.AsyncImage
+
+/**
+ * Now Playing screen - landscape first design.
+ * - Pleasing gradient/blurred background
+ * - Mini station card in corner
+ * - Huge play/pause center
+ * - Metadata text (song title or station name + "Live")
+ */
+@Composable
+fun NowPlayingScreen(
+ viewModel: NowPlayingViewModel,
+ onMixerClick: () -> Unit,
+ onBackClick: () -> Unit
+) {
+ val station by viewModel.currentStation.collectAsState()
+ val isPlaying by viewModel.isPlaying.collectAsState()
+ val metadata by viewModel.metadataText.collectAsState()
+ val savedRadios by viewModel.savedRadios.collectAsState()
+ val isFavorite = remember(station, savedRadios) {
+ station?.let { s -> savedRadios.any { it.id == s.id } } ?: false
+ }
+
+ val playPauseScale by animateFloatAsState(
+ targetValue = if (isPlaying) 1f else 0.9f,
+ animationSpec = tween(200),
+ label = "playPauseScale"
+ )
+
+ val currentStation = station
+ if (currentStation == null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ "No station selected",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ )
+ }
+ return
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Background: gradient with optional blurred artwork
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Blurred station image as background
+ if (!currentStation.imgUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = currentStation.imgUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxSize()
+ .blur(60.dp)
+ .alpha(0.3f),
+ contentScale = ContentScale.Crop
+ )
+ }
+ // Gradient overlay
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
+ MaterialTheme.colorScheme.surface
+ )
+ )
+ )
+ )
+ }
+
+ // Main content - landscape layout
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Left side: Station info card
+ Column(
+ modifier = Modifier
+ .weight(0.35f)
+ .padding(end = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Station artwork
+ Card(
+ shape = RoundedCornerShape(20.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ modifier = Modifier.size(140.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ if (!currentStation.imgUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = currentStation.imgUrl,
+ contentDescription = currentStation.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = currentStation.name,
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 22.sp
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ // Genre/Country
+ val subtitle = buildList {
+ currentStation.tags?.firstOrNull()?.let { if (it.isNotBlank()) add(it) }
+ currentStation.country?.let { if (it.isNotBlank()) add(it) }
+ }
+ if (subtitle.isNotEmpty()) {
+ Text(
+ text = subtitle.joinToString(" \u2022 "),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Favorite button
+ IconButton(
+ onClick = { viewModel.toggleFavorite() },
+ modifier = Modifier.size(56.dp)
+ ) {
+ Icon(
+ imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
+ contentDescription = "Toggle favorite",
+ tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+
+ // Center: Huge play/pause + metadata
+ Column(
+ modifier = Modifier
+ .weight(0.65f),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Now playing text / metadata
+ val displayText = metadata ?: "${currentStation.name} \u2022 Live"
+ Text(
+ text = if (metadata != null) "Now Playing" else "Live",
+ style = MaterialTheme.typography.labelLarge.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium
+ ),
+ color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = displayText,
+ style = MaterialTheme.typography.headlineMedium.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 26.sp
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Huge play/pause button
+ IconButton(
+ onClick = { viewModel.togglePlayPause() },
+ modifier = Modifier
+ .size(120.dp)
+ .clip(CircleShape)
+ .background(
+ if (isPlaying) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ // Unicode pause / play icons for maximum clarity
+ Text(
+ text = if (isPlaying) "\u23F8" else "\u25B6",
+ fontSize = 48.sp,
+ color = if (isPlaying)
+ MaterialTheme.colorScheme.onPrimary
+ else
+ MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Volume + Mixer row
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Volume down
+ IconButton(
+ onClick = { viewModel.playerRepository.volumeDown() },
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Text(
+ text = "\uD83D\uDD09",
+ fontSize = 24.sp
+ )
+ }
+
+ // Volume up
+ IconButton(
+ onClick = { viewModel.playerRepository.volumeUp() },
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Text(
+ text = "\uD83D\uDD0A",
+ fontSize = 24.sp
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Mixer button
+ IconButton(
+ onClick = onMixerClick,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Text(
+ text = "\uD83C\uDFA7",
+ fontSize = 24.sp
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingViewModel.kt b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingViewModel.kt
new file mode 100644
index 0000000..4b6b9ce
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingViewModel.kt
@@ -0,0 +1,55 @@
+package org.onedroid.radiowave.presentation.nowplaying
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.onedroid.radiowave.app.player.PlayerRepository
+import org.onedroid.radiowave.domain.Radio
+import org.onedroid.radiowave.domain.RadioRepository
+
+class NowPlayingViewModel(
+ private val radioRepository: RadioRepository,
+ val playerRepository: PlayerRepository
+) : ViewModel() {
+
+ private val _currentStation = MutableStateFlow(null)
+ val currentStation: StateFlow = _currentStation.asStateFlow()
+
+ private val _isMixerVisible = MutableStateFlow(false)
+ val isMixerVisible: StateFlow = _isMixerVisible.asStateFlow()
+
+ val isPlaying: StateFlow = playerRepository.isPlaying
+ val metadataText: StateFlow = playerRepository.metadataText
+
+ val savedRadios: StateFlow> = radioRepository.getSavedRadios()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+ fun setCurrentStation(radio: Radio?) {
+ _currentStation.value = radio
+ }
+
+ fun togglePlayPause() {
+ playerRepository.pauseResume()
+ }
+
+ fun toggleMixer() {
+ _isMixerVisible.value = !_isMixerVisible.value
+ }
+
+ fun toggleFavorite() {
+ val station = _currentStation.value ?: return
+ viewModelScope.launch {
+ val isSaved = savedRadios.value.any { it.id == station.id }
+ if (isSaved) {
+ radioRepository.deleteFromSaved(station.id)
+ } else {
+ radioRepository.saveRadio(station)
+ }
+ }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.ios.kt b/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.ios.kt
index 45b8fa6..19676e9 100644
--- a/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.ios.kt
+++ b/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/app/player/PlayerController.ios.kt
@@ -1,232 +1,82 @@
package org.onedroid.radiowave.app.player
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandHorizontally
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkHorizontally
-import androidx.compose.foundation.basicMarquee
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import coil3.compose.rememberAsyncImagePainter
-import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import org.jetbrains.compose.resources.stringResource
-import org.onedroid.radiowave.app.theme.small
-import org.onedroid.radiowave.app.theme.thin
-import org.onedroid.radiowave.domain.Radio
-import org.onedroid.radiowave.presentation.home.components.AnimatedRadioCoverImage
-import org.onedroid.radiowave.presentation.home.components.PlayerFullWidthView
-import org.onedroid.radiowave.presentation.home.components.RadioActionButtons
-import org.onedroid.radiowave.presentation.home.components.RadioDetails
+import platform.AVFAudio.AVAudioSession
+import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
-import platform.AVFoundation.addPeriodicTimeObserverForInterval
-import platform.AVFoundation.currentTime
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.rate
-import platform.AVFoundation.removeTimeObserver
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.volume
-import platform.CoreMedia.CMTimeGetSeconds
-import platform.CoreMedia.CMTimeMakeWithSeconds
import platform.Foundation.NSURL
-import radiowave.composeapp.generated.resources.Res
-import radiowave.composeapp.generated.resources.broken_image_radio
-import radiowave.composeapp.generated.resources.detecting
-import radiowave.composeapp.generated.resources.now_playing
-import radiowave.composeapp.generated.resources.unable_to_detect
-import kotlin.Any
-import kotlin.Int
-import kotlin.OptIn
-import kotlin.String
-import kotlin.let
-import kotlin.math.max
-import kotlin.math.min
actual class PlayerController : PlayerRepository {
private val player = AVPlayer()
- private val _metadataState = MutableStateFlow(MetadataState.Detecting)
- private val _isPlaying = MutableStateFlow(false)
- private val _currentPosition = MutableStateFlow(0L)
-
- private var timeObserver: Any? = null
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
- private val metadataState = _metadataState.asStateFlow()
- private val isPlaying = _isPlaying.asStateFlow()
- private val currentPosition = _currentPosition.asStateFlow()
+ private val _isPlaying = MutableStateFlow(false)
+ private val _metadataText = MutableStateFlow(null)
+ private val _playbackState = MutableStateFlow(0)
+
+ override val isPlaying: StateFlow = _isPlaying.asStateFlow()
+ override val metadataText: StateFlow = _metadataText.asStateFlow()
+ override val playbackStateCode: StateFlow = _playbackState.asStateFlow()
+
+ init {
+ try {
+ val session = AVAudioSession.sharedInstance()
+ session.setCategory(AVAudioSessionCategoryPlayback, null)
+ session.setActive(true, null)
+ } catch (_: Exception) {}
+ }
override fun play(audioUrl: String) {
- val url = NSURL(string = audioUrl)
- val item = AVPlayerItem(uRL = url)
- player.replaceCurrentItemWithPlayerItem(item)
+ val url = NSURL.URLWithString(audioUrl) ?: return
+ val playerItem = AVPlayerItem(uRL = url)
+ player.replaceCurrentItemWithPlayerItem(playerItem)
player.play()
_isPlaying.value = true
- startPositionTracking()
- startMetadataDetection(item)
+ _metadataText.value = null
+ _playbackState.value = 2
}
- @OptIn(ExperimentalForeignApi::class)
- private fun startPositionTracking() {
- val interval = CMTimeMakeWithSeconds(0.05, 600)
- timeObserver?.let { player.removeTimeObserver(it) }
-
- timeObserver = player.addPeriodicTimeObserverForInterval(interval, queue = null) {
- val timeSeconds = CMTimeGetSeconds(player.currentTime())
- _currentPosition.value = (timeSeconds * 1000).toLong()
+ override fun pauseResume() {
+ if (player.rate > 0f) {
+ player.pause()
+ _isPlaying.value = false
+ } else {
+ player.play()
+ _isPlaying.value = true
}
}
- private fun startMetadataDetection(item: AVPlayerItem) {
- _metadataState.value = MetadataState.NotAvailable
- }
-
- override fun onCleared() {
+ override fun stop() {
player.pause()
- timeObserver?.let { player.removeTimeObserver(it) }
- coroutineScope.cancel()
- }
-
- @Composable
- override fun PLayerUI(
- radio: Radio,
- isSaved: Boolean,
- onSaveClick: () -> Unit,
- onWebpageClick: () -> Unit,
- onShareClick: () -> Unit
- ) {
- // Use `isPlaying.collectAsState()` and `metadataState.collectAsState()` here
- val isPlayingState by isPlaying.collectAsState()
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(small)
- ) {
- item {
- radio.imgUrl?.let { imgUrl ->
- val imagePainter = rememberAsyncImagePainter(
- model = imgUrl,
- placeholder = org.jetbrains.compose.resources.painterResource(Res.drawable.broken_image_radio),
- error = org.jetbrains.compose.resources.painterResource(Res.drawable.broken_image_radio)
- )
- AnimatedRadioCoverImage(
- isPlaying = isPlayingState,
- painter = imagePainter,
- )
- }
- }
- item {
- RadioDetails(
- radio = radio,
- nowPlayingIndicator = {
- NowPlayingIndicator()
- }
- )
- }
- item {
- RadioActionButtons(
- isSaved = isSaved,
- onSaveClick = onSaveClick,
- onWebpageClick = onWebpageClick,
- onShareClick = onShareClick
- )
- }
-
- item {
- PlayerFullWidthView(
- onPlayClick = { pauseResume() },
- onVolumeUpClick = { volumeUp() },
- onVolumeDownClick = { volumeDown() },
- isPlaying = isPlayingState,
- playerStatusIndicator = {
-
- }
- )
- }
- }
+ _isPlaying.value = false
+ _playbackState.value = 0
}
- private fun pauseResume() {
- if (player.rate.toDouble() == 0.0) {
- player.play()
- } else {
- player.pause()
- }
+ override fun volumeUp() {
+ player.volume = (player.volume + 0.1f).coerceAtMost(1.0f)
}
- private fun volumeUp() {
- player.volume = min(player.volume + 0.1f, 1.0f)
+ override fun volumeDown() {
+ player.volume = (player.volume - 0.1f).coerceAtLeast(0.0f)
}
- private fun volumeDown() {
- player.volume = max(player.volume - 0.1f, 0.0f)
- }
+ override fun getAudioSessionId(): Int = 0
- @Composable
- private fun NowPlayingIndicator() {
- val state by metadataState.collectAsState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = thin)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(thin)
- ) {
- Text(
- text = stringResource(Res.string.now_playing),
- style = MaterialTheme.typography.bodyMedium,
- )
- AnimatedVisibility(
- visible = true,
- enter = fadeIn() + expandHorizontally(),
- exit = fadeOut() + shrinkHorizontally()
- ) {
- Text(
- modifier = Modifier.basicMarquee(
- initialDelayMillis = 0,
- iterations = Int.MAX_VALUE,
- ),
- text = when (state) {
- is MetadataState.Detecting -> stringResource(Res.string.detecting)
- is MetadataState.NotAvailable -> stringResource(Res.string.unable_to_detect)
- is MetadataState.Available -> (state as MetadataState.Available).content
- },
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
- }
+ override fun onCleared() {
+ player.pause()
+ coroutineScope.cancel()
}
-
}
-
-sealed class MetadataState {
- data object Detecting : MetadataState()
- data object NotAvailable : MetadataState()
- data class Available(val content: String) : MetadataState()
-}
\ No newline at end of file
diff --git a/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.ios.kt b/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.ios.kt
new file mode 100644
index 0000000..52ffbd8
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.ios.kt
@@ -0,0 +1,8 @@
+package org.onedroid.radiowave.data.network
+
+actual class RadioBrowserServerResolver actual constructor() {
+ actual suspend fun resolveServers(): List {
+ // iOS: use fallback servers (DNS resolution via NSHost is limited)
+ return FALLBACK_SERVERS.shuffled()
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 964007c..98b6bf4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,10 +1,15 @@
#Kotlin
kotlin.code.style=official
-kotlin.daemon.jvmargs=-Xmx2048M
+kotlin.daemon.jvmargs=-Xmx6144M
#Gradle
-org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=512m
+org.gradle.parallel=true
+org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+# Temporary flag for AGP 9.x compatibility with KMP + androidApplication plugin
+# This will be removed when migrating to the new Android-KMP library plugin (before AGP 10)
+android.enableLegacyVariantApi=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 753ca67..e112ce9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,33 +1,33 @@
[versions]
-agp = "8.5.2"
-android-compileSdk = "35"
-android-minSdk = "24"
-android-targetSdk = "35"
-androidx-activityCompose = "1.9.3"
+agp = "9.1.0"
+android-compileSdk = "36"
+android-minSdk = "31"
+android-targetSdk = "36"
+androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.0"
androidx-constraintlayout = "2.2.0"
androidx-core-ktx = "1.15.0"
androidx-espresso-core = "3.6.1"
-androidx-lifecycle = "2.8.4"
+androidx-lifecycle = "2.10.0"
androidx-material = "1.12.0"
androidx-test-junit = "1.2.1"
-compose-multiplatform = "1.7.0"
+compose-multiplatform = "1.10.3"
desugar_jdk_libs = "2.1.5"
junit = "4.13.2"
-kotlin = "2.1.0"
-koin = "4.0.0"
-kotlinxDatetime = "0.6.2"
-ktor = "3.0.0"
-coil3 = "3.0.0-rc02"
-navigationCompose = "2.8.0-alpha10"
-kotlinx-serialization = "1.7.3"
-media3Exoplayer = "1.5.1"
-core-splashscreen = "1.0.1"
+kotlin = "2.3.20"
+koin = "4.1.1"
+kotlinxDatetime = "0.7.0"
+ktor = "3.4.0"
+coil3 = "3.4.0"
+navigationCompose = "2.9.2"
+kotlinx-serialization = "1.10.0"
+media3Exoplayer = "1.9.2"
+core-splashscreen = "1.2.0"
-ksp = "2.1.0-1.0.29"
-sqlite = "2.5.0-alpha11"
-room = "2.7.0-alpha11"
-datastore = "1.1.1"
+ksp = "2.3.6"
+sqlite = "2.6.2"
+room = "2.8.4"
+datastore = "1.2.0"
[libraries]
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
@@ -114,4 +114,4 @@ exoplayer = [
"androidx-media3-exoplayer-v141",
"androidx-media3-session",
"androidx-media3-exoplayer-hls"
-]
\ No newline at end of file
+]
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 09523c0..c61a118 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME