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
@@ -0,0 +1,5 @@
|
||||
gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
*.png binary
|
||||
*.webp binary
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 44 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 44 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 46 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 52 B |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 52 B |
|
After Width: | Height: | Size: 61 KiB |