Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebdbf23d6c | ||
|
|
06e926d62b | ||
|
|
57cec132f9 | ||
|
|
6498e4c97d | ||
|
|
a613b632c4 | ||
|
|
001279882a | ||
|
|
ed56d4c9fd | ||
|
|
9298353e75 | ||
|
|
df9708ca65 | ||
|
|
f277316f0c | ||
|
|
1b2ad29289 |
@@ -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
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# Local scratch
|
# Local scratch
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable builds of Fogwawe are tracked here. None of these builds have a recoverable build timestamp (reproducible Android builds normalize internal file dates), so versions are numbered in the order they were provided; `v1.0.10` is the current, official latest build.
|
All notable builds of Fogwave are tracked here. None of these builds have a recoverable build timestamp (reproducible Android builds normalize internal file dates), so versions are numbered in the order they were provided; `v1.0.10` is the current, official latest build.
|
||||||
|
|
||||||
| Version | APK | Size | SHA-256 |
|
| Version | APK | Size | SHA-256 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| v1.0.10 | `Fogwawe-v1.0.10.apk` | 2.90 MB | `0e5d8daa71b3b3224192e8d8fc9687bcf4d28c62269c698d0921a2c9a44ed89d` |
|
| v1.0.10 | [`Fogwawe-v1.0.10.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.10/Fogwawe-v1.0.10.apk) | 2.90 MB | `0e5d8daa71b3b3224192e8d8fc9687bcf4d28c62269c698d0921a2c9a44ed89d` |
|
||||||
| v1.0.9 | `Fogwawe-v1.0.9.apk` | 2.85 MB | `dc0e96e13a322b702d673e1c1a0a44c5db6fbb7ac49de69fe60a48d3005fad9b` |
|
| v1.0.9 | [`Fogwawe-v1.0.9.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.9/Fogwawe-v1.0.9.apk) | 2.85 MB | `dc0e96e13a322b702d673e1c1a0a44c5db6fbb7ac49de69fe60a48d3005fad9b` |
|
||||||
| v1.0.8 | `Fogwawe-v1.0.8.apk` | 2.85 MB | `1baca54152ae1fc46f6b6894f798f3eff3f122fdadb22b22a604c90ae457c840` |
|
| v1.0.8 | [`Fogwawe-v1.0.8.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.8/Fogwawe-v1.0.8.apk) | 2.85 MB | `1baca54152ae1fc46f6b6894f798f3eff3f122fdadb22b22a604c90ae457c840` |
|
||||||
| v1.0.7 | `Fogwawe-v1.0.7.apk` | 4.09 MB | `8f6dea613968f3c11bc8ea345b2fc62c79d34fc14129bec2757a00065b17f545` |
|
| v1.0.7 | [`Fogwawe-v1.0.7.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.7/Fogwawe-v1.0.7.apk) | 4.09 MB | `8f6dea613968f3c11bc8ea345b2fc62c79d34fc14129bec2757a00065b17f545` |
|
||||||
| v1.0.6 | `Fogwawe-v1.0.6.apk` | 17.62 MB | `f1fd8479d9996bace2ca91c134ce4a0b835ba76ce69fddad4a3a440a7c137812` |
|
| v1.0.6 | [`Fogwawe-v1.0.6.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.6/Fogwawe-v1.0.6.apk) | 17.62 MB | `f1fd8479d9996bace2ca91c134ce4a0b835ba76ce69fddad4a3a440a7c137812` |
|
||||||
| v1.0.5 | `Fogwawe-v1.0.5.apk` | 2.60 MB | `b3a567c32f3090e7ab91990ef0adf77d5c478cd136814092c81ca93cb044e47c` |
|
| v1.0.5 | [`Fogwawe-v1.0.5.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.5/Fogwawe-v1.0.5.apk) | 2.60 MB | `b3a567c32f3090e7ab91990ef0adf77d5c478cd136814092c81ca93cb044e47c` |
|
||||||
| v1.0.4 | `Fogwawe-v1.0.4.apk` | 2.58 MB | `267c6c86bfa2ec521ab9a926bd3dbf97e69c8591e6d0548e9d59c6278ab8c77d` |
|
| v1.0.4 | [`Fogwawe-v1.0.4.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.4/Fogwawe-v1.0.4.apk) | 2.58 MB | `267c6c86bfa2ec521ab9a926bd3dbf97e69c8591e6d0548e9d59c6278ab8c77d` |
|
||||||
| v1.0.3 | `Fogwawe-v1.0.3.apk` | 2.60 MB | `a011d962890a25f173593d0920b02ffa8ead38853c7dc8102c7eb976f7248461` |
|
| v1.0.3 | [`Fogwawe-v1.0.3.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.3/Fogwawe-v1.0.3.apk) | 2.60 MB | `a011d962890a25f173593d0920b02ffa8ead38853c7dc8102c7eb976f7248461` |
|
||||||
| v1.0.2 | `Fogwawe-v1.0.2.apk` | 2.60 MB | `152b09794e235e81d3421a0f20437d01608c776b180442cdaf57cd958c60c0b3` |
|
| v1.0.2 | [`Fogwawe-v1.0.2.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.2/Fogwawe-v1.0.2.apk) | 2.60 MB | `152b09794e235e81d3421a0f20437d01608c776b180442cdaf57cd958c60c0b3` |
|
||||||
| v1.0.1 | `Fogwawe-v1.0.1.apk` | 2.85 MB | `964979e2f60f7f5369f57c5b5d636563f9be0bae0128bbe318a00eaac8ea13c7` |
|
| v1.0.1 | [`Fogwawe-v1.0.1.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.1/Fogwawe-v1.0.1.apk) | 2.85 MB | `964979e2f60f7f5369f57c5b5d636563f9be0bae0128bbe318a00eaac8ea13c7` |
|
||||||
| v1.0.0 | `Fogwawe-v1.0.0.apk` | 2.83 MB | `9a3fc4a45326315303f636535d9e88790bf72d244a5ffc54648c1b8d1132616d` |
|
| v1.0.0 | [`Fogwawe-v1.0.0.apk`](https://github.com/kalarina-bit/Fogwave/releases/download/v1.0.0/Fogwawe-v1.0.0.apk) | 2.83 MB | `9a3fc4a45326315303f636535d9e88790bf72d244a5ffc54648c1b8d1132616d` |
|
||||||
|
|||||||
@@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
|
|||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
Fogwawe
|
Fogwave
|
||||||
Copyright (C) 2026 Fogwawe contributors
|
Copyright (C) 2026 Fogwave contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
|
|||||||
If the program does terminal interaction, make it output a short
|
If the program does terminal interaction, make it output a short
|
||||||
notice like this when it starts in an interactive mode:
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
Fogwawe Copyright (C) 2026 Fogwawe contributors
|
Fogwave Copyright (C) 2026 Fogwave contributors
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
under certain conditions; type `show c' for details.
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/icon-rounded.png" width="128" alt="Fogwawe icon">
|
<img src="assets/icon-rounded.png" width="128" alt="Fogwave icon">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Fogwawe</h1>
|
<h1 align="center">Fogwave</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="releases/Fogwawe-v1.0.10.apk"><img src="assets/get-it-on-gitea.png" height="60" alt="Get it on Gitea"></a>
|
<a href="https://git.skysparkle.cc/kalarina/Fogwave/releases"><img src="assets/get-it-on-gitea.png" height="60" alt="Get it on Gitea"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/github/downloads/kalarina-bit/Fogwave/latest/total?label=downloads&color=2e7d32&labelColor=1b1b1b" alt="downloads counter">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**A calm, open-source internet radio app for Android.**
|
**A calm, open-source internet radio app for Android.**
|
||||||
@@ -28,25 +32,25 @@ Listen to Lithuania's favorite stations in one tap — with background playback,
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<img src="assets/screenshots/5-feel-closer.png" width="200" alt="Feel closer to Lithuania">
|
||||||
|
<img src="assets/screenshots/4-stations.png" width="200" alt="Browse all stations">
|
||||||
<img src="assets/screenshots/1-find-station.png" width="200" alt="Find a station, then settle in">
|
<img src="assets/screenshots/1-find-station.png" width="200" alt="Find a station, then settle in">
|
||||||
<img src="assets/screenshots/2-your-style.png" width="200" alt="Just your style">
|
<img src="assets/screenshots/2-your-style.png" width="200" alt="Just your style">
|
||||||
<img src="assets/screenshots/3-soundtrack.png" width="200" alt="Your soundtrack">
|
<img src="assets/screenshots/3-soundtrack.png" width="200" alt="Your soundtrack">
|
||||||
<img src="assets/screenshots/4-stations.png" width="200" alt="Browse all stations">
|
|
||||||
<img src="assets/screenshots/5-feel-closer.png" width="200" alt="Feel closer to Lithuania">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
<img src="assets/icon-install.png" width="36" align="left">
|
<img src="assets/icon-install.png" width="36" align="left">
|
||||||
|
|
||||||
1. Download the APK for the version you want from [`releases/`](releases/).
|
1. Download the APK for the version you want from the [Releases page](https://github.com/kalarina-bit/Fogwave/releases).
|
||||||
2. On your Android device, allow installs from unknown sources for the app you use to open the file (Settings → Apps → Special access → Install unknown apps).
|
2. On your Android device, allow installs from unknown sources for the app you use to open the file (Settings → Apps → Special access → Install unknown apps).
|
||||||
3. Open the downloaded `.apk` file and confirm the install.
|
3. Open the downloaded `.apk` file and confirm the install.
|
||||||
|
|
||||||
Or via `adb`:
|
Or via `adb` (after downloading):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
adb install releases/Fogwawe-v1.0.10.apk
|
adb install Fogwawe-v1.0.10.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verifying a download
|
## Verifying a download
|
||||||
@@ -56,23 +60,40 @@ adb install releases/Fogwawe-v1.0.10.apk
|
|||||||
Compare the SHA-256 checksum against the value listed in [CHANGELOG.md](CHANGELOG.md):
|
Compare the SHA-256 checksum against the value listed in [CHANGELOG.md](CHANGELOG.md):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sha256sum releases/Fogwawe-v1.0.10.apk
|
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
|
## Repository structure
|
||||||
|
|
||||||
<img src="assets/icon-structure.png" width="36" align="left">
|
<img src="assets/icon-structure.png" width="36" align="left">
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── releases/ # Versioned APK builds (Fogwawe-vX.Y.Z.apk)
|
├── app/ # Android app source (Kotlin, Jetpack Compose)
|
||||||
├── assets/ # Icon images
|
├── assets/ # README images and logos
|
||||||
│ └── screenshots/ # App screenshots
|
│ └── screenshots/ # App screenshots
|
||||||
├── CHANGELOG.md # Per-version sizes and checksums
|
├── 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
|
├── LICENSE # GNU GPLv3
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
APK builds themselves are published as assets on the [Releases page](https://github.com/kalarina-bit/Fogwave/releases), not stored in this repository.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the **GNU General Public License v3.0** — see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the **GNU General Public License v3.0** — see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
@@ -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_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")
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,175 @@
|
|||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
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,741 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The sheet closes by swiping down, tapping outside it or pressing Back.
|
||||||
|
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
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.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: 63 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: 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 |