From 9c05055c172c688126ff84f79b572af9564f5cbe Mon Sep 17 00:00:00 2001 From: Jeffrey Lotterer Date: Wed, 18 Mar 2026 19:38:49 -0400 Subject: [PATCH 1/4] Create base android radio (vibe-kanban JLO-113) --- composeApp/build.gradle.kts | 3 + .../src/androidMain/AndroidManifest.xml | 17 +- .../org/onedroid/radiowave/MainActivity.kt | 18 +- .../radiowave/app/di/Modules.android.kt | 4 +- .../app/player/AudioEffectsManager.kt | 143 ++++++ .../app/player/PlayerController.android.kt | 478 ++---------------- .../app/player/RadioPlaybackService.kt | 108 ++++ .../RadioBrowserServerResolver.android.kt | 31 ++ .../org/onedroid/radiowave/app/di/Modules.kt | 10 +- .../app/navigation/NavGraphBuilder.kt | 43 +- .../app/navigation/NavigationScreen.kt | 150 +++++- .../radiowave/app/navigation/Route.kt | 11 +- .../radiowave/app/player/MetadataState.kt | 7 + .../radiowave/app/player/PlayerController.kt | 2 +- .../radiowave/app/player/PlayerRepository.kt | 22 +- .../app/player/PlayerRepositoryImpl.kt | 37 +- .../network/RadioBrowserServerResolver.kt | 22 + .../data/network/RemoteRadioDataSource.kt | 8 +- .../data/network/RemoteRadioDataSourceImpl.kt | 41 +- .../data/repository/RadioRepositoryImpl.kt | 6 +- .../radiowave/domain/RadioRepository.kt | 3 +- .../presentation/browse/BrowseScreen.kt | 197 ++++++++ .../presentation/browse/BrowseViewModel.kt | 128 +++++ .../presentation/components/StationCard.kt | 229 +++++++++ .../presentation/favorites/FavoritesScreen.kt | 143 ++++++ .../favorites/FavoritesViewModel.kt | 34 ++ .../radiowave/presentation/home/HomeScreen.kt | 28 +- .../presentation/home/HomeViewModel.kt | 1 + .../presentation/mixer/MixerPanel.kt | 263 ++++++++++ .../nowplaying/NowPlayingScreen.kt | 322 ++++++++++++ .../nowplaying/NowPlayingViewModel.kt | 55 ++ .../app/player/PlayerController.ios.kt | 232 ++------- .../network/RadioBrowserServerResolver.ios.kt | 8 + gradle/libs.versions.toml | 2 +- 34 files changed, 2103 insertions(+), 703 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/AudioEffectsManager.kt create mode 100644 composeApp/src/androidMain/kotlin/org/onedroid/radiowave/app/player/RadioPlaybackService.kt create mode 100644 composeApp/src/androidMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.android.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/app/player/MetadataState.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/browse/BrowseViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/components/StationCard.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/favorites/FavoritesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/mixer/MixerPanel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/onedroid/radiowave/presentation/nowplaying/NowPlayingViewModel.kt create mode 100644 composeApp/src/iosMain/kotlin/org/onedroid/radiowave/data/network/RadioBrowserServerResolver.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3515f0d..7285f82 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -105,6 +105,9 @@ android { targetCompatibility = JavaVersion.VERSION_22 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/libs.versions.toml b/gradle/libs.versions.toml index 753ca67..d6d0727 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.5.2" android-compileSdk = "35" -android-minSdk = "24" +android-minSdk = "31" android-targetSdk = "35" androidx-activityCompose = "1.9.3" androidx-appcompat = "1.7.0" From b7e4d320556905710be38a83d3744193ddd290f8 Mon Sep 17 00:00:00 2001 From: Jeffrey Lotterer Date: Fri, 20 Mar 2026 19:56:48 -0400 Subject: [PATCH 2/4] Check code base (vibe-kanban JLO-116) --- composeApp/build.gradle.kts | 6 +++--- gradle.properties | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7285f82..ccaadda 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -16,7 +16,7 @@ kotlin { } androidTarget { compilerOptions { - jvmTarget.set(JvmTarget.JVM_22) + jvmTarget.set(JvmTarget.JVM_17) } } @@ -101,8 +101,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_22 - targetCompatibility = JavaVersion.VERSION_22 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } buildFeatures { diff --git a/gradle.properties b/gradle.properties index 964007c..5b0e406 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,9 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx2048M +kotlin.daemon.jvmargs=-Xmx4096M #Gradle -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 #Android android.nonTransitiveRClass=true From ee5841c921020cf2fdda309093dc0dc0a474f2fe Mon Sep 17 00:00:00 2001 From: Jeffrey Lotterer Date: Fri, 20 Mar 2026 20:11:59 -0400 Subject: [PATCH 3/4] Check code base (vibe-kanban JLO-117) --- gradle.properties | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 5b0e406..3345064 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,11 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx4096M +kotlin.daemon.jvmargs=-Xmx6144M #Gradle -org.gradle.jvmargs=-Xmx4096M -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 From 13871e3704a8be398bc772d0c6a4d9358795181f Mon Sep 17 00:00:00 2001 From: Jeffrey Lotterer Date: Sun, 22 Mar 2026 09:59:29 -0400 Subject: [PATCH 4/4] Update for Gradle (vibe-kanban JLO-118) --- composeApp/build.gradle.kts | 3 -- gradle.properties | 5 ++- gradle/libs.versions.toml | 40 ++++++++++++------------ gradle/wrapper/gradle-wrapper.properties | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ccaadda..143ecc0 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -11,9 +11,6 @@ plugins { } kotlin { - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - } androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) diff --git a/gradle.properties b/gradle.properties index 3345064..98b6bf4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,4 +9,7 @@ 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 d6d0727..e112ce9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,33 @@ [versions] -agp = "8.5.2" -android-compileSdk = "35" +agp = "9.1.0" +android-compileSdk = "36" android-minSdk = "31" -android-targetSdk = "35" -androidx-activityCompose = "1.9.3" +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