Add app source for F-Droid release (v1.0.11)

Adds the full Android source tree, Gradle wrapper and version catalog,
fastlane store metadata in six languages and the fdroiddata build
recipe. .gitignore now also excludes build output and keystores.
README gains build instructions and an updated structure tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
This commit is contained in:
Claude
2026-10-06 18:23:16 +00:00
parent 6498e4c97d
commit 57cec132f9
159 changed files with 6898 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
*.png binary
*.webp binary
+15 -1
View File
@@ -1,10 +1,24 @@
# Gradle / Android Studio
.gradle/
build/
local.properties
.idea/
*.iml
.kotlin/
captures/
.externalNativeBuild/
.cxx/
# Signing keys - never commit these
*.jks
*.keystore
# OS
.DS_Store
Thumbs.db
# Editors
.vscode/
.idea/
*.swp
# Local scratch
+17 -1
View File
@@ -63,14 +63,30 @@ Compare the SHA-256 checksum against the value listed in [CHANGELOG.md](CHANGELO
sha256sum Fogwawe-v1.0.10.apk
```
## Building from source
Requires JDK 17+ and Android Studio with Android SDK 37.
```sh
./gradlew assembleDebug
```
Release builds are signed only when a keystore is supplied through the `KEYSTORE_PATH`, `STORE_PASSWORD`, `KEY_ALIAS` and `KEY_PASSWORD` environment variables. Without them the release build is unsigned, which is what F-Droid expects. Keystores are excluded by `.gitignore` — never commit them.
For every release: bump `versionCode` and `versionName` in `app/build.gradle.kts`, add `fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, and tag the commit as `v<versionName>` (for example `v1.0.11`).
## Repository structure
<img src="assets/icon-structure.png" width="36" align="left">
```
.
├── assets/ # Icon images
├── app/ # Android app source (Kotlin, Jetpack Compose)
├── assets/ # README images and logos
│ └── screenshots/ # App screenshots
├── fastlane/ # F-Droid store listing, screenshots and changelogs
├── fdroid/ # Build recipe for fdroiddata
├── gradle/ # Gradle wrapper and version catalog
├── CHANGELOG.md # Per-version sizes and checksums (links to GitHub Releases)
├── LICENSE # GNU GPLv3
└── README.md
+107
View File
@@ -0,0 +1,107 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.roborazzi)
}
android {
namespace = "cc.skysparkle.fogwave"
compileSdk { version = release(37) }
defaultConfig {
applicationId = "cc.skysparkle.fogwave"
minSdk = 24
targetSdk = 36
versionCode = 11
versionName = "1.0.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Library versions shown under About -> Licenses, taken from the version catalog so they
// always match what is actually bundled.
buildConfigField("String", "COMPOSE_BOM_VERSION", "\"${libs.versions.composeBom.get()}\"")
buildConfigField("String", "ACTIVITY_VERSION", "\"${libs.versions.activityCompose.get()}\"")
buildConfigField("String", "CORE_KTX_VERSION", "\"${libs.versions.coreKtx.get()}\"")
buildConfigField("String", "LIFECYCLE_VERSION", "\"${libs.versions.lifecycleRuntimeCompose.get()}\"")
buildConfigField("String", "MEDIA3_VERSION", "\"${libs.versions.media3.get()}\"")
buildConfigField("String", "COROUTINES_VERSION", "\"${libs.versions.kotlinxCoroutinesAndroid.get()}\"")
buildConfigField("String", "KOTLIN_VERSION", "\"${libs.versions.kotlin.get()}\"")
buildConfigField("String", "COIL_VERSION", "\"${libs.versions.coilCompose.get()}\"")
}
// Release signing is applied only when a keystore is available, so anyone (including F-Droid,
// which signs with its own key) can build an unsigned release from a fresh clone.
val releaseKeystore = file(System.getenv("KEYSTORE_PATH") ?: "${rootDir}/my-upload-key.jks")
val canSignRelease = releaseKeystore.exists() && System.getenv("STORE_PASSWORD") != null
signingConfigs {
if (canSignRelease) {
create("release") {
storeFile = releaseKeystore
storePassword = System.getenv("STORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS") ?: "upload"
keyPassword = System.getenv("KEY_PASSWORD") ?: System.getenv("STORE_PASSWORD")
}
}
}
buildTypes {
release {
isCrunchPngs = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Keeps release builds reproducible (F-Droid).
vcsInfo.include = false
if (canSignRelease) signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
testOptions { unitTests { isIncludeAndroidResources = true } }
dependenciesInfo {
includeInApk = false
includeInBundle = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.common)
implementation(libs.androidx.media3.session)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.core)
testImplementation(libs.androidx.junit)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.runner)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
@@ -0,0 +1,16 @@
package cc.skysparkle.fogwave
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(BuildConfig.APPLICATION_ID, appContext.packageName)
}
}
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application
android:name=".FogwaveApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Fogwave">
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".player.FogwavePlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,20 @@
package cc.skysparkle.fogwave
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.memory.MemoryCache
class FogwaveApp : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.20)
.build()
}
.crossfade(true)
.respectCacheHeaders(false)
.build()
}
}
@@ -0,0 +1,34 @@
package cc.skysparkle.fogwave
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import cc.skysparkle.fogwave.ui.screens.HomeScreen
import cc.skysparkle.fogwave.ui.theme.FogwaveTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)
)
setContent {
FogwaveTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
HomeScreen()
}
}
}
}
}
@@ -0,0 +1,54 @@
package cc.skysparkle.fogwave.data.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import cc.skysparkle.fogwave.R
@Immutable
data class RadioStation(
val id: String,
val name: String,
@param:StringRes val descriptionRes: Int,
val streamUrl: String,
@param:DrawableRes val iconResId: Int,
val genres: List<Genre>,
val bitrate: String,
val lightBackdrop: Boolean = false
)
enum class Genre(@param:StringRes val labelRes: Int) {
HITS(R.string.genre_hits),
POP(R.string.genre_pop),
DANCE(R.string.genre_dance),
OLDIES(R.string.genre_oldies),
ROCK(R.string.genre_rock),
EASY_LISTENING(R.string.genre_easy_listening),
LITHUANIAN(R.string.genre_lithuanian)
}
enum class ViewMode {
ICONS,
TILES,
LIST,
DETAILS
}
enum class IconSizeStep(val sizeDp: Int) {
S(64),
M(96),
L(132),
XL(176)
}
enum class AppTab(@param:StringRes val titleRes: Int) {
RADIO(R.string.tab_radio),
FAVORITES(R.string.tab_favorites),
ABOUT(R.string.tab_about)
}
enum class ExtraPlayerTab(@param:StringRes val titleRes: Int) {
RECENT(R.string.extra_tab_recent),
SIMILAR(R.string.extra_tab_similar),
INFO(R.string.extra_tab_info)
}
@@ -0,0 +1,263 @@
package cc.skysparkle.fogwave.data.repository
import android.content.Context
import androidx.core.content.edit
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.Genre
import cc.skysparkle.fogwave.data.model.IconSizeStep
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.data.model.ViewMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
class RadioRepository(context: Context) {
private val prefs = context.getSharedPreferences("fogwave_prefs", Context.MODE_PRIVATE)
val stations: List<RadioStation> = listOf(
RadioStation(
id = "zip",
name = "ZIP FM",
descriptionRes = R.string.station_desc_zip,
streamUrl = "https://stream4.radijas.lt/zipfm",
iconResId = R.drawable.ic_station_zipfm,
genres = listOf(Genre.HITS, Genre.POP, Genre.LITHUANIAN),
bitrate = "MP3"
),
RadioStation(
id = "powerhit",
name = "Power Hit Radio",
descriptionRes = R.string.station_desc_powerhit,
// Stable revma entry point: it redirects to a fresh node URL with a short-lived token.
streamUrl = "https://stream.rcs.revma.com/f31w7e0fveuvv",
iconResId = R.drawable.ic_station_powerhit,
genres = listOf(Genre.DANCE, Genre.POP, Genre.HITS),
bitrate = "AAC"
),
RadioStation(
id = "powerhitgold",
name = "Power Hit Radio Gold",
descriptionRes = R.string.station_desc_powerhitgold,
streamUrl = "https://stream.rcs.revma.com/asdpz3qfbhuvv",
iconResId = R.drawable.ic_station_powerhitgold,
genres = listOf(Genre.DANCE, Genre.OLDIES, Genre.HITS),
bitrate = "AAC"
),
RadioStation(
id = "rock",
name = "Rock FM",
descriptionRes = R.string.station_desc_rock,
streamUrl = "https://stream4.radijas.lt/rockfm",
iconResId = R.drawable.ic_station_rockfm,
genres = listOf(Genre.ROCK, Genre.LITHUANIAN),
bitrate = "MP3",
lightBackdrop = true
),
RadioStation(
id = "relax",
name = "Relax FM",
descriptionRes = R.string.station_desc_relax,
streamUrl = "https://stream4.radijas.lt/relaxfm",
iconResId = R.drawable.ic_station_relaxfm,
genres = listOf(Genre.EASY_LISTENING),
bitrate = "MP3"
),
RadioStation(
id = "lietus",
name = "Lietus",
descriptionRes = R.string.station_desc_lietus,
streamUrl = "https://stream.m-1.fm/lietus/aacp64",
iconResId = R.drawable.ic_station_lietus,
genres = listOf(Genre.LITHUANIAN, Genre.POP),
bitrate = "AAC 64 kbps",
lightBackdrop = true
),
RadioStation(
id = "m1",
name = "M-1",
descriptionRes = R.string.station_desc_m1,
streamUrl = "https://stream.m-1.fm/m1/aacp64",
iconResId = R.drawable.ic_station_m1,
genres = listOf(Genre.POP, Genre.HITS),
bitrate = "AAC 64 kbps"
),
RadioStation(
id = "plus",
name = "M-1 Plius",
descriptionRes = R.string.station_desc_plus,
streamUrl = "https://radio.m-1.fm/m1plius/aacp64",
iconResId = R.drawable.ic_station_m1plus,
genres = listOf(Genre.POP, Genre.OLDIES),
bitrate = "AAC 64 kbps",
lightBackdrop = true
),
RadioStation(
id = "goldfm",
name = "Gold FM",
descriptionRes = R.string.station_desc_goldfm,
streamUrl = "https://stream.goldfm.lt/goldfm.aac",
iconResId = R.drawable.ic_station_goldfm,
genres = listOf(Genre.OLDIES, Genre.HITS, Genre.LITHUANIAN),
bitrate = "AAC"
),
RadioStation(
id = "rc",
name = "RC",
descriptionRes = R.string.station_desc_rc,
streamUrl = "https://stream1.rc.lt/rc128.mp3",
iconResId = R.drawable.ic_station_rc,
genres = listOf(Genre.HITS, Genre.LITHUANIAN),
bitrate = "MP3 128 kbps",
lightBackdrop = true
)
)
private val stationIds: Set<String> = stations.mapTo(HashSet()) { it.id }
private val _favoritesFlow = MutableStateFlow<Set<String>>(loadFavorites())
val favoritesFlow: StateFlow<Set<String>> = _favoritesFlow.asStateFlow()
private val _recentsFlow = MutableStateFlow<List<String>>(loadRecents())
val recentsFlow: StateFlow<List<String>> = _recentsFlow.asStateFlow()
private val _viewModeFlow = MutableStateFlow(loadViewMode())
val viewModeFlow: StateFlow<ViewMode> = _viewModeFlow.asStateFlow()
private val _sizeStepFlow = MutableStateFlow(loadSizeStep())
val sizeStepFlow: StateFlow<IconSizeStep> = _sizeStepFlow.asStateFlow()
private val _seenFavoritesFlow = MutableStateFlow(loadSeenFavorites())
val seenFavoritesFlow: StateFlow<Set<String>> = _seenFavoritesFlow.asStateFlow()
private fun loadSeenFavorites(): Set<String> {
prefs.getStringSet("seen_favorites", null)?.let { return it.toSet() }
return loadFavorites()
}
fun markFavoritesSeen(ids: Set<String>) {
val copy = ids.toSet()
_seenFavoritesFlow.value = copy
prefs.edit { putStringSet("seen_favorites", copy); remove("last_seen_favorites_count") }
}
fun getLastStationId(): String? {
return prefs.getString("last_station_id", null)
}
fun setLastStationId(stationId: String) {
prefs.edit { putString("last_station_id", stationId) }
}
private fun loadPlayCounts(): MutableMap<String, Int> {
val raw = prefs.getString("play_counts", null) ?: return mutableMapOf()
val map = mutableMapOf<String, Int>()
raw.split(",").forEach { entry ->
val parts = entry.split(":")
if (parts.size == 2) {
val count = parts[1].toIntOrNull()
if (count != null) map[parts[0]] = count
}
}
return map
}
private fun savePlayCounts(counts: Map<String, Int>) {
val raw = counts.entries.joinToString(",") { (id, count) -> "$id:$count" }
prefs.edit { putString("play_counts", raw) }
}
fun recordStationPlayed(stationId: String) {
val counts = loadPlayCounts()
counts[stationId] = (counts[stationId] ?: 0) + 1
savePlayCounts(counts)
}
fun stationsSortedByPopularity(): List<RadioStation> {
val counts = loadPlayCounts()
if (counts.isEmpty()) return stations
return stations.sortedByDescending { counts[it.id] ?: 0 }
}
private fun loadFavorites(): Set<String> {
// SharedPreferences hands out its internal set, so always work on a copy.
return prefs.getStringSet("favorites", null)?.filterTo(HashSet()) { it in stationIds } ?: emptySet()
}
fun toggleFavorite(stationId: String): Boolean {
val current = _favoritesFlow.value.toMutableSet()
val isNowFav = if (current.contains(stationId)) {
current.remove(stationId)
false
} else {
current.add(stationId)
true
}
prefs.edit { putStringSet("favorites", current) }
_favoritesFlow.value = current
return isNowFav
}
private fun loadRecents(): List<String> {
val raw = prefs.getString("recents", null) ?: return emptyList()
return try {
val arr = JSONArray(raw)
val list = mutableListOf<String>()
for (i in 0 until arr.length()) {
val id = arr.getString(i)
if (id in stationIds && id !in list) list.add(id)
}
list
} catch (_: Exception) {
emptyList()
}
}
fun pushRecent(stationId: String) {
val current = _recentsFlow.value.toMutableList()
current.remove(stationId)
current.add(0, stationId)
val trimmed = current.take(10)
_recentsFlow.value = trimmed
val arr = JSONArray()
trimmed.forEach { arr.put(it) }
prefs.edit { putString("recents", arr.toString()) }
}
private fun loadViewMode(): ViewMode {
val mode = prefs.getString("view_mode", ViewMode.ICONS.name) ?: ViewMode.ICONS.name
return try {
ViewMode.valueOf(mode)
} catch (_: Exception) {
ViewMode.ICONS
}
}
fun setViewMode(mode: ViewMode) {
_viewModeFlow.value = mode
prefs.edit { putString("view_mode", mode.name) }
}
private fun loadSizeStep(): IconSizeStep {
val step = prefs.getString("size_step", IconSizeStep.M.name) ?: IconSizeStep.M.name
return try {
IconSizeStep.valueOf(step)
} catch (_: Exception) {
IconSizeStep.M
}
}
fun setSizeStep(step: IconSizeStep) {
_sizeStepFlow.value = step
prefs.edit { putString("size_step", step.name) }
}
fun hasSeenBatteryOptimizationPrompt(): Boolean {
return prefs.getBoolean("seen_battery_optimization_prompt", false)
}
fun markBatteryOptimizationPromptSeen() {
prefs.edit { putBoolean("seen_battery_optimization_prompt", true) }
}
}
@@ -0,0 +1,58 @@
package cc.skysparkle.fogwave.player
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object AudioEffectsController {
private val _state = MutableStateFlow(AudioEffectsState())
val state: StateFlow<AudioEffectsState> = _state.asStateFlow()
private var engine: AudioEffectsEngine? = null
fun attach(engine: AudioEffectsEngine) {
this.engine = engine
refresh()
}
fun detach(engine: AudioEffectsEngine) {
if (this.engine === engine) {
this.engine = null
_state.value = AudioEffectsState()
}
}
fun setEqualizerEnabled(enabled: Boolean) {
engine?.setEqualizerEnabled(enabled)
refresh()
}
fun setBandLevel(band: Int, levelMillibel: Int) {
engine?.setBandLevel(band, levelMillibel)
refresh()
}
fun setPreset(preset: Int) {
engine?.setPreset(preset)
refresh()
}
fun setLoudnessEnabled(enabled: Boolean) {
engine?.setLoudnessEnabled(enabled)
refresh()
}
fun setLoudnessGain(gainMillibel: Float) {
engine?.setLoudnessGain(gainMillibel)
refresh()
}
fun setDynamicsProcessingEnabled(enabled: Boolean) {
engine?.setDynamicsProcessingEnabled(enabled)
refresh()
}
private fun refresh() {
engine?.let { _state.value = it.currentState() }
}
}
@@ -0,0 +1,247 @@
package cc.skysparkle.fogwave.player
import android.content.Context
import android.content.SharedPreferences
import android.media.audiofx.DynamicsProcessing
import android.media.audiofx.Equalizer
import android.media.audiofx.LoudnessEnhancer
import android.os.Build
import android.util.Log
import androidx.core.content.edit
data class EqualizerBand(
val index: Int,
val centerFreqHz: Int,
val levelMillibel: Int,
val minLevelMillibel: Int,
val maxLevelMillibel: Int
)
data class AudioEffectsState(
val equalizerAvailable: Boolean = false,
val equalizerEnabled: Boolean = false,
val bands: List<EqualizerBand> = emptyList(),
val presets: List<String> = emptyList(),
val currentPreset: Int = -1,
val loudnessAvailable: Boolean = false,
val loudnessEnabled: Boolean = false,
val loudnessGainMillibel: Float = 0f,
val loudnessMaxGainMillibel: Float = 2000f,
val dynamicsProcessingAvailable: Boolean = false,
val dynamicsProcessingEnabled: Boolean = false
)
class AudioEffectsEngine(context: Context, audioSessionId: Int) {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences("fogwave_audio_effects", Context.MODE_PRIVATE)
// Tracked manually: Equalizer.currentPreset is unreliable after bands are moved by hand.
private var currentPresetIndex: Int = prefs.getInt(KEY_PRESET, -1)
private var equalizer: Equalizer? = null
private var loudnessEnhancer: LoudnessEnhancer? = null
private var dynamicsProcessing: DynamicsProcessing? = null
private val presetNames = mutableListOf<String>()
init {
try {
equalizer = Equalizer(0, audioSessionId).apply {
for (p in 0 until numberOfPresets) {
presetNames.add(getPresetName(p.toShort()))
}
val preset = currentPresetIndex
if (preset in 0 until numberOfPresets) {
usePreset(preset.toShort())
} else {
for (b in 0 until numberOfBands) {
val saved = prefs.getInt(KEY_BAND_PREFIX + b, Int.MIN_VALUE)
if (saved != Int.MIN_VALUE) setBandLevel(b.toShort(), saved.toShort())
}
}
enabled = prefs.getBoolean(KEY_EQ_ENABLED, false)
}
} catch (e: Exception) {
Log.e(TAG, "Equalizer unavailable", e)
}
try {
loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply {
setTargetGain(prefs.getInt(KEY_LOUDNESS_GAIN, 0))
enabled = prefs.getBoolean(KEY_LOUDNESS_ENABLED, false)
}
} catch (e: Exception) {
Log.e(TAG, "LoudnessEnhancer unavailable", e)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
dynamicsProcessing = buildDynamicsProcessing(audioSessionId)
} catch (e: Exception) {
Log.e(TAG, "DynamicsProcessing unavailable", e)
}
}
}
private fun buildDynamicsProcessing(audioSessionId: Int): DynamicsProcessing {
// Upper edge of each multiband-compressor band: bass / mids / highs.
val crossovers = floatArrayOf(200f, 4_000f, 20_000f)
val bandCount = crossovers.size
val config = DynamicsProcessing.Config.Builder(
DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
1,
false, 0,
true, bandCount,
false, 0,
true
).build()
val dp = DynamicsProcessing(0, audioSessionId, config)
dp.setInputGainAllChannelsTo(3.0f)
// The effect expands the config to the real channel count of the session (usually stereo),
// so every channel has to be tuned, not only the first one.
for (ch in 0 until dp.channelCount) {
val mbc = dp.getMbcByChannelIndex(ch) ?: continue
mbc.isEnabled = true
for (b in 0 until bandCount) {
val band = DynamicsProcessing.MbcBand(
true,
crossovers[b],
8f,
60f,
2.0f,
-24f,
6f,
-90f,
1f,
0f,
0f
)
mbc.setBand(b, band)
}
dp.setMbcByChannelIndex(ch, mbc)
val limiter = dp.getLimiterByChannelIndex(ch) ?: continue
limiter.isEnabled = true
limiter.attackTime = 3f
limiter.releaseTime = 60f
limiter.ratio = 10f
limiter.threshold = -2f
limiter.postGain = 0f
dp.setLimiterByChannelIndex(ch, limiter)
}
dp.enabled = prefs.getBoolean(KEY_DYNAMICS_ENABLED, false)
return dp
}
fun currentState(): AudioEffectsState {
val eq = equalizer
val bands = if (eq != null) {
(0 until eq.numberOfBands).map { i ->
val idx = i.toShort()
val range = eq.bandLevelRange
EqualizerBand(
index = i,
centerFreqHz = eq.getCenterFreq(idx) / 1000,
levelMillibel = eq.getBandLevel(idx).toInt(),
minLevelMillibel = range[0].toInt(),
maxLevelMillibel = range[1].toInt()
)
}
} else emptyList()
return AudioEffectsState(
equalizerAvailable = eq != null,
equalizerEnabled = eq?.enabled ?: false,
bands = bands,
presets = presetNames,
currentPreset = if (eq != null) currentPresetIndex else -1,
loudnessAvailable = loudnessEnhancer != null,
loudnessEnabled = loudnessEnhancer?.enabled ?: false,
loudnessGainMillibel = try {
loudnessEnhancer?.targetGain ?: 0f
} catch (_: Exception) {
0f
},
dynamicsProcessingAvailable = dynamicsProcessing != null,
dynamicsProcessingEnabled = dynamicsProcessing?.enabled ?: false
)
}
fun setEqualizerEnabled(enabled: Boolean) {
try {
equalizer?.enabled = enabled
prefs.edit { putBoolean(KEY_EQ_ENABLED, enabled) }
} catch (_: Exception) {}
}
fun setBandLevel(band: Int, levelMillibel: Int) {
try {
equalizer?.setBandLevel(band.toShort(), levelMillibel.toShort())
currentPresetIndex = -1
prefs.edit {
putInt(KEY_BAND_PREFIX + band, levelMillibel)
putInt(KEY_PRESET, -1)
}
} catch (_: Exception) {}
}
fun setPreset(preset: Int) {
try {
val eq = equalizer ?: return
eq.usePreset(preset.toShort())
currentPresetIndex = preset
prefs.edit {
putInt(KEY_PRESET, preset)
for (b in 0 until eq.numberOfBands) {
putInt(KEY_BAND_PREFIX + b, eq.getBandLevel(b.toShort()).toInt())
}
}
} catch (_: Exception) {}
}
fun setLoudnessEnabled(enabled: Boolean) {
try {
loudnessEnhancer?.enabled = enabled
prefs.edit { putBoolean(KEY_LOUDNESS_ENABLED, enabled) }
} catch (_: Exception) {}
}
fun setLoudnessGain(gainMillibel: Float) {
try {
val gain = gainMillibel.coerceIn(0f, 2000f).toInt()
loudnessEnhancer?.setTargetGain(gain)
prefs.edit { putInt(KEY_LOUDNESS_GAIN, gain) }
} catch (_: Exception) {}
}
fun setDynamicsProcessingEnabled(enabled: Boolean) {
try {
dynamicsProcessing?.enabled = enabled
prefs.edit { putBoolean(KEY_DYNAMICS_ENABLED, enabled) }
} catch (_: Exception) {}
}
fun release() {
try { equalizer?.release() } catch (_: Exception) {}
try { loudnessEnhancer?.release() } catch (_: Exception) {}
try { dynamicsProcessing?.release() } catch (_: Exception) {}
equalizer = null
loudnessEnhancer = null
dynamicsProcessing = null
}
private companion object {
const val TAG = "AudioEffectsEngine"
const val KEY_EQ_ENABLED = "eq_enabled"
const val KEY_PRESET = "eq_preset"
const val KEY_BAND_PREFIX = "eq_band_"
const val KEY_LOUDNESS_ENABLED = "loudness_enabled"
const val KEY_LOUDNESS_GAIN = "loudness_gain"
const val KEY_DYNAMICS_ENABLED = "dynamics_enabled"
}
}
@@ -0,0 +1,354 @@
package cc.skysparkle.fogwave.player
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
import android.os.SystemClock
import android.util.Log
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.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import cc.skysparkle.fogwave.BuildConfig
import cc.skysparkle.fogwave.MainActivity
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.repository.RadioRepository
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
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.launch
@OptIn(UnstableApi::class)
class FogwavePlaybackService : MediaLibraryService() {
private var mediaLibrarySession: MediaLibrarySession? = null
private lateinit var repository: RadioRepository
private lateinit var player: ExoPlayer
private var audioEffectsEngine: AudioEffectsEngine? = null
private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private val retryDelaysMs = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000, 30_000)
private var retryAttempt = 0
private var retryJob: Job? = null
// Elapsed-realtime timestamp of the last pause, used to jump back to the live edge on resume.
private var pausedAtMs = 0L
private var lastRequestedItem: MediaItem? = null
private var connectivityManager: ConnectivityManager? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private val httpDataSourceFactory: DefaultHttpDataSource.Factory by lazy {
DefaultHttpDataSource.Factory()
.setUserAgent("FogwaveRadio/${BuildConfig.VERSION_NAME} (Android; Icecast/Shoutcast Client)")
.setDefaultRequestProperties(mapOf("Icy-MetaData" to "1"))
.setConnectTimeoutMs(10_000)
.setReadTimeoutMs(10_000)
.setAllowCrossProtocolRedirects(true)
}
private val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(5_000, 15_000, 500, 1_000)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
private val librarySessionCallback = object : MediaLibrarySession.Callback {
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> {
val rootItem = MediaItem.Builder()
.setMediaId(ROOT_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(getString(R.string.app_name))
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
)
.build()
return Futures.immediateFuture(LibraryResult.ofItem(rootItem, params))
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
if (parentId != ROOT_ID) {
return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
}
val items = repository.stations.map { it.toMediaItem(this@FogwavePlaybackService) }
return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
val station = repository.stations.find { it.id == mediaId }
?: return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
return Futures.immediateFuture(LibraryResult.ofItem(station.toMediaItem(this@FogwavePlaybackService), null))
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>
): ListenableFuture<MutableList<MediaItem>> {
val resolved = mediaItems.map { item ->
if (item.localConfiguration != null) {
item
} else {
repository.stations.find { it.id == item.mediaId }
?.toMediaItem(this@FogwavePlaybackService)
?: item
}
}.toMutableList()
resolved.firstOrNull()?.let { item ->
Log.d(TAG, "MediaItem resolved: mediaId=${item.mediaId} uri=${item.localConfiguration?.uri}")
lastRequestedItem = item
retryJob?.cancel()
retryAttempt = 0
}
return Futures.immediateFuture(resolved)
}
}
private val resiliencePlayerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
val stateName = when (playbackState) {
Player.STATE_IDLE -> "IDLE"
Player.STATE_BUFFERING -> "BUFFERING"
Player.STATE_READY -> "READY"
Player.STATE_ENDED -> "ENDED"
else -> "UNKNOWN($playbackState)"
}
Log.d(TAG, "Player state changed: $stateName (playWhenReady=${player.playWhenReady})")
// A live stream never really ends: STATE_ENDED means the server dropped the connection.
if (playbackState == Player.STATE_ENDED && player.playWhenReady) {
scheduleReconnect()
}
if (playbackState == Player.STATE_READY && player.playWhenReady) {
if (retryAttempt != 0) Log.d(TAG, "Reconnect succeeded - resetting backoff")
retryAttempt = 0
retryJob?.cancel()
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, if (isPlaying) "Playing" else "Not playing (paused/buffering/stopped)")
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (!playWhenReady) {
if (retryJob?.isActive == true) {
Log.d(TAG, "Reconnect cancelled - playWhenReady=false (reason=$reason)")
}
retryJob?.cancel()
pausedAtMs = SystemClock.elapsedRealtime()
return
}
val pausedFor = SystemClock.elapsedRealtime() - pausedAtMs
val resumingOldBuffer = pausedAtMs != 0L && pausedFor > LIVE_RESYNC_AFTER_MS &&
player.playbackState == Player.STATE_READY
pausedAtMs = 0L
if (resumingOldBuffer) {
// After a long pause the buffer is stale and the server has usually dropped the
// connection, so reopen the stream at the live edge instead of replaying old audio.
Log.d(TAG, "Resumed after ${pausedFor}ms - reloading live stream")
// Posted (not immediate) so the player is not re-entered from its own callback.
serviceScope.launch(Dispatchers.Main) { restartStream() }
}
}
override fun onPlayerError(error: PlaybackException) {
Log.e(
TAG,
"Playback error: errorCode=${error.errorCode} errorCodeName=${error.errorCodeName} " +
"message=${error.message} cause=${error.cause} " +
"mediaItem=${lastRequestedItem?.localConfiguration?.uri}",
error
)
scheduleReconnect()
}
}
private fun scheduleReconnect(immediate: Boolean = false) {
retryJob?.cancel()
if (!immediate && retryAttempt >= MAX_RETRY_ATTEMPTS) {
// Give up instead of keeping the device busy forever; the error stays visible and a tap
// on play starts over.
Log.w(TAG, "Reconnect abandoned after $retryAttempt attempts")
retryAttempt = 0
player.pause()
return
}
val delayMs = if (immediate) 0L else retryDelaysMs.getOrElse(retryAttempt) { retryDelaysMs.last() }
retryAttempt++
Log.d(TAG, "Reconnect scheduled in ${delayMs}ms (attempt=$retryAttempt)")
retryJob = serviceScope.launch {
delay(delayMs)
if (!player.playWhenReady) {
Log.d(TAG, "Reconnect skipped - no longer playWhenReady")
return@launch
}
restartStream()
}
}
private fun restartStream() {
val item = player.currentMediaItem ?: lastRequestedItem
if (item == null) {
Log.d(TAG, "Restart skipped - nothing to play")
return
}
Log.d(TAG, "Restarting stream: uri=${item.localConfiguration?.uri}")
try {
player.stop()
player.setMediaItem(item)
player.prepare()
player.play()
} catch (e: Exception) {
Log.e(TAG, "Restart failed", e)
}
}
private fun registerNetworkCallback() {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
connectivityManager = cm
val callback = object : ConnectivityManager.NetworkCallback() {
// Called on a ConnectivityManager thread; ExoPlayer must only be accessed on the main thread.
override fun onAvailable(network: Network) {
serviceScope.launch {
if (retryJob?.isActive == true && player.playWhenReady && player.playbackState != Player.STATE_READY) {
Log.d(TAG, "Network available again - reconnecting immediately")
scheduleReconnect(immediate = true)
}
}
}
}
networkCallback = callback
try {
cm.registerDefaultNetworkCallback(callback)
} catch (_: Exception) {
}
}
override fun onCreate() {
super.onCreate()
repository = RadioRepository(applicationContext)
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val audioSessionId = audioManager?.generateAudioSessionId()?.takeIf { it != 0 }
?: C.AUDIO_SESSION_ID_UNSET
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(this).setDataSourceFactory(httpDataSourceFactory))
.setLoadControl(loadControl)
.build()
.apply {
if (audioSessionId != C.AUDIO_SESSION_ID_UNSET) {
setAudioSessionId(audioSessionId)
}
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build(),
true
)
setHandleAudioBecomingNoisy(true)
// Keeps CPU and Wi-Fi awake while streaming; without it playback stops with the screen off.
setWakeMode(C.WAKE_MODE_NETWORK)
addListener(resiliencePlayerListener)
}
Log.d(TAG, "Audio session id for effects: ${player.audioSessionId}")
audioEffectsEngine = AudioEffectsEngine(applicationContext, player.audioSessionId).also {
AudioEffectsController.attach(it)
}
registerNetworkCallback()
// Tapping the media notification brings the app back to the front.
val sessionActivity = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivity)
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = mediaLibrarySession
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady || player.mediaItemCount == 0) {
player.pause()
stopSelf()
}
}
override fun onDestroy() {
retryJob?.cancel()
serviceScope.cancel()
networkCallback?.let { cb ->
try { connectivityManager?.unregisterNetworkCallback(cb) } catch (_: Exception) {}
}
audioEffectsEngine?.let {
AudioEffectsController.detach(it)
it.release()
}
player.removeListener(resiliencePlayerListener)
player.release()
mediaLibrarySession?.release()
mediaLibrarySession = null
super.onDestroy()
}
companion object {
private const val ROOT_ID = "fogwave_root"
private const val TAG = "FogwavePlaybackService"
private const val MAX_RETRY_ATTEMPTS = 12
private const val LIVE_RESYNC_AFTER_MS = 30_000L
}
}
@@ -0,0 +1,166 @@
package cc.skysparkle.fogwave.player
import android.content.ComponentName
import android.content.Context
import android.util.Log
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.RadioStation
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed class PlayerState {
data object Idle : PlayerState()
data object Connecting : PlayerState()
data object Playing : PlayerState()
data object Paused : PlayerState()
data class Error(val message: String) : PlayerState()
}
class RadioPlayerManager(private val context: Context) {
private val _playerState = MutableStateFlow<PlayerState>(PlayerState.Idle)
val playerState: StateFlow<PlayerState> = _playerState.asStateFlow()
private val _currentMediaId = MutableStateFlow<String?>(null)
val currentMediaId: StateFlow<String?> = _currentMediaId.asStateFlow()
private var currentStation: RadioStation? = null
private var pendingStation: RadioStation? = null
private var controllerFuture: ListenableFuture<MediaController>? = null
private var controller: MediaController? = null
private var released = false
private val playerListener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
_playerState.value = deriveState(player)
_currentMediaId.value = player.currentMediaItem?.mediaId
}
override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, "Playback error: ${error.errorCodeName} (${error.errorCode}) ${error.message}", error)
}
}
// Declared after every property it touches so that nothing is read before initialization.
init {
connectToPlaybackService()
}
private fun connectToPlaybackService() {
if (released || controllerFuture != null) return
val sessionToken = SessionToken(context, ComponentName(context, FogwavePlaybackService::class.java))
val future = MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture = future
future.addListener({
if (released) return@addListener
try {
val c = future.get()
controller = c
c.addListener(playerListener)
Log.d(TAG, "MediaController connected")
val station = pendingStation
pendingStation = null
if (station != null) {
playStation(station)
} else {
_playerState.value = deriveState(c)
_currentMediaId.value = c.currentMediaItem?.mediaId
}
} catch (e: Exception) {
Log.e(TAG, "Failed to connect to FogwavePlaybackService", e)
// Allow the next play request to try again.
controllerFuture = null
pendingStation = null
_playerState.value = PlayerState.Error(context.getString(R.string.stream_unavailable))
}
}, MoreExecutors.directExecutor())
}
private fun deriveState(p: Player): PlayerState {
p.playerError?.let { return PlayerState.Error(errorMessage(it)) }
return when (p.playbackState) {
Player.STATE_READY -> if (p.playWhenReady) PlayerState.Playing else PlayerState.Paused
Player.STATE_BUFFERING, Player.STATE_ENDED ->
if (p.playWhenReady) PlayerState.Connecting else PlayerState.Paused
else -> when {
p.mediaItemCount == 0 -> PlayerState.Idle
p.playWhenReady -> PlayerState.Connecting
else -> PlayerState.Paused
}
}
}
private fun errorMessage(error: PlaybackException): String = context.getString(
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> R.string.network_unavailable
else -> R.string.stream_unavailable
}
)
fun playStation(station: RadioStation) {
Log.d(TAG, "Station requested: ${station.id}")
currentStation = station
val c = controller
if (c == null) {
pendingStation = station
_playerState.value = PlayerState.Connecting
connectToPlaybackService()
return
}
_playerState.value = PlayerState.Connecting
try {
c.setMediaItem(station.toMediaItem(context))
c.prepare()
c.play()
} catch (e: Exception) {
Log.e(TAG, "Failed to start ${station.streamUrl}", e)
_playerState.value = PlayerState.Error(context.getString(R.string.stream_unavailable))
}
}
fun play() {
val c = controller
when {
c == null || c.mediaItemCount == 0 -> currentStation?.let { playStation(it) }
// A failed, stopped or finished live stream has to be reopened rather than resumed.
c.playerError != null || c.playbackState == Player.STATE_IDLE || c.playbackState == Player.STATE_ENDED -> {
c.seekToDefaultPosition()
c.prepare()
c.play()
}
else -> c.play()
}
}
fun pause() {
controller?.pause()
}
fun togglePlay() {
when (_playerState.value) {
is PlayerState.Playing, is PlayerState.Connecting -> pause()
is PlayerState.Paused, is PlayerState.Idle, is PlayerState.Error -> play()
}
}
fun release() {
released = true
pendingStation = null
controller?.removeListener(playerListener)
controller = null
controllerFuture?.let { MediaController.releaseFuture(it) }
controllerFuture = null
}
private companion object {
const val TAG = "RadioPlayerManager"
}
}
@@ -0,0 +1,27 @@
package cc.skysparkle.fogwave.player
import android.content.ContentResolver
import android.content.Context
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import cc.skysparkle.fogwave.data.model.RadioStation
fun RadioStation.toMediaItem(context: Context): MediaItem {
val artworkUri = "${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/$iconResId".toUri()
return MediaItem.Builder()
.setUri(streamUrl)
.setMediaId(id)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setArtist(context.getString(descriptionRes))
.setStation(name)
.setArtworkUri(artworkUri)
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
.build()
)
.build()
}
@@ -0,0 +1,226 @@
package cc.skysparkle.fogwave.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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 cc.skysparkle.fogwave.BuildConfig
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
private const val REPOSITORY_URL = "https://git.skysparkle.cc/kalarina/Fogwave"
private const val PRIVACY_POLICY_URL = "https://skysparkle.cc/privacy?lang=en"
@Composable
fun AboutPanel(modifier: Modifier = Modifier) {
val context = LocalContext.current
var showLicenses by rememberSaveable { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(24.dp))
.clip(RoundedCornerShape(24.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(24.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_fogwave_logo,
contentDescription = stringResource(R.string.fogwave_logo_content_desc),
size = 52.dp
)
}
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
letterSpacing = (-0.5).sp,
color = LocalGeoColors.current.deepPurple
)
)
// Comes straight from versionName in app/build.gradle.kts, so it can never drift.
Text(
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
color = LocalGeoColors.current.primary
),
modifier = Modifier.testTag("about_version")
)
Text(
text = stringResource(R.string.about_description),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.5.sp,
lineHeight = 20.sp,
color = LocalGeoColors.current.onSurfaceVariant,
textAlign = TextAlign.Center
),
modifier = Modifier.padding(top = 4.dp)
)
}
AboutLinkCard(
iconRes = R.drawable.ic_repo,
iconTint = null,
title = stringResource(R.string.about_my_repository),
subtitle = REPOSITORY_URL.removePrefix("https://git.skysparkle.cc/"),
trailingIconRes = R.drawable.ic_open_in_new,
onClick = { context.openUrl(REPOSITORY_URL) },
modifier = Modifier.testTag("github_repo_card")
)
AboutLinkCard(
iconRes = R.drawable.ic_copyright,
title = stringResource(R.string.licenses_title),
subtitle = stringResource(R.string.licenses_subtitle),
trailingIconRes = R.drawable.ic_chevron_right,
onClick = { showLicenses = true },
modifier = Modifier.testTag("licenses_card")
)
AboutLinkCard(
iconRes = R.drawable.ic_privacy,
title = stringResource(R.string.privacy_title),
subtitle = stringResource(R.string.privacy_subtitle),
trailingIconRes = R.drawable.ic_open_in_new,
onClick = { context.openUrl(PRIVACY_POLICY_URL) },
modifier = Modifier.testTag("privacy_card")
)
}
if (showLicenses) {
LicensesDialog(onDismiss = { showLicenses = false })
}
}
@Composable
private fun AboutLinkCard(
@DrawableRes iconRes: Int,
title: String,
subtitle: String,
@DrawableRes trailingIconRes: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconTint: Color? = LocalGeoColors.current.deepPurple
) {
val opensBrowser = trailingIconRes == R.drawable.ic_open_in_new
Row(
modifier = modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.2.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(20.dp))
.clickable(
onClickLabel = if (opensBrowser) stringResource(R.string.about_open_in_browser) else null,
role = Role.Button,
onClick = onClick
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(14.dp))
.background(LocalGeoColors.current.primaryContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = iconRes,
tint = iconTint ?: Color.Unspecified,
size = if (iconTint == null) 32.dp else 26.dp
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = LocalGeoColors.current.deepPurple
)
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
color = LocalGeoColors.current.primary
)
)
}
if (opensBrowser) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.primaryContainer),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = trailingIconRes,
tint = LocalGeoColors.current.deepPurple,
size = 18.dp
)
}
} else {
AppIcon(
resId = trailingIconRes,
tint = LocalGeoColors.current.onSurfaceVariant,
size = 18.dp
)
}
}
}
@@ -0,0 +1,29 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun AppIcon(
resId: Int,
modifier: Modifier = Modifier,
tint: Color = Color.Unspecified,
size: Dp = 24.dp,
contentDescription: String? = null
) {
Image(
painter = painterResource(id = resId),
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint),
modifier = modifier.size(size)
)
}
@@ -0,0 +1,39 @@
package cc.skysparkle.fogwave.ui.components
import android.content.Intent
import android.provider.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import cc.skysparkle.fogwave.R
@Composable
// Opens the general battery-optimization list instead of ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS:
// that action requires a permission Google Play rejects for ordinary media apps.
fun BatteryOptimizationDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.battery_opt_title)) },
text = { Text(stringResource(R.string.battery_opt_message)) },
confirmButton = {
TextButton(onClick = {
try {
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
} catch (_: Exception) {
}
onDismiss()
}) {
Text(stringResource(R.string.battery_opt_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.battery_opt_dismiss))
}
}
)
}
@@ -0,0 +1,383 @@
package cc.skysparkle.fogwave.ui.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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
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.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.player.AudioEffectsState
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
import java.util.Locale
import kotlin.math.roundToInt
@Composable
fun EqualizerSheet(
audioEffects: AudioEffectsState,
onClose: () -> Unit,
onSetEqualizerEnabled: (Boolean) -> Unit,
onSetEqualizerBand: (Int, Int) -> Unit,
onSetPreset: (Int) -> Unit,
onResetBands: () -> Unit,
onSetLoudnessEnabled: (Boolean) -> Unit,
onSetDynamicsProcessingEnabled: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(LocalGeoColors.current.surfaceContainer)
// Full-screen overlay: keep taps on empty areas from reaching the player underneath.
.pointerInput(Unit) {}
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onClose, modifier = Modifier.testTag("eq_back_btn")) {
AppIcon(
resId = R.drawable.ic_chevron_left,
contentDescription = stringResource(R.string.back_content_desc),
tint = LocalGeoColors.current.onSurface
)
}
Text(
text = stringResource(R.string.equalizer_title),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.deepPurple
),
modifier = Modifier.weight(1f)
)
Text(
text = if (audioEffects.equalizerEnabled) stringResource(R.string.equalizer_on) else stringResource(R.string.equalizer_off),
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
color = if (audioEffects.equalizerEnabled) LocalGeoColors.current.liveGreen else LocalGeoColors.current.onSurfaceVariant
),
modifier = Modifier.padding(end = 6.dp)
)
Switch(
checked = audioEffects.equalizerEnabled,
onCheckedChange = onSetEqualizerEnabled,
colors = SwitchDefaults.colors(checkedTrackColor = LocalGeoColors.current.primary),
modifier = Modifier.testTag("eq_enable_switch")
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.weight(1f)
.height(1.dp)
.background(LocalGeoColors.current.outlineVariant)
)
Text(
text = stringResource(R.string.equalizer_sound_section),
style = MaterialTheme.typography.labelMedium.copy(
color = LocalGeoColors.current.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
)
Box(
modifier = Modifier
.weight(1f)
.height(1.dp)
.background(LocalGeoColors.current.outlineVariant)
)
}
if (audioEffects.bands.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.alpha(if (audioEffects.equalizerEnabled) 1f else 0.45f),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(LocalGeoColors.current.outlineVariant)
)
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
audioEffects.bands.forEach { band ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxHeight()
) {
val db = (band.levelMillibel / 100f).roundToInt()
Text(
text = if (db > 0) "+$db dB" else "$db dB",
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = if (db == 0) LocalGeoColors.current.onSurfaceVariant
else LocalGeoColors.current.primary
)
)
Box(
modifier = Modifier
.weight(1f)
.width(40.dp),
contentAlignment = Alignment.Center
) {
Slider(
value = band.levelMillibel.toFloat(),
onValueChange = { onSetEqualizerBand(band.index, it.roundToInt()) },
valueRange = band.minLevelMillibel.toFloat()..band.maxLevelMillibel.toFloat(),
enabled = audioEffects.equalizerEnabled,
colors = SliderDefaults.colors(
thumbColor = LocalGeoColors.current.primary,
activeTrackColor = LocalGeoColors.current.primary,
inactiveTrackColor = LocalGeoColors.current.outlineVariant
),
modifier = Modifier
// requiredWidth (not width): the parent is narrower, and width() would be clamped before rotation.
.requiredWidth(160.dp)
.graphicsLayer {
rotationZ = 270f
transformOrigin = TransformOrigin(0.5f, 0.5f)
}
)
}
Text(
text = frequencyLabel(band.centerFreqHz),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
color = LocalGeoColors.current.onSurfaceVariant
)
)
}
}
}
}
} else {
Row(
modifier = Modifier.padding(vertical = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AppIcon(
resId = R.drawable.ic_alert,
tint = LocalGeoColors.current.onSurfaceVariant,
size = 18.dp
)
Text(
text = stringResource(R.string.equalizer_unavailable),
style = MaterialTheme.typography.bodyMedium.copy(color = LocalGeoColors.current.onSurfaceVariant)
)
}
}
if (audioEffects.presets.isNotEmpty()) {
val currentPresetName = audioEffects.presets.getOrNull(audioEffects.currentPreset)
?.let { presetLabel(it) }
?: stringResource(R.string.equalizer_custom_preset)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AppIcon(
resId = R.drawable.ic_sparkle,
contentDescription = null,
tint = LocalGeoColors.current.primary,
size = 18.dp
)
Text(
text = currentPresetName,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
color = LocalGeoColors.current.onSurface
)
)
}
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(audioEffects.presets) { index, name ->
val selected = audioEffects.currentPreset == index
Text(
text = presetLabel(name),
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
color = if (selected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant
),
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (selected) LocalGeoColors.current.primaryContainer else LocalGeoColors.current.surfaceContainerHigh)
.clickable { onSetPreset(index) }
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
if (audioEffects.loudnessAvailable) {
ToggleRow(
label = stringResource(R.string.loudness_row_label),
checked = audioEffects.loudnessEnabled,
onCheckedChange = onSetLoudnessEnabled
)
}
if (audioEffects.dynamicsProcessingAvailable) {
ToggleRow(
label = stringResource(R.string.dynamics_row_label),
checked = audioEffects.dynamicsProcessingEnabled,
onCheckedChange = onSetDynamicsProcessingEnabled
)
}
if (audioEffects.bands.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = onResetBands,
modifier = Modifier.testTag("eq_reset_btn")
) {
AppIcon(
resId = R.drawable.ic_refresh,
contentDescription = null,
tint = LocalGeoColors.current.primary,
size = 18.dp
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.equalizer_reset))
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun ToggleRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
// The whole row is the switch, so the label is a bigger touch target and is read with it.
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(LocalGeoColors.current.surfaceContainerHigh)
.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = LocalGeoColors.current.onSurface
),
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = null,
colors = SwitchDefaults.colors(checkedTrackColor = LocalGeoColors.current.primary)
)
}
}
private fun frequencyLabel(hz: Int): String = when {
hz < 1000 -> "$hz Hz"
hz % 1000 == 0 -> "${hz / 1000} kHz"
else -> String.format(Locale.getDefault(), "%.1f kHz", hz / 1000f)
}
// Preset names come from the device's audio framework in English; translate the standard ones.
@Composable
@ReadOnlyComposable
private fun presetLabel(name: String): String {
val res = when (name.trim().lowercase(Locale.ROOT)) {
"normal" -> R.string.eq_preset_normal
"classical" -> R.string.eq_preset_classical
"dance" -> R.string.eq_preset_dance
"flat" -> R.string.eq_preset_flat
"folk" -> R.string.eq_preset_folk
"heavy metal" -> R.string.eq_preset_heavy_metal
"hip hop" -> R.string.eq_preset_hip_hop
"jazz" -> R.string.eq_preset_jazz
"pop" -> R.string.eq_preset_pop
"rock" -> R.string.eq_preset_rock
else -> return name
}
return stringResource(res)
}
@@ -0,0 +1,223 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.draw.shadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
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.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.IconSizeStep
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.data.model.ViewMode
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
@Composable
fun FavoritesPanel(
favorites: List<RadioStation>,
currentStation: RadioStation?,
isPlaying: Boolean,
viewMode: ViewMode,
sizeStep: IconSizeStep,
onSelectStation: (RadioStation) -> Unit,
onBrowseStations: () -> Unit,
modifier: Modifier = Modifier,
bottomContentPadding: Dp = 140.dp
) {
if (favorites.isEmpty()) {
EmptyFavorites(
onBrowseStations = onBrowseStations,
bottomContentPadding = bottomContentPadding,
modifier = modifier
)
return
}
StationCollection(
stations = favorites,
currentStation = currentStation,
isPlaying = isPlaying,
viewMode = viewMode,
sizeStep = sizeStep,
onSelectStation = onSelectStation,
bottomContentPadding = bottomContentPadding,
modifier = modifier
)
}
/** The station grid (Icons mode) or list (other modes), shared by the Radio and Favorites tabs. */
@Composable
fun StationCollection(
stations: List<RadioStation>,
currentStation: RadioStation?,
isPlaying: Boolean,
viewMode: ViewMode,
sizeStep: IconSizeStep,
onSelectStation: (RadioStation) -> Unit,
bottomContentPadding: Dp,
modifier: Modifier = Modifier
) {
val contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = bottomContentPadding)
when (viewMode) {
ViewMode.ICONS -> LazyVerticalGrid(
columns = GridCells.Adaptive((sizeStep.sizeDp + 16).dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = contentPadding,
modifier = modifier
) {
items(stations, key = { it.id }) { station ->
val isCur = station.id == currentStation?.id
StationItem(
station = station,
isCurrent = isCur,
isPlaying = isCur && isPlaying,
viewMode = viewMode,
sizeStep = sizeStep,
onClick = { onSelectStation(station) }
)
}
}
ViewMode.TILES, ViewMode.LIST, ViewMode.DETAILS -> LazyColumn(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
) {
items(stations, key = { it.id }) { station ->
val isCur = station.id == currentStation?.id
StationItem(
station = station,
isCurrent = isCur,
isPlaying = isCur && isPlaying,
viewMode = viewMode,
sizeStep = sizeStep,
onClick = { onSelectStation(station) }
)
}
}
}
}
@Composable
private fun EmptyFavorites(
onBrowseStations: () -> Unit,
bottomContentPadding: Dp,
modifier: Modifier = Modifier
) {
// Scrollable and at least as tall as the screen, so the card stays centered on phones and is
// still fully reachable in landscape.
BoxWithConstraints(modifier = modifier) {
Box(
modifier = Modifier
.verticalScroll(rememberScrollState())
.heightIn(min = maxHeight)
.fillMaxWidth()
.padding(top = 8.dp, bottom = bottomContentPadding),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.shadow(4.dp, RoundedCornerShape(24.dp))
.clip(RoundedCornerShape(24.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(24.dp))
.padding(24.dp)
.testTag("empty_favorites_card"),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.redFavorite.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_favorite,
tint = LocalGeoColors.current.redFavorite,
size = 28.dp
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
text = stringResource(R.string.favorites_empty_title),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontSize = 16.5.sp,
color = LocalGeoColors.current.deepPurple,
textAlign = TextAlign.Center
)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.favorites_empty_subtitle),
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp,
lineHeight = 18.sp,
color = LocalGeoColors.current.onSurfaceVariant,
textAlign = TextAlign.Center
)
)
Spacer(modifier = Modifier.height(18.dp))
Button(
onClick = onBrowseStations,
colors = ButtonDefaults.buttonColors(
containerColor = LocalGeoColors.current.primaryContainer,
contentColor = LocalGeoColors.current.onPrimaryContainer
)
) {
AppIcon(
resId = R.drawable.ic_radio,
tint = LocalGeoColors.current.onPrimaryContainer,
size = 18.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.browse_stations))
}
}
}
}
}
@@ -0,0 +1,176 @@
package cc.skysparkle.fogwave.ui.components
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import cc.skysparkle.fogwave.BuildConfig
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
private data class LicenseRow(
val name: String,
val attribution: String,
val license: String,
val url: String,
val note: String? = null
)
private const val APACHE = "Apache License 2.0"
private const val AOSP = "The Android Open Source Project"
private const val BROADCASTER = "\u00A9 broadcaster"
// Versions come from gradle/libs.versions.toml through BuildConfig, so this list cannot go stale.
private val LIBRARIES = listOf(
LicenseRow("Jetpack Compose (UI, Foundation, Material 3)", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/compose", "BOM ${BuildConfig.COMPOSE_BOM_VERSION}"),
LicenseRow("AndroidX Activity Compose", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/activity", BuildConfig.ACTIVITY_VERSION),
LicenseRow("AndroidX Core KTX", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/core", BuildConfig.CORE_KTX_VERSION),
LicenseRow("AndroidX Lifecycle (ViewModel + Runtime Compose)", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/lifecycle", BuildConfig.LIFECYCLE_VERSION),
LicenseRow("AndroidX Media3 (ExoPlayer, MediaSession)", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/media3", BuildConfig.MEDIA3_VERSION),
LicenseRow("AndroidX \u2014 other libraries pulled in by the ones above", AOSP, APACHE, "https://developer.android.com/jetpack/androidx"),
LicenseRow("Guava (used by Media3)", "Google", APACHE, "https://github.com/google/guava"),
LicenseRow("kotlinx.coroutines", "JetBrains", APACHE, "https://github.com/Kotlin/kotlinx.coroutines", BuildConfig.COROUTINES_VERSION),
LicenseRow("Kotlin Standard Library", "JetBrains", APACHE, "https://github.com/JetBrains/kotlin", BuildConfig.KOTLIN_VERSION),
LicenseRow("Coil", "Coil Contributors", APACHE, "https://github.com/coil-kt/coil", BuildConfig.COIL_VERSION),
LicenseRow("OkHttp & Okio (used by Coil)", "Square, Inc.", APACHE, "https://square.github.io/okhttp/")
)
private val ASSETS = listOf(
LicenseRow("MingCute Icon (interface icons)", "MingCute Design, via IconBuddy", APACHE, "https://iconbuddy.com/mingcute"),
LicenseRow("Misty mountain background photo", "Federico Bottos, on Unsplash", "Unsplash License", "https://unsplash.com/es/fotos/arboles-verdes-en-la-montana-bajo-nubes-blancas-durante-el-dia-obQacWYxB1I")
)
private val STATIONS = listOf(
LicenseRow("ZIP FM", "Radiocentras group", BROADCASTER, "https://www.zipfm.lt/"),
LicenseRow("Power Hit Radio", "TV3 Group", BROADCASTER, "https://powerhitradio.tv3.lt/"),
LicenseRow("Power Hit Radio Gold", "TV3 Group", BROADCASTER, "https://powerhitradio.tv3.lt/"),
LicenseRow("Rock FM", "Radiocentras group", BROADCASTER, "https://www.radijas.lt/rockfm"),
LicenseRow("Relax FM", "Radiocentras group", BROADCASTER, "https://www.radijas.lt/relaxfm"),
LicenseRow("Lietus", "M-1 group", BROADCASTER, "https://www.m-1.fm/"),
LicenseRow("M-1", "M-1 group", BROADCASTER, "https://www.m-1.fm/"),
LicenseRow("M-1 Plius", "M-1 group", BROADCASTER, "https://www.m-1.fm/"),
LicenseRow("Gold FM", "Gold FM", BROADCASTER, "https://www.goldfm.lt/"),
LicenseRow("RC", "Radiocentras group", BROADCASTER, "https://www.rc.lt/")
)
@Composable
fun LicensesDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(LocalGeoColors.current.surfaceContainer)
.padding(20.dp)
) {
Text(
text = stringResource(R.string.licenses_title),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.deepPurple
)
)
Text(
text = stringResource(R.string.licenses_intro),
style = MaterialTheme.typography.bodySmall.copy(color = LocalGeoColors.current.onSurfaceVariant),
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp)
)
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
LicenseGroup(stringResource(R.string.licenses_group_libraries), LIBRARIES, context)
LicenseGroup(stringResource(R.string.licenses_group_assets), ASSETS, context)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
LicenseGroup(stringResource(R.string.licenses_group_stations), STATIONS, context)
Text(
text = stringResource(R.string.licenses_stations_hint),
style = MaterialTheme.typography.labelSmall.copy(color = LocalGeoColors.current.onSurfaceVariant),
modifier = Modifier.padding(top = 4.dp)
)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
Text(
text = stringResource(R.string.close_content_desc),
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.SemiBold,
color = LocalGeoColors.current.deepPurple
),
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable(role = Role.Button, onClick = onDismiss)
.padding(horizontal = 14.dp, vertical = 8.dp)
)
}
}
}
}
@Composable
private fun LicenseGroup(title: String, rows: List<LicenseRow>, context: Context) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.primary
)
)
rows.forEach { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(role = Role.Button) { context.openUrl(row.url) }
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.name,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium)
)
val meta = listOfNotNull(row.attribution, row.note).joinToString(" \u00B7 ")
Text(
text = meta,
style = MaterialTheme.typography.labelSmall.copy(color = LocalGeoColors.current.onSurfaceVariant)
)
Text(
text = row.license,
style = MaterialTheme.typography.labelSmall.copy(color = LocalGeoColors.current.primary)
)
}
AppIcon(
resId = R.drawable.ic_open_in_new,
tint = LocalGeoColors.current.onSurfaceVariant,
size = 14.dp
)
}
}
}
}
@@ -0,0 +1,62 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val BarDurations = intArrayOf(520, 740, 610, 830, 570)
private val IdleLevels = floatArrayOf(0.3f, 0.45f, 0.35f, 0.5f, 0.3f)
@Composable
fun LiveBars(active: Boolean, color: Color, modifier: Modifier = Modifier) {
if (active) {
val transition = rememberInfiniteTransition(label = "live_bars")
val levels: List<State<Float>> = BarDurations.mapIndexed { i, d ->
transition.animateFloat(
initialValue = 0.25f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(d, delayMillis = i * 60, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "bar_$i"
)
}
Bars(modifier, color) { i -> levels[i].value }
} else {
Bars(modifier, color.copy(alpha = 0.45f)) { i -> IdleLevels[i] }
}
}
@Composable
private fun Bars(modifier: Modifier, color: Color, level: (Int) -> Float) {
Canvas(modifier = modifier.width(46.dp).height(22.dp)) {
val count = BarDurations.size
val gap = size.width * 0.08f
val barW = (size.width - gap * (count - 1)) / count
for (i in 0 until count) {
val h = size.height * level(i)
drawRoundRect(
color = color,
topLeft = Offset(i * (barW + gap), size.height - h),
size = Size(barW, h),
cornerRadius = CornerRadius(barW / 2f)
)
}
}
}
@@ -0,0 +1,260 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.player.PlayerState
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
import kotlin.math.roundToInt
private val TABLET_BREAKPOINT_DP = 600.dp
private val TABLET_MINI_PLAYER_MAX_WIDTH_DP = 640.dp
@Composable
fun MiniPlayer(
station: RadioStation?,
playerState: PlayerState,
onTogglePlay: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onOpenNowPlaying: () -> Unit,
onHide: () -> Unit,
modifier: Modifier = Modifier
) {
if (station == null) return
val isPlaying = playerState is PlayerState.Playing
val isConnecting = playerState is PlayerState.Connecting
val isError = playerState is PlayerState.Error
val subText = when {
isError -> playerState.message
isConnecting -> stringResource(R.string.player_connecting)
isPlaying -> stringResource(R.string.player_on_air)
else -> stationDescription(station)
}
val density = LocalDensity.current
var dragOffsetPx by remember { mutableFloatStateOf(0f) }
val dismissThresholdPx = with(density) { 44.dp.toPx() }
val flingVelocityThresholdPx = with(density) { 1200.dp.toPx() }
val hideTargetPx = with(density) { 160.dp.toPx() }
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
contentAlignment = Alignment.TopCenter
) {
val isTablet = maxWidth > TABLET_BREAKPOINT_DP
Box(
modifier = (if (isTablet) Modifier.widthIn(max = TABLET_MINI_PLAYER_MAX_WIDTH_DP) else Modifier.fillMaxWidth())
.offset { IntOffset(0, dragOffsetPx.roundToInt()) }
.graphicsLayer {
alpha = 1f - (dragOffsetPx / hideTargetPx).coerceIn(0f, 1f)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.shadow(6.dp, RoundedCornerShape(32.dp))
.clip(RoundedCornerShape(32.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(32.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = true)
) {
onOpenNowPlaying()
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
dragOffsetPx = (dragOffsetPx + delta).coerceAtLeast(0f)
},
onDragStopped = { velocity ->
val shouldHide =
dragOffsetPx > dismissThresholdPx || velocity > flingVelocityThresholdPx
if (shouldHide) {
animate(
initialValue = dragOffsetPx,
targetValue = hideTargetPx,
initialVelocity = velocity,
animationSpec = tween(
durationMillis = 180,
easing = FastOutLinearInEasing
)
) { value, _ -> dragOffsetPx = value }
onHide()
dragOffsetPx = 0f
} else {
animate(
initialValue = dragOffsetPx,
targetValue = 0f,
initialVelocity = velocity,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) { value, _ -> dragOffsetPx = value }
}
}
)
.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
.testTag("mini_player"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(14.dp))
.background(stationTileColor(station))
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp))
) {
Image(
painter = painterResource(id = station.iconResId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
fontSize = 14.5.sp,
color = LocalGeoColors.current.deepPurple
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = subText,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 11.5.sp,
color = if (isError) LocalGeoColors.current.redFavorite else (if (isPlaying) LocalGeoColors.current.liveGreen else LocalGeoColors.current.onSurfaceVariant)
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
IconButton(
onClick = onPrevious,
modifier = Modifier
.size(36.dp)
.testTag("mini_prev_btn")
) {
AppIcon(
resId = R.drawable.ic_chevron_left,
contentDescription = stringResource(R.string.previous_station_content_desc),
tint = LocalGeoColors.current.onSurfaceVariant,
size = 24.dp
)
}
Box(
modifier = Modifier
.size(42.dp)
.shadow(2.dp, CircleShape)
.clip(CircleShape)
.background(LocalGeoColors.current.accentLavender)
.clickable { onTogglePlay() }
.testTag("mini_play_btn"),
contentAlignment = Alignment.Center
) {
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = LocalGeoColors.current.onAccentLavender,
strokeWidth = 2.dp
)
} else {
AppIcon(
resId = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
contentDescription = if (isPlaying) stringResource(R.string.pause_content_desc) else stringResource(R.string.play_content_desc),
tint = LocalGeoColors.current.onAccentLavender,
size = 22.dp
)
}
}
IconButton(
onClick = onNext,
modifier = Modifier
.size(36.dp)
.testTag("mini_next_btn")
) {
AppIcon(
resId = R.drawable.ic_chevron_right,
contentDescription = stringResource(R.string.next_station_content_desc),
tint = LocalGeoColors.current.onSurfaceVariant,
size = 24.dp
)
}
}
}
}
}
}
@@ -0,0 +1,53 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.size.Scale
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
@Composable
fun MistyMountainBackground(modifier: Modifier = Modifier) {
val context = LocalContext.current
val request = remember(context) {
ImageRequest.Builder(context)
.data(R.drawable.bg_misty_mountain)
.scale(Scale.FILL)
.crossfade(200)
.build()
}
Box(modifier = modifier.fillMaxSize().background(LocalGeoColors.current.background)) {
AsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
val scrimBase = LocalGeoColors.current.background
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
scrimBase.copy(alpha = 0.62f),
scrimBase.copy(alpha = 0.48f),
scrimBase.copy(alpha = 0.52f),
scrimBase.copy(alpha = 0.78f)
)
)
)
)
}
}
@@ -0,0 +1,181 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.AppTab
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
@Composable
fun NavigationTabs(
selectedTab: AppTab,
favoritesCount: Int,
onTabSelected: (AppTab) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.shadow(6.dp, RoundedCornerShape(26.dp))
.clip(RoundedCornerShape(26.dp))
.background(LocalGeoColors.current.surfaceContainer.copy(alpha = 0.96f))
.border(
width = 1.dp,
color = LocalGeoColors.current.outlineVariant,
shape = RoundedCornerShape(26.dp)
)
.padding(horizontal = 6.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
FitTabsRow(modifier = Modifier.fillMaxWidth()) {
AppTab.entries.forEach { tab ->
val isSelected = tab == selectedTab
val bgColor by animateColorAsState(
targetValue = if (isSelected) LocalGeoColors.current.primaryContainer else Color.Transparent,
label = "tab_bg"
)
val tintColor by animateColorAsState(
targetValue = if (isSelected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant,
label = "tab_tint"
)
val tabIcon: Int = when (tab) {
AppTab.RADIO -> R.drawable.ic_radio
AppTab.FAVORITES -> R.drawable.ic_favorite
AppTab.ABOUT -> R.drawable.ic_info
}
Row(
modifier = Modifier
.testTag("tab_${tab.name.lowercase()}")
.clip(RoundedCornerShape(20.dp))
.background(bgColor)
.selectable(
selected = isSelected,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = true),
role = Role.Tab,
onClick = { onTabSelected(tab) }
)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally)
) {
AppIcon(
resId = tabIcon,
tint = if (tab == AppTab.FAVORITES && isSelected) LocalGeoColors.current.redFavorite else tintColor,
size = 17.dp
)
Text(
text = stringResource(tab.titleRes),
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 13.sp,
color = tintColor
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// Weighted so the icon and badge keep their size when a long label is squeezed.
modifier = Modifier.weight(1f, fill = false)
)
if (tab == AppTab.FAVORITES && favoritesCount > 0 && !isSelected) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.primary.copy(alpha = 0.8f)),
contentAlignment = Alignment.Center
) {
Text(
text = if (favoritesCount > 9) "9+" else "$favoritesCount",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
color = LocalGeoColors.current.onPrimary
)
)
}
}
}
}
}
}
}
/**
* Lays tabs out like `Arrangement.SpaceEvenly`, but when the labels are too long for the screen
* (narrow phones, long translations) it shrinks only the widest tabs until everything fits.
*/
@Composable
private fun FitTabsRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val natural = measurables.map { it.maxIntrinsicWidth(constraints.maxHeight) }
val available = if (constraints.hasBoundedWidth) constraints.maxWidth else natural.sum()
val widths = if (natural.sum() <= available) {
natural
} else {
// Water-filling: find the largest cap so that sum(min(width, cap)) fits.
val sorted = natural.sorted()
var remaining = available
var cap = 0
for ((index, width) in sorted.withIndex()) {
val left = sorted.size - index
if (width * left <= remaining) {
remaining -= width
} else {
cap = remaining / left
break
}
}
natural.map { minOf(it, cap) }
}
val placeables = measurables.mapIndexed { i, measurable ->
measurable.measure(Constraints.fixedWidth(widths[i]).copy(maxHeight = constraints.maxHeight))
}
val height = placeables.maxOfOrNull { it.height } ?: 0
val gap = ((available - placeables.sumOf { it.width }) / (placeables.size + 1)).coerceAtLeast(0)
layout(available, height) {
var x = gap
placeables.forEach { placeable ->
placeable.placeRelative(x, (height - placeable.height) / 2)
x += placeable.width + gap
}
}
}
}
@@ -0,0 +1,765 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.ExtraPlayerTab
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.player.AudioEffectsState
import cc.skysparkle.fogwave.player.PlayerState
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
import kotlin.math.roundToInt
@Composable
fun NowPlayingSheet(
station: RadioStation?,
playerState: PlayerState,
isFavorite: Boolean,
onToggleFavorite: () -> Unit,
onTogglePlay: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onClose: () -> Unit,
onOpenEqualizer: () -> Unit,
audioEffects: AudioEffectsState,
activeExtraTab: ExtraPlayerTab,
onSelectExtraTab: (ExtraPlayerTab) -> Unit,
recents: List<RadioStation>,
similarStations: List<RadioStation>,
onSelectStation: (RadioStation) -> Unit,
modifier: Modifier = Modifier,
sheetModifier: Modifier = Modifier
) {
if (station == null) return
val isPlaying = playerState is PlayerState.Playing
val isConnecting = playerState is PlayerState.Connecting
val isError = playerState is PlayerState.Error
val density = LocalDensity.current
val currentOnClose by rememberUpdatedState(onClose)
val drag = remember(density) {
SheetDragState(
dismissThresholdPx = with(density) { 110.dp.toPx() },
flingThresholdPx = with(density) { 1400.dp.toPx() },
offscreenPx = with(density) { 900.dp.toPx() },
onDismiss = { currentOnClose() }
)
}
val artScale by animateFloatAsState(
targetValue = if (isPlaying) 1.02f else 0.98f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "art_scale"
)
Box(
modifier = modifier
.fillMaxSize()
// Read in the draw phase so pulling the sheet does not recompose the whole player.
.drawBehind {
val progress = (drag.offsetPx / drag.dismissThresholdPx).coerceIn(0f, 1f)
drawRect(Color.Black.copy(alpha = 0.55f * (1f - progress)))
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onClose()
}
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTablet = maxWidth > 600.dp
val sheetWidthModifier = if (isTablet) Modifier.widthIn(max = 560.dp) else Modifier.fillMaxWidth()
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.then(sheetModifier)
.offset { IntOffset(0, drag.offsetPx.roundToInt()) }
.then(sheetWidthModifier)
.fillMaxHeight(0.94f)
.shadow(16.dp, RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp))
.clip(RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp))
.background(LocalGeoColors.current.background)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp))
// Swallow taps so they do not fall through to the scrim (which closes the sheet),
// without exposing the sheet itself as a button to accessibility services.
.pointerInput(Unit) { detectTapGestures { } }
// The whole sheet scrolls; drags that the content cannot use pull the sheet down.
.nestedScroll(drag.nestedScrollConnection)
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(bottom = 28.dp)
.testTag("now_playing_sheet"),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.padding(top = 12.dp, bottom = 4.dp)
.size(width = 48.dp, height = 5.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.outlineVariant)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.now_playing_label),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 11.5.sp,
letterSpacing = 1.2.sp,
color = LocalGeoColors.current.primary
)
)
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape)
.clickable { onClose() }
.testTag("np_close_btn"),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_close,
contentDescription = stringResource(R.string.close_content_desc),
tint = LocalGeoColors.current.deepPurple,
size = 18.dp
)
}
}
Box(
modifier = Modifier
.padding(horizontal = 44.dp, vertical = 10.dp)
.fillMaxWidth()
.aspectRatio(1f)
.scale(artScale)
.shadow(8.dp, RoundedCornerShape(32.dp))
.clip(RoundedCornerShape(32.dp))
.background(stationTileColor(station))
.border(1.5.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(32.dp))
) {
Image(
painter = painterResource(id = station.iconResId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = station.name,
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.ExtraBold,
fontSize = 25.sp,
letterSpacing = (-0.5).sp,
color = LocalGeoColors.current.deepPurple,
textAlign = TextAlign.Center
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stationDescription(station),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
color = LocalGeoColors.current.onSurfaceVariant,
textAlign = TextAlign.Center
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.clip(CircleShape)
.background(if (isPlaying) LocalGeoColors.current.primaryContainer else LocalGeoColors.current.surfaceContainer)
.border(
width = 1.dp,
color = if (isPlaying) LocalGeoColors.current.primary.copy(alpha = 0.4f) else LocalGeoColors.current.outlineVariant,
shape = CircleShape
)
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isPlaying -> LiveOnAirDot()
isError -> AppIcon(
resId = R.drawable.ic_alert,
tint = LocalGeoColors.current.redFavorite,
size = 12.dp
)
!isConnecting -> AppIcon(
resId = R.drawable.ic_sleep,
tint = LocalGeoColors.current.onSurfaceVariant,
size = 12.dp
)
else -> Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.onSurfaceVariant)
)
}
Text(
text = when {
isError -> playerState.message
isConnecting -> stringResource(R.string.player_connecting)
isPlaying -> stringResource(R.string.now_playing_live)
else -> stringResource(R.string.player_stopped)
},
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
color = if (isPlaying) LocalGeoColors.current.deepPurple else (if (isError) LocalGeoColors.current.redFavorite else LocalGeoColors.current.onSurfaceVariant)
)
)
}
}
LiveBars(
active = isPlaying,
color = LocalGeoColors.current.primary,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(vertical = 10.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape)
.clickable { onOpenEqualizer() }
.testTag("np_equalizer_toggle"),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_equalizer,
contentDescription = stringResource(R.string.equalizer_content_desc),
tint = if (audioEffects.equalizerEnabled) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant.copy(alpha = 0.5f),
size = 20.dp
)
}
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape)
.clickable { onPrevious() }
.testTag("np_prev_btn"),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_skip_previous,
contentDescription = stringResource(R.string.previous_station_content_desc),
tint = LocalGeoColors.current.deepPurple,
size = 24.dp
)
}
Box(
modifier = Modifier
.size(72.dp)
.shadow(4.dp, RoundedCornerShape(26.dp))
.clip(RoundedCornerShape(26.dp))
.background(LocalGeoColors.current.accentLavender)
.clickable { onTogglePlay() }
.testTag("np_play_btn"),
contentAlignment = Alignment.Center
) {
if (isConnecting) {
val connecting = stringResource(R.string.player_connecting)
CircularProgressIndicator(
modifier = Modifier
.size(32.dp)
.semantics { contentDescription = connecting },
color = LocalGeoColors.current.onAccentLavender,
strokeWidth = 3.dp
)
} else {
AppIcon(
resId = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
contentDescription = if (isPlaying) stringResource(R.string.pause_content_desc) else stringResource(R.string.play_content_desc),
tint = LocalGeoColors.current.onAccentLavender,
size = 34.dp
)
}
}
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape)
.clickable { onNext() }
.testTag("np_next_btn"),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_skip_next,
contentDescription = stringResource(R.string.next_station_content_desc),
tint = LocalGeoColors.current.deepPurple,
size = 24.dp
)
}
val favScale by animateFloatAsState(
targetValue = if (isFavorite) 1.12f else 1.0f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "fav_scale"
)
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(if (isFavorite) LocalGeoColors.current.redFavorite.copy(alpha = 0.15f) else LocalGeoColors.current.surfaceContainer)
.border(
width = 1.2.dp,
color = if (isFavorite) LocalGeoColors.current.redFavorite else LocalGeoColors.current.outlineVariant,
shape = CircleShape
)
.scale(favScale)
.clickable { onToggleFavorite() }
.testTag("np_favorite_toggle"),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = if (isFavorite) R.drawable.ic_heart_fill else R.drawable.ic_heart_outline,
contentDescription = if (isFavorite) stringResource(R.string.remove_from_favorites) else stringResource(R.string.add_to_favorites),
tint = if (isFavorite) LocalGeoColors.current.redFavorite else LocalGeoColors.current.deepPurple,
size = 22.dp
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ExtraPlayerTab.entries.forEach { tab ->
val isSelected = tab == activeExtraTab
val bg by animateColorAsState(
targetValue = if (isSelected) LocalGeoColors.current.primaryContainer else Color.Transparent,
label = "extra_tab_bg"
)
val textColor by animateColorAsState(
targetValue = if (isSelected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant,
label = "extra_tab_text"
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(bg)
.clickable { onSelectExtraTab(tab) }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(tab.titleRes),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 12.sp,
color = textColor
)
)
}
}
}
when (activeExtraTab) {
ExtraPlayerTab.RECENT -> {
if (recents.isEmpty()) {
Text(
text = stringResource(R.string.recent_empty),
style = MaterialTheme.typography.bodySmall.copy(
color = LocalGeoColors.current.onSurfaceVariant,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
recents.forEach { s ->
val isCur = s.id == station.id
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(if (isCur) LocalGeoColors.current.primaryContainer.copy(alpha = 0.6f) else LocalGeoColors.current.surfaceContainer)
.border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp))
.clickable { onSelectStation(s) }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(stationTileColor(s))
) {
Image(
painter = painterResource(id = s.iconResId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isCur && isPlaying) {
LiveOnAirDot()
}
Text(
text = s.name,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = if (isCur) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = stationDescription(s),
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 11.5.sp,
color = LocalGeoColors.current.onSurfaceVariant
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AppIcon(
resId = R.drawable.ic_play,
contentDescription = stringResource(R.string.play_content_desc),
tint = LocalGeoColors.current.deepPurple,
size = 18.dp
)
}
}
}
}
}
ExtraPlayerTab.SIMILAR -> {
if (similarStations.isEmpty()) {
Text(
text = stringResource(R.string.similar_empty),
style = MaterialTheme.typography.bodySmall.copy(
color = LocalGeoColors.current.onSurfaceVariant,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
similarStations.forEach { s ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp))
.clickable { onSelectStation(s) }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(stationTileColor(s))
) {
Image(
painter = painterResource(id = s.iconResId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = s.name,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.deepPurple
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${stationDescription(s)} • ${stationGenres(s)}",
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 11.5.sp,
color = LocalGeoColors.current.onSurfaceVariant
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AppIcon(
resId = R.drawable.ic_play,
contentDescription = stringResource(R.string.play_content_desc),
tint = LocalGeoColors.current.deepPurple,
size = 18.dp
)
}
}
}
}
}
ExtraPlayerTab.INFO -> {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(16.dp))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
InfoRow(key = stringResource(R.string.info_station), value = station.name)
InfoRow(key = stringResource(R.string.info_description), value = stationDescription(station))
InfoRow(key = stringResource(R.string.info_genre), value = stationGenres(station))
InfoRow(key = stringResource(R.string.info_quality), value = station.bitrate)
InfoRow(
key = stringResource(R.string.info_status),
value = when (playerState) {
is PlayerState.Playing -> stringResource(R.string.status_live)
is PlayerState.Connecting -> stringResource(R.string.player_connecting)
is PlayerState.Error -> playerState.message
else -> stringResource(R.string.player_stopped)
}
)
}
}
}
}
}
}
}
}
@Composable
private fun InfoRow(key: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = key,
style = MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.SemiBold,
color = LocalGeoColors.current.onSurfaceVariant
)
)
// Long values (descriptions, genre lists) wrap on the right instead of pushing the key out.
Text(
text = value,
style = MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.deepPurple,
textAlign = TextAlign.End
),
modifier = Modifier.weight(1f)
)
}
}
/**
* Offset of the now-playing sheet while it is being pulled down. Driven through nested scroll so
* the gesture works anywhere on the (scrollable) sheet once its content is scrolled to the top.
*/
@Stable
private class SheetDragState(
val dismissThresholdPx: Float,
private val flingThresholdPx: Float,
private val offscreenPx: Float,
private val onDismiss: () -> Unit
) {
var offsetPx by mutableFloatStateOf(0f)
private set
private fun dragBy(delta: Float): Float {
val old = offsetPx
offsetPx = (offsetPx + delta).coerceAtLeast(0f)
return offsetPx - old
}
private suspend fun settle(velocity: Float) {
if (offsetPx > dismissThresholdPx || velocity > flingThresholdPx) {
animate(
initialValue = offsetPx,
targetValue = offscreenPx,
initialVelocity = velocity,
animationSpec = tween(durationMillis = 220, easing = FastOutLinearInEasing)
) { value, _ -> offsetPx = value }
onDismiss()
} else {
animate(
initialValue = offsetPx,
targetValue = 0f,
initialVelocity = velocity,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) { value, _ -> offsetPx = value }
}
}
val nestedScrollConnection = object : NestedScrollConnection {
// Dragging back up first collapses the pull-down offset before the content scrolls.
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
if (available.y < 0f && offsetPx > 0f) Offset(0f, dragBy(available.y)) else Offset.Zero
// Whatever downward drag the content could not consume (it is at the top) moves the sheet.
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset =
if (available.y > 0f && source == NestedScrollSource.UserInput) {
Offset(0f, dragBy(available.y))
} else {
Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (offsetPx <= 0f) return Velocity.Zero
settle(available.y)
return available
}
}
}
@@ -0,0 +1,462 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.aspectRatio
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
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 cc.skysparkle.fogwave.data.model.IconSizeStep
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.data.model.ViewMode
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
import coil.compose.AsyncImage
@Composable
fun LiveOnAirDot(modifier: Modifier = Modifier) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.25f,
animationSpec = infiniteRepeatable(
animation = tween(900, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "live_pulse"
)
Box(
modifier = modifier
.size(7.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.clip(CircleShape)
.background(LocalGeoColors.current.liveGreen)
)
}
private val GridTextShadow = Shadow(color = Color.Black.copy(alpha = 0.55f), blurRadius = 8f)
@Composable
fun StationItem(
station: RadioStation,
isCurrent: Boolean,
isPlaying: Boolean,
viewMode: ViewMode,
sizeStep: IconSizeStep,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val pressScale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMediumLow),
label = "station_press"
)
val cardModifier = modifier.graphicsLayer {
scaleX = pressScale
scaleY = pressScale
}
when (viewMode) {
ViewMode.ICONS -> {
StationIconsCard(
station = station,
isCurrent = isCurrent,
isPlaying = isPlaying,
sizeDp = sizeStep.sizeDp.dp,
onClick = onClick,
interactionSource = interactionSource,
modifier = cardModifier
)
}
ViewMode.TILES -> {
StationTilesCard(
station = station,
isCurrent = isCurrent,
isPlaying = isPlaying,
sizeDp = (sizeStep.sizeDp * 0.7f).dp,
onClick = onClick,
interactionSource = interactionSource,
modifier = cardModifier
)
}
ViewMode.LIST -> {
StationListRow(
station = station,
isCurrent = isCurrent,
isPlaying = isPlaying,
sizeDp = (sizeStep.sizeDp * 0.45f).coerceAtLeast(36f).dp,
onClick = onClick,
interactionSource = interactionSource,
modifier = cardModifier
)
}
ViewMode.DETAILS -> {
StationDetailsRow(
station = station,
isCurrent = isCurrent,
isPlaying = isPlaying,
sizeDp = (sizeStep.sizeDp * 0.45f).coerceAtLeast(36f).dp,
onClick = onClick,
interactionSource = interactionSource,
modifier = cardModifier
)
}
}
}
@Composable
private fun StationIconsCard(
station: RadioStation,
isCurrent: Boolean,
isPlaying: Boolean,
sizeDp: Dp,
onClick: () -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.width(sizeDp)
.clip(RoundedCornerShape(20.dp))
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Button,
onClick = onClick
)
.padding(4.dp)
.testTag("station_${station.id}"),
horizontalAlignment = Alignment.Start
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.shadow(if (isCurrent) 4.dp else 1.dp, RoundedCornerShape(22.dp))
.clip(RoundedCornerShape(22.dp))
.background(stationTileColor(station))
.border(
width = if (isCurrent) 2.dp else 1.dp,
color = if (isCurrent) LocalGeoColors.current.primary else LocalGeoColors.current.outlineVariant,
shape = RoundedCornerShape(22.dp)
)
) {
AsyncImage(
model = station.iconResId,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isCurrent && isPlaying) {
LiveOnAirDot()
}
Text(
text = station.name,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 14.5.sp,
lineHeight = 17.sp,
color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface,
shadow = GridTextShadow
),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = stationDescription(station),
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 11.5.sp,
color = LocalGeoColors.current.onSurface.copy(alpha = 0.82f),
shadow = GridTextShadow
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun StationTilesCard(
station: RadioStation,
isCurrent: Boolean,
isPlaying: Boolean,
sizeDp: Dp,
onClick: () -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(if (isCurrent) LocalGeoColors.current.primaryContainer.copy(alpha = 0.7f) else LocalGeoColors.current.surfaceContainer.copy(alpha = 0.9f))
.border(
width = if (isCurrent) 1.5.dp else 1.dp,
color = if (isCurrent) LocalGeoColors.current.primary else LocalGeoColors.current.outlineVariant,
shape = RoundedCornerShape(18.dp)
)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Button,
onClick = onClick
)
.padding(10.dp)
.testTag("station_${station.id}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(sizeDp)
.clip(RoundedCornerShape(14.dp))
.background(stationTileColor(station))
.border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp))
) {
AsyncImage(
model = station.iconResId,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isCurrent && isPlaying) {
LiveOnAirDot()
}
Text(
text = station.name,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = stationDescription(station),
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
color = LocalGeoColors.current.onSurfaceVariant
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun StationListRow(
station: RadioStation,
isCurrent: Boolean,
isPlaying: Boolean,
sizeDp: Dp,
onClick: () -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(if (isCurrent) LocalGeoColors.current.primaryContainer.copy(alpha = 0.6f) else Color.Transparent)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Button,
onClick = onClick
)
.padding(horizontal = 10.dp, vertical = 7.dp)
.testTag("station_${station.id}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(sizeDp)
.clip(RoundedCornerShape(10.dp))
.background(stationTileColor(station))
.border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(10.dp))
) {
AsyncImage(
model = station.iconResId,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isCurrent && isPlaying) {
LiveOnAirDot()
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun StationDetailsRow(
station: RadioStation,
isCurrent: Boolean,
isPlaying: Boolean,
sizeDp: Dp,
onClick: () -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(if (isCurrent) LocalGeoColors.current.primaryContainer.copy(alpha = 0.6f) else Color.Transparent)
.border(
width = 0.8.dp,
color = if (isCurrent) LocalGeoColors.current.primary else LocalGeoColors.current.outlineVariant.copy(alpha = 0.6f),
shape = RoundedCornerShape(14.dp)
)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Button,
onClick = onClick
)
.padding(horizontal = 10.dp, vertical = 8.dp)
.testTag("station_${station.id}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(sizeDp)
.clip(RoundedCornerShape(10.dp))
.background(stationTileColor(station))
.border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(10.dp))
) {
AsyncImage(
model = station.iconResId,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
}
Column(modifier = Modifier.weight(1.3f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isCurrent && isPlaying) {
LiveOnAirDot()
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = stationDescription(station),
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 11.5.sp,
color = LocalGeoColors.current.onSurfaceVariant
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = stationGenres(station),
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 11.5.sp,
color = LocalGeoColors.current.primary
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(0.9f)
)
}
}
@@ -0,0 +1,27 @@
package cc.skysparkle.fogwave.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri
import cc.skysparkle.fogwave.data.model.RadioStation
@Composable
@ReadOnlyComposable
fun stationDescription(station: RadioStation): String = stringResource(station.descriptionRes)
@Composable
@ReadOnlyComposable
fun stationGenres(station: RadioStation): String =
station.genres.map { stringResource(it.labelRes) }.joinToString(", ")
/** Opens [url] in the user's browser; silently does nothing when no browser is installed. */
fun Context.openUrl(url: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
} catch (_: ActivityNotFoundException) {
}
}
@@ -0,0 +1,15 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
private val LightLogoBackdrop = Color(0xFFECE8F1)
// Dark station logos are unreadable on the dark tile, so they get a light backdrop.
@Composable
@ReadOnlyComposable
fun stationTileColor(station: RadioStation): Color =
if (station.lightBackdrop) LightLogoBackdrop else LocalGeoColors.current.surfaceContainer
@@ -0,0 +1,82 @@
package cc.skysparkle.fogwave.ui.components
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
@Composable
fun TopBrandBar(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.background(LocalGeoColors.current.surfaceContainer.copy(alpha = 0.92f))
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.fillMaxWidth()
.testTag("top_brand_bar")
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_fogwave_logo,
size = 34.dp
)
}
Column(
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
letterSpacing = (-0.4).sp,
color = LocalGeoColors.current.deepPurple
),
modifier = Modifier.semantics { heading() }
)
Text(
text = stringResource(R.string.app_subtitle),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Medium,
fontSize = 11.5.sp,
color = LocalGeoColors.current.onSurfaceVariant,
letterSpacing = 0.2.sp
)
)
}
}
}
}
@@ -0,0 +1,287 @@
package cc.skysparkle.fogwave.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.sp
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.IconSizeStep
import cc.skysparkle.fogwave.data.model.ViewMode
import cc.skysparkle.fogwave.ui.theme.LocalGeoColors
import kotlin.math.roundToInt
@Composable
fun StationsToolbar(
title: String,
isOpen: Boolean,
onToggleOpen: () -> Unit,
viewMode: ViewMode,
onSelectViewMode: (ViewMode) -> Unit,
sizeStep: IconSizeStep,
onSelectSizeStep: (IconSizeStep) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
letterSpacing = (-0.4).sp,
color = LocalGeoColors.current.deepPurple
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(end = 12.dp)
)
Box(
modifier = Modifier
.size(38.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (isOpen) LocalGeoColors.current.primaryContainer else LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(12.dp))
.clickable(role = Role.Button) { onToggleOpen() }
.testTag("view_options_trigger"),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = R.drawable.ic_table,
contentDescription = stringResource(R.string.view_mode_content_desc),
tint = if (isOpen) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant,
size = 19.dp
)
}
}
AnimatedVisibility(
visible = isOpen,
enter = expandVertically(
animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow),
expandFrom = Alignment.Top
) + fadeIn(tween(200)) + scaleIn(
initialScale = 0.96f,
transformOrigin = TransformOrigin(1f, 0f),
animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow)
),
exit = shrinkVertically(
animationSpec = tween(180, easing = FastOutLinearInEasing),
shrinkTowards = Alignment.Top
) + fadeOut(tween(140)) + scaleOut(
targetScale = 0.96f,
transformOrigin = TransformOrigin(1f, 0f),
animationSpec = tween(180)
)
) {
Column(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(24.dp))
.clip(RoundedCornerShape(24.dp))
.background(LocalGeoColors.current.surfaceContainer)
.border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(24.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(R.string.display_mode_label),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 10.5.sp,
letterSpacing = 0.8.sp,
color = LocalGeoColors.current.primary
)
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(LocalGeoColors.current.surface)
.border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(12.dp))
.padding(3.dp)
.selectableGroup(),
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
ViewModeSegment(
title = stringResource(R.string.view_mode_list),
icon = R.drawable.ic_list,
isSelected = viewMode == ViewMode.LIST,
onClick = { onSelectViewMode(ViewMode.LIST) },
modifier = Modifier.weight(1f)
)
ViewModeSegment(
title = stringResource(R.string.view_mode_table),
icon = R.drawable.ic_layout_columns,
isSelected = viewMode == ViewMode.DETAILS,
onClick = { onSelectViewMode(ViewMode.DETAILS) },
modifier = Modifier.weight(1f)
)
ViewModeSegment(
title = stringResource(R.string.view_mode_tiles),
icon = R.drawable.ic_layout_rows,
isSelected = viewMode == ViewMode.TILES,
onClick = { onSelectViewMode(ViewMode.TILES) },
modifier = Modifier.weight(1f)
)
ViewModeSegment(
title = stringResource(R.string.view_mode_icons),
icon = R.drawable.ic_grid,
isSelected = viewMode == ViewMode.ICONS,
onClick = { onSelectViewMode(ViewMode.ICONS) },
modifier = Modifier.weight(1f)
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.size_label),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 10.5.sp,
letterSpacing = 0.8.sp,
color = LocalGeoColors.current.primary
)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "S",
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.onSurfaceVariant
)
)
val steps = IconSizeStep.entries
val currentIndex = steps.indexOf(sizeStep).toFloat()
val sizeLabel = stringResource(R.string.size_label)
Slider(
value = currentIndex,
onValueChange = { indexFloat ->
val step = steps[indexFloat.roundToInt().coerceIn(0, steps.size - 1)]
// The slider reports every drag frame; only persist real changes.
if (step != sizeStep) onSelectSizeStep(step)
},
valueRange = 0f..(steps.size - 1).toFloat(),
steps = steps.size - 2,
colors = SliderDefaults.colors(
thumbColor = LocalGeoColors.current.primary,
activeTrackColor = LocalGeoColors.current.primary,
inactiveTrackColor = LocalGeoColors.current.outlineVariant
),
modifier = Modifier
.weight(1f)
.semantics { contentDescription = sizeLabel }
)
Text(
text = "XL",
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
color = LocalGeoColors.current.onSurfaceVariant
)
)
}
}
}
}
}
}
@Composable
private fun ViewModeSegment(
title: String,
icon: Int,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val bg by animateColorAsState(
targetValue = if (isSelected) LocalGeoColors.current.primaryContainer else Color.Transparent,
animationSpec = tween(220),
label = "segment_bg"
)
val tint by animateColorAsState(
targetValue = if (isSelected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant,
animationSpec = tween(220),
label = "segment_tint"
)
Box(
modifier = modifier
.height(34.dp)
.clip(RoundedCornerShape(9.dp))
.background(bg)
.selectable(selected = isSelected, role = Role.RadioButton, onClick = onClick),
contentAlignment = Alignment.Center
) {
AppIcon(
resId = icon,
contentDescription = title,
tint = tint,
size = 18.dp
)
}
}
@@ -0,0 +1,305 @@
package cc.skysparkle.fogwave.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import cc.skysparkle.fogwave.R
import cc.skysparkle.fogwave.data.model.AppTab
import cc.skysparkle.fogwave.player.PlayerState
import cc.skysparkle.fogwave.ui.components.AboutPanel
import cc.skysparkle.fogwave.ui.components.BatteryOptimizationDialog
import cc.skysparkle.fogwave.ui.components.EqualizerSheet
import cc.skysparkle.fogwave.ui.components.FavoritesPanel
import cc.skysparkle.fogwave.ui.components.MiniPlayer
import cc.skysparkle.fogwave.ui.components.MistyMountainBackground
import cc.skysparkle.fogwave.ui.components.NavigationTabs
import cc.skysparkle.fogwave.ui.components.NowPlayingSheet
import cc.skysparkle.fogwave.ui.components.StationCollection
import cc.skysparkle.fogwave.ui.components.StationsToolbar
import cc.skysparkle.fogwave.ui.components.TopBrandBar
import cc.skysparkle.fogwave.ui.viewmodel.RadioViewModel
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun HomeScreen(
viewModel: RadioViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isPlaying = uiState.playerState is PlayerState.Playing
val favoriteStations = remember(uiState.stations, uiState.favorites) {
uiState.stations.filter { it.id in uiState.favorites }
}
val similarStations = remember(uiState.stations, uiState.currentStation) {
viewModel.getSimilarStations()
}
val recentStations = remember(uiState.recents, uiState.currentStation) {
uiState.recents.filter { it.id != uiState.currentStation?.id }
}
BackHandler(enabled = uiState.isEqualizerPopOpen) { viewModel.closeEqualizerPop() }
BackHandler(enabled = uiState.isNowPlayingOpen && !uiState.isEqualizerPopOpen) { viewModel.closeNowPlaying() }
BackHandler(enabled = uiState.isViewPopOpen && !uiState.isNowPlayingOpen) { viewModel.closeViewPop() }
BackHandler(
enabled = uiState.selectedTab != AppTab.RADIO && !uiState.isNowPlayingOpen && !uiState.isViewPopOpen
) { viewModel.setSelectedTab(AppTab.RADIO) }
val showBatteryOptimizationPrompt by viewModel.showBatteryOptimizationPrompt.collectAsStateWithLifecycle()
if (showBatteryOptimizationPrompt) {
BatteryOptimizationDialog(onDismiss = { viewModel.dismissBatteryOptimizationPrompt() })
}
val density = LocalDensity.current
var dockHeight by remember { mutableStateOf(140.dp) }
val scrollBottomPadding = dockHeight + 16.dp
Box(modifier = Modifier.fillMaxSize()) {
MistyMountainBackground()
Column(
modifier = Modifier.fillMaxSize()
) {
TopBrandBar()
Box(modifier = Modifier.weight(1f)) {
AnimatedContent(
targetState = uiState.selectedTab,
transitionSpec = {
val dir = if (targetState.ordinal > initialState.ordinal) 1 else -1
(fadeIn(tween(220, delayMillis = 50)) +
slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it / 10 * dir })
.togetherWith(
fadeOut(tween(140)) +
slideOutHorizontally(tween(200)) { -it / 10 * dir }
)
},
label = "tabs"
) { tab ->
when (tab) {
AppTab.RADIO, AppTab.FAVORITES -> {
val isFavorites = tab == AppTab.FAVORITES
Column(modifier = Modifier.fillMaxSize()) {
StationsToolbar(
title = stringResource(
if (isFavorites) R.string.favorite_stations_title else R.string.stations_title
),
isOpen = uiState.isViewPopOpen,
onToggleOpen = { viewModel.toggleViewPop() },
viewMode = uiState.viewMode,
onSelectViewMode = { viewModel.setViewMode(it) },
sizeStep = uiState.sizeStep,
onSelectSizeStep = { viewModel.setSizeStep(it) }
)
Box(modifier = Modifier.weight(1f)) {
AnimatedContent(
targetState = uiState.viewMode,
transitionSpec = {
(fadeIn(tween(220, delayMillis = 60)) +
scaleIn(initialScale = 0.985f, animationSpec = tween(260)))
.togetherWith(fadeOut(tween(120)))
},
label = "view_mode"
) { mode ->
if (isFavorites) {
FavoritesPanel(
favorites = favoriteStations,
currentStation = uiState.currentStation,
isPlaying = isPlaying,
viewMode = mode,
sizeStep = uiState.sizeStep,
onSelectStation = { viewModel.onStationClick(it) },
onBrowseStations = { viewModel.setSelectedTab(AppTab.RADIO) },
bottomContentPadding = scrollBottomPadding,
modifier = Modifier.fillMaxSize()
)
} else {
StationCollection(
stations = uiState.stations,
currentStation = uiState.currentStation,
isPlaying = isPlaying,
viewMode = mode,
sizeStep = uiState.sizeStep,
onSelectStation = { viewModel.onStationClick(it) },
bottomContentPadding = scrollBottomPadding,
modifier = Modifier.fillMaxSize()
)
}
}
PopoverScrim(
visible = uiState.isViewPopOpen,
onDismiss = { viewModel.closeViewPop() }
)
}
}
}
AppTab.ABOUT -> {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = scrollBottomPadding)
) {
AboutPanel()
}
}
}
}
}
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
// Measured before the inset padding so list content clears the system bar too.
.onGloballyPositioned { coordinates ->
val measured = with(density) { coordinates.size.height.toDp() }
if (measured > 0.dp && measured != dockHeight) {
dockHeight = measured
}
}
// Insets live on the dock itself so the tabs stay clear of the gesture bar
// even when the mini player is swiped away.
.navigationBarsPadding()
) {
NavigationTabs(
selectedTab = uiState.selectedTab,
favoritesCount = uiState.unseenFavoritesCount,
onTabSelected = { viewModel.setSelectedTab(it) },
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp)
)
AnimatedVisibility(
visible = !uiState.isMiniPlayerHidden,
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = ExitTransition.None
) {
MiniPlayer(
station = uiState.currentStation,
playerState = uiState.playerState,
onTogglePlay = { viewModel.togglePlay() },
onPrevious = { viewModel.previousStation() },
onNext = { viewModel.nextStation() },
onOpenNowPlaying = { viewModel.openNowPlaying() },
onHide = { viewModel.hideMiniPlayer() }
)
}
}
AnimatedVisibility(
visible = uiState.isNowPlayingOpen,
enter = fadeIn(tween(220)),
exit = fadeOut(tween(220))
) {
val isCurFav = uiState.currentStation?.let { uiState.favorites.contains(it.id) } == true
NowPlayingSheet(
station = uiState.currentStation,
playerState = uiState.playerState,
isFavorite = isCurFav,
onToggleFavorite = { viewModel.toggleFavorite() },
onTogglePlay = { viewModel.togglePlay() },
onPrevious = { viewModel.previousStation() },
onNext = { viewModel.nextStation() },
onClose = { viewModel.closeNowPlaying() },
onOpenEqualizer = { viewModel.toggleEqualizerPop() },
audioEffects = uiState.audioEffects,
activeExtraTab = uiState.activeExtraTab,
onSelectExtraTab = { viewModel.setActiveExtraTab(it) },
recents = recentStations,
similarStations = similarStations,
onSelectStation = { viewModel.selectStation(it) },
sheetModifier = Modifier.animateEnterExit(
enter = slideInVertically(
animationSpec = spring(dampingRatio = 0.86f, stiffness = Spring.StiffnessMediumLow)
) { it },
exit = slideOutVertically(
animationSpec = tween(220, easing = FastOutLinearInEasing)
) { it }
)
)
}
AnimatedVisibility(
visible = uiState.isEqualizerPopOpen,
enter = fadeIn(tween(200)) + slideInVertically(
animationSpec = spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)
) { it / 3 },
exit = fadeOut(tween(180)) + slideOutVertically(
animationSpec = tween(200, easing = FastOutLinearInEasing)
) { it / 3 }
) {
EqualizerSheet(
audioEffects = uiState.audioEffects,
onClose = { viewModel.closeEqualizerPop() },
onSetEqualizerEnabled = { viewModel.setEqualizerEnabled(it) },
onSetEqualizerBand = { band, level -> viewModel.setEqualizerBand(band, level) },
onSetPreset = { viewModel.setEqualizerPreset(it) },
onResetBands = { viewModel.resetEqualizerBands() },
onSetLoudnessEnabled = { viewModel.setLoudnessEnabled(it) },
onSetDynamicsProcessingEnabled = { viewModel.setDynamicsProcessingEnabled(it) }
)
}
}
}
@Composable
private fun PopoverScrim(visible: Boolean, onDismiss: () -> Unit) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(160))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.38f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss
)
)
}
}
@@ -0,0 +1,58 @@
package cc.skysparkle.fogwave.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class GeoColors(
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val onSurfaceVariant: Color,
val outline: Color,
val outlineVariant: Color,
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondary: Color,
val onSecondary: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val accentLavender: Color,
val onAccentLavender: Color,
val deepPurple: Color,
val liveGreen: Color,
val redFavorite: Color
)
val DarkGeoColors = GeoColors(
background = Color(0xFF141218),
onBackground = Color(0xFFE6E0E9),
surface = Color(0xFF141218),
onSurface = Color(0xFFE6E0E9),
surfaceContainer = Color(0xFF211F26),
surfaceContainerHigh = Color(0xFF2B2930),
onSurfaceVariant = Color(0xFFCAC4D0),
outline = Color(0xFF938F99),
outlineVariant = Color(0xFF49454F),
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFFCCC2DC),
onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF4A4458),
onSecondaryContainer = Color(0xFFE8DEF8),
accentLavender = Color(0xFF4F378B),
onAccentLavender = Color(0xFFEADDFF),
deepPurple = Color(0xFFD0BCFF),
liveGreen = Color(0xFF81C995),
redFavorite = Color(0xFFF2B8B5)
)
val LocalGeoColors = staticCompositionLocalOf { DarkGeoColors }
@@ -0,0 +1,33 @@
package cc.skysparkle.fogwave.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
private val DarkColorScheme = with(DarkGeoColors) {
darkColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onSurface,
surfaceVariant = outlineVariant,
onSurfaceVariant = onSurfaceVariant,
outline = outline
)
}
@Composable
fun FogwaveTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalGeoColors provides DarkGeoColors) {
MaterialTheme(colorScheme = DarkColorScheme, typography = Typography, content = content)
}
}
@@ -0,0 +1,19 @@
package cc.skysparkle.fogwave.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
)
)
@@ -0,0 +1,323 @@
package cc.skysparkle.fogwave.ui.viewmodel
import android.app.Application
import android.content.Context
import android.os.PowerManager
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cc.skysparkle.fogwave.data.model.AppTab
import cc.skysparkle.fogwave.data.model.ExtraPlayerTab
import cc.skysparkle.fogwave.data.model.IconSizeStep
import cc.skysparkle.fogwave.data.model.RadioStation
import cc.skysparkle.fogwave.data.model.ViewMode
import cc.skysparkle.fogwave.data.repository.RadioRepository
import cc.skysparkle.fogwave.player.AudioEffectsController
import cc.skysparkle.fogwave.player.AudioEffectsState
import cc.skysparkle.fogwave.player.PlayerState
import cc.skysparkle.fogwave.player.RadioPlayerManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@Immutable
data class RadioUiState(
val stations: List<RadioStation> = emptyList(),
val currentStation: RadioStation? = null,
val playerState: PlayerState = PlayerState.Idle,
val favorites: Set<String> = emptySet(),
val recents: List<RadioStation> = emptyList(),
val selectedTab: AppTab = AppTab.RADIO,
val viewMode: ViewMode = ViewMode.ICONS,
val sizeStep: IconSizeStep = IconSizeStep.M,
val isNowPlayingOpen: Boolean = false,
val isEqualizerPopOpen: Boolean = false,
val isViewPopOpen: Boolean = false,
val activeExtraTab: ExtraPlayerTab = ExtraPlayerTab.RECENT,
val audioEffects: AudioEffectsState = AudioEffectsState(),
val isMiniPlayerHidden: Boolean = false,
val unseenFavoritesCount: Int = 0
)
class RadioViewModel(application: Application) : AndroidViewModel(application) {
private val repository = RadioRepository(application.applicationContext)
private val playerManager = RadioPlayerManager(application.applicationContext)
// Sorted once per launch so the grid and next/previous stay stable during a session.
private val _stations = MutableStateFlow(repository.stationsSortedByPopularity())
private val stations: List<RadioStation> get() = _stations.value
private val initialStation: RadioStation? = run {
val lastId = repository.getLastStationId()
stations.find { it.id == lastId } ?: stations.firstOrNull()
}
private val _currentStation = MutableStateFlow(initialStation)
private val _selectedTab = MutableStateFlow(AppTab.RADIO)
private val _isNowPlayingOpen = MutableStateFlow(false)
private val _isEqualizerPopOpen = MutableStateFlow(false)
private val _isViewPopOpen = MutableStateFlow(false)
private val _activeExtraTab = MutableStateFlow(ExtraPlayerTab.RECENT)
private val _isMiniPlayerHidden = MutableStateFlow(false)
private val _showBatteryOptimizationPrompt = MutableStateFlow(false)
val showBatteryOptimizationPrompt: StateFlow<Boolean> = _showBatteryOptimizationPrompt.asStateFlow()
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return true
return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun dismissBatteryOptimizationPrompt() {
repository.markBatteryOptimizationPromptSeen()
_showBatteryOptimizationPrompt.value = false
}
val uiState: StateFlow<RadioUiState> = combine(
_currentStation,
playerManager.playerState,
repository.favoritesFlow,
repository.recentsFlow,
_selectedTab,
repository.viewModeFlow,
repository.sizeStepFlow,
_isNowPlayingOpen,
_isEqualizerPopOpen,
_isViewPopOpen,
_activeExtraTab,
AudioEffectsController.state,
_isMiniPlayerHidden,
repository.seenFavoritesFlow,
_stations
) { params ->
val current = params[0] as? RadioStation
val pState = params[1] as PlayerState
@Suppress("UNCHECKED_CAST")
val favs = params[2] as Set<String>
@Suppress("UNCHECKED_CAST")
val recentsIds = params[3] as List<String>
val tab = params[4] as AppTab
val mode = params[5] as ViewMode
val size = params[6] as IconSizeStep
val nowPlaying = params[7] as Boolean
val eqPop = params[8] as Boolean
val viewPop = params[9] as Boolean
val extraTab = params[10] as ExtraPlayerTab
val effects = params[11] as AudioEffectsState
val miniHidden = params[12] as Boolean
@Suppress("UNCHECKED_CAST")
val seenFavs = params[13] as Set<String>
@Suppress("UNCHECKED_CAST")
val orderedStations = params[14] as List<RadioStation>
val byId = orderedStations.associateBy { it.id }
val recentsList = recentsIds.mapNotNull { byId[it] }
RadioUiState(
stations = orderedStations,
currentStation = current,
playerState = pState,
favorites = favs,
recents = recentsList,
selectedTab = tab,
viewMode = mode,
sizeStep = size,
isNowPlayingOpen = nowPlaying,
isEqualizerPopOpen = eqPop,
isViewPopOpen = viewPop,
activeExtraTab = extraTab,
audioEffects = effects,
isMiniPlayerHidden = miniHidden,
unseenFavoritesCount = favs.count { it !in seenFavs }
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = RadioUiState(
stations = stations,
currentStation = initialStation,
favorites = repository.favoritesFlow.value,
viewMode = repository.viewModeFlow.value,
sizeStep = repository.sizeStepFlow.value,
audioEffects = AudioEffectsController.state.value,
unseenFavoritesCount = repository.favoritesFlow.value.count { it !in repository.seenFavoritesFlow.value }
)
)
init {
viewModelScope.launch {
playerManager.currentMediaId.collect { id ->
if (id != null && id != _currentStation.value?.id) {
stations.find { it.id == id }?.let { onAir ->
_currentStation.value = onAir
repository.setLastStationId(onAir.id)
}
}
}
}
}
/** Tap on a station tile: open the player for the station already on air, otherwise tune in. */
fun onStationClick(station: RadioStation) {
val state = playerManager.playerState.value
val onAir = station.id == _currentStation.value?.id &&
(state is PlayerState.Playing || state is PlayerState.Connecting)
if (onAir) {
_isMiniPlayerHidden.value = false
openNowPlaying()
} else {
selectStation(station)
}
}
fun selectStation(station: RadioStation) {
_currentStation.value = station
_isMiniPlayerHidden.value = false
repository.setLastStationId(station.id)
repository.pushRecent(station.id)
repository.recordStationPlayed(station.id)
if (!repository.hasSeenBatteryOptimizationPrompt() &&
!isIgnoringBatteryOptimizations(getApplication<Application>())
) {
_showBatteryOptimizationPrompt.value = true
}
playerManager.playStation(station)
}
fun hideMiniPlayer() {
_isMiniPlayerHidden.value = true
}
fun togglePlay() {
val current = _currentStation.value ?: return
when (playerManager.playerState.value) {
// Start from the station shown in the UI: it is kept in sync with whatever the
// session is playing, including stations picked from Android Auto or the notification.
is PlayerState.Idle, is PlayerState.Error -> playerManager.playStation(current)
else -> playerManager.togglePlay()
}
}
fun nextStation() {
val list = stations
if (list.isEmpty()) return
val currentIndex = list.indexOfFirst { it.id == _currentStation.value?.id }
val nextIndex = if (currentIndex in 0 until list.size - 1) currentIndex + 1 else 0
selectStation(list[nextIndex])
}
fun previousStation() {
val list = stations
if (list.isEmpty()) return
val currentIndex = list.indexOfFirst { it.id == _currentStation.value?.id }
val prevIndex = if (currentIndex > 0) currentIndex - 1 else list.size - 1
selectStation(list[prevIndex])
}
fun toggleFavorite(stationId: String? = _currentStation.value?.id) {
if (stationId != null) {
repository.toggleFavorite(stationId)
if (_selectedTab.value == AppTab.FAVORITES) {
repository.markFavoritesSeen(repository.favoritesFlow.value)
}
}
}
fun getSimilarStations(): List<RadioStation> {
val cur = _currentStation.value ?: return emptyList()
val curGenres = cur.genres
val others = stations.filter { it.id != cur.id }
val similar = others
.map { s -> s to s.genres.count { it in curGenres } }
.filter { it.second > 0 }
.sortedByDescending { it.second }
.map { it.first }
return similar.ifEmpty { others }
}
fun setEqualizerEnabled(enabled: Boolean) {
AudioEffectsController.setEqualizerEnabled(enabled)
}
fun setEqualizerBand(band: Int, levelMillibel: Int) {
AudioEffectsController.setBandLevel(band, levelMillibel)
}
fun setEqualizerPreset(preset: Int) {
AudioEffectsController.setPreset(preset)
}
fun resetEqualizerBands() {
AudioEffectsController.state.value.bands.forEach { band ->
AudioEffectsController.setBandLevel(band.index, 0)
}
}
fun setLoudnessEnabled(enabled: Boolean) {
AudioEffectsController.setLoudnessEnabled(enabled)
}
fun setLoudnessGain(gainMillibel: Float) {
AudioEffectsController.setLoudnessGain(gainMillibel)
}
fun setDynamicsProcessingEnabled(enabled: Boolean) {
AudioEffectsController.setDynamicsProcessingEnabled(enabled)
}
fun setSelectedTab(tab: AppTab) {
_selectedTab.value = tab
_isViewPopOpen.value = false
if (tab == AppTab.FAVORITES) {
repository.markFavoritesSeen(repository.favoritesFlow.value)
}
}
fun setViewMode(mode: ViewMode) {
repository.setViewMode(mode)
}
fun setSizeStep(step: IconSizeStep) {
repository.setSizeStep(step)
}
fun openNowPlaying() {
_isNowPlayingOpen.value = true
}
fun closeNowPlaying() {
_isNowPlayingOpen.value = false
_isEqualizerPopOpen.value = false
}
fun toggleEqualizerPop() {
_isEqualizerPopOpen.value = !_isEqualizerPopOpen.value
}
fun closeEqualizerPop() {
_isEqualizerPopOpen.value = false
}
fun toggleViewPop() {
_isViewPopOpen.value = !_isViewPopOpen.value
}
fun closeViewPop() {
_isViewPopOpen.value = false
}
fun setActiveExtraTab(tab: ExtraPlayerTab) {
_activeExtraTab.value = tab
}
override fun onCleared() {
super.onCleared()
playerManager.release()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Some files were not shown because too many files have changed in this diff Show More