diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7f2e02b7..cbda408af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,13 +1,21 @@
# Changelog
-## [Unreleased]
+## [4.3.0] - 2026-10-17
-### Added
+### Added & Improved
- **Lyrics Share:** Export and share styled lyric cards (Song and Lyrics modes) with dynamic palette extraction, customizable color swatches, full multilingual support (Latin, Devanagari, Arabic, CJK), and system share sheet & MediaStore gallery export.
+- **On-Spot Lyrics Provider Switching:** Added a dedicated lyrics provider icon in lyrics controls to switch provider or exclude current source on the fly with smooth auto-hide controls integration.
+- **100% Localization Coverage:** Complete translation across all 12 supported locales.
+- **Dynamic APK Variant Selection:** Automatically detects device architecture and system version to download the appropriate APK variant (universal, uncompressed, or android7).
+- **Audio Fallback:** Falls back to stereo lossless when Dolby Atmos is unavailable.
+
+### Changed & Fixed
+- **Version Bump:** Bumped version to 4.3.0 (versionCode 25).
## [4.2.4] - 2026-10-05
### Added & Improved
+- **Lyrics Share:** Export and share styled lyric cards (Song and Lyrics modes) with dynamic palette extraction, customizable color swatches, full multilingual support (Latin, Devanagari, Arabic, CJK), and system share sheet & MediaStore gallery export.
- **Resource Usage & Smooth UI:** Optimized rendering pipelines, reduced background resource contention, and smoothed UI interactions.
- **Fast YouTube Search:** Restored instant 2-pass YouTube search resolution, eliminating multi-stage search fallback delays.
- **Dolby Atmos Downloads:** Aligned spatial audio capability detection in `TrackDownloadManager` with `MusicPlayer`'s system `Spatializer` check, resolving Atmos download skips on Android 12L+ devices.
@@ -347,4 +355,4 @@
Next/previous now move through the rest of your downloads normally.
Files changed:
- `app/src/main/java/com/lastwave/app/ui/settings/DownloadsViewModel.kt`
+ `app/src/main/java/com/lastwave/app/ui/settings/DownloadsViewModel.kt`
\ No newline at end of file
diff --git a/README.md b/README.md
index 945fdbd8c..a5382f059 100644
--- a/README.md
+++ b/README.md
@@ -1,160 +1,160 @@
-
-
-

-
-# LastWave
-
-**Next-Gen YouTube Music Client with Algorithmic Smart Playlist Generator, Real-Time Synced Lyrics & Universal Last.fm Scrobbler for Android.**
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-##
Overview
-
-**LastWave** is a modern native Android music client powered by the global YouTube Music catalog, designed for listeners who want intelligent recommendations, kinetic visuals, and seamless music tracking.
-
-Built with **Material 3 Expressive**, LastWave combines effortless ad-free streaming, an AI-powered smart playlist generator, real-time synchronized karaoke lyrics, and a built-in Last.fm scrobbler that watches your playback across your favorite music apps.
-
----
-
-##
Key Features
-
-| Icon | Feature | Highlight |
-|:---:|:---|:---|
-|
| **YouTube Music Client** | Stream tracks, albums, artists, and public playlists from YouTube Music with zero ads and background playback. |
-|
| **Smart Playlist Generator** | Algorithmic taste mixes and mood radios generated from your listening history, seed artists, loved tracks, and top genres. |
-|
| **Universal Last.fm Scrobbler** | Built-in media scrobbler tracking listening activity across YouTube Music, Spotify, Apple Music, and local players with zero battery drain. |
-|
| **Real-Time Synced Lyrics** | Millisecond-accurate animated karaoke lyrics powered by LRCLIB with 8 customizable fluid physics motions. |
-|
| **Offline Downloader** | One-tap downloads saved directly to local storage (`Music/LastWave`), fully tagged with high-res cover art and synchronized `.lrc` lyrics. |
-|
| **Discovery Feed & Genre DNA** | Personalized recommendation radar with deep genre breakdowns, weekly listening recaps, and instant "Start Mix" radios. |
-|
| **Cross-Platform Playlist Import** | Instantly import public playlists from Spotify and Apple Music directly into your LastWave library. |
-|
| **Social Feed & Friends** | Follow friends' listening activity via Last.fm, browse their recent scrobbles, and explore their top tracks. |
-|
| **Material 3 Expressive** | Dynamic wallpaper theming, custom HSL color palette engine, album art color extraction, fluid card animations, and tactile haptics. |
-
----
-
-##
Tech Stack & Architecture
-
-- **Language & UI:** 100% Kotlin + Jetpack Compose (Material 3 Expressive)
-- **Audio Engine:** AndroidX Media3 ExoPlayer with lockscreen media controls & Android Auto integration
-- **Streaming Catalog:** YouTube Music streaming engine with high-efficiency Opus audio
-- **Scrobbling & Tracking:** Last.fm API with native OS media session tracking
-- **Lyrics Engine:** [LRCLIB](https://lrclib.net) millisecond-synchronized lyrics
-- **Architecture:** MVVM + Clean Architecture, Room DB, Jetpack DataStore, Dagger Hilt, Coroutines & Flow
-
----
-
-##
Getting Started
-
-1. Download the latest APK from the **[Releases](https://github.com/Clash-Projects/LastWave-native/releases)** or **[Actions](https://github.com/Clash-Projects/LastWave-native/actions)** tab.
-2. Install `LastWave-v4.2.4-release.apk` on your Android device (Android 7.0+).
-3. Connect your Last.fm account in **Settings → Integrations** to unlock the scrobbler, taste mixes, and personalized discovery radar.
-4. Enjoy ad-free streaming and smart playlist generation!
-
----
-
-##
Building from Source
-
-```bash
-git clone https://github.com/Clash-Projects/LastWave-native.git
-cd LastWave-native
-./gradlew assembleRelease
-```
-
----
-
-##
Community & Support
-
-*
**Updates & Support:** [Join @clashprojects on Telegram](https://t.me/clashprojects)
-*
**More From Us:** [Join @MaterialYouApp on Telegram](https://t.me/MaterialYouApp)
-*
**Discord Community:** [Join Discord](https://discord.gg/DmyM2p2fMe)
-*
**LastWave Website:** [visit site now](https://lastwave.pages.dev)
-
----
-
-##
Disclaimer
-
-> [!NOTE]
-> **Educational & Research Notice**
->
-> LastWave is an open-source, non-commercial application developed strictly for research and educational purposes to demonstrate modern Android application architecture with Jetpack Compose and AndroidX Media3.
->
-> **No Affiliation & Content Policy**
-> - LastWave is an independent project and is **not affiliated with, endorsed, or sponsored by Google LLC, YouTube, YouTube Music, Spotify, Apple Inc., Last.fm, or any music service**.
-> - LastWave **does not host, stream from private servers, or distribute any copyrighted media**. All content and metadata are accessed directly from public endpoints in accordance with their respective terms. All trademarks belong to their respective owners.
-
----
-
-
-
-[](https://gitgem.org/github/Clash-Projects/LastWave-Native)
+
+
+

+
+# LastWave
+
+**Next-Gen YouTube Music Client with Algorithmic Smart Playlist Generator, Real-Time Synced Lyrics & Universal Last.fm Scrobbler for Android.**
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+##
Overview
+
+**LastWave** is a modern native Android music client powered by the global YouTube Music catalog, designed for listeners who want intelligent recommendations, kinetic visuals, and seamless music tracking.
+
+Built with **Material 3 Expressive**, LastWave combines effortless ad-free streaming, an AI-powered smart playlist generator, real-time synchronized karaoke lyrics, and a built-in Last.fm scrobbler that watches your playback across your favorite music apps.
+
+---
+
+##
Key Features
+
+| Icon | Feature | Highlight |
+|:---:|:---|:---|
+|
| **YouTube Music Client** | Stream tracks, albums, artists, and public playlists from YouTube Music with zero ads and background playback. |
+|
| **Smart Playlist Generator** | Algorithmic taste mixes and mood radios generated from your listening history, seed artists, loved tracks, and top genres. |
+|
| **Universal Last.fm Scrobbler** | Built-in media scrobbler tracking listening activity across YouTube Music, Spotify, Apple Music, and local players with zero battery drain. |
+|
| **Real-Time Synced Lyrics** | Millisecond-accurate animated karaoke lyrics powered by LRCLIB with 8 customizable fluid physics motions. |
+|
| **Offline Downloader** | One-tap downloads saved directly to local storage (`Music/LastWave`), fully tagged with high-res cover art and synchronized `.lrc` lyrics. |
+|
| **Discovery Feed & Genre DNA** | Personalized recommendation radar with deep genre breakdowns, weekly listening recaps, and instant "Start Mix" radios. |
+|
| **Cross-Platform Playlist Import** | Instantly import public playlists from Spotify and Apple Music directly into your LastWave library. |
+|
| **Social Feed & Friends** | Follow friends' listening activity via Last.fm, browse their recent scrobbles, and explore their top tracks. |
+|
| **Material 3 Expressive** | Dynamic wallpaper theming, custom HSL color palette engine, album art color extraction, fluid card animations, and tactile haptics. |
+
+---
+
+##
Tech Stack & Architecture
+
+- **Language & UI:** 100% Kotlin + Jetpack Compose (Material 3 Expressive)
+- **Audio Engine:** AndroidX Media3 ExoPlayer with lockscreen media controls & Android Auto integration
+- **Streaming Catalog:** YouTube Music streaming engine with high-efficiency Opus audio
+- **Scrobbling & Tracking:** Last.fm API with native OS media session tracking
+- **Lyrics Engine:** [LRCLIB](https://lrclib.net) millisecond-synchronized lyrics
+- **Architecture:** MVVM + Clean Architecture, Room DB, Jetpack DataStore, Dagger Hilt, Coroutines & Flow
+
+---
+
+##
Getting Started
+
+1. Download the latest APK from the **[Releases](https://github.com/Clash-Projects/LastWave-native/releases)** or **[Actions](https://github.com/Clash-Projects/LastWave-native/actions)** tab.
+2. Install `LastWave-v4.3.0-release.apk` on your Android device (Android 7.0+).
+3. Connect your Last.fm account in **Settings → Integrations** to unlock the scrobbler, taste mixes, and personalized discovery radar.
+4. Enjoy ad-free streaming and smart playlist generation!
+
+---
+
+##
Building from Source
+
+```bash
+git clone https://github.com/Clash-Projects/LastWave-native.git
+cd LastWave-native
+./gradlew assembleRelease
+```
+
+---
+
+##
Community & Support
+
+*
**Updates & Support:** [Join @clashprojects on Telegram](https://t.me/clashprojects)
+*
**More From Us:** [Join @MaterialYouApp on Telegram](https://t.me/MaterialYouApp)
+*
**Discord Community:** [Join Discord](https://discord.gg/DmyM2p2fMe)
+*
**LastWave Website:** [visit site now](https://lastwave.pages.dev)
+
+---
+
+##
Disclaimer
+
+> [!NOTE]
+> **Educational & Research Notice**
+>
+> LastWave is an open-source, non-commercial application developed strictly for research and educational purposes to demonstrate modern Android application architecture with Jetpack Compose and AndroidX Media3.
+>
+> **No Affiliation & Content Policy**
+> - LastWave is an independent project and is **not affiliated with, endorsed, or sponsored by Google LLC, YouTube, YouTube Music, Spotify, Apple Inc., Last.fm, or any music service**.
+> - LastWave **does not host, stream from private servers, or distribute any copyrighted media**. All content and metadata are accessed directly from public endpoints in accordance with their respective terms. All trademarks belong to their respective owners.
+
+---
+
+
+
+[](https://gitgem.org/github/Clash-Projects/LastWave-Native)
diff --git a/SECURITY.md b/SECURITY.md
index 40111f7bb..239cec897 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,32 +1,32 @@
-# Security Policy
-
-## Supported Versions
-
-Security fixes are provided for the latest stable release of LastWave.
-
-| Version | Supported |
-| ------- | ------------------ |
-| 4.2.x | Yes |
-| < 4.2.4 | No |
-
-## Reporting a Vulnerability
-
-If you discover a security vulnerability in LastWave, please report it privately rather than creating a public issue.
-
-### How to Report
-* Reach out privately to the maintainers on Telegram:
- * Channel: https://t.me/clashprojects
- * Discussion Group: https://t.me/clashdiscussion
-* Alternatively, submit a private security advisory through the GitHub repository.
-* Include:
- * Description of the vulnerability
- * Affected versions and components
- * Steps to reproduce or proof-of-concept
- * Potential impact assessment
-
-### Response Timeline
-* **Initial acknowledgment:** Within 48 hours.
-* **Assessment & mitigation plan:** Within 5 business days.
-* **Public disclosure:** Coordinated after a patched release is published.
-
-Thank you for helping keep LastWave secure for all users.
+# Security Policy
+
+## Supported Versions
+
+Security fixes are provided for the latest stable release of LastWave.
+
+| Version | Supported |
+| ------- | ------------------ |
+| 4.3.x | Yes |
+| < 4.3.0 | No |
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability in LastWave, please report it privately rather than creating a public issue.
+
+### How to Report
+* Reach out privately to the maintainers on Telegram:
+ * Channel: https://t.me/clashprojects
+ * Discussion Group: https://t.me/clashdiscussion
+* Alternatively, submit a private security advisory through the GitHub repository.
+* Include:
+ * Description of the vulnerability
+ * Affected versions and components
+ * Steps to reproduce or proof-of-concept
+ * Potential impact assessment
+
+### Response Timeline
+* **Initial acknowledgment:** Within 48 hours.
+* **Assessment & mitigation plan:** Within 5 business days.
+* **Public disclosure:** Coordinated after a patched release is published.
+
+Thank you for helping keep LastWave secure for all users.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b35fce6bc..34b678d54 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,351 +1,351 @@
-import java.io.File
-import java.util.Base64
-import java.util.Properties
-
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.kotlin.serialization)
- alias(libs.plugins.ksp)
- alias(libs.plugins.hilt)
-}
-
-android {
- namespace = "com.lastwave.app"
- compileSdk = 37
-
- val localProps = Properties().apply {
- val localPropsFile = rootProject.file("local.properties")
- if (localPropsFile.exists()) {
- localPropsFile.inputStream().use { load(it) }
- }
- val envFile = rootProject.file(".env")
- if (envFile.exists()) {
- envFile.readLines().forEach { line ->
- val trimmed = line.trim()
- if (trimmed.isNotEmpty() && !trimmed.startsWith("#") && trimmed.contains("=")) {
- val parts = trimmed.split("=", limit = 2)
- setProperty(parts[0].trim(), parts[1].trim())
- }
- }
- }
- }
-
- fun resolveSecret(vararg keys: String): String {
- for (key in keys) {
- val fromEnv = System.getenv(key)
- if (!fromEnv.isNullOrBlank()) return fromEnv.trim().replace("\r", "").replace("\n", "").replace("\"", "").replace("\\", "")
- val fromGradle = project.findProperty(key) as? String
- if (!fromGradle.isNullOrBlank()) return fromGradle.trim().replace("\r", "").replace("\n", "").replace("\"", "").replace("\\", "")
- val fromLocal = localProps.getProperty(key)
- if (!fromLocal.isNullOrBlank()) return fromLocal.trim().replace("\r", "").replace("\n", "").replace("\"", "").replace("\\", "")
- val dotEnv = rootProject.file(".env")
- if (dotEnv.isFile) {
- dotEnv.useLines { lines ->
- for (line in lines) {
- val trimmed = line.trim()
- if (trimmed.startsWith("$key=")) {
- val v = trimmed.substringAfter("=").trim().replace("\"", "").replace("\\", "")
- if (v.isNotBlank()) return v
- }
- }
- }
- }
- }
- return ""
- }
-
- defaultConfig {
- applicationId = "com.lastwave.app"
- minSdk = (project.findProperty("minSdk") as? String)?.toIntOrNull() ?: 29
- targetSdk = 35
- versionCode = 24
- versionName = "4.2.4"
-
- val lastWaveLyricsToken = resolveSecret("LASTWAVE_LYRICS_TOKEN")
- val lastWaveLyricsUrl = resolveSecret("LASTWAVE_LYRICS_URL")
- buildConfigField("String", "LASTWAVE_LYRICS_TOKEN", "\"$lastWaveLyricsToken\"")
- buildConfigField("String", "LASTWAVE_LYRICS_URL", "\"$lastWaveLyricsUrl\"")
-
- // Native secrets (addon client lock) live strictly in native .so via
- // SecretsBridge_generated.h (tools/generate_native_secrets.py).
- // No secret fields are exposed in DEX / BuildConfig.
-
- externalNativeBuild {
- cmake {
- arguments += listOf(
- "-DANDROID_STL=c++_shared",
- // Android 15+ can boot with 16 KB memory pages; all native
- // libraries must be built/aligned accordingly. Ignored
- // harmlessly by NDK toolchains that predate the flag.
- "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
- "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
- )
- cFlags += "-Wl,-z,max-page-size=16384"
- cppFlags += "-Wl,-z,max-page-size=16384"
- }
- }
- }
-
- signingConfigs {
- create("release_config") {
- val base64Key = resolveSecret("SIGNING_KEY")
- val storeFilePath = resolveSecret("RELEASE_STORE_FILE")
- val storePasswordProp = resolveSecret("RELEASE_STORE_PASSWORD", "KEY_STORE_PASSWORD")
- val keyAliasProp = resolveSecret("RELEASE_KEY_ALIAS", "ALIAS").ifBlank { "release_key" }
- val keyPasswordProp = resolveSecret("RELEASE_KEY_PASSWORD", "KEY_PASSWORD").ifBlank { storePasswordProp }
-
- val keystoreFile: File? = when {
- base64Key.isNotBlank() -> {
- try {
- val decodedBytes = Base64.getDecoder().decode(base64Key.trim())
- val tempKeystore = layout.buildDirectory.file("signing/release.keystore").get().asFile
- tempKeystore.parentFile.mkdirs()
- tempKeystore.writeBytes(decodedBytes)
- tempKeystore
- } catch (_: Exception) {
- null
- }
- }
- storeFilePath.isNotBlank() -> file(storeFilePath)
- else -> null
- }
-
- if (keystoreFile != null && keystoreFile.exists() && storePasswordProp.isNotBlank()) {
- storeFile = keystoreFile
- storePassword = storePasswordProp
- keyAlias = keyAliasProp
- keyPassword = keyPasswordProp
- enableV1Signing = true
- enableV2Signing = true
- enableV3Signing = true
- } else {
- // Never silently fall back to the debug key for a release
- // variant. A release APK signed with the runner's random debug
- // key installs once, then every future update fails with
- // INSTALL_FAILED_UPDATE_INCOMPATIBLE (signature conflict) and
- // forces the user to uninstall and lose their data. apksigner
- // verify only proves *a* signature is valid, not whose it is,
- // so we must refuse to produce the APK in the first place.
- //
- // Only fail when a release variant is actually being requested,
- // so debug builds and unit tests keep working with no signing
- // secrets present on a dev machine.
- val requestsRelease =
- gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
- if (requestsRelease) {
- throw GradleException(
- "No release signing key found. Provide SIGNING_KEY (base64 " +
- "keystore) or RELEASE_STORE_FILE + RELEASE_STORE_PASSWORD + " +
- "RELEASE_KEY_ALIAS. Refusing to sign a release variant with " +
- "the debug key."
- )
- }
- initWith(getByName("debug"))
- }
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- isShrinkResources = true
- signingConfig = signingConfigs.getByName("release_config")
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
- }
- create("rawRelease") {
- initWith(getByName("release"))
- isMinifyEnabled = false
- isShrinkResources = false
- // Raw variant — no code/resource shrinking, no ProGuard/R8
- signingConfig = signingConfigs.getByName("release_config")
- // proguardFiles from initWith are ignored when minify is off
- }
- debug {
- isDebuggable = true
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- // Required by org.jellyfin.media3:media3-ffmpeg-decoder AAR metadata.
- isCoreLibraryDesugaringEnabled = true
- }
-
- buildFeatures {
- compose = true
- buildConfig = true
- prefab = true
- }
- externalNativeBuild {
- cmake {
- path = file("src/main/cpp/CMakeLists.txt")
- version = "3.22.1"
- }
- }
-
- packaging {
- resources {
- excludes += "/META-INF/{AL2.0,LGPL2.1}"
- }
- jniLibs {
- useLegacyPackaging = true
- }
- }
-
- lint {
- checkReleaseBuilds = false
- abortOnError = false
- ignoreWarnings = true
- }
-
- testOptions {
- unitTests.isReturnDefaultValues = true
- }
-}
-
-dependencies {
- implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
- implementation("org.nanohttpd:nanohttpd:2.3.1")
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.lifecycle.viewmodel.compose)
- implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.androidx.activity.compose)
- implementation(libs.androidx.splashscreen)
- implementation(libs.androidx.browser)
- implementation(libs.androidx.palette)
-
- // Home-screen "Now Playing" widget (Glance — Compose-style APIs over
- // RemoteViews), driven by the same MediaController access the local
- // scrobbler (MediaScrobbleListenerService) already holds.
- implementation("androidx.glance:glance-appwidget:1.1.1")
- implementation("androidx.glance:glance-material3:1.1.1")
-
- // Required even in a Compose-only app: Theme.Material3.DayNight.NoActionBar
- // (used as the AndroidManifest/splash theme parent in themes.xml) is an XML
- // style resource shipped by this artifact. androidx.compose.material3 is
- // Compose-only Kotlin and contributes no AAPT-resolvable style/ resources,
- // so without this dependency that parent can never be found by the linker.
- implementation(libs.material)
-
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.ui)
- implementation(libs.androidx.ui.graphics)
- implementation(libs.kyant.backdrop)
- implementation(libs.kyant.shapes)
- implementation(libs.androidx.ui.tooling.preview)
- implementation(libs.androidx.material3)
- implementation(libs.androidx.material.icons.extended)
- implementation(libs.androidx.navigation.compose)
- debugImplementation(libs.androidx.ui.tooling)
-
- implementation(libs.hilt.android)
- ksp(libs.hilt.compiler)
- implementation(libs.hilt.navigation.compose)
-
- implementation(libs.kotlinx.coroutines.android)
- implementation(libs.kotlinx.serialization.json)
-
- implementation(libs.retrofit.core)
- implementation(libs.retrofit.kotlinx.serialization)
- implementation(libs.okhttp.core)
- implementation(libs.okhttp.logging)
- implementation(libs.jsoup)
-
- implementation(libs.room.runtime)
- implementation(libs.room.ktx)
- ksp(libs.room.compiler)
-
- implementation(libs.datastore.preferences)
- implementation(libs.coil.compose)
- // Lyrics sync uses the in-house reference implementation
- // (mirrors Metrolist/SimpMusic start-based active-index + per-frame
- // position, no external karaoke canvas). No gradle lyrics library.
- // Installs the baseline profiles bundled inside Compose (and other
- // androidx) AARs so hot UI paths are AOT-compiled on device instead of
- // running through JIT on first use — a large, zero-code smoothness win
- // for scrolling and animations in release builds.
- implementation(libs.androidx.profileinstaller)
-
- // Native in-app audio playback, background service, system media
- // controls, Bluetooth/headset controls and a MediaController-backed UI.
- implementation("androidx.media3:media3-exoplayer:1.2.1")
- implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
- // Segmented provider-module path: DASH chunk source + CDM decryption.
- // Pinned to the same 1.2.1 line as exoplayer/hls to avoid binary mismatch.
- implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
- // MediaBrowserServiceCompat/MediaSessionCompat bridge used by Android
- // Auto to browse the LastWave library and control the same player.
- implementation("androidx.media:media:1.7.0")
-
- // GPLv3 Media3-matched FFmpeg software decoder (distribution must comply).
- // The renderer factory prefers FFmpeg for every codec it supports so all
- // devices decode through one deterministic, OEM-bug-free path; platform
- // decoders remain as automatic fallbacks.
- implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.2.1+1")
-
- // Core library desugaring required by the FFmpeg decoder AAR metadata.
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
-
- // Low-latency native output. Version 1.10 remains API-compatible with the
- // requested Oboe 1.8+ baseline and exposes its CMake target through Prefab.
- implementation("com.google.oboe:oboe:1.10.0")
-
- // Metadata/search remains local InnerTube/NewPipe functionality; playback
- // resolves through InnerTubeX first and retains NewPipe as a fallback.
- implementation(libs.newpipe.extractor)
-
- // InnerTubeX is invoked through the compatibility adapter because its
- // current release is built with a newer Kotlin metadata version than the
- // app. Runtime-only keeps the app compiler on its existing Kotlin line.
- runtimeOnly(libs.innertubex)
- runtimeOnly("io.ktor:ktor-client-cio:3.5.2")
-
- // Unit Testing dependencies
- testImplementation("junit:junit:4.13.2")
- testImplementation("com.google.truth:truth:1.4.2")
- testImplementation("org.robolectric:robolectric:4.12.2")
- testImplementation("io.mockk:mockk:1.13.10")
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
-
- // Bit-perfect USB exclusive output: audio_engine UsbAudioDriver.
- implementation(project(":audio:decent-usb-audio-driver"))
-}
-
-kotlin {
- compilerOptions {
- jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
- optIn.add("androidx.compose.foundation.ExperimentalFoundationApi")
- }
-}
-
-configurations.all {
- resolutionStrategy.eachDependency {
- if (requested.group == "org.jetbrains.kotlin") {
- useVersion(libs.versions.kotlin.get())
- }
- }
-}
-
-tasks.withType {
- maxHeapSize = "2048m"
-}
-
-// Generate native secrets header before CMake configures.
-// CI provides ADDON_CLIENT_SECRET / RELEASE_CERT_SHA256 via env/secrets.
-val generateNativeSecrets by tasks.registering(Exec::class) {
- workingDir = rootProject.projectDir
- val py = org.gradle.internal.os.OperatingSystem.current().let {
- if (it.isWindows) "python" else "python3"
- }
- commandLine(py, "tools/generate_native_secrets.py")
- // Never fail public builds when secrets absent; script emits empty header.
- isIgnoreExitValue = true
-}
-tasks.matching { it.name.startsWith("preBuild") || it.name.startsWith("configureCMake") }.configureEach {
- dependsOn(generateNativeSecrets)
-}
-
+import java.io.File
+import java.util.Base64
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ namespace = "com.lastwave.app"
+ compileSdk = 37
+
+ val localProps = Properties().apply {
+ val localPropsFile = rootProject.file("local.properties")
+ if (localPropsFile.exists()) {
+ localPropsFile.inputStream().use { load(it) }
+ }
+ val envFile = rootProject.file(".env")
+ if (envFile.exists()) {
+ envFile.readLines().forEach { line ->
+ val trimmed = line.trim()
+ if (trimmed.isNotEmpty() && !trimmed.startsWith("#") && trimmed.contains("=")) {
+ val parts = trimmed.split("=", limit = 2)
+ setProperty(parts[0].trim(), parts[1].trim())
+ }
+ }
+ }
+ }
+
+ fun resolveSecret(vararg keys: String): String {
+ for (key in keys) {
+ val fromEnv = System.getenv(key)
+ if (!fromEnv.isNullOrBlank()) return fromEnv.trim().replace("\r", "").replace("\n", "").replace("\"", "").replace("\\", "")
+ val fromGradle = project.findProperty(key) as? String
+ if (!fromGradle.isNullOrBlank()) return fromGradle.trim().replace("\r", "").replace("\n", "").replace("\"", "").replace("\\", "")
+ val fromLocal = localProps.getProperty(key)
+ if (!fromLocal.isNullOrBlank()) return fromLocal.trim().replace("\r", "").replace("\n", "").replace("\"", "").replace("\\", "")
+ val dotEnv = rootProject.file(".env")
+ if (dotEnv.isFile) {
+ dotEnv.useLines { lines ->
+ for (line in lines) {
+ val trimmed = line.trim()
+ if (trimmed.startsWith("$key=")) {
+ val v = trimmed.substringAfter("=").trim().replace("\"", "").replace("\\", "")
+ if (v.isNotBlank()) return v
+ }
+ }
+ }
+ }
+ }
+ return ""
+ }
+
+ defaultConfig {
+ applicationId = "com.lastwave.app"
+ minSdk = (project.findProperty("minSdk") as? String)?.toIntOrNull() ?: 29
+ targetSdk = 35
+ versionCode = 25
+ versionName = "4.3.0"
+
+ val lastWaveLyricsToken = resolveSecret("LASTWAVE_LYRICS_TOKEN")
+ val lastWaveLyricsUrl = resolveSecret("LASTWAVE_LYRICS_URL")
+ buildConfigField("String", "LASTWAVE_LYRICS_TOKEN", "\"$lastWaveLyricsToken\"")
+ buildConfigField("String", "LASTWAVE_LYRICS_URL", "\"$lastWaveLyricsUrl\"")
+
+ // Native secrets (addon client lock) live strictly in native .so via
+ // SecretsBridge_generated.h (tools/generate_native_secrets.py).
+ // No secret fields are exposed in DEX / BuildConfig.
+
+ externalNativeBuild {
+ cmake {
+ arguments += listOf(
+ "-DANDROID_STL=c++_shared",
+ // Android 15+ can boot with 16 KB memory pages; all native
+ // libraries must be built/aligned accordingly. Ignored
+ // harmlessly by NDK toolchains that predate the flag.
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
+ "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
+ )
+ cFlags += "-Wl,-z,max-page-size=16384"
+ cppFlags += "-Wl,-z,max-page-size=16384"
+ }
+ }
+ }
+
+ signingConfigs {
+ create("release_config") {
+ val base64Key = resolveSecret("SIGNING_KEY")
+ val storeFilePath = resolveSecret("RELEASE_STORE_FILE")
+ val storePasswordProp = resolveSecret("RELEASE_STORE_PASSWORD", "KEY_STORE_PASSWORD")
+ val keyAliasProp = resolveSecret("RELEASE_KEY_ALIAS", "ALIAS").ifBlank { "release_key" }
+ val keyPasswordProp = resolveSecret("RELEASE_KEY_PASSWORD", "KEY_PASSWORD").ifBlank { storePasswordProp }
+
+ val keystoreFile: File? = when {
+ base64Key.isNotBlank() -> {
+ try {
+ val decodedBytes = Base64.getDecoder().decode(base64Key.trim())
+ val tempKeystore = layout.buildDirectory.file("signing/release.keystore").get().asFile
+ tempKeystore.parentFile.mkdirs()
+ tempKeystore.writeBytes(decodedBytes)
+ tempKeystore
+ } catch (_: Exception) {
+ null
+ }
+ }
+ storeFilePath.isNotBlank() -> file(storeFilePath)
+ else -> null
+ }
+
+ if (keystoreFile != null && keystoreFile.exists() && storePasswordProp.isNotBlank()) {
+ storeFile = keystoreFile
+ storePassword = storePasswordProp
+ keyAlias = keyAliasProp
+ keyPassword = keyPasswordProp
+ enableV1Signing = true
+ enableV2Signing = true
+ enableV3Signing = true
+ } else {
+ // Never silently fall back to the debug key for a release
+ // variant. A release APK signed with the runner's random debug
+ // key installs once, then every future update fails with
+ // INSTALL_FAILED_UPDATE_INCOMPATIBLE (signature conflict) and
+ // forces the user to uninstall and lose their data. apksigner
+ // verify only proves *a* signature is valid, not whose it is,
+ // so we must refuse to produce the APK in the first place.
+ //
+ // Only fail when a release variant is actually being requested,
+ // so debug builds and unit tests keep working with no signing
+ // secrets present on a dev machine.
+ val requestsRelease =
+ gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
+ if (requestsRelease) {
+ throw GradleException(
+ "No release signing key found. Provide SIGNING_KEY (base64 " +
+ "keystore) or RELEASE_STORE_FILE + RELEASE_STORE_PASSWORD + " +
+ "RELEASE_KEY_ALIAS. Refusing to sign a release variant with " +
+ "the debug key."
+ )
+ }
+ initWith(getByName("debug"))
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ signingConfig = signingConfigs.getByName("release_config")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ create("rawRelease") {
+ initWith(getByName("release"))
+ isMinifyEnabled = false
+ isShrinkResources = false
+ // Raw variant — no code/resource shrinking, no ProGuard/R8
+ signingConfig = signingConfigs.getByName("release_config")
+ // proguardFiles from initWith are ignored when minify is off
+ }
+ debug {
+ isDebuggable = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ // Required by org.jellyfin.media3:media3-ffmpeg-decoder AAR metadata.
+ isCoreLibraryDesugaringEnabled = true
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ prefab = true
+ }
+ externalNativeBuild {
+ cmake {
+ path = file("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ jniLibs {
+ useLegacyPackaging = true
+ }
+ }
+
+ lint {
+ checkReleaseBuilds = false
+ abortOnError = false
+ ignoreWarnings = true
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+dependencies {
+ implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
+ implementation("org.nanohttpd:nanohttpd:2.3.1")
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.splashscreen)
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.palette)
+
+ // Home-screen "Now Playing" widget (Glance — Compose-style APIs over
+ // RemoteViews), driven by the same MediaController access the local
+ // scrobbler (MediaScrobbleListenerService) already holds.
+ implementation("androidx.glance:glance-appwidget:1.1.1")
+ implementation("androidx.glance:glance-material3:1.1.1")
+
+ // Required even in a Compose-only app: Theme.Material3.DayNight.NoActionBar
+ // (used as the AndroidManifest/splash theme parent in themes.xml) is an XML
+ // style resource shipped by this artifact. androidx.compose.material3 is
+ // Compose-only Kotlin and contributes no AAPT-resolvable style/ resources,
+ // so without this dependency that parent can never be found by the linker.
+ implementation(libs.material)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.kyant.backdrop)
+ implementation(libs.kyant.shapes)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.navigation.compose)
+ debugImplementation(libs.androidx.ui.tooling)
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.navigation.compose)
+
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.serialization.json)
+
+ implementation(libs.retrofit.core)
+ implementation(libs.retrofit.kotlinx.serialization)
+ implementation(libs.okhttp.core)
+ implementation(libs.okhttp.logging)
+ implementation(libs.jsoup)
+
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+ ksp(libs.room.compiler)
+
+ implementation(libs.datastore.preferences)
+ implementation(libs.coil.compose)
+ // Lyrics sync uses the in-house reference implementation
+ // (mirrors Metrolist/SimpMusic start-based active-index + per-frame
+ // position, no external karaoke canvas). No gradle lyrics library.
+ // Installs the baseline profiles bundled inside Compose (and other
+ // androidx) AARs so hot UI paths are AOT-compiled on device instead of
+ // running through JIT on first use — a large, zero-code smoothness win
+ // for scrolling and animations in release builds.
+ implementation(libs.androidx.profileinstaller)
+
+ // Native in-app audio playback, background service, system media
+ // controls, Bluetooth/headset controls and a MediaController-backed UI.
+ implementation("androidx.media3:media3-exoplayer:1.2.1")
+ implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
+ // Segmented provider-module path: DASH chunk source + CDM decryption.
+ // Pinned to the same 1.2.1 line as exoplayer/hls to avoid binary mismatch.
+ implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
+ // MediaBrowserServiceCompat/MediaSessionCompat bridge used by Android
+ // Auto to browse the LastWave library and control the same player.
+ implementation("androidx.media:media:1.7.0")
+
+ // GPLv3 Media3-matched FFmpeg software decoder (distribution must comply).
+ // The renderer factory prefers FFmpeg for every codec it supports so all
+ // devices decode through one deterministic, OEM-bug-free path; platform
+ // decoders remain as automatic fallbacks.
+ implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.2.1+1")
+
+ // Core library desugaring required by the FFmpeg decoder AAR metadata.
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
+
+ // Low-latency native output. Version 1.10 remains API-compatible with the
+ // requested Oboe 1.8+ baseline and exposes its CMake target through Prefab.
+ implementation("com.google.oboe:oboe:1.10.0")
+
+ // Metadata/search remains local InnerTube/NewPipe functionality; playback
+ // resolves through InnerTubeX first and retains NewPipe as a fallback.
+ implementation(libs.newpipe.extractor)
+
+ // InnerTubeX is invoked through the compatibility adapter because its
+ // current release is built with a newer Kotlin metadata version than the
+ // app. Runtime-only keeps the app compiler on its existing Kotlin line.
+ runtimeOnly(libs.innertubex)
+ runtimeOnly("io.ktor:ktor-client-cio:3.5.2")
+
+ // Unit Testing dependencies
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("com.google.truth:truth:1.4.2")
+ testImplementation("org.robolectric:robolectric:4.12.2")
+ testImplementation("io.mockk:mockk:1.13.10")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
+
+ // Bit-perfect USB exclusive output: audio_engine UsbAudioDriver.
+ implementation(project(":audio:decent-usb-audio-driver"))
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ optIn.add("androidx.compose.foundation.ExperimentalFoundationApi")
+ }
+}
+
+configurations.all {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.jetbrains.kotlin") {
+ useVersion(libs.versions.kotlin.get())
+ }
+ }
+}
+
+tasks.withType {
+ maxHeapSize = "2048m"
+}
+
+// Generate native secrets header before CMake configures.
+// CI provides ADDON_CLIENT_SECRET / RELEASE_CERT_SHA256 via env/secrets.
+val generateNativeSecrets by tasks.registering(Exec::class) {
+ workingDir = rootProject.projectDir
+ val py = org.gradle.internal.os.OperatingSystem.current().let {
+ if (it.isWindows) "python" else "python3"
+ }
+ commandLine(py, "tools/generate_native_secrets.py")
+ // Never fail public builds when secrets absent; script emits empty header.
+ isIgnoreExitValue = true
+}
+tasks.matching { it.name.startsWith("preBuild") || it.name.startsWith("configureCMake") }.configureEach {
+ dependsOn(generateNativeSecrets)
+}
+
diff --git a/app/src/main/java/com/lastwave/app/data/download/ModuleFlacTranscoder.kt b/app/src/main/java/com/lastwave/app/data/download/ModuleFlacTranscoder.kt
index 5177780e4..ed93170de 100644
--- a/app/src/main/java/com/lastwave/app/data/download/ModuleFlacTranscoder.kt
+++ b/app/src/main/java/com/lastwave/app/data/download/ModuleFlacTranscoder.kt
@@ -1,300 +1,300 @@
-package com.lastwave.app.data.download
-
-import android.content.Context
-import android.media.MediaCodec
-import android.media.MediaFormat
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.PlaybackException
-import androidx.media3.common.Player
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.exoplayer.DefaultRenderersFactory
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
-import com.lastwave.app.data.plugin.ModuleDrmFactory
-import com.lastwave.app.data.plugin.SegmentedDashBridge
-import com.lastwave.app.data.plugin.SegmentedStreamDescriptor
-import com.lastwave.app.playback.CapturingAudioSink
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withTimeout
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-data class TranscodedFlac(
- val file: File,
- val sampleRate: Int,
- val channels: Int,
-)
-
-/**
- * Decrypts a downloaded module track into a plain, universally playable FLAC.
- *
- * Path: local-file MPD (offline bytes, zero CDN) -> headless ExoPlayer with a
- * capturing sink (proven streaming DRM path, decode runs flat-out, not
- * realtime) -> PCM file -> FLAC encode -> true .flac.
- *
- * Returns null on ANY failure (no FLAC encoder, secure-output refusal, decode
- * error, timeout) so the caller falls back to the license-persisted
- * encrypted path. All player calls happen on the calling thread; listener
- * callbacks only resume the coroutine.
- */
-@UnstableApi
-@Singleton
-class ModuleFlacTranscoder @Inject constructor(
- @ApplicationContext private val context: Context,
- private val drmFactory: ModuleDrmFactory,
- private val segBridge: SegmentedDashBridge,
-) {
- companion object {
- private const val MAX_TRANSCODE_MS = 10 * 60 * 1000L
- private const val PUMP_BYTES = 64 * 1024
- }
-
- suspend fun transcodeToFlac(
- sourceFile: File,
- descriptor: SegmentedStreamDescriptor,
- title: String,
- artist: String,
- album: String?,
- artworkUri: String?,
- ): TranscodedFlac? = withContext(Dispatchers.IO) {
- runCatching {
- withTimeout(MAX_TRANSCODE_MS) {
- doTranscode(sourceFile, descriptor, title, artist, album, artworkUri)
- }
- }.getOrNull()
- }
-
- private suspend fun doTranscode(
- sourceFile: File,
- descriptor: SegmentedStreamDescriptor,
- title: String,
- artist: String,
- album: String?,
- artworkUri: String?,
- ): TranscodedFlac {
- val pcmFile = File.createTempFile("trans_pcm_", ".pcm", context.cacheDir)
- val sink = CapturingAudioSink(pcmFile)
- // Local-file MPD: same bytes the download verified, no network except
- // the streaming license exchange (module envelope via the callback).
- val localMpd = segBridge.mpdUriForBase(
- descriptor,
- Uri.fromFile(sourceFile).toString(),
- )
- val item = androidx.media3.common.MediaItem.Builder()
- .setMediaId("transcode:${descriptor.trackId}")
- .setUri(localMpd)
- .setMimeType(MimeTypes.APPLICATION_MPD)
- .setMediaMetadata(
- androidx.media3.common.MediaMetadata.Builder()
- .setTitle(title).setArtist(artist).setAlbumTitle(album)
- .setArtworkUri(artworkUri?.let(Uri::parse))
- .setIsPlayable(true).build(),
- )
- .apply {
- descriptor.drm?.let { drm ->
- if (!drm.pssh.isBlank() && !drm.licenseUrl.isBlank()) {
- setDrmConfiguration(
- androidx.media3.common.MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
- .setLicenseUri(drm.licenseUrl)
- .setMultiSession(false)
- .apply {
- val headers = descriptor.headers.toMutableMap()
- if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
- headers["User-Agent"] = "LastWave/4.2.4"
- }
- setLicenseRequestHeaders(headers)
- }
- .build(),
- )
- }
- }
- }
- .build()
-
- // NOTE: deliberately NOT reusing ModuleDrmFactory.sessionManagerFor
- // here: that manager is tuned for the foreground player; the transcode
- // player owns an isolated DefaultDrmSessionManager with the same
- // module-envelope callback so license behavior stays identical.
- val renderers = object : DefaultRenderersFactory(context) {
- override fun buildAudioSink(
- context: Context,
- enableFloatOutput: Boolean,
- enableAudioTrackPlaybackParams: Boolean,
- ): androidx.media3.exoplayer.audio.AudioSink = sink
- }
- val mediaSourceFactory = DefaultMediaSourceFactory(context)
- .setDrmSessionManagerProvider { drmFactory.sessionManagerFor(descriptor) }
- val player = ExoPlayer.Builder(context, renderers)
- .setMediaSourceFactory(mediaSourceFactory)
- .build()
- try {
- awaitEnded(player, item)
- if (!sink.isConfigured() || sink.framesWritten <= 0) {
- error("No PCM captured")
- }
- val flacFile = File.createTempFile("trans_flac_", ".flac", context.cacheDir)
- if (!encodePcmToFlac(pcmFile, sink.sampleRate, sink.channelCount, flacFile)) {
- runCatching { flacFile.delete() }
- error("FLAC encode unavailable")
- }
- return TranscodedFlac(flacFile, sink.sampleRate, sink.channelCount)
- } finally {
- runCatching { player.release() }
- runCatching { pcmFile.delete() }
- }
- }
-
- private suspend fun awaitEnded(
- player: ExoPlayer,
- item: androidx.media3.common.MediaItem,
- ): Unit = suspendCancellableCoroutine { cont ->
- val listener = object : Player.Listener {
- override fun onPlaybackStateChanged(state: Int) {
- if (state == Player.STATE_ENDED && cont.isActive) {
- player.removeListener(this)
- cont.resume(Unit)
- }
- }
-
- override fun onPlayerError(error: PlaybackException) {
- if (cont.isActive) {
- player.removeListener(this)
- cont.resumeWithException(error)
- }
- }
- }
- player.addListener(listener)
- cont.invokeOnCancellation {
- runCatching { player.removeListener(listener) }
- }
- player.setMediaItem(item)
- player.prepare()
- player.playWhenReady = true
- }
-
- /**
- * PCM16 file -> framed .flac. STREAMINFO comes from the encoder's csd-0;
- * frames are appended verbatim (encoder output is already framed).
- */
- private fun encodePcmToFlac(pcmFile: File, sampleRate: Int, channels: Int, outFile: File): Boolean {
- if (sampleRate <= 0 || channels <= 0) return false
- val codec = try {
- MediaCodec.createEncoderByType(MimeTypes.AUDIO_FLAC)
- } catch (_: Exception) {
- return false
- }
- try {
- val format = MediaFormat().apply {
- setString(MediaFormat.KEY_MIME, MimeTypes.AUDIO_FLAC)
- setInteger(MediaFormat.KEY_SAMPLE_RATE, sampleRate)
- setInteger(MediaFormat.KEY_CHANNEL_COUNT, channels)
- }
- codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
- codec.start()
- var streamInfo: ByteArray? = null
- val pendingFrames = mutableListOf()
- var out: java.io.OutputStream? = null
- fun ensureOutput(): java.io.OutputStream {
- var o = out
- if (o == null) {
- val csd = streamInfo ?: error("no STREAMINFO")
- o = outFile.outputStream().buffered()
- o.write("fLaC".toByteArray(Charsets.US_ASCII))
- // METADATA_BLOCK_HEADER: last-block + STREAMINFO(0) + length 34
- o.write(byteArrayOf(0x90.toByte(), 0x00, 0x00, 0x22.toByte()))
- o.write(csd)
- for (f in pendingFrames) o.write(f)
- pendingFrames.clear()
- out = o
- }
- return o
- }
- fun onStreamInfo(bytes: ByteArray) {
- if (streamInfo == null) {
- streamInfo = bytes
- // Frames may have arrived before the header: flush them now.
- if (pendingFrames.isNotEmpty()) ensureOutput()
- }
- }
- pcmFile.inputStream().buffered().use { input ->
- var inputEos = false
- var outputEos = false
- val bufferInfo = MediaCodec.BufferInfo()
- val chunk = ByteArray(PUMP_BYTES)
- while (!outputEos) {
- if (!inputEos) {
- val inIndex = codec.dequeueInputBuffer(10_000)
- if (inIndex >= 0) {
- val inBuf = codec.getInputBuffer(inIndex) ?: error("null input")
- inBuf.clear()
- var read = 0
- while (read < inBuf.remaining()) {
- val n = input.read(chunk, 0, minOf(chunk.size, inBuf.remaining()))
- if (n < 0) break
- inBuf.put(chunk, 0, n)
- read += n
- }
- if (read <= 0) {
- codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
- inputEos = true
- } else {
- codec.queueInputBuffer(inIndex, 0, read, 0, 0)
- }
- }
- }
- val outIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000)
- when {
- outIndex >= 0 -> {
- val outBuf = codec.getOutputBuffer(outIndex)
- if (outBuf != null && bufferInfo.size > 0) {
- val bytes = ByteArray(bufferInfo.size)
- outBuf.position(bufferInfo.offset)
- outBuf.limit(bufferInfo.offset + bufferInfo.size)
- outBuf.get(bytes)
- if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
- onStreamInfo(bytes)
- } else if (streamInfo != null) {
- ensureOutput().write(bytes)
- } else {
- pendingFrames.add(bytes)
- }
- }
- if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
- outputEos = true
- }
- codec.releaseOutputBuffer(outIndex, false)
- }
- outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
- codec.outputFormat.getByteBuffer("csd-0")?.let { csdBuf ->
- val bytes = ByteArray(csdBuf.remaining())
- csdBuf.get(bytes)
- onStreamInfo(bytes)
- }
- }
- // INFO_TRY_AGAIN_LATER: loop.
- }
- }
- }
- if (out == null && streamInfo != null && pendingFrames.isNotEmpty()) {
- ensureOutput()
- }
- out?.flush()
- out?.close()
- return streamInfo != null && outFile.length() > 42
- } catch (_: Exception) {
- runCatching { outFile.delete() }
- return false
- } finally {
- runCatching { codec.stop() }
- runCatching { codec.release() }
- }
- }
-}
+package com.lastwave.app.data.download
+
+import android.content.Context
+import android.media.MediaCodec
+import android.media.MediaFormat
+import android.net.Uri
+import androidx.media3.common.C
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.DefaultRenderersFactory
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import com.lastwave.app.data.plugin.ModuleDrmFactory
+import com.lastwave.app.data.plugin.SegmentedDashBridge
+import com.lastwave.app.data.plugin.SegmentedStreamDescriptor
+import com.lastwave.app.playback.CapturingAudioSink
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withTimeout
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+data class TranscodedFlac(
+ val file: File,
+ val sampleRate: Int,
+ val channels: Int,
+)
+
+/**
+ * Decrypts a downloaded module track into a plain, universally playable FLAC.
+ *
+ * Path: local-file MPD (offline bytes, zero CDN) -> headless ExoPlayer with a
+ * capturing sink (proven streaming DRM path, decode runs flat-out, not
+ * realtime) -> PCM file -> FLAC encode -> true .flac.
+ *
+ * Returns null on ANY failure (no FLAC encoder, secure-output refusal, decode
+ * error, timeout) so the caller falls back to the license-persisted
+ * encrypted path. All player calls happen on the calling thread; listener
+ * callbacks only resume the coroutine.
+ */
+@UnstableApi
+@Singleton
+class ModuleFlacTranscoder @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val drmFactory: ModuleDrmFactory,
+ private val segBridge: SegmentedDashBridge,
+) {
+ companion object {
+ private const val MAX_TRANSCODE_MS = 10 * 60 * 1000L
+ private const val PUMP_BYTES = 64 * 1024
+ }
+
+ suspend fun transcodeToFlac(
+ sourceFile: File,
+ descriptor: SegmentedStreamDescriptor,
+ title: String,
+ artist: String,
+ album: String?,
+ artworkUri: String?,
+ ): TranscodedFlac? = withContext(Dispatchers.IO) {
+ runCatching {
+ withTimeout(MAX_TRANSCODE_MS) {
+ doTranscode(sourceFile, descriptor, title, artist, album, artworkUri)
+ }
+ }.getOrNull()
+ }
+
+ private suspend fun doTranscode(
+ sourceFile: File,
+ descriptor: SegmentedStreamDescriptor,
+ title: String,
+ artist: String,
+ album: String?,
+ artworkUri: String?,
+ ): TranscodedFlac {
+ val pcmFile = File.createTempFile("trans_pcm_", ".pcm", context.cacheDir)
+ val sink = CapturingAudioSink(pcmFile)
+ // Local-file MPD: same bytes the download verified, no network except
+ // the streaming license exchange (module envelope via the callback).
+ val localMpd = segBridge.mpdUriForBase(
+ descriptor,
+ Uri.fromFile(sourceFile).toString(),
+ )
+ val item = androidx.media3.common.MediaItem.Builder()
+ .setMediaId("transcode:${descriptor.trackId}")
+ .setUri(localMpd)
+ .setMimeType(MimeTypes.APPLICATION_MPD)
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(title).setArtist(artist).setAlbumTitle(album)
+ .setArtworkUri(artworkUri?.let(Uri::parse))
+ .setIsPlayable(true).build(),
+ )
+ .apply {
+ descriptor.drm?.let { drm ->
+ if (!drm.pssh.isBlank() && !drm.licenseUrl.isBlank()) {
+ setDrmConfiguration(
+ androidx.media3.common.MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
+ .setLicenseUri(drm.licenseUrl)
+ .setMultiSession(false)
+ .apply {
+ val headers = descriptor.headers.toMutableMap()
+ if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
+ headers["User-Agent"] = "LastWave/4.3.0"
+ }
+ setLicenseRequestHeaders(headers)
+ }
+ .build(),
+ )
+ }
+ }
+ }
+ .build()
+
+ // NOTE: deliberately NOT reusing ModuleDrmFactory.sessionManagerFor
+ // here: that manager is tuned for the foreground player; the transcode
+ // player owns an isolated DefaultDrmSessionManager with the same
+ // module-envelope callback so license behavior stays identical.
+ val renderers = object : DefaultRenderersFactory(context) {
+ override fun buildAudioSink(
+ context: Context,
+ enableFloatOutput: Boolean,
+ enableAudioTrackPlaybackParams: Boolean,
+ ): androidx.media3.exoplayer.audio.AudioSink = sink
+ }
+ val mediaSourceFactory = DefaultMediaSourceFactory(context)
+ .setDrmSessionManagerProvider { drmFactory.sessionManagerFor(descriptor) }
+ val player = ExoPlayer.Builder(context, renderers)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .build()
+ try {
+ awaitEnded(player, item)
+ if (!sink.isConfigured() || sink.framesWritten <= 0) {
+ error("No PCM captured")
+ }
+ val flacFile = File.createTempFile("trans_flac_", ".flac", context.cacheDir)
+ if (!encodePcmToFlac(pcmFile, sink.sampleRate, sink.channelCount, flacFile)) {
+ runCatching { flacFile.delete() }
+ error("FLAC encode unavailable")
+ }
+ return TranscodedFlac(flacFile, sink.sampleRate, sink.channelCount)
+ } finally {
+ runCatching { player.release() }
+ runCatching { pcmFile.delete() }
+ }
+ }
+
+ private suspend fun awaitEnded(
+ player: ExoPlayer,
+ item: androidx.media3.common.MediaItem,
+ ): Unit = suspendCancellableCoroutine { cont ->
+ val listener = object : Player.Listener {
+ override fun onPlaybackStateChanged(state: Int) {
+ if (state == Player.STATE_ENDED && cont.isActive) {
+ player.removeListener(this)
+ cont.resume(Unit)
+ }
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ if (cont.isActive) {
+ player.removeListener(this)
+ cont.resumeWithException(error)
+ }
+ }
+ }
+ player.addListener(listener)
+ cont.invokeOnCancellation {
+ runCatching { player.removeListener(listener) }
+ }
+ player.setMediaItem(item)
+ player.prepare()
+ player.playWhenReady = true
+ }
+
+ /**
+ * PCM16 file -> framed .flac. STREAMINFO comes from the encoder's csd-0;
+ * frames are appended verbatim (encoder output is already framed).
+ */
+ private fun encodePcmToFlac(pcmFile: File, sampleRate: Int, channels: Int, outFile: File): Boolean {
+ if (sampleRate <= 0 || channels <= 0) return false
+ val codec = try {
+ MediaCodec.createEncoderByType(MimeTypes.AUDIO_FLAC)
+ } catch (_: Exception) {
+ return false
+ }
+ try {
+ val format = MediaFormat().apply {
+ setString(MediaFormat.KEY_MIME, MimeTypes.AUDIO_FLAC)
+ setInteger(MediaFormat.KEY_SAMPLE_RATE, sampleRate)
+ setInteger(MediaFormat.KEY_CHANNEL_COUNT, channels)
+ }
+ codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
+ codec.start()
+ var streamInfo: ByteArray? = null
+ val pendingFrames = mutableListOf()
+ var out: java.io.OutputStream? = null
+ fun ensureOutput(): java.io.OutputStream {
+ var o = out
+ if (o == null) {
+ val csd = streamInfo ?: error("no STREAMINFO")
+ o = outFile.outputStream().buffered()
+ o.write("fLaC".toByteArray(Charsets.US_ASCII))
+ // METADATA_BLOCK_HEADER: last-block + STREAMINFO(0) + length 34
+ o.write(byteArrayOf(0x90.toByte(), 0x00, 0x00, 0x22.toByte()))
+ o.write(csd)
+ for (f in pendingFrames) o.write(f)
+ pendingFrames.clear()
+ out = o
+ }
+ return o
+ }
+ fun onStreamInfo(bytes: ByteArray) {
+ if (streamInfo == null) {
+ streamInfo = bytes
+ // Frames may have arrived before the header: flush them now.
+ if (pendingFrames.isNotEmpty()) ensureOutput()
+ }
+ }
+ pcmFile.inputStream().buffered().use { input ->
+ var inputEos = false
+ var outputEos = false
+ val bufferInfo = MediaCodec.BufferInfo()
+ val chunk = ByteArray(PUMP_BYTES)
+ while (!outputEos) {
+ if (!inputEos) {
+ val inIndex = codec.dequeueInputBuffer(10_000)
+ if (inIndex >= 0) {
+ val inBuf = codec.getInputBuffer(inIndex) ?: error("null input")
+ inBuf.clear()
+ var read = 0
+ while (read < inBuf.remaining()) {
+ val n = input.read(chunk, 0, minOf(chunk.size, inBuf.remaining()))
+ if (n < 0) break
+ inBuf.put(chunk, 0, n)
+ read += n
+ }
+ if (read <= 0) {
+ codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+ inputEos = true
+ } else {
+ codec.queueInputBuffer(inIndex, 0, read, 0, 0)
+ }
+ }
+ }
+ val outIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000)
+ when {
+ outIndex >= 0 -> {
+ val outBuf = codec.getOutputBuffer(outIndex)
+ if (outBuf != null && bufferInfo.size > 0) {
+ val bytes = ByteArray(bufferInfo.size)
+ outBuf.position(bufferInfo.offset)
+ outBuf.limit(bufferInfo.offset + bufferInfo.size)
+ outBuf.get(bytes)
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
+ onStreamInfo(bytes)
+ } else if (streamInfo != null) {
+ ensureOutput().write(bytes)
+ } else {
+ pendingFrames.add(bytes)
+ }
+ }
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
+ outputEos = true
+ }
+ codec.releaseOutputBuffer(outIndex, false)
+ }
+ outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
+ codec.outputFormat.getByteBuffer("csd-0")?.let { csdBuf ->
+ val bytes = ByteArray(csdBuf.remaining())
+ csdBuf.get(bytes)
+ onStreamInfo(bytes)
+ }
+ }
+ // INFO_TRY_AGAIN_LATER: loop.
+ }
+ }
+ }
+ if (out == null && streamInfo != null && pendingFrames.isNotEmpty()) {
+ ensureOutput()
+ }
+ out?.flush()
+ out?.close()
+ return streamInfo != null && outFile.length() > 42
+ } catch (_: Exception) {
+ runCatching { outFile.delete() }
+ return false
+ } finally {
+ runCatching { codec.stop() }
+ runCatching { codec.release() }
+ }
+ }
+}
diff --git a/app/src/main/java/com/lastwave/app/data/lyrics/LyricsRepository.kt b/app/src/main/java/com/lastwave/app/data/lyrics/LyricsRepository.kt
index 4a7545b35..fac7cac3b 100644
--- a/app/src/main/java/com/lastwave/app/data/lyrics/LyricsRepository.kt
+++ b/app/src/main/java/com/lastwave/app/data/lyrics/LyricsRepository.kt
@@ -74,6 +74,30 @@ sealed interface LyricsResult {
data class Error(val message: String) : LyricsResult
}
+fun providerForSource(source: String?): com.lastwave.app.data.local.LyricsProvider? {
+ if (source.isNullOrBlank()) return null
+ val s = source.lowercase()
+ return when {
+ s.startsWith("lastwave") -> com.lastwave.app.data.local.LyricsProvider.LASTWAVE
+ s.startsWith("lrc.red") -> com.lastwave.app.data.local.LyricsProvider.LRC_RED
+ s.startsWith("apple music") -> com.lastwave.app.data.local.LyricsProvider.APPLE_MUSIC
+ s.startsWith("betterlyrics") -> com.lastwave.app.data.local.LyricsProvider.BETTER_LYRICS
+ s.startsWith("qq karaoke") || s.startsWith("portato") -> com.lastwave.app.data.local.LyricsProvider.BETTER_LYRICS_PORTATO
+ s.startsWith("kugou") -> com.lastwave.app.data.local.LyricsProvider.KUGOU
+ s.startsWith("syllable-sync") || s.startsWith("bini") -> com.lastwave.app.data.local.LyricsProvider.BINI_LYRICS
+ s.startsWith("video-match") || s.startsWith("simp") -> com.lastwave.app.data.local.LyricsProvider.SIMP_MUSIC
+ s.startsWith("catalog") || s.startsWith("musixmatch") -> com.lastwave.app.data.local.LyricsProvider.MUSIXMATCH
+ s.startsWith("lrclib") -> com.lastwave.app.data.local.LyricsProvider.LRCLIB
+ s.startsWith("lyricsplus") -> com.lastwave.app.data.local.LyricsProvider.LYRICS_PLUS
+ s.startsWith("unison") -> com.lastwave.app.data.local.LyricsProvider.UNISON
+ s.startsWith("youtube captions") -> com.lastwave.app.data.local.LyricsProvider.YOUTUBE_CAPTIONS
+ s.startsWith("youtube music") -> com.lastwave.app.data.local.LyricsProvider.YOUTUBE_MUSIC
+ s.startsWith("megalobiz") -> com.lastwave.app.data.local.LyricsProvider.MEGALOBIZ
+ s.startsWith("genius") -> com.lastwave.app.data.local.LyricsProvider.GENIUS
+ else -> null
+ }
+}
+
@Singleton
class LyricsRepository @Inject constructor(
private val betterLyricsApi: BetterLyricsApi,
@@ -114,9 +138,13 @@ class LyricsRepository @Inject constructor(
isrc: String? = null,
/** Local songs: `.lrc` imported next to the audio file; wins over every provider. */
localLrcPath: String? = null,
+ overrideProvider: com.lastwave.app.data.local.LyricsProvider? = null,
+ excludedProviders: Set = emptySet(),
onPartialResult: suspend (LyricsResult.Success) -> Unit = {},
): LyricsResult = withContext(Dispatchers.Default) {
- if (!localLrcPath.isNullOrBlank()) {
+ val skipLocal = (overrideProvider != null && overrideProvider != com.lastwave.app.data.local.LyricsProvider.AUTO) ||
+ excludedProviders.isNotEmpty()
+ if (!skipLocal && !localLrcPath.isNullOrBlank()) {
val lrc = runCatching {
java.io.File(localLrcPath).takeIf { it.isFile && it.length() > 0 }?.readText()
}.getOrNull()
@@ -139,11 +167,13 @@ class LyricsRepository @Inject constructor(
// User-chosen provider order: tried first, automatic fallback to the
// rest when it returns nothing. Part of the cache key so switching
// providers never serves the previous provider's result.
- val preferred = runCatching {
+ val defaultPref = runCatching {
settingsPreferences.settings.first().lyricsProvider
}.getOrDefault(com.lastwave.app.data.local.LyricsProvider.AUTO)
+ val preferred = if (excludedProviders.contains(defaultPref)) com.lastwave.app.data.local.LyricsProvider.AUTO else defaultPref
val effectiveVideoId = videoId?.trim()?.takeIf { it.isNotBlank() }
- val cacheKey = "${effectiveVideoId ?: ""}|${artist.trim().lowercase()}|${title.trim().lowercase()}|${album?.trim()?.lowercase()}|$durationSeconds|$wordByWord|${preferred.id}"
+ val excludedKey = excludedProviders.map { it.id }.sorted().joinToString(",")
+ val cacheKey = "${effectiveVideoId ?: ""}|${artist.trim().lowercase()}|${title.trim().lowercase()}|${album?.trim()?.lowercase()}|$durationSeconds|$wordByWord|${preferred.id}|${overrideProvider?.id ?: ""}|$excludedKey"
// Bounded: a long session must not grow this map without limit.
// Eviction is coarse (oldest-first is untracked); a miss re-fetches
// in milliseconds, so dropping hot entries only costs one fetch.
@@ -157,39 +187,59 @@ class LyricsRepository @Inject constructor(
}
var localLyrics: LyricsResult.Success? = null
- run {
- // 0. LOCAL OFFLINE: Check if this track is downloaded with embedded or saved lyrics.
- // "Artist - Title" and "Title (feat. X)" variants must still hit the
- // same row, otherwise a correct offline copy is skipped and a
- // wrong online hit is served instead.
- val localTrack = runCatching {
- val dao = downloadedTrackDao.get()
- dao.findByTitleAndArtist(title, artist)
- ?: dao.findByTrackKey("${artist.lowercase()}_${title.lowercase()}")
- ?: run {
- val ct = LrclibLyricsApi.cleanTrackTitle(title)
- val ca = LrclibLyricsApi.cleanArtistName(artist)
- if (ct != title || ca != artist) dao.findByTitleAndArtist(ct, ca) else null
+ if (!skipLocal) {
+ run {
+ // 0. LOCAL OFFLINE: Check if this track is downloaded with embedded or saved lyrics.
+ // "Artist - Title" and "Title (feat. X)" variants must still hit the
+ // same row, otherwise a correct offline copy is skipped and a
+ // wrong online hit is served instead.
+ val localTrack = runCatching {
+ val dao = downloadedTrackDao.get()
+ dao.findByTitleAndArtist(title, artist)
+ ?: dao.findByTrackKey("${artist.lowercase()}_${title.lowercase()}")
+ ?: run {
+ val ct = LrclibLyricsApi.cleanTrackTitle(title)
+ val ca = LrclibLyricsApi.cleanArtistName(artist)
+ if (ct != title || ca != artist) dao.findByTitleAndArtist(ct, ca) else null
+ }
+ }.getOrNull()
+ if (localTrack != null && (localTrack.hasLyrics || !localTrack.plainLyrics.isNullOrBlank() || !localTrack.syncedLyrics.isNullOrBlank() || !localTrack.lrcFilePath.isNullOrBlank())) {
+ var synced = localTrack.syncedLyrics
+ if (synced.isNullOrBlank() && !localTrack.lrcFilePath.isNullOrBlank()) {
+ val lrcFile = java.io.File(localTrack.lrcFilePath)
+ if (lrcFile.exists() && lrcFile.length() > 0) {
+ synced = runCatching { lrcFile.readText() }.getOrNull()
+ }
}
- }.getOrNull()
- if (localTrack != null && (localTrack.hasLyrics || !localTrack.plainLyrics.isNullOrBlank() || !localTrack.syncedLyrics.isNullOrBlank() || !localTrack.lrcFilePath.isNullOrBlank())) {
- var synced = localTrack.syncedLyrics
- if (synced.isNullOrBlank() && !localTrack.lrcFilePath.isNullOrBlank()) {
- val lrcFile = java.io.File(localTrack.lrcFilePath)
- if (lrcFile.exists() && lrcFile.length() > 0) {
- synced = runCatching { lrcFile.readText() }.getOrNull()
+ if (!synced.isNullOrBlank()) {
+ val lines = parseLrc(synced)
+ if (lines.isNotEmpty()) {
+ val result = LyricsResult.Success(
+ lines = lines,
+ isSynced = true,
+ isWordSynced = lines.any { it.hasSyllables },
+ plainLyrics = localTrack.plainLyrics,
+ isInstrumental = false,
+ source = "Downloaded Lyrics (LRC)",
+ )
+ if (!wordByWord) {
+ cache[cacheKey] = result
+ return@withContext result
+ }
+ localLyrics = result
+ onPartialResult(result)
+ return@run
+ }
}
- }
- if (!synced.isNullOrBlank()) {
- val lines = parseLrc(synced)
- if (lines.isNotEmpty()) {
+ val plain = localTrack.plainLyrics
+ if (!plain.isNullOrBlank()) {
val result = LyricsResult.Success(
- lines = lines,
- isSynced = true,
- isWordSynced = lines.any { it.hasSyllables },
- plainLyrics = localTrack.plainLyrics,
+ lines = emptyList(),
+ isSynced = false,
+ isWordSynced = false,
+ plainLyrics = plain.trim(),
isInstrumental = false,
- source = "Downloaded Lyrics (LRC)",
+ source = "Downloaded Lyrics (Plain)",
)
if (!wordByWord) {
cache[cacheKey] = result
@@ -197,26 +247,8 @@ class LyricsRepository @Inject constructor(
}
localLyrics = result
onPartialResult(result)
- return@run
}
}
- val plain = localTrack.plainLyrics
- if (!plain.isNullOrBlank()) {
- val result = LyricsResult.Success(
- lines = emptyList(),
- isSynced = false,
- isWordSynced = false,
- plainLyrics = plain.trim(),
- isInstrumental = false,
- source = "Downloaded Lyrics (Plain)",
- )
- if (!wordByWord) {
- cache[cacheKey] = result
- return@withContext result
- }
- localLyrics = result
- onPartialResult(result)
- }
}
}
@@ -245,12 +277,37 @@ class LyricsRepository @Inject constructor(
}
}
+ // On-the-spot specific provider override: if the user explicitly picked
+ // an individual provider, query only that provider directly.
+ if (overrideProvider != null && overrideProvider != com.lastwave.app.data.local.LyricsProvider.AUTO) {
+ val directHit = if (overrideProvider == com.lastwave.app.data.local.LyricsProvider.LRCLIB) {
+ fetchFromLrclib(title, artist, album, durationSeconds)
+ } else {
+ fetchPreferredWord(
+ preferred = overrideProvider,
+ title = title,
+ artist = artist,
+ album = album,
+ durationSeconds = durationSeconds,
+ videoId = effectiveVideoId,
+ isrc = recordingIsrc,
+ biniHit = biniHit,
+ )
+ }
+ if (directHit != null) {
+ cache[cacheKey] = directHit
+ return@withContext directHit
+ } else {
+ return@withContext LyricsResult.Empty
+ }
+ }
+
// LRCLIB chosen explicitly: try it before the word providers so
// the preference is honored. Word-sync found later still wins;
// its line-sync result outranks every other fallback below.
var lrclibAttempted = false
var preferredFallback: LyricsResult.Success? = null
- if (preferred == com.lastwave.app.data.local.LyricsProvider.LRCLIB) {
+ if (preferred == com.lastwave.app.data.local.LyricsProvider.LRCLIB && !excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.LRCLIB)) {
lrclibAttempted = true
val first = fetchFromLrclib(title, artist, album, durationSeconds)
if (first != null) {
@@ -268,7 +325,7 @@ class LyricsRepository @Inject constructor(
// word-sync (or instrumental) short-circuits; its line-sync
// result is stashed as the top fallback so a word-synced hit
// from anywhere else still wins.
- if (preferred.isWordProvider) {
+ if (preferred.isWordProvider && !excludedProviders.contains(preferred)) {
val single = withTimeoutOrNull(PREFERRED_HEAD_START_MS) {
fetchPreferredWord(
preferred = preferred,
@@ -296,74 +353,76 @@ class LyricsRepository @Inject constructor(
var lineFallback: LyricsResult.Success? = null
val wordResult = withTimeoutOrNull(RACE_TOTAL_MS) {
coroutineScope {
- val requests = mutableListOf(
- async {
- fetchWordFromLastWave(title, artist, album, durationSeconds, recordingIsrc, effectiveVideoId)
- },
- async {
- fetchWordFromLrcRed(title, artist, album, durationSeconds, recordingIsrc, biniHit, effectiveVideoId)
- },
- async {
- fetchWordFromAppleMusic(title, artist, album, durationSeconds)
- },
- async {
- fetchWordFromBini(title, artist, album, durationSeconds, recordingIsrc, biniHit, effectiveVideoId)
- },
- async {
- fetchWordFromBetterLyrics(title, artist, album, durationSeconds)
- },
- async {
- fetchWordFromKugou(title, artist, durationSeconds)
- },
- async {
- fetchWordFromSimpMusic(effectiveVideoId, durationSeconds)
- },
- async {
- fetchWordFromPortato(title, artist, album, durationSeconds)
- },
- async {
- fetchWordFromLyricsPlus(title, artist, album, durationSeconds, recordingIsrc)
- },
- async {
- fetchWordFromUnison(title, artist, album, durationSeconds)
- },
- async {
- fetchWordFromYouTubeCaptions(effectiveVideoId, durationSeconds)
- },
- )
- try {
- while (requests.isNotEmpty()) {
- val (request, result) = select {
- requests.forEach { request ->
- request.onAwait { request to it }
+ val requests = mutableListOf>()
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.LASTWAVE)) {
+ requests.add(async { fetchWordFromLastWave(title, artist, album, durationSeconds, recordingIsrc, effectiveVideoId) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.LRC_RED)) {
+ requests.add(async { fetchWordFromLrcRed(title, artist, album, durationSeconds, recordingIsrc, biniHit, effectiveVideoId) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.APPLE_MUSIC)) {
+ requests.add(async { fetchWordFromAppleMusic(title, artist, album, durationSeconds) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.BINI_LYRICS)) {
+ requests.add(async { fetchWordFromBini(title, artist, album, durationSeconds, recordingIsrc, biniHit, effectiveVideoId) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.BETTER_LYRICS)) {
+ requests.add(async { fetchWordFromBetterLyrics(title, artist, album, durationSeconds) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.KUGOU)) {
+ requests.add(async { fetchWordFromKugou(title, artist, durationSeconds) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.SIMP_MUSIC)) {
+ requests.add(async { fetchWordFromSimpMusic(effectiveVideoId, durationSeconds) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.BETTER_LYRICS_PORTATO)) {
+ requests.add(async { fetchWordFromPortato(title, artist, album, durationSeconds) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.LYRICS_PLUS)) {
+ requests.add(async { fetchWordFromLyricsPlus(title, artist, album, durationSeconds, recordingIsrc) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.UNISON)) {
+ requests.add(async { fetchWordFromUnison(title, artist, album, durationSeconds) })
+ }
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.YOUTUBE_CAPTIONS)) {
+ requests.add(async { fetchWordFromYouTubeCaptions(effectiveVideoId, durationSeconds) })
+ }
+ if (requests.isEmpty()) {
+ return@coroutineScope null
+ }
+ try {
+ while (requests.isNotEmpty()) {
+ val (request, result) = select, LyricsResult.Success?>> {
+ requests.forEach { req ->
+ req.onAwait { res -> req to res }
+ }
}
- }
- requests.remove(request)
- if (result?.isWordSynced == true) {
- // Fastest word-sync wins — but only if its
- // timeline plausibly fits this recording. A
- // wrong-cut hit keeps racing as a line fallback
- // instead of locking in broken sync.
- if (result.isInstrumental || plausibleDuration(result.lines, durationSeconds)) {
- return@coroutineScope result
+ requests.remove(request)
+ if (result?.isWordSynced == true) {
+ // Fastest word-sync wins — but only if its
+ // timeline plausibly fits this recording. A
+ // wrong-cut hit keeps racing as a line fallback
+ // instead of locking in broken sync.
+ if (result.isInstrumental || plausibleDuration(result.lines, durationSeconds)) {
+ return@coroutineScope result
+ }
+ }
+ // Line-sync fallback must also fit the recording: an
+ // unchecked wrong-cut timeline is exactly how plain
+ // line-by-line sync breaks.
+ if (result != null && lineFallback == null &&
+ (result.isInstrumental || plausibleDuration(result.lines, durationSeconds))
+ ) {
+ lineFallback = result
+ onPartialResult(result)
}
}
- // Line-sync fallback must also fit the recording: an
- // unchecked wrong-cut timeline is exactly how plain
- // line-by-line sync breaks.
- if (result != null && lineFallback == null &&
- (result.isInstrumental || plausibleDuration(result.lines, durationSeconds))
- ) {
- lineFallback = result
- onPartialResult(result)
- }
+ lineFallback
+ } finally {
+ requests.forEach { it.cancel() }
}
- lineFallback
- } finally {
- requests.forEach { it.cancel() }
}
}
- }
if (wordResult?.isWordSynced == true) {
cache[cacheKey] = wordResult
return@withContext wordResult
@@ -382,9 +441,11 @@ class LyricsRepository @Inject constructor(
}
// Extra line-sync catalogue: biggest database, tried after the
// word race so a timed hit from anywhere above still wins.
- fetchWordFromMusixmatch(title, artist, durationSeconds)?.let { mxm ->
- cache[cacheKey] = mxm
- return@withContext mxm
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.MUSIXMATCH)) {
+ fetchWordFromMusixmatch(title, artist, durationSeconds)?.let { mxm ->
+ cache[cacheKey] = mxm
+ return@withContext mxm
+ }
}
}
localLyrics?.let {
@@ -395,7 +456,7 @@ class LyricsRepository @Inject constructor(
// Fall back to LRCLIB line-by-line sync (skipped when it was
// already tried as the preferred provider above).
- if (!lrclibAttempted) {
+ if (!lrclibAttempted && !excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.LRCLIB)) {
fetchFromLrclib(title, artist, album, durationSeconds)?.let { result ->
// A local synced copy outranks a remote plain-only hit:
// keep scrolling lines over static text.
@@ -423,17 +484,23 @@ class LyricsRepository @Inject constructor(
// 4. LAST RESORTS: community-scraped line-sync, then the video's own
// plain text (exact cut, no timing), then the web catalogue. Reached
// only when every timed source above found nothing.
- fetchWordFromMegalobiz(title, artist, durationSeconds)?.let { scraped ->
- cache[cacheKey] = scraped
- return@withContext scraped
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.MEGALOBIZ)) {
+ fetchWordFromMegalobiz(title, artist, durationSeconds)?.let { scraped ->
+ cache[cacheKey] = scraped
+ return@withContext scraped
+ }
}
- fetchPlainFromYouTubeMusic(effectiveVideoId)?.let { videoLyrics ->
- cache[cacheKey] = videoLyrics
- return@withContext videoLyrics
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.YOUTUBE_MUSIC)) {
+ fetchPlainFromYouTubeMusic(effectiveVideoId)?.let { videoLyrics ->
+ cache[cacheKey] = videoLyrics
+ return@withContext videoLyrics
+ }
}
- fetchPlainFromGenius(title, artist)?.let { webLyrics ->
- cache[cacheKey] = webLyrics
- return@withContext webLyrics
+ if (!excludedProviders.contains(com.lastwave.app.data.local.LyricsProvider.GENIUS)) {
+ fetchPlainFromGenius(title, artist)?.let { webLyrics ->
+ cache[cacheKey] = webLyrics
+ return@withContext webLyrics
+ }
}
// 5. FALLBACK: If all fail, return Empty (no lyrics)
diff --git a/app/src/main/java/com/lastwave/app/data/plugin/SegmentedDashBridge.kt b/app/src/main/java/com/lastwave/app/data/plugin/SegmentedDashBridge.kt
index 6dd8162a6..fc53660e1 100644
--- a/app/src/main/java/com/lastwave/app/data/plugin/SegmentedDashBridge.kt
+++ b/app/src/main/java/com/lastwave/app/data/plugin/SegmentedDashBridge.kt
@@ -1,181 +1,181 @@
-package com.lastwave.app.data.plugin
-
-import android.content.Context
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.common.MediaItem
-import androidx.media3.common.MimeTypes
-import androidx.media3.common.util.UnstableApi
-import com.lastwave.app.data.plugin.SegmentedMpdBuilder
-import com.lastwave.app.data.plugin.SegmentedStreamDescriptor
-import com.lastwave.app.data.plugin.stableCacheKey
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.io.File
-import java.security.MessageDigest
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Chunk-by-chunk provider path into the existing MusicPlayer.
- *
- * Input: [SegmentedStreamDescriptor] from a provider module's `getPlayback()`
- * call (baseUrl + init range + N mediaRange segments + Widevine pssh/license).
- * Output: a [MediaItem] the current ExoPlayer graph can open directly:
- * MPD file uri + Widevine [MediaItem.DrmConfiguration] + stable cache key.
- *
- * Decryption stays inside the CDM: ExoPlayer fetches each chunk with a Range
- * header, MediaCrypto decrypts, and clear PCM flows to the existing audio
- * sink. No manual AES, no key exposure. Seeks map to segment indexes.
- */
-@UnstableApi
-@Singleton
-class SegmentedDashBridge @Inject constructor(
- @ApplicationContext private val context: Context,
-) {
- fun isSegmentedCacheKey(cacheKey: String?): Boolean =
- cacheKey?.startsWith("segdrm:") == true
-
- /** MPD uri for [descriptor]; rewrites the file only when content changes. */
- fun mpdUri(descriptor: SegmentedStreamDescriptor): Uri =
- mpdUriForBase(descriptor, descriptor.stream.baseUrl)
-
- /** Offline variant: same chunks, local audio file as the base. */
- fun mpdUriForBase(descriptor: SegmentedStreamDescriptor, baseUrl: String): Uri {
- val mpd = when {
- baseUrl.startsWith("data:application/dash+xml;base64,") -> {
- SegmentedMpdBuilder.sanitizeMpdXml(
- String(android.util.Base64.decode(baseUrl.substringAfter("base64,"), android.util.Base64.DEFAULT), Charsets.UTF_8)
- )
- }
- baseUrl.startsWith(" SegmentedMpdBuilder.sanitizeMpdXml(baseUrl)
- else -> SegmentedMpdBuilder.build(descriptor, baseUrl)
- }
- val digest = MessageDigest.getInstance("SHA-256")
- .digest(mpd.toByteArray(Charsets.UTF_8))
- .joinToString("") { "%02x".format(it) }
- .take(16)
- val dir = File(context.cacheDir, "segdrm_mpd").apply { mkdirs() }
- val file = File(dir, "seg_${descriptor.trackId}_${descriptor.stream.quality}_$digest.mpd")
- if (!file.exists()) file.writeText(mpd, Charsets.UTF_8)
- return Uri.fromFile(file)
- }
-
- fun drmConfiguration(descriptor: SegmentedStreamDescriptor): MediaItem.DrmConfiguration? =
- drmConfigurationFor(descriptor)
-
- companion object {
- /** Pure helper so non-injected call sites can attach DRM to items. */
- fun drmConfigurationFor(descriptor: SegmentedStreamDescriptor): MediaItem.DrmConfiguration? {
- val drm = descriptor.drm ?: return null
- if (drm.licenseUrl.isBlank() && drm.keySetIdB64.isBlank()) return null
- if (drm.pssh.isBlank() && drm.keySetIdB64.isBlank()) return null
- // Offline download: persisted keys unlock without network.
- drm.keySetIdB64.takeIf { it.isNotBlank() }?.let { keys ->
- val builder = MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
- .setKeySetId(android.util.Base64.decode(keys, android.util.Base64.DEFAULT))
- .setMultiSession(false)
- if (drm.licenseUrl.isNotBlank()) builder.setLicenseUri(drm.licenseUrl)
- return builder.build()
- }
- if (drm.licenseUrl.isBlank() || drm.pssh.isBlank()) return null
- return MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
- .setLicenseUri(drm.licenseUrl)
- .setMultiSession(false)
- .setForceDefaultLicenseUri(true)
- .apply {
- val headers = descriptor.headers.toMutableMap()
- // ExoPlayer still needs a UA on the license POST.
- if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
- headers["User-Agent"] = "LastWave/4.2.4"
- }
- setLicenseRequestHeaders(headers)
- }
- .build()
- }
-
- /**
- * Pure label mapping — no context needed, so it is unit-testable.
- *
- * Dolby Atmos is signalled by the codec the provider module emits
- * ("atmos" = E-AC-3 JOC spatial) as much as by the quality tier, so
- * check both. This must never fall through to a stereo/OPUS label or
- * the player + download badges silently drop the Atmos mix.
- */
- fun audioBadgeLabel(descriptor: SegmentedStreamDescriptor): String {
- if (descriptor.stream.codec.equals("atmos", ignoreCase = true)) return "DOLBY ATMOS"
- val codec = descriptor.stream.codec.lowercase()
- return when (descriptor.stream.quality.uppercase()) {
- "ATMOS", "DOLBY_ATMOS" -> "DOLBY ATMOS"
- "UHD", "HI_RES_96" -> "UHD FLAC"
- "HD" -> "HD FLAC"
- "SD" -> if (codec.contains("mp3")) "320k MP3" else "AAC 320"
- "LOW" -> if (codec.contains("opus")) "OPUS" else "HE-AAC"
- else -> when {
- codec.contains("flac") -> if (((descriptor.stream.bitDepth) ?: 0) > 16 || (descriptor.stream.sampleRate) > 48000) "UHD FLAC" else "HD FLAC"
- codec.contains("mp3") -> "320k MP3"
- codec.contains("aac") || codec.contains("mp4a") -> if (descriptor.stream.bandwidth in 1..128000) "HE-AAC" else "AAC 320"
- codec.contains("opus") -> "OPUS"
- else -> "AUDIO"
- }
- }
- }
-
- /**
- * The codec actually declared inside the descriptor's DASH manifest, or
- * null when it cannot be read.
- *
- * This is the source of truth for what the bytes really are: the addon's
- * descriptor can claim `flac`/`UHD` even when the manifest it fetched is
- * AAC (Tidal silently answers the HIGH tier with `mp4a.40.2`), so both the
- * player badge and the download path must trust this over `stream.codec`.
- */
- fun declaredManifestCodec(descriptor: SegmentedStreamDescriptor): String? = runCatching {
- val baseUrl = descriptor.stream.baseUrl
- val xml = when {
- baseUrl.startsWith("data:application/dash+xml;base64,") -> String(
- android.util.Base64.decode(baseUrl.substringAfter("base64,"), android.util.Base64.DEFAULT),
- Charsets.UTF_8,
- )
- baseUrl.startsWith(" baseUrl
- else -> return@runCatching null
- }
- Regex("""codecs="([^"]+)"""").find(xml)
- ?.groupValues
- ?.get(1)
- ?.trim()
- ?.lowercase()
- ?.takeIf { it.isNotBlank() }
- }.getOrNull()
- }
-
- /** Full MediaItem for a segmented track; caller sets title/artist metadata. */
- fun toMediaItem(
- descriptor: SegmentedStreamDescriptor,
- title: String,
- artist: String,
- album: String?,
- artworkUri: Uri?,
- ): MediaItem {
- val builder = MediaItem.Builder()
- .setMediaId("segdrm:${descriptor.trackId}")
- .setUri(mpdUri(descriptor))
- .setMimeType(MimeTypes.APPLICATION_MPD)
- .setCustomCacheKey(descriptor.stableCacheKey())
- .setMediaMetadata(
- androidx.media3.common.MediaMetadata.Builder()
- .setTitle(title.ifBlank { descriptor.title })
- .setArtist(artist.ifBlank { descriptor.artist })
- .setAlbumTitle(album ?: descriptor.album)
- .setArtworkUri(artworkUri)
- .setIsPlayable(true)
- .build(),
- )
- drmConfiguration(descriptor)?.let(builder::setDrmConfiguration)
- return builder.build()
- }
-
- fun audioBadge(descriptor: SegmentedStreamDescriptor): String = audioBadgeLabel(descriptor)
-
- fun declaredManifestCodec(descriptor: SegmentedStreamDescriptor): String? =
- Companion.declaredManifestCodec(descriptor)
-}
+package com.lastwave.app.data.plugin
+
+import android.content.Context
+import android.net.Uri
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.util.UnstableApi
+import com.lastwave.app.data.plugin.SegmentedMpdBuilder
+import com.lastwave.app.data.plugin.SegmentedStreamDescriptor
+import com.lastwave.app.data.plugin.stableCacheKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.security.MessageDigest
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Chunk-by-chunk provider path into the existing MusicPlayer.
+ *
+ * Input: [SegmentedStreamDescriptor] from a provider module's `getPlayback()`
+ * call (baseUrl + init range + N mediaRange segments + Widevine pssh/license).
+ * Output: a [MediaItem] the current ExoPlayer graph can open directly:
+ * MPD file uri + Widevine [MediaItem.DrmConfiguration] + stable cache key.
+ *
+ * Decryption stays inside the CDM: ExoPlayer fetches each chunk with a Range
+ * header, MediaCrypto decrypts, and clear PCM flows to the existing audio
+ * sink. No manual AES, no key exposure. Seeks map to segment indexes.
+ */
+@UnstableApi
+@Singleton
+class SegmentedDashBridge @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ fun isSegmentedCacheKey(cacheKey: String?): Boolean =
+ cacheKey?.startsWith("segdrm:") == true
+
+ /** MPD uri for [descriptor]; rewrites the file only when content changes. */
+ fun mpdUri(descriptor: SegmentedStreamDescriptor): Uri =
+ mpdUriForBase(descriptor, descriptor.stream.baseUrl)
+
+ /** Offline variant: same chunks, local audio file as the base. */
+ fun mpdUriForBase(descriptor: SegmentedStreamDescriptor, baseUrl: String): Uri {
+ val mpd = when {
+ baseUrl.startsWith("data:application/dash+xml;base64,") -> {
+ SegmentedMpdBuilder.sanitizeMpdXml(
+ String(android.util.Base64.decode(baseUrl.substringAfter("base64,"), android.util.Base64.DEFAULT), Charsets.UTF_8)
+ )
+ }
+ baseUrl.startsWith(" SegmentedMpdBuilder.sanitizeMpdXml(baseUrl)
+ else -> SegmentedMpdBuilder.build(descriptor, baseUrl)
+ }
+ val digest = MessageDigest.getInstance("SHA-256")
+ .digest(mpd.toByteArray(Charsets.UTF_8))
+ .joinToString("") { "%02x".format(it) }
+ .take(16)
+ val dir = File(context.cacheDir, "segdrm_mpd").apply { mkdirs() }
+ val file = File(dir, "seg_${descriptor.trackId}_${descriptor.stream.quality}_$digest.mpd")
+ if (!file.exists()) file.writeText(mpd, Charsets.UTF_8)
+ return Uri.fromFile(file)
+ }
+
+ fun drmConfiguration(descriptor: SegmentedStreamDescriptor): MediaItem.DrmConfiguration? =
+ drmConfigurationFor(descriptor)
+
+ companion object {
+ /** Pure helper so non-injected call sites can attach DRM to items. */
+ fun drmConfigurationFor(descriptor: SegmentedStreamDescriptor): MediaItem.DrmConfiguration? {
+ val drm = descriptor.drm ?: return null
+ if (drm.licenseUrl.isBlank() && drm.keySetIdB64.isBlank()) return null
+ if (drm.pssh.isBlank() && drm.keySetIdB64.isBlank()) return null
+ // Offline download: persisted keys unlock without network.
+ drm.keySetIdB64.takeIf { it.isNotBlank() }?.let { keys ->
+ val builder = MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
+ .setKeySetId(android.util.Base64.decode(keys, android.util.Base64.DEFAULT))
+ .setMultiSession(false)
+ if (drm.licenseUrl.isNotBlank()) builder.setLicenseUri(drm.licenseUrl)
+ return builder.build()
+ }
+ if (drm.licenseUrl.isBlank() || drm.pssh.isBlank()) return null
+ return MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
+ .setLicenseUri(drm.licenseUrl)
+ .setMultiSession(false)
+ .setForceDefaultLicenseUri(true)
+ .apply {
+ val headers = descriptor.headers.toMutableMap()
+ // ExoPlayer still needs a UA on the license POST.
+ if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
+ headers["User-Agent"] = "LastWave/4.3.0"
+ }
+ setLicenseRequestHeaders(headers)
+ }
+ .build()
+ }
+
+ /**
+ * Pure label mapping — no context needed, so it is unit-testable.
+ *
+ * Dolby Atmos is signalled by the codec the provider module emits
+ * ("atmos" = E-AC-3 JOC spatial) as much as by the quality tier, so
+ * check both. This must never fall through to a stereo/OPUS label or
+ * the player + download badges silently drop the Atmos mix.
+ */
+ fun audioBadgeLabel(descriptor: SegmentedStreamDescriptor): String {
+ if (descriptor.stream.codec.equals("atmos", ignoreCase = true)) return "DOLBY ATMOS"
+ val codec = descriptor.stream.codec.lowercase()
+ return when (descriptor.stream.quality.uppercase()) {
+ "ATMOS", "DOLBY_ATMOS" -> "DOLBY ATMOS"
+ "UHD", "HI_RES_96" -> "UHD FLAC"
+ "HD" -> "HD FLAC"
+ "SD" -> if (codec.contains("mp3")) "320k MP3" else "AAC 320"
+ "LOW" -> if (codec.contains("opus")) "OPUS" else "HE-AAC"
+ else -> when {
+ codec.contains("flac") -> if (((descriptor.stream.bitDepth) ?: 0) > 16 || (descriptor.stream.sampleRate) > 48000) "UHD FLAC" else "HD FLAC"
+ codec.contains("mp3") -> "320k MP3"
+ codec.contains("aac") || codec.contains("mp4a") -> if (descriptor.stream.bandwidth in 1..128000) "HE-AAC" else "AAC 320"
+ codec.contains("opus") -> "OPUS"
+ else -> "AUDIO"
+ }
+ }
+ }
+
+ /**
+ * The codec actually declared inside the descriptor's DASH manifest, or
+ * null when it cannot be read.
+ *
+ * This is the source of truth for what the bytes really are: the addon's
+ * descriptor can claim `flac`/`UHD` even when the manifest it fetched is
+ * AAC (Tidal silently answers the HIGH tier with `mp4a.40.2`), so both the
+ * player badge and the download path must trust this over `stream.codec`.
+ */
+ fun declaredManifestCodec(descriptor: SegmentedStreamDescriptor): String? = runCatching {
+ val baseUrl = descriptor.stream.baseUrl
+ val xml = when {
+ baseUrl.startsWith("data:application/dash+xml;base64,") -> String(
+ android.util.Base64.decode(baseUrl.substringAfter("base64,"), android.util.Base64.DEFAULT),
+ Charsets.UTF_8,
+ )
+ baseUrl.startsWith(" baseUrl
+ else -> return@runCatching null
+ }
+ Regex("""codecs="([^"]+)"""").find(xml)
+ ?.groupValues
+ ?.get(1)
+ ?.trim()
+ ?.lowercase()
+ ?.takeIf { it.isNotBlank() }
+ }.getOrNull()
+ }
+
+ /** Full MediaItem for a segmented track; caller sets title/artist metadata. */
+ fun toMediaItem(
+ descriptor: SegmentedStreamDescriptor,
+ title: String,
+ artist: String,
+ album: String?,
+ artworkUri: Uri?,
+ ): MediaItem {
+ val builder = MediaItem.Builder()
+ .setMediaId("segdrm:${descriptor.trackId}")
+ .setUri(mpdUri(descriptor))
+ .setMimeType(MimeTypes.APPLICATION_MPD)
+ .setCustomCacheKey(descriptor.stableCacheKey())
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(title.ifBlank { descriptor.title })
+ .setArtist(artist.ifBlank { descriptor.artist })
+ .setAlbumTitle(album ?: descriptor.album)
+ .setArtworkUri(artworkUri)
+ .setIsPlayable(true)
+ .build(),
+ )
+ drmConfiguration(descriptor)?.let(builder::setDrmConfiguration)
+ return builder.build()
+ }
+
+ fun audioBadge(descriptor: SegmentedStreamDescriptor): String = audioBadgeLabel(descriptor)
+
+ fun declaredManifestCodec(descriptor: SegmentedStreamDescriptor): String? =
+ Companion.declaredManifestCodec(descriptor)
+}
diff --git a/app/src/main/java/com/lastwave/app/data/update/AppUpdateManager.kt b/app/src/main/java/com/lastwave/app/data/update/AppUpdateManager.kt
index 02a7bb5c7..b127d8479 100644
--- a/app/src/main/java/com/lastwave/app/data/update/AppUpdateManager.kt
+++ b/app/src/main/java/com/lastwave/app/data/update/AppUpdateManager.kt
@@ -1,278 +1,278 @@
-package com.lastwave.app.data.update
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import com.lastwave.app.BuildConfig
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import org.json.JSONObject
-import javax.inject.Inject
-import javax.inject.Singleton
-
-data class UpdateInfo(
- val isChecking: Boolean = false,
- val isUpdateAvailable: Boolean = false,
- val latestVersion: String = "",
- val currentVersion: String = "",
- val releaseNotes: String = "",
- val releaseUrl: String = "",
- val downloadUrl: String? = null,
- val downloadFileName: String? = null,
- val isDismissed: Boolean = false,
- val message: String? = null,
-)
-
-data class ReleaseAsset(
- val name: String,
- val downloadUrl: String,
-)
-
-enum class ApkVariant {
- UNCOMPRESSED,
- ANDROID7,
- UNIVERSAL,
-}
-
-@Singleton
-class AppUpdateManager @Inject constructor(
- @ApplicationContext private val context: Context,
- private val okHttpClient: OkHttpClient,
-) {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private val prefs = context.getSharedPreferences("lastwave_updates", Context.MODE_PRIVATE)
-
- private val _updateInfo = MutableStateFlow(
- UpdateInfo(
- currentVersion = getCurrentVersion(),
- )
- )
- val updateInfo: StateFlow = _updateInfo.asStateFlow()
-
- init {
- checkForUpdate(isSilent = true)
- }
-
- fun isUncompressedBuild(): Boolean {
- val pref = prefs.getString("preferred_variant", null)
- if (pref != null) {
- return pref.equals("uncompressed", ignoreCase = true) || pref.equals("raw", ignoreCase = true)
- }
- val buildType = runCatching { BuildConfig.BUILD_TYPE }.getOrDefault("")
- return buildType.equals("rawRelease", ignoreCase = true) ||
- buildType.contains("raw", ignoreCase = true) ||
- buildType.contains("uncompressed", ignoreCase = true)
- }
-
- fun getPreferredVariant(): String? = prefs.getString("preferred_variant", null)
-
- fun setPreferredVariant(variant: String?) {
- if (variant == null) {
- prefs.edit().remove("preferred_variant").apply()
- } else {
- prefs.edit().putString("preferred_variant", variant).apply()
- }
- checkForUpdate(isSilent = true)
- }
-
- fun getCurrentVersion(): String = try {
- context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.2.4"
- } catch (_: Exception) {
- "4.2.4"
- }
-
- fun checkForUpdate(isSilent: Boolean = false) {
- scope.launch {
- _updateInfo.update {
- it.copy(
- isChecking = true,
- message = if (!isSilent) "Checking for updates..." else it.message,
- )
- }
- try {
- val request = Request.Builder()
- .url("https://api.github.com/repos/Clash-Projects/LastWave-native/releases/latest")
- .header("Accept", "application/vnd.github.v3+json")
- .header("User-Agent", "LastWave-Android")
- .build()
-
- val response = withContext(Dispatchers.IO) {
- okHttpClient.newCall(request).execute()
- }
-
- if (!response.isSuccessful) {
- val code = response.code
- _updateInfo.update {
- it.copy(
- isChecking = false,
- message = if (!isSilent) "Could not check updates (HTTP $code)" else null,
- )
- }
- return@launch
- }
-
- val body = response.body?.string().orEmpty()
- val json = JSONObject(body)
- val tagName = json.optString("tag_name", "")
- val releaseUrl = json.optString("html_url", "https://github.com/Clash-Projects/LastWave-native/releases")
- val releaseNotes = json.optString("body", "")
-
- val apkAssets = mutableListOf()
- val assets = json.optJSONArray("assets")
- if (assets != null) {
- for (i in 0 until assets.length()) {
- val asset = assets.optJSONObject(i) ?: continue
- val name = asset.optString("name", "").trim()
- val download = asset.optString("browser_download_url", "").trim()
- if (name.endsWith(".apk", ignoreCase = true) && download.isNotBlank()) {
- apkAssets.add(ReleaseAsset(name = name, downloadUrl = download))
- }
- }
- }
-
- val bestAsset = selectBestAsset(
- assets = apkAssets,
- isUncompressed = isUncompressedBuild(),
- sdkInt = Build.VERSION.SDK_INT,
- )
- val downloadUrl = bestAsset?.downloadUrl ?: releaseUrl
- val downloadFileName = bestAsset?.name
-
- val currentVersion = getCurrentVersion()
- val hasNewer = isNewerVersion(tagName, currentVersion)
- val cleanTag = tagName.removePrefix("v").removePrefix("V")
- val dismissedVersion = prefs.getString("dismissed_version", null)
- val isDismissed = dismissedVersion == cleanTag
-
- _updateInfo.update {
- it.copy(
- isChecking = false,
- isUpdateAvailable = hasNewer,
- latestVersion = cleanTag,
- currentVersion = currentVersion,
- releaseNotes = releaseNotes,
- releaseUrl = releaseUrl,
- downloadUrl = downloadUrl,
- downloadFileName = downloadFileName,
- isDismissed = isDismissed,
- message = if (!isSilent) {
- if (hasNewer) "New version $cleanTag available!" else "You're on the latest version ($currentVersion)"
- } else null,
- )
- }
- } catch (e: Exception) {
- _updateInfo.update {
- it.copy(
- isChecking = false,
- message = if (!isSilent) "Check failed: ${e.message ?: "Network error"}" else null,
- )
- }
- }
- }
- }
-
- fun dismissUpdate(version: String) {
- val clean = version.removePrefix("v").removePrefix("V")
- prefs.edit().putString("dismissed_version", clean).apply()
- _updateInfo.update { it.copy(isDismissed = true) }
- }
-
- fun openUpdate(context: Context) {
- val targetUrl = _updateInfo.value.downloadUrl ?: _updateInfo.value.releaseUrl.ifBlank {
- "https://github.com/Clash-Projects/LastWave-native/releases"
- }
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl)).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- try {
- context.startActivity(intent)
- } catch (_: Exception) { }
- }
-
- fun isNewerVersion(remote: String, local: String): Boolean {
- if (remote.isBlank() || local.isBlank()) return false
- val cleanRemote = remote.removePrefix("v").removePrefix("V").substringBefore("-")
- val cleanLocal = local.removePrefix("v").removePrefix("V").substringBefore("-")
- if (cleanRemote == cleanLocal) return false
-
- val rParts = cleanRemote.split(".").mapNotNull { it.toIntOrNull() }
- val cParts = cleanLocal.split(".").mapNotNull { it.toIntOrNull() }
-
- val maxLen = maxOf(rParts.size, cParts.size)
- for (i in 0 until maxLen) {
- val r = rParts.getOrElse(i) { 0 }
- val c = cParts.getOrElse(i) { 0 }
- if (r > c) return true
- if (r < c) return false
- }
- return false
- }
-
- companion object {
- fun classifyAsset(name: String): ApkVariant {
- val lower = name.lowercase()
- return when {
- lower.contains("uncompressed") || lower.contains("-raw") || lower.contains("_raw") -> ApkVariant.UNCOMPRESSED
- lower.contains("android7") || lower.contains("legacy") -> ApkVariant.ANDROID7
- else -> ApkVariant.UNIVERSAL
- }
- }
-
- fun selectBestAsset(
- assets: List,
- isUncompressed: Boolean,
- sdkInt: Int,
- ): ReleaseAsset? {
- val apkAssets = assets.filter { it.name.endsWith(".apk", ignoreCase = true) && it.downloadUrl.isNotBlank() }
- if (apkAssets.isEmpty()) return null
-
- val target = when {
- sdkInt < 29 -> ApkVariant.ANDROID7
- isUncompressed -> ApkVariant.UNCOMPRESSED
- else -> ApkVariant.UNIVERSAL
- }
-
- // Direct match for target variant, preferring "universal" naming if present
- val matches = apkAssets.filter { classifyAsset(it.name) == target }
- if (matches.isNotEmpty()) {
- return matches.maxByOrNull { asset ->
- val lower = asset.name.lowercase()
- when (target) {
- ApkVariant.UNIVERSAL -> if (lower.contains("universal")) 2 else 1
- ApkVariant.UNCOMPRESSED -> if (lower.contains("universal")) 2 else 1
- ApkVariant.ANDROID7 -> if (lower.contains("android7")) 2 else 1
- }
- }
- }
-
- // Fallback hierarchy if target variant was not present in this release:
- return when (target) {
- ApkVariant.UNCOMPRESSED -> {
- apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.UNIVERSAL }
- ?: apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.ANDROID7 }
- ?: apkAssets.firstOrNull()
- }
- ApkVariant.UNIVERSAL -> {
- apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.UNCOMPRESSED }
- ?: apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.ANDROID7 }
- ?: apkAssets.firstOrNull()
- }
- ApkVariant.ANDROID7 -> {
- apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.UNIVERSAL }
- ?: apkAssets.firstOrNull()
- }
- }
- }
- }
-}
+package com.lastwave.app.data.update
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import com.lastwave.app.BuildConfig
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class UpdateInfo(
+ val isChecking: Boolean = false,
+ val isUpdateAvailable: Boolean = false,
+ val latestVersion: String = "",
+ val currentVersion: String = "",
+ val releaseNotes: String = "",
+ val releaseUrl: String = "",
+ val downloadUrl: String? = null,
+ val downloadFileName: String? = null,
+ val isDismissed: Boolean = false,
+ val message: String? = null,
+)
+
+data class ReleaseAsset(
+ val name: String,
+ val downloadUrl: String,
+)
+
+enum class ApkVariant {
+ UNCOMPRESSED,
+ ANDROID7,
+ UNIVERSAL,
+}
+
+@Singleton
+class AppUpdateManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val okHttpClient: OkHttpClient,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val prefs = context.getSharedPreferences("lastwave_updates", Context.MODE_PRIVATE)
+
+ private val _updateInfo = MutableStateFlow(
+ UpdateInfo(
+ currentVersion = getCurrentVersion(),
+ )
+ )
+ val updateInfo: StateFlow = _updateInfo.asStateFlow()
+
+ init {
+ checkForUpdate(isSilent = true)
+ }
+
+ fun isUncompressedBuild(): Boolean {
+ val pref = prefs.getString("preferred_variant", null)
+ if (pref != null) {
+ return pref.equals("uncompressed", ignoreCase = true) || pref.equals("raw", ignoreCase = true)
+ }
+ val buildType = runCatching { BuildConfig.BUILD_TYPE }.getOrDefault("")
+ return buildType.equals("rawRelease", ignoreCase = true) ||
+ buildType.contains("raw", ignoreCase = true) ||
+ buildType.contains("uncompressed", ignoreCase = true)
+ }
+
+ fun getPreferredVariant(): String? = prefs.getString("preferred_variant", null)
+
+ fun setPreferredVariant(variant: String?) {
+ if (variant == null) {
+ prefs.edit().remove("preferred_variant").apply()
+ } else {
+ prefs.edit().putString("preferred_variant", variant).apply()
+ }
+ checkForUpdate(isSilent = true)
+ }
+
+ fun getCurrentVersion(): String = try {
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.3.0"
+ } catch (_: Exception) {
+ "4.3.0"
+ }
+
+ fun checkForUpdate(isSilent: Boolean = false) {
+ scope.launch {
+ _updateInfo.update {
+ it.copy(
+ isChecking = true,
+ message = if (!isSilent) "Checking for updates..." else it.message,
+ )
+ }
+ try {
+ val request = Request.Builder()
+ .url("https://api.github.com/repos/Clash-Projects/LastWave-native/releases/latest")
+ .header("Accept", "application/vnd.github.v3+json")
+ .header("User-Agent", "LastWave-Android")
+ .build()
+
+ val response = withContext(Dispatchers.IO) {
+ okHttpClient.newCall(request).execute()
+ }
+
+ if (!response.isSuccessful) {
+ val code = response.code
+ _updateInfo.update {
+ it.copy(
+ isChecking = false,
+ message = if (!isSilent) "Could not check updates (HTTP $code)" else null,
+ )
+ }
+ return@launch
+ }
+
+ val body = response.body?.string().orEmpty()
+ val json = JSONObject(body)
+ val tagName = json.optString("tag_name", "")
+ val releaseUrl = json.optString("html_url", "https://github.com/Clash-Projects/LastWave-native/releases")
+ val releaseNotes = json.optString("body", "")
+
+ val apkAssets = mutableListOf()
+ val assets = json.optJSONArray("assets")
+ if (assets != null) {
+ for (i in 0 until assets.length()) {
+ val asset = assets.optJSONObject(i) ?: continue
+ val name = asset.optString("name", "").trim()
+ val download = asset.optString("browser_download_url", "").trim()
+ if (name.endsWith(".apk", ignoreCase = true) && download.isNotBlank()) {
+ apkAssets.add(ReleaseAsset(name = name, downloadUrl = download))
+ }
+ }
+ }
+
+ val bestAsset = selectBestAsset(
+ assets = apkAssets,
+ isUncompressed = isUncompressedBuild(),
+ sdkInt = Build.VERSION.SDK_INT,
+ )
+ val downloadUrl = bestAsset?.downloadUrl ?: releaseUrl
+ val downloadFileName = bestAsset?.name
+
+ val currentVersion = getCurrentVersion()
+ val hasNewer = isNewerVersion(tagName, currentVersion)
+ val cleanTag = tagName.removePrefix("v").removePrefix("V")
+ val dismissedVersion = prefs.getString("dismissed_version", null)
+ val isDismissed = dismissedVersion == cleanTag
+
+ _updateInfo.update {
+ it.copy(
+ isChecking = false,
+ isUpdateAvailable = hasNewer,
+ latestVersion = cleanTag,
+ currentVersion = currentVersion,
+ releaseNotes = releaseNotes,
+ releaseUrl = releaseUrl,
+ downloadUrl = downloadUrl,
+ downloadFileName = downloadFileName,
+ isDismissed = isDismissed,
+ message = if (!isSilent) {
+ if (hasNewer) "New version $cleanTag available!" else "You're on the latest version ($currentVersion)"
+ } else null,
+ )
+ }
+ } catch (e: Exception) {
+ _updateInfo.update {
+ it.copy(
+ isChecking = false,
+ message = if (!isSilent) "Check failed: ${e.message ?: "Network error"}" else null,
+ )
+ }
+ }
+ }
+ }
+
+ fun dismissUpdate(version: String) {
+ val clean = version.removePrefix("v").removePrefix("V")
+ prefs.edit().putString("dismissed_version", clean).apply()
+ _updateInfo.update { it.copy(isDismissed = true) }
+ }
+
+ fun openUpdate(context: Context) {
+ val targetUrl = _updateInfo.value.downloadUrl ?: _updateInfo.value.releaseUrl.ifBlank {
+ "https://github.com/Clash-Projects/LastWave-native/releases"
+ }
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (_: Exception) { }
+ }
+
+ fun isNewerVersion(remote: String, local: String): Boolean {
+ if (remote.isBlank() || local.isBlank()) return false
+ val cleanRemote = remote.removePrefix("v").removePrefix("V").substringBefore("-")
+ val cleanLocal = local.removePrefix("v").removePrefix("V").substringBefore("-")
+ if (cleanRemote == cleanLocal) return false
+
+ val rParts = cleanRemote.split(".").mapNotNull { it.toIntOrNull() }
+ val cParts = cleanLocal.split(".").mapNotNull { it.toIntOrNull() }
+
+ val maxLen = maxOf(rParts.size, cParts.size)
+ for (i in 0 until maxLen) {
+ val r = rParts.getOrElse(i) { 0 }
+ val c = cParts.getOrElse(i) { 0 }
+ if (r > c) return true
+ if (r < c) return false
+ }
+ return false
+ }
+
+ companion object {
+ fun classifyAsset(name: String): ApkVariant {
+ val lower = name.lowercase()
+ return when {
+ lower.contains("uncompressed") || lower.contains("-raw") || lower.contains("_raw") -> ApkVariant.UNCOMPRESSED
+ lower.contains("android7") || lower.contains("legacy") -> ApkVariant.ANDROID7
+ else -> ApkVariant.UNIVERSAL
+ }
+ }
+
+ fun selectBestAsset(
+ assets: List,
+ isUncompressed: Boolean,
+ sdkInt: Int,
+ ): ReleaseAsset? {
+ val apkAssets = assets.filter { it.name.endsWith(".apk", ignoreCase = true) && it.downloadUrl.isNotBlank() }
+ if (apkAssets.isEmpty()) return null
+
+ val target = when {
+ sdkInt < 29 -> ApkVariant.ANDROID7
+ isUncompressed -> ApkVariant.UNCOMPRESSED
+ else -> ApkVariant.UNIVERSAL
+ }
+
+ // Direct match for target variant, preferring "universal" naming if present
+ val matches = apkAssets.filter { classifyAsset(it.name) == target }
+ if (matches.isNotEmpty()) {
+ return matches.maxByOrNull { asset ->
+ val lower = asset.name.lowercase()
+ when (target) {
+ ApkVariant.UNIVERSAL -> if (lower.contains("universal")) 2 else 1
+ ApkVariant.UNCOMPRESSED -> if (lower.contains("universal")) 2 else 1
+ ApkVariant.ANDROID7 -> if (lower.contains("android7")) 2 else 1
+ }
+ }
+ }
+
+ // Fallback hierarchy if target variant was not present in this release:
+ return when (target) {
+ ApkVariant.UNCOMPRESSED -> {
+ apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.UNIVERSAL }
+ ?: apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.ANDROID7 }
+ ?: apkAssets.firstOrNull()
+ }
+ ApkVariant.UNIVERSAL -> {
+ apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.UNCOMPRESSED }
+ ?: apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.ANDROID7 }
+ ?: apkAssets.firstOrNull()
+ }
+ ApkVariant.ANDROID7 -> {
+ apkAssets.firstOrNull { classifyAsset(it.name) == ApkVariant.UNIVERSAL }
+ ?: apkAssets.firstOrNull()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/lastwave/app/ui/player/LyricsSourceDialog.kt b/app/src/main/java/com/lastwave/app/ui/player/LyricsSourceDialog.kt
new file mode 100644
index 000000000..c83a64625
--- /dev/null
+++ b/app/src/main/java/com/lastwave/app/ui/player/LyricsSourceDialog.kt
@@ -0,0 +1,161 @@
+package com.lastwave.app.ui.player
+
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lyrics
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.lastwave.app.R
+import com.lastwave.app.data.local.LyricsProvider
+
+@Composable
+fun LyricsSourceDialog(
+ currentSource: String?,
+ currentProvider: LyricsProvider?,
+ onSelectProvider: (LyricsProvider) -> Unit,
+ onTryAnotherSource: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ stringResource(R.string.ly_source_title),
+ fontWeight = FontWeight.Bold,
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (!currentSource.isNullOrBlank()) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
+ contentColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Filled.Lyrics,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ text = stringResource(R.string.ly_source_current, currentSource),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ }
+
+ FilledTonalButton(
+ onClick = onTryAnotherSource,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Filled.Refresh,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.ly_try_another_source),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ stringResource(R.string.ly_try_another_source_sub),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f),
+ )
+ }
+ }
+ }
+
+ Text(
+ stringResource(R.string.ly_source_all_header),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+
+ LyricsProvider.entries.forEach { provider ->
+ val isSelected = provider == currentProvider
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onSelectProvider(provider) }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = isSelected,
+ onClick = { onSelectProvider(provider) },
+ )
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ provider.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
+ )
+ Text(
+ provider.subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.common_done))
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt b/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt
index 7c103a375..11af54c2c 100644
--- a/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt
+++ b/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt
@@ -135,10 +135,12 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.lastwave.app.data.local.LyricsProvider
import com.lastwave.app.data.lyrics.LyricLine
import com.lastwave.app.data.lyrics.LyricSyllable
import com.lastwave.app.data.lyrics.LyricsTranslation
import com.lastwave.app.data.lyrics.isRtlText
+import com.lastwave.app.data.lyrics.providerForSource
import com.lastwave.app.data.lyrics.translationLanguageName
import com.lastwave.app.data.lyrics.translationWireTarget
import com.lastwave.app.playback.MusicPlayer
@@ -200,6 +202,8 @@ fun ModernLyricsPanel(
primaryColor: Color = MaterialTheme.colorScheme.primary,
secondaryColor: Color = MaterialTheme.colorScheme.secondary,
tertiaryColor: Color = MaterialTheme.colorScheme.tertiary,
+ onSelectLyricsProvider: ((LyricsProvider) -> Unit)? = null,
+ onTryAnotherLyricsSource: (() -> Unit)? = null,
resolvedArtworkUrl: String? = null,
) {
state.current ?: return
@@ -225,8 +229,9 @@ fun ModernLyricsPanel(
onControlsVisibleChange?.invoke(visible)
}
var fontSliderOpen by rememberSaveable { mutableStateOf(false) }
- LaunchedEffect(controlsVisible, controlsAutoHideSeconds, fontSliderOpen, isFullscreen) {
- if (controlsVisible && controlsAutoHideSeconds > 0 && !fontSliderOpen) {
+ var sourceDialogOpen by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(controlsVisible, controlsAutoHideSeconds, fontSliderOpen, sourceDialogOpen, isFullscreen) {
+ if (controlsVisible && controlsAutoHideSeconds > 0 && !fontSliderOpen && !sourceDialogOpen) {
delay(controlsAutoHideSeconds * 1000L)
setControlsVisible(false)
}
@@ -605,6 +610,9 @@ fun ModernLyricsPanel(
onToggleRomanization = onToggleRomanization,
showFontSlider = fontSliderOpen,
onShowFontSliderChange = { fontSliderOpen = it },
+ onOpenLyricsSource = if (onSelectLyricsProvider != null || onTryAnotherLyricsSource != null) {
+ { sourceDialogOpen = true }
+ } else null,
lyricsFontScale = effectiveFontScale,
onLyricsFontScaleChange = { newScale ->
effectiveFontScale = newScale
@@ -623,6 +631,24 @@ fun ModernLyricsPanel(
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp, top = 6.dp),
)
+
+ if (sourceDialogOpen) {
+ val currentSource = (lyricsState as? LyricsUiState.Success)?.source
+ val currentProvider = remember(currentSource) { providerForSource(currentSource) }
+ LyricsSourceDialog(
+ currentSource = currentSource,
+ currentProvider = currentProvider,
+ onSelectProvider = { provider ->
+ sourceDialogOpen = false
+ onSelectLyricsProvider?.invoke(provider)
+ },
+ onTryAnotherSource = {
+ sourceDialogOpen = false
+ onTryAnotherLyricsSource?.invoke()
+ },
+ onDismiss = { sourceDialogOpen = false },
+ )
+ }
}
if (showShareDialog) {
@@ -1431,6 +1457,7 @@ private fun ModernLyricsControlsHost(
onToggleRomanization: () -> Unit = {},
showFontSlider: Boolean = false,
onShowFontSliderChange: (Boolean) -> Unit = {},
+ onOpenLyricsSource: (() -> Unit)? = null,
lyricsFontScale: Float = 1f,
onLyricsFontScaleChange: (Float) -> Unit = {},
onLyricsFontScalePreview: (Float) -> Unit = {},
@@ -1461,6 +1488,7 @@ private fun ModernLyricsControlsHost(
onToggleRomanization = onToggleRomanization,
showFontSlider = showFontSlider,
onShowFontSliderChange = onShowFontSliderChange,
+ onOpenLyricsSource = onOpenLyricsSource,
lyricsFontScale = lyricsFontScale,
onLyricsFontScaleChange = onLyricsFontScaleChange,
onLyricsFontScalePreview = onLyricsFontScalePreview,
@@ -1493,6 +1521,7 @@ private fun ModernLyricsControls(
onToggleRomanization: () -> Unit = {},
showFontSlider: Boolean = false,
onShowFontSliderChange: (Boolean) -> Unit = {},
+ onOpenLyricsSource: (() -> Unit)? = null,
lyricsFontScale: Float = 1f,
onLyricsFontScaleChange: (Float) -> Unit = {},
onLyricsFontScalePreview: (Float) -> Unit = {},
@@ -1608,6 +1637,41 @@ private fun ModernLyricsControls(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
+ if (onOpenLyricsSource != null) {
+ val sourceInteraction = remember { MutableInteractionSource() }
+ val isSourcePressed by sourceInteraction.collectIsPressedAsState()
+ val sourceScale by animateFloatAsState(
+ targetValue = if (isSourcePressed) 0.82f else 1.0f,
+ animationSpec = ExpressiveMotion.spatialSpring(),
+ label = "sourceBtnScale",
+ )
+ IconButton(
+ onClick = onOpenLyricsSource,
+ enabled = controlsVisible,
+ interactionSource = sourceInteraction,
+ modifier = Modifier
+ .size(44.dp)
+ .graphicsLayer {
+ scaleX = sourceScale
+ scaleY = sourceScale
+ }
+ .clip(CircleShape)
+ .liquidGlassChrome(CircleShape, LocalLiquidGlass.current, LiquidGlassPreset.FloatingControls, interactionSource = sourceInteraction)
+ .background(
+ liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)),
+ ),
+ ) {
+ Icon(
+ Icons.Filled.Lyrics,
+ contentDescription = stringResource(R.string.ly_source_cd),
+ modifier = Modifier.size(22.dp),
+ tint = Color.White.copy(alpha = 0.90f),
+ )
+ }
+
+ Spacer(Modifier.width(10.dp))
+ }
+
// Translation and romanization toggles sit left of the size
// controls. The active mode's button tints with the theme colour
// and turns itself off on a second tap; while its request is in
diff --git a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt
index aad87535f..6cc69f0c5 100644
--- a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt
+++ b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt
@@ -371,6 +371,7 @@ class PlayerViewModel @Inject constructor(
private var currentTrackLyricsKey: String? = null
private var lyricsJob: Job? = null
+ private var spotLyricsOverride: Pair> = Pair(null, emptySet())
private val _canvasState = MutableStateFlow(null)
val canvasState = _canvasState.asStateFlow()
@@ -408,6 +409,7 @@ class PlayerViewModel @Inject constructor(
val key = track?.let { "${it.videoId ?: ""}|${it.artist}|${it.title}|$wordByWord" }
if (key != currentTrackLyricsKey) {
currentTrackLyricsKey = key
+ spotLyricsOverride = Pair(null, emptySet())
if (track != null) {
loadLyrics(track, forceRefresh = false)
} else {
@@ -509,7 +511,31 @@ class PlayerViewModel @Inject constructor(
}
}
- fun loadLyrics(track: PlayableTrack, forceRefresh: Boolean = false) {
+ fun selectLyricsProvider(provider: com.lastwave.app.data.local.LyricsProvider) {
+ val track = player.state.value.current ?: return
+ spotLyricsOverride = Pair(provider, emptySet())
+ loadLyrics(track, forceRefresh = true, overrideProvider = provider, excludedProviders = emptySet())
+ }
+
+ fun tryAnotherLyricsSource() {
+ val track = player.state.value.current ?: return
+ val currentSource = (_lyricsState.value as? LyricsUiState.Success)?.source
+ val currentProvider = com.lastwave.app.data.lyrics.providerForSource(currentSource)
+ val newExcluded = if (currentProvider != null) {
+ spotLyricsOverride.second + currentProvider
+ } else {
+ spotLyricsOverride.second
+ }
+ spotLyricsOverride = Pair(null, newExcluded)
+ loadLyrics(track, forceRefresh = true, overrideProvider = null, excludedProviders = newExcluded)
+ }
+
+ fun loadLyrics(
+ track: PlayableTrack,
+ forceRefresh: Boolean = false,
+ overrideProvider: com.lastwave.app.data.local.LyricsProvider? = spotLyricsOverride.first,
+ excludedProviders: Set = spotLyricsOverride.second,
+ ) {
lyricsJob?.cancel()
lyricsJob = viewModelScope.launch {
// Lyrics hit other hosts (LRCLIB, Apple, SimpMusic). Starting
@@ -545,6 +571,8 @@ class PlayerViewModel @Inject constructor(
localLrcPath = com.lastwave.app.data.generate.LocalAudioFormats
.importedPathOrNull(track.playbackUrl)
?.let(com.lastwave.app.data.generate.LocalAudioFormats::lrcPathFor),
+ overrideProvider = overrideProvider,
+ excludedProviders = excludedProviders,
onPartialResult = { partial ->
withContext(Dispatchers.Main.immediate) {
coroutineContext.ensureActive()
@@ -1005,6 +1033,8 @@ private fun ExpandedPlayer(
onToggleLiked = { currentTrack?.let(viewModel::toggleLiked) },
onDoubleTapLike = { currentTrack?.let(viewModel::like) },
onDragStatusChange = onDragStatusChange,
+ onSelectLyricsProvider = viewModel::selectLyricsProvider,
+ onTryAnotherLyricsSource = viewModel::tryAnotherLyricsSource,
)
}
@@ -1818,6 +1848,8 @@ private fun FullPlayer(
onToggleLiked: () -> Unit = {},
onDoubleTapLike: () -> Unit = {},
onDragStatusChange: (Boolean) -> Unit = {},
+ onSelectLyricsProvider: ((com.lastwave.app.data.local.LyricsProvider) -> Unit)? = null,
+ onTryAnotherLyricsSource: (() -> Unit)? = null,
) {
val track = state.current ?: return
var showLyricsOffsetDialog by remember { mutableStateOf(false) }
@@ -2414,6 +2446,8 @@ private fun FullPlayer(
onControlsVisibleChange = { lyricsControlsVisible = it },
translationLanguage = translationLanguage,
onRetry = onRetryLyrics,
+ onSelectLyricsProvider = onSelectLyricsProvider,
+ onTryAnotherLyricsSource = onTryAnotherLyricsSource,
onToggleFullscreen = { lyricsFullscreen = !lyricsFullscreen },
isFullscreen = lyricsFullscreen,
onOpenLyricsOffset = { showLyricsOffsetDialog = true },
diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt
index a52c4b5bb..54e7e6528 100644
--- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt
@@ -1,5312 +1,5312 @@
-package com.lastwave.app.ui.settings
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInHorizontally
-import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.animation.togetherWith
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.filled.SearchOff
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.withStyle
-import com.lastwave.app.ui.common.HeaderActionIcon
-import android.content.Intent
-import android.net.Uri
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.animateContentSize
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.spring
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.LocalIndication
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsPressedAsState
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.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.LazyListState
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.relocation.BringIntoViewRequester
-import androidx.compose.foundation.relocation.bringIntoViewRequester
-import androidx.compose.ui.composed
-import androidx.compose.ui.graphics.Shape
-import kotlinx.coroutines.delay
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
-import androidx.compose.material.icons.automirrored.filled.Send
-import androidx.compose.material.icons.filled.Album
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material.icons.filled.Backup
-import androidx.compose.material.icons.filled.Bolt
-import androidx.compose.material.icons.filled.Check
-import com.lastwave.app.util.BatteryOptimizationHelper
-import androidx.compose.material.icons.filled.Code
-import androidx.compose.material.icons.filled.ChevronRight
-import androidx.compose.material.icons.filled.CloudDownload
-import androidx.compose.material.icons.filled.CloudSync
-import androidx.compose.material.icons.filled.Colorize
-import androidx.compose.material.icons.filled.Contrast
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Download
-import androidx.compose.material.icons.filled.Public
-import androidx.compose.material.icons.filled.Apps
-import androidx.compose.material.icons.filled.AutoAwesome
-import androidx.compose.material.icons.filled.BubbleChart
-import androidx.compose.material.icons.filled.GraphicEq
-import androidx.compose.material.icons.filled.Group
-import androidx.compose.material.icons.filled.Dashboard
-import androidx.compose.material.icons.filled.Lyrics
-import androidx.compose.material.icons.filled.NotificationsActive
-import androidx.compose.material.icons.filled.Waves
-import com.lastwave.app.data.local.LyricsProvider
-import com.lastwave.app.ui.player.LyricsOffsetDialog
-import com.lastwave.app.ui.player.LyricsSizeDialog
-import androidx.compose.material.icons.filled.FormatSize
-import kotlin.math.roundToInt
-import androidx.compose.material.icons.filled.Timer
-import androidx.compose.material.icons.filled.Translate
-import androidx.compose.material.icons.filled.Logout
-import androidx.compose.material.icons.filled.Palette
-import androidx.compose.material.icons.filled.RestartAlt
-import androidx.compose.material.icons.filled.HighQuality
-import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material.icons.filled.Usb
-import androidx.compose.material.icons.filled.SpeakerPhone
-import androidx.compose.material.icons.filled.TextFields
-import androidx.compose.material.icons.filled.VolumeUp
-import androidx.compose.material.icons.filled.Language
-import androidx.compose.material.icons.filled.Visibility
-import androidx.compose.material.icons.filled.VisibilityOff
-import androidx.compose.material.icons.filled.PlayCircle
-import androidx.compose.material.icons.filled.SmartDisplay
-import androidx.compose.material.icons.filled.SwitchAccount
-import androidx.compose.material.icons.filled.History
-import androidx.compose.material.icons.filled.QueueMusic
-import androidx.compose.material.icons.filled.FileDownload
-import androidx.compose.material.icons.filled.FormatListBulleted
-import androidx.compose.material.icons.filled.Extension
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.FilledTonalIconButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Surface
-import androidx.compose.material3.RadioButton
-import androidx.compose.material3.Switch
-import androidx.compose.material3.SwitchDefaults
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.rememberModalBottomSheetState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableLongStateOf
-import androidx.compose.runtime.mutableStateOf
-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.alpha
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import com.lastwave.app.data.local.AppLanguage
-import com.lastwave.app.data.local.nativeDisplayName
-import com.lastwave.app.data.lyrics.TRANSLATION_LANGUAGES
-import com.lastwave.app.data.lyrics.translationLanguageName
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.compose.foundation.gestures.detectVerticalDragGestures
-import androidx.compose.foundation.horizontalScroll
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.statusBars
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.Canvas
-import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.PathEffect
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.graphics.StrokeJoin
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.input.pointer.pointerInput
-import java.util.Locale
-
-import com.lastwave.app.ui.player.LocalMiniPlayerScrollClearance
-import com.lastwave.app.R
-import com.lastwave.app.data.local.AccentMode
-import com.lastwave.app.data.local.ThemeMode
-import com.lastwave.app.data.local.EQ_BAND_FREQS_HZ
-import com.lastwave.app.data.local.EQ_MAX_GAIN_DB
-import com.lastwave.app.data.local.EqualizerPresets
-import com.lastwave.app.data.local.EqualizerSettings
-import com.lastwave.app.playback.ClarityPresets
-import com.lastwave.app.playback.LoudnessMode
-import com.lastwave.app.data.local.eqBandLabel
-import com.lastwave.app.ui.common.ConnectedButtonGroup
-import com.lastwave.app.ui.common.ConnectedButtonItem
-import com.lastwave.app.ui.common.ExpressiveHeader
-import com.lastwave.app.ui.common.safeDrawingBottomPadding
-import com.lastwave.app.ui.common.safeHorizontalContentPadding
-import com.lastwave.app.ui.common.adaptiveContentWidth
-import com.lastwave.app.ui.theme.ExpressivePillShape
-import androidx.compose.material.icons.filled.BrightnessAuto
-import androidx.compose.material.icons.filled.LightMode
-import androidx.compose.material.icons.filled.DarkMode
-import kotlin.math.roundToInt
-import kotlin.math.roundToLong
-
-private data class AccentPreset(val name: String, val hex: String)
-private val ACCENT_PRESETS = listOf(
- AccentPreset("Crimson", "#E03030"),
- AccentPreset("Violet", "#7C4DFF"),
- AccentPreset("Ocean", "#2196C6"),
- AccentPreset("Sage", "#6B9E6B"),
- AccentPreset("Amber", "#E0A030"),
- AccentPreset("Rose", "#E0507A"),
-)
-
-// -- Expressive shape scale used only within this screen --
-private val CardOuterShape = RoundedCornerShape(28.dp)
-private val IconBadgeShape = RoundedCornerShape(14.dp)
-
-/** Where a row sits within a visually-connected group of settings rows —
- * drives per-row corner radii so a multi-row group reads as one premium
- * surface split into rows, not a stack of separate cards (see groupShape
- * below and the GROUP_GAP spacing used between rows in a group's Column). */
-private enum class GroupPosition { SINGLE, TOP, MIDDLE, BOTTOM }
-
-private val GROUP_OUTER_RADIUS = 28.dp
-private val GROUP_INNER_RADIUS = 6.dp
-private val GROUP_GAP = 3.dp
-
-private fun groupShape(position: GroupPosition): RoundedCornerShape = when (position) {
- GroupPosition.SINGLE -> RoundedCornerShape(GROUP_OUTER_RADIUS)
- GroupPosition.TOP -> RoundedCornerShape(
- topStart = GROUP_OUTER_RADIUS, topEnd = GROUP_OUTER_RADIUS,
- bottomStart = GROUP_INNER_RADIUS, bottomEnd = GROUP_INNER_RADIUS,
- )
- GroupPosition.MIDDLE -> RoundedCornerShape(GROUP_INNER_RADIUS)
- GroupPosition.BOTTOM -> RoundedCornerShape(
- topStart = GROUP_INNER_RADIUS, topEnd = GROUP_INNER_RADIUS,
- bottomStart = GROUP_OUTER_RADIUS, bottomEnd = GROUP_OUTER_RADIUS,
- )
-}
-
-/** Wraps a fixed list of settings rows and assigns each one its
- * GroupPosition automatically — SINGLE for a lone row, TOP/BOTTOM for the
- * ends of a longer group, MIDDLE for everything between. Rows are stacked
- * with a tiny GROUP_GAP rather than normal item spacing, so the group
- * reads as one connected surface with rows peeking through a hairline gap
- * rather than a list of separate cards. */
-@Composable
-private fun SettingsGroup(rowCount: Int, content: @Composable (index: Int, position: GroupPosition) -> Unit) {
- Column(verticalArrangement = Arrangement.spacedBy(GROUP_GAP)) {
- for (i in 0 until rowCount) {
- val position = when {
- rowCount == 1 -> GroupPosition.SINGLE
- i == 0 -> GroupPosition.TOP
- i == rowCount - 1 -> GroupPosition.BOTTOM
- else -> GroupPosition.MIDDLE
- }
- content(i, position)
- }
- }
-}
-
-/**
- * Determines the target LazyColumn item index (section) for a searchable setting.
- */
-private fun getTargetSectionIndex(settingId: String?): Int {
- if (settingId == null) return 0
- val entry = SettingsSearchIndex.allEntries.firstOrNull { it.id == settingId } ?: return 0
- return when (entry.parentTab) {
- SettingsTab.AUDIO -> if (entry.section == "Output & Loudness") 1 else 0
- SettingsTab.APPEARANCE -> when (entry.section) {
- "Accent Color" -> 1
- "Experimental & Features", "Experimental & Player" -> 2
- "Canvas" -> 3
- else -> 0
- }
- SettingsTab.LIBRARY -> when (entry.section) {
- "Imports" -> 1
- "Modules & Addons" -> 2
- else -> 0
- }
- SettingsTab.DATA_BACKUP -> if (entry.section == "Data Management") 1 else 0
- SettingsTab.ABOUT -> if (entry.section == "About & Community") 1 else 0
- else -> 0
- }
-}
-
-/**
- * Attaches a BringIntoViewRequester and renders an expressive pulsing highlight glow
- * with outer colored shadow halo, accent border, and auto-scroll when isHighlighted is true.
- */
-@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
-private fun Modifier.settingHighlightGlow(
- isHighlighted: Boolean,
- shape: Shape = CardOuterShape,
-): Modifier = composed {
- val bringIntoViewRequester = remember { BringIntoViewRequester() }
- val primaryColor = MaterialTheme.colorScheme.primary
-
- LaunchedEffect(isHighlighted) {
- if (isHighlighted) {
- delay(160L)
- bringIntoViewRequester.bringIntoView()
- }
- }
-
- if (!isHighlighted) {
- return@composed this.bringIntoViewRequester(bringIntoViewRequester)
- }
-
- val infiniteTransition = rememberInfiniteTransition(label = "settingHighlightGlow")
- val glowProgress by infiniteTransition.animateFloat(
- initialValue = 0.35f,
- targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(650, easing = FastOutSlowInEasing),
- repeatMode = RepeatMode.Reverse,
- ),
- label = "glowProgress",
- )
-
- this
- .bringIntoViewRequester(bringIntoViewRequester)
- .shadow(
- elevation = (7 * glowProgress).dp,
- shape = shape,
- ambientColor = primaryColor.copy(alpha = 0.40f * glowProgress),
- spotColor = primaryColor.copy(alpha = 0.55f * glowProgress),
- )
- .border(
- width = 2.5.dp,
- color = primaryColor.copy(alpha = glowProgress),
- shape = shape,
- )
-}
-
-/**
- * Faithful port of settings.js (par 8): Last.fm account management, appearance
- * (AMOLED / Dynamic Color / Monochrome / accent presets / custom color
- * wheel), iTunes/ListenBrainz artwork toggles, data management (clear
- * recommendation exclusions, clear all data), backup & restore, and app info.
- *
- * Visuals only: restyled into a Material 3 Expressive presentation
- * (larger touch targets, per-row cards, tonal icon badges, spring-based
- * press feedback). Every setting, callback, and piece of state below is
- * unchanged from the original implementation.
- */
-
-enum class SettingsTab(
- @androidx.annotation.StringRes val titleRes: Int,
- @androidx.annotation.StringRes val subtitleRes: Int,
- val title: String,
- val subtitle: String,
- val icon: androidx.compose.ui.graphics.vector.ImageVector,
-) {
- AUDIO(
- titleRes = R.string.set_cat_audio,
- subtitleRes = R.string.set_cat_audio_sub,
- title = "Audio & Playback",
- subtitle = "Streaming quality, Audio engine, Equalizer, Output & Loudness",
- icon = Icons.Filled.GraphicEq,
- ),
- APPEARANCE(
- titleRes = R.string.set_cat_appear,
- subtitleRes = R.string.set_cat_appear_sub,
- title = "Appearance & Visuals",
- subtitle = "Themes, Colors, Artwork, Lyrics",
- icon = Icons.Filled.Palette,
- ),
- YOUTUBE(
- titleRes = R.string.set_cat_yt,
- subtitleRes = R.string.set_cat_yt_sub,
- title = "YouTube & Sync",
- subtitle = "Account connection, 24/7 Library sync, Channels, History",
- icon = Icons.Filled.CloudSync,
- ),
- LAST_FM(
- titleRes = R.string.set_cat_lfm,
- subtitleRes = R.string.set_cat_lfm_sub,
- title = "Last.fm",
- subtitle = "Account connection, Scrobbling sync & API credentials",
- icon = Icons.Filled.Album,
- ),
- LIBRARY(
- titleRes = R.string.set_cat_lib,
- subtitleRes = R.string.set_cat_lib_sub,
- title = "Library & Content",
- subtitle = "Home layout, Playlist imports, Downloads, Exclusions",
- icon = Icons.Filled.QueueMusic,
- ),
- DATA_BACKUP(
- titleRes = R.string.set_cat_data,
- subtitleRes = R.string.set_cat_data_sub,
- title = "Data Management",
- subtitle = "Cache cleanup, Export & Import settings, Database tools",
- icon = Icons.Filled.Backup,
- ),
- ABOUT(
- titleRes = R.string.set_cat_about,
- subtitleRes = R.string.set_cat_about_sub,
- title = "About & Community",
- subtitle = "Version info, Telegram community, GitHub repository",
- icon = Icons.Filled.AutoAwesome,
- ),
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun SettingsScreen(
- onBack: () -> Unit = {},
- onLoggedOut: () -> Unit = {},
- onOpenDownloads: () -> Unit = {},
- onOpenModules: () -> Unit = {},
- onOpenHomeSections: () -> Unit = {},
- onOpenExcludedSongs: () -> Unit = {},
- onOpenYouTubeImport: () -> Unit = {},
- onOpenYouTubeLogin: () -> Unit = {},
- onOpenExternalImport: () -> Unit = {},
- viewModel: SettingsViewModel = hiltViewModel(),
-) {
- val session by viewModel.session.collectAsStateWithLifecycle()
- val avatarUrl by viewModel.avatarUrl.collectAsStateWithLifecycle()
- val theme by viewModel.theme.collectAsStateWithLifecycle()
- val misc by viewModel.misc.collectAsStateWithLifecycle()
- val state by viewModel.uiState.collectAsStateWithLifecycle()
- val downloadCount by viewModel.downloadCount.collectAsStateWithLifecycle()
- val downloadTotalBytes by viewModel.downloadTotalBytes.collectAsStateWithLifecycle()
- val ytConnection by viewModel.ytConnection.collectAsStateWithLifecycle()
- val ytSyncEnabled by viewModel.ytSyncEnabled.collectAsStateWithLifecycle()
- val ytHistorySyncEnabled by viewModel.ytHistorySyncEnabled.collectAsStateWithLifecycle()
- val ytSyncState by viewModel.ytSyncState.collectAsStateWithLifecycle()
- val ytLastSyncAt by viewModel.ytLastSyncAt.collectAsStateWithLifecycle()
- val syncedPlaylistIds by viewModel.syncedPlaylistIds.collectAsStateWithLifecycle()
- val allPlaylists by viewModel.allPlaylists.collectAsStateWithLifecycle()
- val ytAccountPlaylists by viewModel.ytAccountPlaylists.collectAsStateWithLifecycle()
- val hiddenYtLibraryPlaylistIds by viewModel.hiddenYtLibraryPlaylistIds.collectAsStateWithLifecycle()
- val ytChannels by viewModel.ytChannels.collectAsStateWithLifecycle()
- val ytChannelsLoading by viewModel.ytChannelsLoading.collectAsStateWithLifecycle()
- val eq by viewModel.equalizer.collectAsStateWithLifecycle()
- val usbExclusiveEnabled by viewModel.usbExclusiveEnabled.collectAsStateWithLifecycle()
- val internalDirectEnabled by viewModel.internalDirectEnabled.collectAsStateWithLifecycle()
- val forceSoftwareVolume by viewModel.forceSoftwareVolume.collectAsStateWithLifecycle()
- val internalDirectAvailability by viewModel.internalDirectAvailability.collectAsStateWithLifecycle()
- val loudness by viewModel.loudness.collectAsStateWithLifecycle()
- val updateInfo by viewModel.updateInfo.collectAsStateWithLifecycle()
- val discordPresenceEnabled by viewModel.discordPresenceEnabled.collectAsStateWithLifecycle()
- val isLastFmConnected by viewModel.isLastFmConnected.collectAsStateWithLifecycle()
- val hasApiKey by viewModel.hasApiKey.collectAsStateWithLifecycle()
- val lastFmAuthUrl by viewModel.lastFmAuthUrl.collectAsStateWithLifecycle()
- val lastFmConnecting by viewModel.lastFmConnecting.collectAsStateWithLifecycle()
- val context = LocalContext.current
-
- // Last.fm web auth (Settings → Integrations): open the auth URL in Custom
- // Tabs the moment SettingsViewModel produces one.
- androidx.compose.runtime.LaunchedEffect(lastFmAuthUrl) {
- lastFmAuthUrl?.let { url ->
- runCatching {
- androidx.browser.customtabs.CustomTabsIntent.Builder().build()
- .launchUrl(context, android.net.Uri.parse(url))
- }.onFailure {
- viewModel.showToast(context.getString(R.string.toast_no_browser_lfm))
- viewModel.cancelLastFmConnect()
- }
- }
- }
- var showQualityDialog by remember { mutableStateOf(false) }
- var showDownloadQualityDialog by remember { mutableStateOf(false) }
- var showEqSheet by remember { mutableStateOf(false) }
- var showLyricsProviderDialog by remember { mutableStateOf(false) }
- var showLyricsOffsetDialog by remember { mutableStateOf(false) }
- var showLyricsSizeDialog by remember { mutableStateOf(false) }
- var showLyricsAutoHideDialog by remember { mutableStateOf(false) }
- var showTranslationLanguageDialog by remember { mutableStateOf(false) }
- var showLoudnessDialog by remember { mutableStateOf(false) }
- var showClarityPresetDialog by remember { mutableStateOf(false) }
- var showSyncPlaylistsSheet by remember { mutableStateOf(false) }
- var showYtLibraryVisibilitySheet by remember { mutableStateOf(false) }
- var showYtChannelSheet by remember { mutableStateOf(false) }
- var showYtDisconnectConfirm by remember { mutableStateOf(false) }
- var showLanguageDialog by remember { mutableStateOf(false) }
- val currentLanguage = remember(misc.appLanguageTag) { AppLanguage.fromTag(misc.appLanguageTag) }
-
- // Sends the user to Android's own Notification Listener access screen
- // — the one permission this feature needs that the app can never grant
-
- // "*/*" rather than "application/json": many document providers (Drive,
- // Downloads, some file managers) report a .json file as
- // application/octet-stream or text/plain, and GetContent's mime filter
- // hides anything that doesn't match — the user's own backup file would
- // silently not show up. Real validation happens right after the file is
- // read (stagePendingRestore), so being permissive here is safe.
- val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
- if (uri != null) {
- viewModel.handleRestorePicked(uri)
- }
- }
-
- val csvPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
- if (uri != null) {
- viewModel.handleCsvPicked(uri)
- }
- }
-
- var showYouTubeImportSheet by remember { mutableStateOf(false) }
-
- // Lets the user pick exactly where the backup file is saved (SAF), so
- // it's guaranteed to be somewhere restoreLauncher's picker can browse
- // back to later — unlike a silent write into app-private storage.
- val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
- if (uri != null) viewModel.exportBackup(uri, appVersionName(context))
- }
-
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.TopCenter,
- ) {
- Column(
- Modifier
- .fillMaxSize()
- .adaptiveContentWidth(maxWidth = 760.dp),
- ) {
- var activeTab by remember { mutableStateOf(null) }
- var isSearchBarVisible by rememberSaveable { mutableStateOf(false) }
- var highlightedSettingId by remember { mutableStateOf(null) }
- val focusManager = LocalFocusManager.current
-
- val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
- val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
-
- LaunchedEffect(highlightedSettingId) {
- if (highlightedSettingId != null) {
- delay(3200L)
- highlightedSettingId = null
- }
- }
-
- BackHandler(enabled = searchQuery.isNotBlank() || isSearchBarVisible || activeTab != null) {
- if (searchQuery.isNotBlank() || isSearchBarVisible) {
- viewModel.clearSearch()
- isSearchBarVisible = false
- } else if (activeTab != null) {
- activeTab = null
- }
- }
-
- ExpressiveHeader(
- title = if (searchQuery.isNotBlank()) stringResource(R.string.settings_search_title) else (activeTab?.title ?: stringResource(R.string.settings)),
- onBack = {
- if (searchQuery.isNotBlank() || isSearchBarVisible) {
- viewModel.clearSearch()
- isSearchBarVisible = false
- } else if (activeTab != null) {
- activeTab = null
- } else {
- onBack()
- }
- },
- actions = {
- HeaderActionIcon(
- icon = if (isSearchBarVisible || searchQuery.isNotBlank()) Icons.Filled.Close else Icons.Filled.Search,
- contentDescription = if (isSearchBarVisible || searchQuery.isNotBlank()) stringResource(R.string.settings_search_clear) else stringResource(R.string.settings_search_title),
- onClick = {
- if (isSearchBarVisible || searchQuery.isNotBlank()) {
- viewModel.clearSearch()
- isSearchBarVisible = false
- } else {
- isSearchBarVisible = true
- }
- },
- )
- },
- )
-
- AnimatedVisibility(
- visible = isSearchBarVisible,
- enter = expandVertically() + fadeIn(),
- exit = shrinkVertically() + fadeOut(),
- ) {
- SettingsSearchBar(
- query = searchQuery,
- onQueryChange = viewModel::onSearchQueryChange,
- onClear = {
- if (searchQuery.isNotBlank()) {
- viewModel.clearSearch()
- } else {
- isSearchBarVisible = false
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp),
- )
- }
-
- if (searchQuery.isNotBlank()) {
- SettingsSearchResultsList(
- query = searchQuery,
- results = searchResults,
- onResultClick = { entry ->
- focusManager.clearFocus()
- viewModel.clearSearch()
- isSearchBarVisible = false
- activeTab = entry.parentTab
- highlightedSettingId = entry.id
- },
- modifier = Modifier.weight(1f).fillMaxWidth(),
- )
- } else {
- AnimatedContent(
- targetState = activeTab,
- transitionSpec = {
- if (targetState != null && initialState == null) {
- (slideInHorizontally { width -> width / 4 } + fadeIn()).togetherWith(
- slideOutHorizontally { width -> -width / 4 } + fadeOut(),
- )
- } else if (targetState == null && initialState != null) {
- (slideInHorizontally { width -> -width / 4 } + fadeIn()).togetherWith(
- slideOutHorizontally { width -> width / 4 } + fadeOut(),
- )
- } else {
- fadeIn().togetherWith(fadeOut())
- }
- },
- label = "SettingsTabTransition",
- modifier = Modifier.weight(1f).fillMaxWidth(),
- ) { tab ->
- val listState = remember(tab) { LazyListState() }
-
- LaunchedEffect(highlightedSettingId, tab) {
- if (highlightedSettingId != null && tab != null) {
- val targetIndex = getTargetSectionIndex(highlightedSettingId)
- if (targetIndex > 0) {
- delay(80L)
- listState.animateScrollToItem(targetIndex)
- } else if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
- delay(80L)
- listState.animateScrollToItem(0)
- }
- }
- }
-
- LazyColumn(
- state = listState,
- contentPadding = PaddingValues(
- start = 16.dp,
- end = 16.dp,
- top = 16.dp,
- bottom = 32.dp + LocalMiniPlayerScrollClearance.current + safeDrawingBottomPadding(),
- ),
- verticalArrangement = Arrangement.spacedBy(24.dp),
- modifier = Modifier.fillMaxSize().safeHorizontalContentPadding(),
- ) {
- when (tab) {
- null -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel("Services & Add-ons")
- SettingsGroup(rowCount = 3) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.Extension,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_modules_title),
- subtitle = stringResource(R.string.settings_modules_sub),
- onClick = onOpenModules,
- position = position,
- isHighlighted = (highlightedSettingId == "services.modules" || highlightedSettingId == "library.modules"),
- )
- 1 -> SettingsActionCard(
- icon = Icons.Filled.Album,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_cat_lfm),
- subtitle = if (isLastFmConnected) "Connected as ${session.username} \u2022 Scrobbling active" else "Connect account, scrobbles & statistics sync",
- onClick = { activeTab = SettingsTab.LAST_FM },
- position = position,
- )
- else -> SettingsActionCard(
- icon = Icons.Filled.CloudSync,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_cat_yt),
- subtitle = if (ytConnection.isConnected) "Connected as ${ytConnection.accountName} \u2022 24/7 sync" else "Connect account, 24/7 playlist sync, channels",
- onClick = { activeTab = SettingsTab.YOUTUBE },
- position = position,
- )
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel("Playback & UI")
- SettingsGroup(rowCount = 3) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.GraphicEq,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_cat_audio),
- subtitle = stringResource(R.string.set_cat_audio_sub),
- onClick = { activeTab = SettingsTab.AUDIO },
- position = position,
- )
- 1 -> SettingsActionCard(
- icon = Icons.Filled.Palette,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.set_cat_appear),
- subtitle = stringResource(R.string.set_cat_appear_sub),
- onClick = { activeTab = SettingsTab.APPEARANCE },
- position = position,
- )
- else -> SettingsActionCard(
- icon = Icons.Filled.QueueMusic,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_cat_lib),
- subtitle = run {
- val count = downloadCount
- if (count > 0) "$count songs downloaded • Imports, Home layout" else "Home layout, Playlist imports, Downloads, Exclusions"
- },
- onClick = { activeTab = SettingsTab.LIBRARY },
- position = position,
- )
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel("System & About")
- SettingsGroup(rowCount = 2) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.Backup,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.set_cat_data),
- subtitle = stringResource(R.string.set_cat_data_sub),
- onClick = { activeTab = SettingsTab.DATA_BACKUP },
- position = position,
- )
- else -> SettingsActionCard(
- icon = Icons.Filled.AutoAwesome,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_cat_about),
- subtitle = if (updateInfo.isUpdateAvailable) "Update Available (${updateInfo.latestVersion}) \u2022 v${appVersionName(context)}" else "v${appVersionName(context)} \u2022 Language, Community, Source code",
- onClick = { activeTab = SettingsTab.ABOUT },
- position = position,
- )
- }
- }
- }
- }
- }
-
- SettingsTab.AUDIO -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_audio))
- val qualitySubtitle = when (misc.losslessQuality) {
- 28 -> "Dolby Atmos (Spatial Immersive Audio)"
- 27 -> "Max (Up to 24-bit / 192 kHz)"
- 7 -> "Hi-Res (24-bit / 96 kHz)"
- 6 -> "CD Lossless (16-bit / 44.1 kHz FLAC)"
- 5 -> "Standard (320 kbps MP3)"
- 4 -> "Data Saver (96 kbps HE-AAC)"
- -1 -> "YouTube Music (AAC / Opus)"
- else -> "Max (Up to 24-bit / 192 kHz)"
- }
- val downloadQualitySubtitle = when (misc.downloadQuality) {
- 28 -> "Dolby Atmos (Spatial Immersive Audio)"
- 27 -> "Hi-Res (24-bit / 192 kHz FLAC)"
- 7 -> "Hi-Res (24-bit / 96 kHz FLAC)"
- 6 -> "CD Lossless (16-bit / 44.1 kHz FLAC)"
- 5 -> "Standard (320 kbps MP3)"
- 4 -> "Data Saver (96 kbps HE-AAC)"
- -1 -> "YouTube Music (AAC / Opus)"
- else -> "Hi-Res (24-bit / 192 kHz FLAC)"
- }
-
- val isIgnored = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
- val totalAudioRows = if (misc.crossfadeEnabled) {
- if (isIgnored) 7 else 8
- } else {
- if (isIgnored) 6 else 7
- }
- SettingsGroup(rowCount = totalAudioRows) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.HighQuality,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_streaming_quality),
- subtitle = if (misc.losslessQuality == -1) "YouTube Music • Native stream" else "$qualitySubtitle • YouTube Music fallback",
- onClick = { showQualityDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.streaming_quality"),
- )
- 1 -> SettingsActionCard(
- icon = Icons.Filled.CloudDownload,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_download_quality),
- subtitle = "$downloadQualitySubtitle \u2022 Lossless & YouTube",
- onClick = { showDownloadQualityDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.download_quality"),
- )
- 2 -> SettingsToggleCard(
- icon = Icons.Filled.Tune,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_bit_perfect),
- subtitle = if (misc.isBitPerfectEnabled) {
- stringResource(R.string.settings_bit_perfect_on_detail)
- } else {
- stringResource(R.string.settings_bit_perfect_off_detail)
- },
- checked = misc.isBitPerfectEnabled,
- onCheckedChange = viewModel::setBitPerfectEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.bit_perfect"),
- )
- 3 -> SettingsToggleCard(
- icon = Icons.Filled.GraphicEq,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_crossfade),
- subtitle = if (misc.crossfadeEnabled && misc.isBitPerfectEnabled) {
- "Paused while Bit-Perfect is enabled"
- } else if (misc.crossfadeEnabled) {
- "Smooth transition between tracks \u2022 ${misc.crossfadeSeconds} sec"
- } else {
- "Blend the end of a track into the next one"
- },
- checked = misc.crossfadeEnabled,
- onCheckedChange = viewModel::setCrossfadeEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.crossfade"),
- )
- 4 -> if (misc.crossfadeEnabled) {
- CrossfadeDurationRow(
- seconds = misc.crossfadeSeconds,
- onSecondsChange = viewModel::setCrossfadeSeconds,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.crossfade_duration"),
- )
- } else {
- SettingsToggleCard(
- icon = Icons.Filled.Lyrics,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_keep_lyrics_screen_awake),
- subtitle = stringResource(R.string.settings_keep_lyrics_screen_awake_desc),
- checked = misc.keepLyricsScreenAwake,
- onCheckedChange = viewModel::setKeepLyricsScreenAwake,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.keep_lyrics_screen_awake"),
- )
- }
- 5 -> if (misc.crossfadeEnabled) {
- SettingsToggleCard(
- icon = Icons.Filled.Lyrics,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_keep_lyrics_screen_awake),
- subtitle = stringResource(R.string.settings_keep_lyrics_screen_awake_desc),
- checked = misc.keepLyricsScreenAwake,
- onCheckedChange = viewModel::setKeepLyricsScreenAwake,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.keep_lyrics_screen_awake"),
- )
- } else {
- SettingsToggleCard(
- icon = Icons.Filled.Lyrics,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_download_lyrics),
- subtitle = if (misc.downloadLyrics) {
- "Save .lrc companion files & embed lyrics in downloads"
- } else {
- "Do not fetch or save lyrics when downloading"
- },
- checked = misc.downloadLyrics,
- onCheckedChange = viewModel::setDownloadLyrics,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.download_lyrics"),
- )
- }
- 6 -> if (misc.crossfadeEnabled) {
- SettingsToggleCard(
- icon = Icons.Filled.Lyrics,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_download_lyrics),
- subtitle = if (misc.downloadLyrics) {
- "Save .lrc companion files & embed lyrics in downloads"
- } else {
- "Do not fetch or save lyrics when downloading"
- },
- checked = misc.downloadLyrics,
- onCheckedChange = viewModel::setDownloadLyrics,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.download_lyrics"),
- )
- } else if (!isIgnored) {
- SettingsActionCard(
- icon = Icons.Filled.Bolt,
- iconContainer = MaterialTheme.colorScheme.errorContainer,
- iconTint = MaterialTheme.colorScheme.onErrorContainer,
- title = stringResource(R.string.settings_battery_title),
- subtitle = stringResource(R.string.set_battery_restricted),
- onClick = { BatteryOptimizationHelper.requestIgnoreBatteryOptimizations(context) },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.battery_optimization"),
- )
- }
- 7 -> if (!isIgnored) {
- SettingsActionCard(
- icon = Icons.Filled.Bolt,
- iconContainer = MaterialTheme.colorScheme.errorContainer,
- iconTint = MaterialTheme.colorScheme.onErrorContainer,
- title = stringResource(R.string.settings_battery_title),
- subtitle = stringResource(R.string.set_battery_restricted),
- onClick = { BatteryOptimizationHelper.requestIgnoreBatteryOptimizations(context) },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.battery_optimization"),
- )
- }
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel("Output & Loudness")
- val clarityPreset = remember(misc.clarityPreset) { ClarityPresets.fromIndex(misc.clarityPreset) }
- val loudnessSubtitle = when (loudness.mode) {
- LoudnessMode.TRACK -> "Track \u2022 Match every track to -14 LUFS"
- LoudnessMode.ALBUM -> "Album \u2022 Keep intentional album dynamics"
- else -> "Off \u2022 Play tagged tracks at original level"
- }
- SettingsGroup(rowCount = 9) { index, position ->
- when (index) {
- 0 -> SettingsToggleCard(
- icon = Icons.Filled.Usb,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_usb_title),
- subtitle = if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
- stringResource(R.string.set_usb_old)
- } else if (usbExclusiveEnabled || misc.isBitPerfectEnabled) {
- stringResource(R.string.set_usb_on)
- } else {
- stringResource(R.string.set_usb_off)
- },
- checked = usbExclusiveEnabled,
- onCheckedChange = viewModel::setUsbExclusiveEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.usb_exclusive"),
- )
- 1 -> {
- // Probe what this output accepts only once the row is on screen.
- LaunchedEffect(Unit) { viewModel.refreshInternalDirectAvailability() }
- SettingsToggleCard(
- icon = Icons.Filled.SpeakerPhone,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_internal_direct),
- subtitle = when {
- !internalDirectEnabled -> stringResource(R.string.settings_internal_direct_off)
- internalDirectAvailability.available ->
- stringResource(R.string.settings_internal_direct_on)
- else -> stringResource(
- R.string.settings_internal_direct_inactive,
- stringResource(com.lastwave.app.playback.internalFailureDetail(internalDirectAvailability.reason)),
- )
- },
- checked = internalDirectEnabled,
- // Greyed out when this output cannot be bypassed, but an
- // enabled toggle always stays reachable so it can be turned off.
- enabled = internalDirectEnabled || internalDirectAvailability.available ||
- internalDirectAvailability.reason == com.lastwave.app.playback.InternalDirectReason.CHECKING,
- onCheckedChange = viewModel::setInternalDirectEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.internal_direct"),
- )
- }
- 2 -> SettingsToggleCard(
- icon = Icons.Filled.VolumeUp,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_force_sw_volume),
- subtitle = if (forceSoftwareVolume) {
- stringResource(R.string.settings_force_sw_volume_on)
- } else {
- stringResource(R.string.settings_force_sw_volume_off)
- },
- checked = forceSoftwareVolume,
- onCheckedChange = viewModel::setForceSoftwareVolume,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.force_software_volume"),
- )
- 3 -> SettingsActionCard(
- icon = Icons.Filled.VolumeUp,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_loud_title),
- subtitle = loudnessSubtitle,
- onClick = { showLoudnessDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.loudness_normalization"),
- )
- 4 -> SettingsActionCard(
- icon = Icons.Filled.GraphicEq,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_equalizer),
- subtitle = if (misc.isBitPerfectEnabled && eq.enabled) {
- stringResource(R.string.set_eq_on_preset, eq.presetName)
- } else if (eq.enabled) {
- stringResource(R.string.set_eq_on_band, eq.presetName)
- } else {
- "Shape your sound across 15 frequencies"
- },
- onClick = { showEqSheet = true },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.equalizer"),
- )
- 5 -> SettingsToggleCard(
- icon = Icons.Filled.VolumeUp,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.set_sys_fx_title),
- subtitle = if (misc.systemEffectsMode) {
- stringResource(R.string.set_sys_fx_on)
- } else {
- stringResource(R.string.set_sys_fx_off)
- },
- checked = misc.systemEffectsMode,
- onCheckedChange = viewModel::setSystemEffectsMode,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.system_audio_effects"),
- )
- 6 -> SettingsToggleCard(
- icon = Icons.Filled.Waves,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_studio_clarity),
- subtitle = if (misc.isStudioMasterClarityEnabled) {
- stringResource(R.string.settings_studio_clarity_on)
- } else {
- stringResource(R.string.settings_studio_clarity_off)
- },
- checked = misc.isStudioMasterClarityEnabled,
- onCheckedChange = viewModel::setStudioMasterClarity,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.studio_clarity"),
- )
- 7 -> SettingsActionCard(
- icon = Icons.Filled.Tune,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.set_clarity_title),
- subtitle = stringResource(
- R.string.set_clarity_row,
- clarityPresetName(clarityPreset),
- clarityPresetDescription(clarityPreset),
- ),
- onClick = { showClarityPresetDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "audio.clarity_preset"),
- )
- 8 -> SettingsToggleCard(
- icon = Icons.Filled.GraphicEq,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_clarity_bypass),
- subtitle = if (misc.clarityAtmosBypass) {
- "Clarity bypasses on multichannel/spatial sources"
- } else {
- "Off \u2022 Clarity processes every source"
- },
- checked = misc.clarityAtmosBypass,
- onCheckedChange = viewModel::setClarityAtmosBypass,
- position = position,
- isHighlighted = (highlightedSettingId == "audio.clarity_spatial_bypass"),
- )
- }
- }
- }
- }
- }
-
- SettingsTab.APPEARANCE -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_appearance))
- SettingsGroup(rowCount = 8) { index, position ->
- when (index) {
- 0 -> ThemeModeSelectorCard(
- currentThemeMode = theme?.themeMode ?: ThemeMode.SYSTEM,
- onSelectThemeMode = viewModel::setThemeMode,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.theme_mode"),
- )
- 1 -> SettingsToggleCard(
- icon = Icons.Filled.Contrast,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_amoled),
- subtitle = stringResource(R.string.settings_amoled_sub),
- checked = theme?.amoled ?: false,
- enabled = theme?.themeMode != ThemeMode.LIGHT,
- onCheckedChange = viewModel::setAmoled,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.amoled"),
- )
- 2 -> SettingsToggleCard(
- icon = Icons.Filled.Palette,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_dynamic_color),
- subtitle = stringResource(R.string.settings_dynamic_color_sub),
- checked = theme?.mode == AccentMode.DYNAMIC,
- onCheckedChange = { enabled ->
- viewModel.setAccentMode(if (enabled) AccentMode.DYNAMIC else AccentMode.MANUAL)
- },
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.dynamic_color"),
- )
- 3 -> SettingsToggleCard(
- icon = Icons.Filled.BubbleChart,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_liquid_glass),
- subtitle = stringResource(R.string.settings_liquid_glass_sub),
- checked = theme?.liquidGlass ?: true,
- onCheckedChange = viewModel::setLiquidGlass,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.liquid_glass"),
- )
- 4 -> SettingsToggleCard(
- icon = Icons.Filled.Apps,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_md3_nav_bar),
- subtitle = stringResource(R.string.settings_md3_nav_bar_sub),
- checked = theme?.md3NavBar ?: false,
- onCheckedChange = viewModel::setMd3NavBar,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.md3_nav_bar"),
- )
- 5 -> SettingsToggleCard(
- icon = Icons.Filled.Album,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_dynamic_now_playing),
- subtitle = stringResource(R.string.settings_dynamic_now_playing_sub),
- checked = misc.dynamicNowPlayingEnabled,
- onCheckedChange = viewModel::setDynamicNowPlaying,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.dynamic_now_playing"),
- )
- 6 -> SettingsToggleCard(
- icon = Icons.Filled.TextFields,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_app_font),
- subtitle = stringResource(R.string.settings_app_font_sub),
- checked = misc.useCustomFont,
- onCheckedChange = viewModel::setUseCustomFont,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.app_font"),
- )
- 7 -> SettingsActionCard(
- icon = Icons.Filled.Dashboard,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_home_sections),
- subtitle = run {
- val total = com.lastwave.app.data.local.HomeSection.entries.size
- val visible = total - misc.hiddenHomeSections.size
- stringResource(R.string.home_sections_visible, visible, total)
- },
- onClick = onOpenHomeSections,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.home_sections"),
- )
- }
- }
- }
- }
-
- item {
- Column {
- SectionLabel(stringResource(R.string.settings_section_accent))
- Spacer(Modifier.height(10.dp))
- Card(
- shape = CardOuterShape,
- colors = CardDefaults.cardColors(
- containerColor = if (highlightedSettingId == "appearance.accent_preset") MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- modifier = Modifier
- .fillMaxWidth()
- .settingHighlightGlow(
- isHighlighted = (highlightedSettingId == "appearance.accent_preset"),
- shape = CardOuterShape,
- ),
- ) {
- Column(Modifier.padding(20.dp)) {
- AccentPresetGrid(
- currentMode = theme?.mode ?: AccentMode.MANUAL,
- selectedHex = theme?.accentColorHex,
- onPickPreset = { hex -> viewModel.setManualAccent(Color(android.graphics.Color.parseColor(hex))) },
- onPickMono = { viewModel.setAccentMode(AccentMode.MONOCHROME) },
- onPickCustom = viewModel::openColorWheel,
- )
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_experimental))
- SettingsGroup(rowCount = 9) { index, position ->
- when (index) {
- 0 -> SettingsToggleCard(
- icon = Icons.Filled.Lyrics,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = "Word-by-word lyrics",
- subtitle = if (misc.wordByWordLyrics) {
- "Real-time syllable sync & karaoke highlight"
- } else {
- "Line-by-line synced lyrics"
- },
- checked = misc.wordByWordLyrics,
- onCheckedChange = viewModel::setWordByWordLyrics,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.word_by_word_lyrics"),
- )
- 1 -> SettingsToggleCard(
- icon = Icons.Filled.Waves,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_wavy_seekbar),
- subtitle = if (misc.wavySeekbarEnabled) {
- stringResource(R.string.settings_wavy_seekbar_on)
- } else {
- stringResource(R.string.settings_wavy_seekbar_off)
- },
- checked = misc.wavySeekbarEnabled,
- onCheckedChange = viewModel::setWavySeekbarEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.wavy_seekbar"),
- )
- 2 -> SettingsToggleCard(
- icon = Icons.Filled.RestartAlt,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_rotating_background),
- subtitle = if (misc.rotatingBackgroundEnabled) {
- stringResource(R.string.settings_rotating_background_on)
- } else {
- stringResource(R.string.settings_rotating_background_off)
- },
- checked = misc.rotatingBackgroundEnabled,
- onCheckedChange = viewModel::setRotatingBackgroundEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.rotating_background"),
- )
- 3 -> SettingsToggleCard(
- icon = Icons.Filled.RestartAlt,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_lyrics_rotating_background),
- subtitle = if (misc.lyricsRotatingBackgroundEnabled) {
- stringResource(R.string.settings_lyrics_rotating_background_on)
- } else {
- stringResource(R.string.settings_lyrics_rotating_background_off)
- },
- checked = misc.lyricsRotatingBackgroundEnabled,
- onCheckedChange = viewModel::setLyricsRotatingBackgroundEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.lyrics_rotating_background"),
- )
- 4 -> SettingsActionCard(
- icon = Icons.Filled.Lyrics,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_lyrics_provider),
- subtitle = "${misc.lyricsProvider.title} \u2022 ${misc.lyricsProvider.subtitle}",
- onClick = { showLyricsProviderDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.lyrics_provider"),
- )
- 5 -> SettingsActionCard(
- icon = Icons.Filled.Timer,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_lyrics_offset_title),
- subtitle = if (misc.lyricsOffsetMs == 0L) {
- stringResource(R.string.set_offset_off)
- } else {
- stringResource(
- R.string.set_offset_value,
- if (misc.lyricsOffsetMs > 0) "+" else "",
- misc.lyricsOffsetMs,
- )
- },
- onClick = { showLyricsOffsetDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.lyrics_offset"),
- )
- 6 -> SettingsActionCard(
- icon = Icons.Filled.FormatSize,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_lyrics_size_title),
- subtitle = stringResource(
- R.string.set_size_value,
- (misc.lyricsFontScale * 100).roundToInt(),
- when {
- misc.lyricsFontScale < 0.85f -> stringResource(R.string.set_size_small)
- misc.lyricsFontScale in 0.85f..1.15f -> stringResource(R.string.set_size_standard)
- misc.lyricsFontScale in 1.16f..1.35f -> stringResource(R.string.set_size_large)
- else -> stringResource(R.string.set_size_xlarge)
- },
- ),
- onClick = { showLyricsSizeDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.lyrics_size"),
- )
- 7 -> SettingsActionCard(
- icon = Icons.Filled.VisibilityOff,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_lyrics_autohide_title),
- subtitle = if (misc.lyricsButtonsAutoHideSeconds == 0) {
- stringResource(R.string.set_lyrics_autohide_never)
- } else {
- stringResource(
- R.string.set_lyrics_autohide_value,
- misc.lyricsButtonsAutoHideSeconds,
- )
- },
- onClick = { showLyricsAutoHideDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.lyrics_autohide"),
- )
- 8 -> SettingsActionCard(
- icon = Icons.Filled.Translate,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_translation_language_title),
- subtitle = if (misc.translationLanguage.isBlank()) {
- stringResource(R.string.set_translation_language_follows)
- } else {
- translationLanguageName(misc.translationLanguage, Locale.getDefault())
- },
- onClick = { showTranslationLanguageDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.translation_language"),
- )
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_canvas_enabled))
- val canvasRowCount = if (misc.canvasEnabled) 3 else 2
- SettingsGroup(rowCount = canvasRowCount) { index, position ->
- when (index) {
- 0 -> SettingsToggleCard(
- icon = Icons.Filled.SmartDisplay,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_canvas_enabled),
- subtitle = stringResource(R.string.settings_canvas_enabled_sub),
- checked = misc.canvasEnabled,
- onCheckedChange = viewModel::setCanvasEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.canvas_enabled"),
- )
- 1 -> SettingsToggleCard(
- icon = Icons.Filled.Visibility,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_canvas_full_bleed),
- subtitle = stringResource(R.string.settings_canvas_full_bleed_sub),
- checked = misc.canvasFullBleed,
- onCheckedChange = viewModel::setCanvasFullBleed,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.canvas_full_bleed"),
- )
- 2 -> SettingsToggleCard(
- icon = Icons.Filled.CloudDownload,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_canvas_cellular),
- subtitle = stringResource(R.string.settings_canvas_cellular_sub),
- checked = misc.canvasOverCellular,
- onCheckedChange = viewModel::setCanvasOverCellular,
- position = position,
- isHighlighted = (highlightedSettingId == "appearance.canvas_cellular"),
- )
- }
- }
- }
- }
- }
-
- SettingsTab.YOUTUBE -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_youtube))
- val ytConnected = ytConnection.isConnected
- val syncSubtitle = when (val sync = ytSyncState) {
- is com.lastwave.app.data.ytmusic.YtSyncState.Running ->
- "Syncing ${sync.current}/${sync.total} \u2022 ${sync.label}"
- is com.lastwave.app.data.ytmusic.YtSyncState.Completed ->
- "Playlists mirror to your account \u2022 synced ${relativeTime(sync.atMillis)}"
- is com.lastwave.app.data.ytmusic.YtSyncState.Failed ->
- "Last pass failed \u2014 will retry automatically"
- else ->
- if (!ytConnected) "Connect an account first"
- else if (ytSyncEnabled) "Selected playlists mirror to your account, 24/7" + lastSyncSuffix(ytLastSyncAt)
- else "Keep your YT Music library in sync with LastWave"
- }
- // Display name of the active channel: the picked entry when
- // the channel list was loaded, otherwise the stored name.
- val currentChannelName = ytChannels.firstOrNull {
- it.channelId == ytConnection.onBehalfOfUser &&
- it.authUserIndex == ytConnection.authUserIndex &&
- it.pageId == ytConnection.pageId
- }?.accountName ?: ytConnection.accountName
- val ytRowCount = if (ytConnected) 7 else 2
- SettingsGroup(rowCount = ytRowCount) { index, position ->
- when (index) {
- 0 -> if (ytConnected) {
- YouTubeAccountRow(
- accountName = ytConnection.accountName,
- channelHandle = ytConnection.channelHandle,
- onDisconnect = { showYtDisconnectConfirm = true },
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.account"),
- )
- } else {
- SettingsActionCard(
- icon = Icons.Filled.CloudSync,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_connect_yt),
- subtitle = stringResource(R.string.settings_connect_yt_sub),
- onClick = onOpenYouTubeLogin,
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.account"),
- )
- }
- 1 -> SettingsToggleCard(
- icon = Icons.Filled.CloudSync,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_yt_sync),
- subtitle = syncSubtitle,
- checked = ytConnected && ytSyncEnabled,
- onCheckedChange = viewModel::setYtSyncEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.sync"),
- )
- 2 -> if (ytConnected) {
- SettingsActionCard(
- icon = Icons.Filled.SwitchAccount,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_yt_channel_row),
- subtitle = currentChannelName.ifBlank { stringResource(R.string.set_channel_default) },
- onClick = {
- viewModel.loadYtChannels()
- showYtChannelSheet = true
- },
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.channel"),
- )
- }
- 3 -> if (ytConnected) {
- val selectedCount = syncedPlaylistIds?.size ?: allPlaylists.size
- val syncCountText = if (syncedPlaylistIds == null || selectedCount == allPlaylists.size) {
- stringResource(R.string.set_sync_all, allPlaylists.size)
- } else {
- stringResource(R.string.set_sync_partial, selectedCount, allPlaylists.size)
- }
- SettingsActionCard(
- icon = Icons.Filled.FormatListBulleted,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_select_playlists),
- subtitle = syncCountText,
- onClick = { showSyncPlaylistsSheet = true },
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.select_playlists"),
- )
- } else {
- SettingsActionCard(
- icon = Icons.Filled.QueueMusic,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.set_import_yt),
- subtitle = stringResource(R.string.set_import_yt_sub),
- onClick = onOpenYouTubeImport,
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.import"),
- )
- }
- 4 -> if (ytConnected) {
- val shownCount = ytAccountPlaylists.count { it.id !in hiddenYtLibraryPlaylistIds }
- val visibilitySubtitle = if (shownCount == ytAccountPlaylists.size) {
- stringResource(R.string.set_yt_shown_all, ytAccountPlaylists.size)
- } else {
- stringResource(R.string.set_yt_shown_partial, shownCount, ytAccountPlaylists.size)
- }
- SettingsActionCard(
- icon = Icons.Filled.Visibility,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_yt_shown),
- subtitle = visibilitySubtitle,
- onClick = { showYtLibraryVisibilitySheet = true },
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.shown_playlists"),
- )
- }
- 5 -> SettingsActionCard(
- icon = Icons.Filled.QueueMusic,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_make_local),
- subtitle = stringResource(R.string.settings_make_local_sub),
- onClick = onOpenYouTubeImport,
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.import"),
- )
- 6 -> if (ytConnected) {
- SettingsToggleCard(
- icon = Icons.Filled.History,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_yt_history),
- subtitle = if (ytHistorySyncEnabled) {
- stringResource(R.string.set_yt_hist_on)
- } else {
- stringResource(R.string.set_yt_hist_off)
- },
- checked = ytHistorySyncEnabled,
- onCheckedChange = viewModel::setYtHistorySyncEnabled,
- position = position,
- isHighlighted = (highlightedSettingId == "youtube.history"),
- )
- }
- }
- }
- }
- }
- }
-
- SettingsTab.LAST_FM -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel("Integrations / Scrobbling")
- // Last.fm is optional here — never a gate. Connected:
- // global scrobbles + stats sync. Disconnected: Stats and
- // recommendations run local-first from Room.
- LastFmIntegrationCard(
- isConnected = isLastFmConnected,
- username = session.username,
- avatarUrl = avatarUrl,
- connecting = lastFmConnecting,
- awaitingApproval = lastFmAuthUrl != null,
- hasApiKey = hasApiKey,
- isHighlighted = (highlightedSettingId in listOf("lastfm.connect", "lastfm.api_credentials")),
- onConnect = { viewModel.beginLastFmConnect() },
- onCancel = viewModel::cancelLastFmConnect,
- onDisconnect = viewModel::disconnectLastFm,
- onSaveKeys = viewModel::saveApiCredentials,
- onRemoveKey = viewModel::clearApiKey,
- onOpenCreateKeyPage = {
- runCatching {
- androidx.browser.customtabs.CustomTabsIntent.Builder().build()
- .launchUrl(context, android.net.Uri.parse(LAST_FM_CREATE_KEY_URL))
- }.onFailure {
- viewModel.showToast(context.getString(R.string.toast_no_browser_open_lfm))
- }
- },
- )
- // Discord presence, next to Last.fm because both are "tell
- // other services what I'm listening to" integrations and
- // neither is required for playback. Unlike Last.fm this
- // needs no account in LastWave — it publishes as the
- // LastWave application through the Discord app itself, so
- // the copy says exactly that.
- SettingsToggleCard(
- icon = Icons.Filled.Public,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_discord_title),
- subtitle = if (discordPresenceEnabled) {
- stringResource(R.string.set_discord_on)
- } else {
- stringResource(R.string.set_discord_off)
- },
- checked = discordPresenceEnabled,
- onCheckedChange = viewModel::setDiscordPresenceEnabled,
- isHighlighted = (highlightedSettingId == "discord.presence"),
- )
- SectionLabel("Global Live Telemetry & Map")
- SettingsToggleCard(
- icon = Icons.Filled.Public,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = "Global Live Map Telemetry",
- subtitle = if (misc.liveTelemetryEnabled) {
- "On • Listening ticks broadcast anonymously to the live world map"
- } else {
- "Off • Playback telemetry stays completely local"
- },
- checked = misc.liveTelemetryEnabled,
- onCheckedChange = viewModel::setLiveTelemetryEnabled,
- isHighlighted = (highlightedSettingId == "telemetry.enabled"),
- )
- SettingsGroup(rowCount = 1) { _, position ->
- SettingsActionCard(
- icon = Icons.Filled.Public,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = "Open Global Live Map",
- subtitle = "telemetry.clashprojects.qd.je • Real-time vector map of global listeners",
- onClick = {
- runCatching {
- androidx.browser.customtabs.CustomTabsIntent.Builder().build()
- .launchUrl(context, Uri.parse("https://telemetry.clashprojects.qd.je"))
- }.onFailure {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://telemetry.clashprojects.qd.je")).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- if (!startActivitySafely(context, intent)) {
- viewModel.showToast("No compatible browser is available")
- }
- }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "telemetry.open_map"),
- )
- }
- }
- }
- }
-
-
-
- SettingsTab.LIBRARY -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel("Home & Organization")
- SettingsGroup(rowCount = 2) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.Dashboard,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_home_sections),
- subtitle = run {
- val total = com.lastwave.app.data.local.HomeSection.entries.size
- val visible = total - misc.hiddenHomeSections.size
- stringResource(R.string.home_sections_visible, visible, total)
- },
- onClick = onOpenHomeSections,
- position = position,
- isHighlighted = (highlightedSettingId == "library.home_sections"),
- )
- else -> SettingsActionCard(
- icon = Icons.Filled.RestartAlt,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_excluded_songs),
- subtitle = stringResource(R.string.set_excl_count, state.recommendationExclusionCount),
- onClick = onOpenExcludedSongs,
- position = position,
- isHighlighted = (highlightedSettingId == "library.excluded_songs"),
- )
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_imports))
- SettingsGroup(rowCount = 3) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.QueueMusic,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.set_import_yt),
- subtitle = stringResource(R.string.set_import_yt_sub),
- onClick = onOpenYouTubeImport,
- position = position,
- isHighlighted = (highlightedSettingId == "library.import_yt"),
- )
- 1 -> SettingsActionCard(
- icon = Icons.Filled.QueueMusic,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.set_import_spotify),
- subtitle = stringResource(R.string.set_import_spotify_sub),
- onClick = onOpenExternalImport,
- position = position,
- isHighlighted = (highlightedSettingId == "library.import_external"),
- )
- else -> SettingsActionCard(
- icon = Icons.Filled.FileDownload,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.settings_import_file),
- subtitle = stringResource(R.string.settings_import_file_sub),
- onClick = {
- runCatching {
- csvPickerLauncher.launch(arrayOf("text/*", "text/csv", "application/csv", "audio/x-mpegurl", "application/x-mpegurl", "application/vnd.apple.mpegurl", "*/*"))
- }.onFailure { viewModel.showToast(context.getString(R.string.toast_no_picker)) }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "library.import_file"),
- )
- }
- }
- }
- }
-
-
-
- }
-
- SettingsTab.DATA_BACKUP -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_backup))
- SettingsGroup(rowCount = 2) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.Backup,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_backup),
- subtitle = stringResource(R.string.settings_backup_sub),
- onClick = {
- runCatching { backupLauncher.launch("lastwave-backup-${System.currentTimeMillis()}.json") }
- .onFailure { viewModel.showToast(context.getString(R.string.toast_no_picker)) }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "backup.create"),
- )
- 1 -> SettingsActionCard(
- icon = Icons.Filled.CloudDownload,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_restore),
- subtitle = stringResource(R.string.settings_restore_sub),
- onClick = {
- runCatching { restoreLauncher.launch(arrayOf("*/*")) }
- .onFailure { viewModel.showToast(context.getString(R.string.toast_no_picker)) }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "backup.restore"),
- )
- }
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_data))
- SettingsGroup(rowCount = 1) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.Delete,
- iconContainer = MaterialTheme.colorScheme.errorContainer,
- iconTint = MaterialTheme.colorScheme.onErrorContainer,
- title = stringResource(R.string.settings_clear_all),
- subtitle = stringResource(R.string.settings_clear_all_sub),
- danger = true,
- onClick = viewModel::requestClearAllData,
- position = position,
- isHighlighted = (highlightedSettingId == "backup.clear_all"),
- )
- }
- }
- }
- }
- }
-
- SettingsTab.ABOUT -> {
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_language))
- SettingsGroup(rowCount = 1) { _, position ->
- SettingsActionCard(
- icon = Icons.Filled.Language,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_language_title),
- subtitle = currentLanguage.nativeDisplayName(),
- onClick = { showLanguageDialog = true },
- position = position,
- isHighlighted = (highlightedSettingId == "about.language"),
- )
- }
- }
- }
-
- item {
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- SectionLabel(stringResource(R.string.settings_section_about))
- SettingsGroup(rowCount = 5) { index, position ->
- when (index) {
- 0 -> SettingsActionCard(
- icon = Icons.Filled.Public,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = "Global Live Map",
- subtitle = "telemetry.clashprojects.qd.je • Real-time listener telemetry",
- onClick = {
- runCatching {
- androidx.browser.customtabs.CustomTabsIntent.Builder().build()
- .launchUrl(context, Uri.parse("https://telemetry.clashprojects.qd.je"))
- }.onFailure {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://telemetry.clashprojects.qd.je")).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- if (!startActivitySafely(context, intent)) {
- viewModel.showToast("No compatible browser is available")
- }
- }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "about.live_map"),
- )
- 1 -> SettingsActionCard(
- icon = Icons.AutoMirrored.Filled.Send,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_updates_support),
- subtitle = stringResource(R.string.set_tg_sub),
- onClick = {
- if (!openTelegramChannel(context, "clashprojects")) {
- viewModel.showToast(context.getString(R.string.toast_no_browser_tg))
- }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "about.telegram_support"),
- )
- 2 -> SettingsActionCard(
- icon = Icons.Filled.Group,
- iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
- iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
- title = stringResource(R.string.set_discord_support),
- subtitle = stringResource(R.string.set_discord_support_sub),
- onClick = {
- val discordIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://discord.gg/TMCEPSUNk2"))
- if (!startActivitySafely(context, discordIntent)) {
- viewModel.showToast(context.getString(R.string.toast_no_browser))
- }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "about.discord_support"),
- )
- 3 -> SettingsActionCard(
- icon = Icons.Filled.AutoAwesome,
- iconContainer = MaterialTheme.colorScheme.primaryContainer,
- iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
- title = stringResource(R.string.settings_more_from_us),
- subtitle = stringResource(R.string.set_more_apps_sub),
- onClick = {
- if (!openTelegramChannel(context, "MaterialYouApp")) {
- viewModel.showToast(context.getString(R.string.toast_no_browser_tg))
- }
- },
- position = position,
- isHighlighted = (highlightedSettingId == "about.more_apps"),
- )
- 4 -> SettingsActionCard(
- icon = Icons.Filled.Code,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_diagnostics),
- subtitle = stringResource(R.string.settings_diagnostics_sub),
- onClick = { viewModel.exportDiagnostics() },
- position = position,
- isHighlighted = (highlightedSettingId == "about.diagnostics"),
- )
- }
- }
- Spacer(Modifier.height(4.dp))
-
- // Prominent Update Available Banner Card (if newer version detected)
- if (updateInfo.isUpdateAvailable) {
- Surface(
- shape = CardOuterShape,
- color = MaterialTheme.colorScheme.primaryContainer,
- shadowElevation = 3.dp,
- modifier = Modifier
- .fillMaxWidth()
- .clip(CardOuterShape)
- .clickable { viewModel.openUpdate(context) },
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- Box(
- modifier = Modifier
- .size(46.dp)
- .background(MaterialTheme.colorScheme.primary, CircleShape),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = Icons.Filled.CloudDownload,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(24.dp),
- )
- }
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Update Available!",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Text(
- text = "Version ${updateInfo.latestVersion} is ready to install",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
- )
- }
- Surface(
- shape = RoundedCornerShape(14.dp),
- color = MaterialTheme.colorScheme.primary,
- ) {
- Text(
- text = "Update",
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
- )
- }
- }
- }
- Spacer(Modifier.height(8.dp))
- }
-
- AboutCard(versionName = appVersionName(context))
-
- SettingsActionCard(
- icon = Icons.Filled.CloudDownload,
- iconContainer = if (updateInfo.isUpdateAvailable) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
- iconTint = if (updateInfo.isUpdateAvailable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSecondaryContainer,
- title = if (updateInfo.isUpdateAvailable) "Update Ready (${updateInfo.latestVersion})" else "Check for Updates",
- subtitle = when {
- updateInfo.isChecking -> "Checking GitHub releases..."
- updateInfo.isUpdateAvailable -> {
- val fileName = updateInfo.downloadFileName
- if (!fileName.isNullOrBlank()) "Tap to download $fileName" else "Tap to download new version"
- }
- !updateInfo.message.isNullOrBlank() -> updateInfo.message.orEmpty()
- else -> "Current version: ${appVersionName(context)}"
- },
- onClick = {
- if (updateInfo.isUpdateAvailable) {
- viewModel.openUpdate(context)
- } else {
- viewModel.checkForUpdates()
- }
- },
- isHighlighted = (highlightedSettingId == "about.check_updates"),
- )
-
- SettingsActionCard(
- icon = Icons.Filled.Code,
- iconContainer = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- title = stringResource(R.string.settings_source_code),
- subtitle = "github.com/Clash-Projects/LastWave-native",
- onClick = {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Clash-Projects/LastWave-native"))
- if (!startActivitySafely(context, intent)) {
- viewModel.showToast(context.getString(R.string.toast_no_browser_plain))
- }
- },
- isHighlighted = (highlightedSettingId == "about.source_code"),
- )
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // -- Custom color wheel dialog (par 8.4) --
- if (state.showColorWheel) {
- ColorWheelSheet(onDismiss = viewModel::dismissColorWheel, onApply = viewModel::applyCustomColor)
- }
-
- // -- Clear-all-data confirm --
- if (state.showClearAllConfirm) {
- AlertDialog(
- onDismissRequest = viewModel::dismissClearAllConfirm,
- title = { Text(stringResource(R.string.dialog_clear_all_title)) },
- text = { Text(stringResource(R.string.dialog_clear_all_text)) },
- confirmButton = { TextButton(onClick = { viewModel.confirmClearAllData(onLoggedOut) }) { Text(stringResource(R.string.dialog_clear_all_confirm)) } },
- dismissButton = { TextButton(onClick = viewModel::dismissClearAllConfirm) { Text(stringResource(R.string.common_cancel)) } },
- )
- }
-
- // -- YouTube Music disconnect confirm --
- if (showYtDisconnectConfirm) {
- AlertDialog(
- onDismissRequest = { showYtDisconnectConfirm = false },
- title = { Text(stringResource(R.string.dialog_disconnect_yt_title)) },
- text = { Text(stringResource(R.string.dialog_disconnect_yt_text)) },
- confirmButton = {
- TextButton(onClick = {
- showYtDisconnectConfirm = false
- viewModel.disconnectYouTube()
- }) { Text(stringResource(R.string.common_disconnect)) }
- },
- dismissButton = {
- TextButton(onClick = { showYtDisconnectConfirm = false }) { Text(stringResource(R.string.common_cancel)) }
- },
- )
- }
-
- // -- Restore confirm --
- if (state.showRestoreConfirm) { val isPlaylistMirror = state.pendingRestoreKind == PendingRestoreKind.PLAYLIST_MIRROR
- AlertDialog(
- onDismissRequest = viewModel::dismissRestoreConfirm,
- title = { Text(if (isPlaylistMirror) "Sync playlist JSON?" else "Restore backup?") },
- text = {
- Text(
- if (isPlaylistMirror) {
- "This will merge ${state.pendingRestorePlaylistCount ?: 0} playlist(s) from the local JSON file and reconnect automatic syncing."
- } else {
- "This will replace your current data with ${state.pendingRestorePlaylistCount ?: 0} playlist(s) and all settings from the backup file."
- },
- )
- },
- confirmButton = { TextButton(onClick = { viewModel.confirmRestore(onBack) }) { Text(if (isPlaylistMirror) stringResource(R.string.common_sync) else stringResource(R.string.common_restore)) } },
- dismissButton = { TextButton(onClick = viewModel::dismissRestoreConfirm) { Text(stringResource(R.string.common_cancel)) } },
- )
- }
-
- // -- App language picker (Settings -> Language) --
- if (showLanguageDialog) {
- AlertDialog(
- onDismissRequest = { showLanguageDialog = false },
- title = { Text(stringResource(R.string.settings_language_dialog_title)) },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
- AppLanguage.SELECTABLE.forEach { language ->
- val selected = language == currentLanguage
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .clickable {
- viewModel.setAppLanguage(language)
- showLanguageDialog = false
- }
- .padding(horizontal = 8.dp, vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(Modifier.weight(1f)) {
- Text(
- language.nativeDisplayName(),
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
- )
- if (language == AppLanguage.SYSTEM) {
- Text(
- stringResource(R.string.settings_language_system_sub),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- if (selected) {
- Icon(
- Icons.Filled.Check,
- contentDescription = stringResource(R.string.common_selected),
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(18.dp),
- )
- }
- }
- }
- }
- },
- confirmButton = {
- TextButton(onClick = { showLanguageDialog = false }) {
- Text(stringResource(R.string.common_done))
- }
- },
- )
- }
-
- // -- Enable Scrobbling password dialog --
- if (state.showSessionKeyDialog) {
- var password by remember { mutableStateOf("") }
- AlertDialog(
- onDismissRequest = viewModel::dismissSessionKeyDialog,
- title = { Text(stringResource(R.string.set_scrobble_title)) },
- text = {
- Column {
- Text(
- "Last.fm only allows scrobbling through a signed session, and the only way to get one without a browser is with your password. It's sent once, directly to Last.fm over HTTPS, and never stored.",
- style = MaterialTheme.typography.bodyMedium,
- )
- Spacer(Modifier.height(16.dp))
- androidx.compose.material3.OutlinedTextField(
- value = password,
- onValueChange = { password = it },
- label = { Text(stringResource(R.string.set_scrobble_pass)) },
- singleLine = true,
- visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
- isError = state.sessionKeyError != null,
- supportingText = state.sessionKeyError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
- modifier = Modifier.fillMaxWidth(),
- )
- }
- },
- confirmButton = {
- TextButton(
- onClick = { viewModel.submitPassword(password) },
- enabled = !state.sessionKeyLoading,
- ) {
- if (state.sessionKeyLoading) {
- com.lastwave.app.ui.common.ExpressiveInlineLoadingIndicator(size = 18.dp)
- } else {
- Text(stringResource(R.string.set_scrobble_enable))
- }
- }
- },
- dismissButton = { TextButton(onClick = viewModel::dismissSessionKeyDialog) { Text(stringResource(R.string.common_cancel)) } },
- )
- }
-
- // -- Experimental 15-band equalizer --
- if (showEqSheet) {
- EqualizerSheet(
- eq = eq,
- onDismiss = { showEqSheet = false },
- onSetEnabled = viewModel::setEqualizerEnabled,
- onPickPreset = viewModel::applyEqPreset,
- onBandPreview = viewModel::previewEqBandGain,
- onBandChange = viewModel::setEqBandGain,
- )
- }
-
-
-
- // -- Lyrics provider picker: preferred source first, automatic
- // fallback to the rest when it returns nothing --
- if (showLyricsProviderDialog) {
- LyricsProviderDialog(
- current = misc.lyricsProvider,
- onSelect = {
- viewModel.setLyricsProvider(it)
- showLyricsProviderDialog = false
- },
- onDismiss = { showLyricsProviderDialog = false },
- )
- }
-
- // -- Lyrics sync offset stepper: shifts highlight/focus only --
- if (showLyricsOffsetDialog) {
- LyricsOffsetDialog(
- currentMs = misc.lyricsOffsetMs,
- onSelect = { viewModel.setLyricsOffsetMs(it) },
- onDismiss = { showLyricsOffsetDialog = false },
- )
- }
-
- // -- Lyrics text size dialog: scales font size with live preview --
- if (showLyricsSizeDialog) {
- LyricsSizeDialog(
- currentScale = misc.lyricsFontScale,
- onSelect = { viewModel.setLyricsFontScale(it) },
- onDismiss = { showLyricsSizeDialog = false },
- )
- }
-
- // -- Floating lyrics buttons: idle time before they fade out --
- if (showLyricsAutoHideDialog) {
- LyricsAutoHideDialog(
- currentSeconds = misc.lyricsButtonsAutoHideSeconds,
- onSelect = {
- viewModel.setLyricsButtonsAutoHideSeconds(it)
- showLyricsAutoHideDialog = false
- },
- onDismiss = { showLyricsAutoHideDialog = false },
- )
- }
-
- // -- Language the lyrics translate button turns songs into --
- if (showTranslationLanguageDialog) {
- TranslationLanguageDialog(
- current = misc.translationLanguage,
- onSelect = {
- viewModel.setTranslationLanguage(it)
- showTranslationLanguageDialog = false
- },
- onDismiss = { showTranslationLanguageDialog = false },
- )
- }
-
- if (showLoudnessDialog) {
- LoudnessModeDialog(
- current = loudness.mode,
- onSelect = {
- viewModel.setLoudnessMode(it)
- showLoudnessDialog = false
- },
- onDismiss = { showLoudnessDialog = false },
- )
- }
-
- if (showClarityPresetDialog) {
- ClarityPresetDialog(
- currentIndex = misc.clarityPreset,
- onSelect = {
- viewModel.setClarityPreset(it)
- showClarityPresetDialog = false
- },
- onDismiss = { showClarityPresetDialog = false },
- )
- }
-
- // -- Selective Playlist Sync sheet --
- if (showSyncPlaylistsSheet) {
- SyncPlaylistsSheet(
- playlists = allPlaylists,
- syncedIds = syncedPlaylistIds,
- onToggleSync = viewModel::togglePlaylistSync,
- onSelectAll = viewModel::selectAllPlaylistsForSync,
- onDismiss = { showSyncPlaylistsSheet = false },
- )
- }
-
- if (showYtLibraryVisibilitySheet) {
- YouTubeLibraryVisibilitySheet(
- playlists = ytAccountPlaylists,
- hiddenIds = hiddenYtLibraryPlaylistIds,
- onSetVisible = viewModel::setYtLibraryPlaylistVisible,
- onSetAllVisible = viewModel::setAllYtLibraryPlaylistsVisible,
- onDismiss = { showYtLibraryVisibilitySheet = false },
- )
- }
-
- if (showYtChannelSheet) {
- YouTubeChannelSheet(
- channels = ytChannels,
- isLoading = ytChannelsLoading,
- selectedChannelId = ytConnection.onBehalfOfUser,
- selectedAuthUser = ytConnection.authUserIndex,
- selectedPageId = ytConnection.pageId,
- onReload = viewModel::loadYtChannels,
- onSelect = {
- viewModel.selectYtChannel(it)
- showYtChannelSheet = false
- },
- onDismiss = { showYtChannelSheet = false },
- )
- }
-
- if (showQualityDialog) {
- val tiers = listOf(
- Triple(28, "Dolby Atmos", "Spatial Immersive Audio • Master Audio" to "ATMOS"),
- Triple(27, "Hi-Res Audio (Max)", "Up to 24-bit / 192 kHz • Lossless Studio FLAC" to "24-BIT / 192k"),
- Triple(7, "Hi-Res Audio", "24-bit / 96 kHz • Lossless Studio FLAC" to "24-BIT / 96k"),
- Triple(6, "CD Lossless", "16-bit / 44.1 kHz • Lossless CD FLAC" to "16-BIT / 44.1k"),
- Triple(5, "Standard Quality", "320 kbps • MP3 / AAC" to "320 kbps"),
- Triple(4, "Data Saver", "96 kbps • High Efficiency AAC" to "96 kbps"),
- Triple(-1, "YouTube Music", "128-256 kbps • YouTube Music AAC / Opus stream" to "YOUTUBE"),
- )
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
-
- ModalBottomSheet(
- onDismissRequest = { showQualityDialog = false },
- sheetState = sheetState,
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- dragHandle = {
- Surface(
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
- modifier = Modifier
- .padding(top = 12.dp, bottom = 8.dp)
- .size(width = 36.dp, height = 4.dp),
- ) {}
- },
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 640.dp)
- .align(Alignment.CenterHorizontally)
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 20.dp)
- .padding(bottom = 24.dp + safeDrawingBottomPadding()),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(top = 4.dp),
- ) {
- Box(
- modifier = Modifier
- .size(44.dp)
- .clip(RoundedCornerShape(14.dp))
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.HighQuality,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.size(24.dp),
- )
- }
- Spacer(Modifier.width(14.dp))
- Column {
- Text(
- "Streaming Quality",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- Text(
- "Select preferred audio resolution & bit depth",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-
- Text(
- "YouTube Music plays strictly as-is (never upgrades). Dolby Atmos plays only when selected — otherwise it never plays. Atmos falls back to Hi-Res → CD → 320 kbps → YouTube. Hi-Res steps down to lower lossless tiers, then YouTube last.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.outline,
- )
-
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- tiers.forEach { (qualityId, title, meta) ->
- val (subtitle, badge) = meta
- val isSelected = misc.losslessQuality == qualityId
- Surface(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- viewModel.setLosslessQuality(qualityId)
- showQualityDialog = false
- },
- shape = RoundedCornerShape(20.dp),
- color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
- border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary) else null,
- shadowElevation = if (isSelected) 3.dp else 0.dp,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- title,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
- color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
- )
- Spacer(Modifier.width(8.dp))
- Surface(
- shape = RoundedCornerShape(6.dp),
- color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest,
- ) {
- Text(
- badge,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.Bold,
- color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
- )
- }
- }
- Spacer(Modifier.height(4.dp))
- Text(
- subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- if (isSelected) {
- Box(
- modifier = Modifier
- .size(28.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.Check,
- contentDescription = stringResource(R.string.set_selected_cd),
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(18.dp),
- )
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- if (showDownloadQualityDialog) {
- val downloadTiers = listOf(
- Triple(28, "Dolby Atmos", "Spatial Immersive Audio • Master Audio" to "ATMOS"),
- Triple(27, "Hi-Res Audio (Max)", "Up to 24-bit / 192 kHz • Studio Master FLAC" to "24-BIT / 192k"),
- Triple(7, "Hi-Res Audio", "24-bit / 96 kHz • Studio FLAC" to "24-BIT / 96k"),
- Triple(6, "CD Lossless", "16-bit / 44.1 kHz • Bit-Exact CD FLAC" to "16-BIT / 44.1k"),
- Triple(5, "Standard Quality", "320 kbps • High-Bitrate MP3 / AAC" to "320 kbps"),
- Triple(4, "Data Saver", "96 kbps • High Efficiency AAC" to "96 kbps"),
- Triple(-1, "YouTube Music", "128-256 kbps • YouTube Music AAC / Opus stream" to "YOUTUBE"),
- )
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
-
- ModalBottomSheet(
- onDismissRequest = { showDownloadQualityDialog = false },
- sheetState = sheetState,
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- dragHandle = {
- Surface(
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
- modifier = Modifier
- .padding(top = 12.dp, bottom = 8.dp)
- .size(width = 36.dp, height = 4.dp),
- ) {}
- },
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 640.dp)
- .align(Alignment.CenterHorizontally)
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 20.dp)
- .padding(bottom = 24.dp + safeDrawingBottomPadding()),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(top = 4.dp),
- ) {
- Box(
- modifier = Modifier
- .size(44.dp)
- .clip(RoundedCornerShape(14.dp))
- .background(MaterialTheme.colorScheme.secondaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.CloudDownload,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.size(24.dp),
- )
- }
- Spacer(Modifier.width(14.dp))
- Column {
- Text(
- "Download Quality",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- Text(
- "Select offline audio resolution & bit depth",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-
- Text(
- "YouTube Music downloads strictly as-is (never upgrades). Dolby Atmos downloads only when selected — otherwise it never downloads. Atmos falls back to Hi-Res → CD → 320 kbps → YouTube. Hi-Res steps down to lower tiers, then YouTube last.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.outline,
- )
-
- Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- downloadTiers.forEach { (qualityId, title, meta) ->
- val (subtitle, badge) = meta
- val isSelected = misc.downloadQuality == qualityId
- Surface(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- viewModel.setDownloadQuality(qualityId)
- showDownloadQualityDialog = false
- },
- shape = RoundedCornerShape(20.dp),
- color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
- border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary) else null,
- shadowElevation = if (isSelected) 3.dp else 0.dp,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- title,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
- color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
- )
- Spacer(Modifier.width(8.dp))
- Surface(
- shape = RoundedCornerShape(6.dp),
- color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest,
- ) {
- Text(
- badge,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.Bold,
- color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
- )
- }
- }
- Spacer(Modifier.height(4.dp))
- Text(
- subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- if (isSelected) {
- Box(
- modifier = Modifier
- .size(28.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.Check,
- contentDescription = stringResource(R.string.set_selected_cd),
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(18.dp),
- )
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- if (showYouTubeImportSheet) {
- YouTubeImportSheet(
- onDismiss = { showYouTubeImportSheet = false },
- innerTube = viewModel.innerTube,
- importManager = viewModel.playlistImportManager,
- onImportSuccess = { saved ->
- viewModel.showToast(context.getString(R.string.toast_imported, saved.title, saved.tracks.size))
- },
- )
- }
-
- state.toastMessage?.let { msg ->
- LaunchedEffect(msg) {
- kotlinx.coroutines.delay(3000)
- viewModel.dismissToast()
- }
- Box(
- Modifier
- .fillMaxSize()
- .safeHorizontalContentPadding()
- .padding(
- bottom = 24.dp +
- LocalMiniPlayerScrollClearance.current +
- safeDrawingBottomPadding(),
- ),
- contentAlignment = Alignment.BottomCenter,
- ) {
- Surface(shape = ExpressivePillShape, color = MaterialTheme.colorScheme.inverseSurface, tonalElevation = 6.dp) {
- Text(msg, color = MaterialTheme.colorScheme.inverseOnSurface, modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp))
- }
- }
- }
-}
-
-private fun appVersionName(context: android.content.Context): String = try {
- context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.2.4"
-} catch (error: Exception) {
- "4.2.4"
-} catch (error: LinkageError) {
- "4.2.4"
-}
-
-/** Small tap-scale used across the row-style cards on this screen for a
- * softer, springier press response than the plain ripple alone gives. */
-@Composable
-private fun rememberPressScale(interactionSource: MutableInteractionSource): Float {
- val pressed by interactionSource.collectIsPressedAsState()
- val scale by animateFloatAsState(
- targetValue = if (pressed) 0.97f else 1f,
- animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
- label = "pressScale",
- )
- return scale
-}
-
-@Composable
-private fun SectionLabel(text: String) {
- Text(
- text = text,
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(start = 4.dp, bottom = 2.dp),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-}
-
-@Composable
-private fun IconBadge(icon: ImageVector, container: Color, tint: Color, modifier: Modifier = Modifier) {
- Box(
- modifier
- .size(44.dp)
- .clip(IconBadgeShape)
- .background(container),
- contentAlignment = Alignment.Center,
- ) {
- Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
- }
-}
-
-@Composable
-private fun ThemeModeSelectorCard(
- currentThemeMode: ThemeMode,
- onSelectThemeMode: (ThemeMode) -> Unit,
- position: GroupPosition = GroupPosition.SINGLE,
- isHighlighted: Boolean = false,
-) {
- val shape = groupShape(position)
-
- Card(
- shape = shape,
- colors = CardDefaults.cardColors(
- containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- modifier = Modifier
- .fillMaxWidth()
- .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
- ) {
- Column(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- IconBadge(
- Icons.Filled.BrightnessAuto,
- MaterialTheme.colorScheme.primaryContainer,
- MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(
- stringResource(R.string.settings_theme_mode),
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- )
- Text(
- stringResource(R.string.settings_theme_mode_sub),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- Spacer(Modifier.height(14.dp))
- val items = listOf(
- ConnectedButtonItem(stringResource(R.string.theme_mode_system), Icons.Filled.BrightnessAuto),
- ConnectedButtonItem(stringResource(R.string.theme_mode_light), Icons.Filled.LightMode),
- ConnectedButtonItem(stringResource(R.string.theme_mode_dark), Icons.Filled.DarkMode),
- )
- val selectedIdx = when (currentThemeMode) {
- ThemeMode.SYSTEM -> 0
- ThemeMode.LIGHT -> 1
- ThemeMode.DARK -> 2
- }
- ConnectedButtonGroup(
- items = items,
- selectedIndex = selectedIdx,
- onSelect = { idx ->
- val mode = when (idx) {
- 0 -> ThemeMode.SYSTEM
- 1 -> ThemeMode.LIGHT
- else -> ThemeMode.DARK
- }
- onSelectThemeMode(mode)
- },
- )
- }
- }
-}
-
-@Composable
-private fun SettingsToggleCard(
- icon: ImageVector,
- iconContainer: Color,
- iconTint: Color,
- title: String,
- subtitle: String,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- position: GroupPosition = GroupPosition.SINGLE,
- enabled: Boolean = true,
- isHighlighted: Boolean = false,
-) {
- val interactionSource = remember { MutableInteractionSource() }
- val scale = rememberPressScale(interactionSource)
- val shape = groupShape(position)
-
- Card(
- onClick = { if (enabled) onCheckedChange(!checked) },
- shape = shape,
- enabled = enabled,
- colors = CardDefaults.cardColors(
- containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh,
- disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f),
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- interactionSource = interactionSource,
- modifier = Modifier
- .fillMaxWidth()
- .scale(if (enabled) scale else 1f)
- .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
- ) {
- Row(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconBadge(
- icon,
- if (enabled) iconContainer else iconContainer.copy(alpha = 0.5f),
- if (enabled) iconTint else iconTint.copy(alpha = 0.5f),
- )
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(
- title,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- Text(
- subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f),
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- }
- Spacer(Modifier.width(8.dp))
- Switch(
- checked = checked,
- enabled = enabled,
- onCheckedChange = if (enabled) onCheckedChange else null,
- thumbContent = if (checked) {
- {
- Icon(
- Icons.Filled.Check,
- contentDescription = null,
- modifier = Modifier.size(SwitchDefaults.IconSize),
- )
- }
- } else null,
- )
- }
- }
-}
-
-
-
-@Composable
-private fun CrossfadeDurationRow(
- seconds: Int,
- onSecondsChange: (Int) -> Unit,
- position: GroupPosition = GroupPosition.SINGLE,
- isHighlighted: Boolean = false,
-) {
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
- var sliderValue by remember { mutableStateOf(seconds.coerceIn(1, 12).toFloat()) }
- var isDragging by remember { mutableStateOf(false) }
- LaunchedEffect(seconds) {
- if (!isDragging) sliderValue = seconds.coerceIn(1, 12).toFloat()
- }
- val shape = groupShape(position)
-
- val blendStyle = when (sliderValue.roundToInt()) {
- in 1..2 -> "Quick DJ overlap"
- in 3..5 -> "Standard smooth blend"
- in 6..8 -> "Long musical transition"
- else -> "Extended cinematic crossfade"
- }
-
- Card(
- shape = shape,
- colors = CardDefaults.cardColors(
- containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- modifier = Modifier
- .fillMaxWidth()
- .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
- ) {
- Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- IconBadge(
- Icons.Filled.Tune,
- MaterialTheme.colorScheme.secondaryContainer,
- MaterialTheme.colorScheme.onSecondaryContainer,
- )
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(stringResource(R.string.set_xfade_dur), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
- Text(
- "${sliderValue.roundToInt()}s \u2022 $blendStyle",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = MaterialTheme.colorScheme.primaryContainer,
- ) {
- Text(
- "${sliderValue.roundToInt()}s",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
- )
- }
- }
- Slider(
- value = sliderValue,
- onValueChange = { value ->
- isDragging = true
- val rounded = value.roundToInt().coerceIn(1, 12)
- if (rounded.toFloat() != sliderValue) {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
- sliderValue = rounded.toFloat()
- onSecondsChange(rounded)
- }
- },
- onValueChangeFinished = {
- isDragging = false
- },
- valueRange = 1f..12f,
- steps = 10,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 6.dp),
- )
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(stringResource(R.string.set_xfade_1), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
- Text(stringResource(R.string.set_xfade_6), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
- Text(stringResource(R.string.set_xfade_12), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
- }
- }
- }
-}
-
-@Composable
-private fun SettingsActionCard(
- icon: ImageVector,
- iconContainer: Color,
- iconTint: Color,
- title: String,
- subtitle: String,
- danger: Boolean = false,
- onClick: () -> Unit,
- position: GroupPosition = GroupPosition.SINGLE,
- isHighlighted: Boolean = false,
-) {
- val interactionSource = remember { MutableInteractionSource() }
- val scale = rememberPressScale(interactionSource)
- val titleColor = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
- val shape = groupShape(position)
-
- Card(
- onClick = onClick,
- shape = shape,
- colors = CardDefaults.cardColors(
- containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- interactionSource = interactionSource,
- modifier = Modifier
- .fillMaxWidth()
- .scale(scale)
- .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
- ) {
- Row(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconBadge(icon, iconContainer, iconTint)
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = titleColor, maxLines = 1, softWrap = false, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
- Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
- }
- Icon(
- Icons.Filled.ChevronRight,
- contentDescription = null,
- tint = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
-
-// ==========================================
-// SETTINGS SEARCH COMPOSABLES
-// ==========================================
-
-/**
- * Builds an [AnnotatedString] from [text], highlighting ranges from [ranges]
- * with primary bold styling for search query matches.
- */
-private fun buildHighlightedText(
- text: String,
- ranges: List,
- highlightColor: Color,
-): AnnotatedString {
- if (ranges.isEmpty() || text.isEmpty()) {
- return AnnotatedString(text)
- }
- return buildAnnotatedString {
- var lastIndex = 0
- for (range in ranges) {
- val start = range.first.coerceIn(0, text.length)
- val end = (range.last + 1).coerceIn(0, text.length)
- if (start > lastIndex) {
- append(text.substring(lastIndex, start))
- }
- if (end > start) {
- withStyle(SpanStyle(color = highlightColor, fontWeight = FontWeight.Bold)) {
- append(text.substring(start, end))
- }
- lastIndex = end
- }
- }
- if (lastIndex < text.length) {
- append(text.substring(lastIndex))
- }
- }
-}
-
-@Composable
-private fun SettingsSearchBar(
- query: String,
- onQueryChange: (String) -> Unit,
- onClear: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val focusRequester = remember { FocusRequester() }
- val focusManager = LocalFocusManager.current
-
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
- }
-
- OutlinedTextField(
- value = query,
- onValueChange = onQueryChange,
- modifier = modifier.focusRequester(focusRequester),
- shape = RoundedCornerShape(20.dp),
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
- unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
- focusedBorderColor = MaterialTheme.colorScheme.primary,
- unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
- ),
- placeholder = {
- Text(
- stringResource(R.string.settings_search_hint),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
- )
- },
- leadingIcon = {
- Icon(
- Icons.Filled.Search,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- )
- },
- trailingIcon = {
- if (query.isNotEmpty()) {
- IconButton(onClick = onClear) {
- Icon(
- Icons.Filled.Close,
- contentDescription = stringResource(R.string.settings_search_clear),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- },
- singleLine = true,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
- keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
- textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
- )
-}
-
-@Composable
-private fun SettingsSearchResultsList(
- query: String,
- results: List,
- onResultClick: (SettingsEntry) -> Unit,
- modifier: Modifier = Modifier,
-) {
- LazyColumn(
- contentPadding = PaddingValues(
- start = 16.dp,
- end = 16.dp,
- top = 8.dp,
- bottom = 32.dp + LocalMiniPlayerScrollClearance.current + safeDrawingBottomPadding(),
- ),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = modifier.fillMaxSize().safeHorizontalContentPadding(),
- ) {
- if (results.isEmpty()) {
- item {
- SettingsSearchEmptyState(query = query)
- }
- } else {
- item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp, vertical = 2.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = "${results.size} setting${if (results.size == 1) "" else "s"} found",
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- }
- items(results, key = { it.entry.id }) { result ->
- SearchResultCard(
- result = result,
- onClick = { onResultClick(result.entry) },
- )
- }
- }
- }
-}
-
-@Composable
-private fun SearchResultCard(
- result: MatchResult,
- onClick: () -> Unit,
-) {
- val interactionSource = remember { MutableInteractionSource() }
- val scale = rememberPressScale(interactionSource)
- val highlightColor = MaterialTheme.colorScheme.primary
-
- val annotatedTitle = remember(result.entry.title, result.titleMatchedRanges, highlightColor) {
- buildHighlightedText(result.entry.title, result.titleMatchedRanges, highlightColor)
- }
-
- val annotatedSubtitle = remember(result.entry.subtitle, result.subtitleMatchedRanges, highlightColor) {
- buildHighlightedText(result.entry.subtitle, result.subtitleMatchedRanges, highlightColor)
- }
-
- Card(
- onClick = onClick,
- shape = CardOuterShape,
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- interactionSource = interactionSource,
- modifier = Modifier
- .fillMaxWidth()
- .scale(scale),
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 14.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
- ) {
- Text(
- text = "${result.entry.parentTab.title} › ${result.entry.section}",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Medium,
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
- )
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconBadge(
- result.entry.icon,
- result.entry.iconContainer(),
- result.entry.iconTint(),
- )
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(
- text = annotatedTitle,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- Spacer(Modifier.height(2.dp))
- Text(
- text = annotatedSubtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 2,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- }
- Spacer(Modifier.width(8.dp))
- Icon(
- Icons.Filled.ChevronRight,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- modifier = Modifier.size(20.dp),
- )
- }
- }
- }
-}
-
-@Composable
-private fun SettingsSearchEmptyState(query: String) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 48.dp, horizontal = 24.dp),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Surface(
- shape = CircleShape,
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
- modifier = Modifier.size(72.dp),
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- imageVector = Icons.Filled.SearchOff,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.outline,
- modifier = Modifier.size(36.dp),
- )
- }
- }
- Text(
- text = stringResource(R.string.settings_search_no_results, query),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(R.string.settings_search_no_results_sub),
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
-
-@Composable
-private fun YouTubeAccountRow(
- accountName: String,
- channelHandle: String?,
- onDisconnect: () -> Unit,
- position: GroupPosition,
- isHighlighted: Boolean = false,
-) {
- val shape = groupShape(position)
- Card(
- shape = shape,
- colors = CardDefaults.cardColors(
- containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- modifier = Modifier
- .fillMaxWidth()
- .animateContentSize()
- .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
- ) {
- Row(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconBadge(
- Icons.Filled.SmartDisplay,
- MaterialTheme.colorScheme.errorContainer,
- MaterialTheme.colorScheme.onErrorContainer,
- )
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(
- "YouTube Music Account",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- accountName.ifBlank { "Connected" },
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- if (!channelHandle.isNullOrBlank()) {
- Text(
- channelHandle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- Spacer(Modifier.width(8.dp))
- FilledTonalIconButton(
- onClick = onDisconnect,
- colors = IconButtonDefaults.filledTonalIconButtonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer,
- ),
- ) {
- Icon(Icons.Filled.Logout, contentDescription = stringResource(R.string.set_disconnect_cd))
- }
- }
- }
-}
-
-private fun lastSyncSuffix(lastSyncAtMillis: Long): String {
- if (lastSyncAtMillis <= 0L) return ""
- return " \u2022 synced ${relativeTime(lastSyncAtMillis)}"
-}
-
-private fun relativeTime(timestampMillis: Long): String {
- if (timestampMillis <= 0L) return "never"
- val delta = System.currentTimeMillis() - timestampMillis
- val minutes = delta / 60_000L
- return when {
- minutes < 1 -> "just now"
- minutes < 60 -> "${minutes}m ago"
- minutes < 60 * 24 -> "${minutes / 60}h ago"
- else -> "${minutes / (60 * 24)}d ago"
- }
-}
-
-private const val LAST_FM_CREATE_KEY_URL = "https://www.last.fm/api/account/create"
-
-@Composable
-private fun LastFmIntegrationCard(
- isConnected: Boolean,
- username: String,
- avatarUrl: String?,
- connecting: Boolean,
- awaitingApproval: Boolean,
- hasApiKey: Boolean,
- onConnect: () -> Unit,
- onCancel: () -> Unit,
- onDisconnect: () -> Unit,
- onSaveKeys: (String, String) -> Unit,
- onRemoveKey: () -> Unit,
- onOpenCreateKeyPage: () -> Unit,
- isHighlighted: Boolean = false,
-) {
- var showDisconnectConfirm by remember { mutableStateOf(false) }
- // No shared key exists, so the form starts open until a key is saved.
- var showKeyForm by remember(hasApiKey) { mutableStateOf(!hasApiKey) }
- var keyInput by remember { mutableStateOf("") }
- var secretInput by remember { mutableStateOf("") }
-
- Card(
- shape = CardOuterShape,
- colors = CardDefaults.cardColors(
- containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
- modifier = Modifier
- .fillMaxWidth()
- .animateContentSize()
- .settingHighlightGlow(isHighlighted = isHighlighted, shape = CardOuterShape),
- ) {
- Column(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = if (isConnected) 14.dp else 16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (isConnected) {
- if (avatarUrl != null) {
- coil.compose.AsyncImage(
- model = avatarUrl,
- contentDescription = null,
- modifier = Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
- contentScale = androidx.compose.ui.layout.ContentScale.Crop
- )
- } else {
- IconBadge(
- Icons.Filled.CloudSync,
- MaterialTheme.colorScheme.primaryContainer,
- MaterialTheme.colorScheme.onPrimaryContainer,
- )
- }
- } else {
- Box(
- Modifier
- .size(48.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- if (isConnected && username.isNotBlank()) username.take(1).uppercase() else "L",
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.titleMedium,
- )
- }
- }
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(
- "Last.fm",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- if (isConnected && username.isNotBlank()) username else "Not connected",
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- if (!isConnected) {
- Text(
- "Optional • Stats use local listening when disconnected",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- if (isConnected) {
- Spacer(Modifier.width(8.dp))
- FilledTonalIconButton(
- onClick = { showDisconnectConfirm = true },
- colors = IconButtonDefaults.filledTonalIconButtonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer,
- ),
- ) {
- Icon(Icons.Filled.Logout, contentDescription = stringResource(R.string.set_disconnect_cd))
- }
- }
- }
- if (!isConnected) {
- when {
- awaitingApproval || connecting -> {
- com.lastwave.app.ui.common.ExpressiveLoadingIndicator(
- message = if (connecting) "Connecting to Last.fm…" else "Waiting for approval in the browser…",
- )
- Spacer(Modifier.height(4.dp))
- TextButton(onClick = onCancel) { Text(stringResource(R.string.common_cancel)) }
- }
- else -> {
- Button(
- onClick = onConnect,
- enabled = hasApiKey,
- shape = ExpressivePillShape,
- modifier = Modifier.fillMaxWidth(),
- ) { Text(stringResource(R.string.set_lfm_connect)) }
- Text(
- if (hasApiKey) "Approve in your browser. You can disconnect anytime — Stats keep working locally."
- else "Add your API key below first, then connect.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
-
- if (!isConnected) {
- // ── Bring-your-own-key (required, no shared key): Last.fm
- // rate-limits per API key, so each person adds their own key
- // here before connecting.
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(Modifier.weight(1f)) {
- Text(
- stringResource(R.string.set_lfm_key_label),
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Medium,
- )
- Text(
- if (hasApiKey) stringResource(R.string.set_lfm_key_saved)
- else stringResource(R.string.set_lfm_key_hint),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- TextButton(onClick = { showKeyForm = !showKeyForm }) {
- Text(if (showKeyForm) "Hide" else if (hasApiKey) "Change" else "Add key")
- }
- }
- if (showKeyForm) {
- androidx.compose.material3.OutlinedTextField(
- value = keyInput,
- onValueChange = { keyInput = it.trim() },
- label = { Text(stringResource(R.string.set_lfm_apikey)) },
- singleLine = true,
- shape = RoundedCornerShape(16.dp),
- modifier = Modifier.fillMaxWidth(),
- )
- androidx.compose.material3.OutlinedTextField(
- value = secretInput,
- onValueChange = { secretInput = it.trim() },
- label = { Text(stringResource(R.string.set_lfm_secret)) },
- singleLine = true,
- shape = RoundedCornerShape(16.dp),
- modifier = Modifier.fillMaxWidth(),
- )
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
- Button(
- onClick = {
- onSaveKeys(keyInput, secretInput)
- keyInput = ""
- secretInput = ""
- showKeyForm = false
- },
- enabled = keyInput.length >= 16 && secretInput.length >= 16,
- shape = ExpressivePillShape,
- modifier = Modifier.weight(1f),
- ) { Text(stringResource(R.string.set_lfm_save)) }
- if (hasApiKey) {
- OutlinedButton(
- onClick = onRemoveKey,
- shape = ExpressivePillShape,
- ) { Text(stringResource(R.string.set_lfm_remove)) }
- }
- }
- TextButton(onClick = onOpenCreateKeyPage) {
- Text(stringResource(R.string.set_lfm_get))
- }
- } else {
- TextButton(onClick = onOpenCreateKeyPage) {
- Text(stringResource(R.string.set_lfm_how))
- }
- }
- }
- }
- }
- }
-
- if (showDisconnectConfirm) {
- AlertDialog(
- onDismissRequest = { showDisconnectConfirm = false },
- title = { Text(stringResource(R.string.set_lfm_disc_title)) },
- text = { Text(stringResource(R.string.set_lfm_disc_text)) },
- confirmButton = {
- TextButton(onClick = {
- showDisconnectConfirm = false
- onDisconnect()
- }) { Text(stringResource(R.string.set_lfm_disc_btn)) }
- },
- dismissButton = { TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.common_cancel)) } },
- )
- }
-}
-
-@Composable
-private fun AccentPresetGrid(
- currentMode: AccentMode,
- selectedHex: String?,
- onPickPreset: (String) -> Unit,
- onPickMono: () -> Unit,
- onPickCustom: () -> Unit,
-) {
- // A preset only reads as "selected" while the user is actually in
- // manual mode — Dynamic/Monochrome shouldn't light up whichever preset
- // happens to hex-match by coincidence.
- fun isPresetSelected(hex: String) =
- currentMode == AccentMode.MANUAL && selectedHex?.equals(hex, ignoreCase = true) == true
- val customSelected = currentMode == AccentMode.MANUAL &&
- selectedHex != null &&
- ACCENT_PRESETS.none { it.hex.equals(selectedHex, ignoreCase = true) }
- val monoSelected = currentMode == AccentMode.MONOCHROME
-
- // One unified 4-column tile grid — six color presets plus Mono and
- // Custom as tiles of their own, not a separate row of pill buttons.
- Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
- ACCENT_PRESETS.take(4).forEach { preset ->
- ColorTile(
- label = preset.name,
- selected = isPresetSelected(preset.hex),
- modifier = Modifier.weight(1f),
- onClick = { onPickPreset(preset.hex) },
- ) {
- PaletteTilePreview(preset.hex)
- }
- }
- }
- Spacer(Modifier.height(14.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
- ACCENT_PRESETS.drop(4).forEach { preset ->
- ColorTile(
- label = preset.name,
- selected = isPresetSelected(preset.hex),
- modifier = Modifier.weight(1f),
- onClick = { onPickPreset(preset.hex) },
- ) {
- PaletteTilePreview(preset.hex)
- }
- }
- ColorTile(
- label = "Mono",
- selected = monoSelected,
- modifier = Modifier.weight(1f),
- onClick = onPickMono,
- ) {
- Box(
- Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.surfaceContainerHighest),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.Contrast,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(22.dp),
- )
- }
- }
- ColorTile(
- label = stringResource(R.string.set_eq_custom),
- selected = customSelected,
- modifier = Modifier.weight(1f),
- onClick = onPickCustom,
- ) {
- Box(
- Modifier
- .fillMaxSize()
- .background(
- androidx.compose.ui.graphics.Brush.sweepGradient(
- listOf(Color(0xFFE03030), Color(0xFFE0A030), Color(0xFF6B9E6B), Color(0xFF2196C6), Color(0xFF7C4DFF), Color(0xFFE03030)),
- ),
- ),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.Colorize,
- contentDescription = null,
- tint = Color.White,
- modifier = Modifier.size(22.dp),
- )
- }
- }
- }
-}
-
-/**
- * Four coordinated shades derived from one preset hex via HSV — Dark,
- * Medium, Light, and a punchier Accent tone — used to render a real
- * multi-tone palette preview inside each Accent tile instead of one flat
- * swatch. Order returned: [dark, medium, light, accent].
- *
- * Tuned for a muted, Material You / Monet feel rather than raw HSV
- * vibrance: saturation is capped well below 100% even for the "accent"
- * shade (Monet's HCT-derived tonal palettes rarely reach full chroma —
- * that's what read as neon here), and the value range is narrower so
- * "dark" and "light" stay closer to the preset's own character instead of
- * swinging to near-black/near-white extremes.
- */
-private fun accentShades(hex: String): List {
- val argb = android.graphics.Color.parseColor(hex)
- val hsv = FloatArray(3)
- android.graphics.Color.colorToHSV(argb, hsv)
- val (h, s, v) = hsv
- val mutedBase = s * 0.72f // the single biggest lever for "less neon"
-
- fun shade(saturation: Float, value: Float): Color {
- val arr = floatArrayOf(h, saturation.coerceIn(0f, 0.82f), value.coerceIn(0.2f, 0.92f))
- return Color(android.graphics.Color.HSVToColor(arr))
- }
-
- return listOf(
- shade(mutedBase.coerceAtLeast(0.42f), v * 0.62f), // dark
- shade(mutedBase, v * 0.80f), // medium — closest to the preset's own tone
- shade((mutedBase * 0.55f), (v + (1f - v) * 0.45f).coerceAtLeast(0.78f)), // light
- shade((mutedBase * 1.15f), (v * 0.95f)), // accent — a touch richer, never maxed out
- )
-}
-
-/** Renders a preset's four shades as a 2x2 block grid filling the tile,
- * so selecting a preset previews its whole coordinated palette rather
- * than one flat color. */
-@Composable
-private fun PaletteTilePreview(hex: String) {
- val shades = remember(hex) { accentShades(hex) }
- Column(Modifier.fillMaxSize()) {
- Row(Modifier.weight(1f).fillMaxWidth()) {
- Box(Modifier.weight(1f).fillMaxHeight().background(shades[0]))
- Box(Modifier.weight(1f).fillMaxHeight().background(shades[1]))
- }
- Row(Modifier.weight(1f).fillMaxWidth()) {
- Box(Modifier.weight(1f).fillMaxHeight().background(shades[2]))
- Box(Modifier.weight(1f).fillMaxHeight().background(shades[3]))
- }
- }
-}
-
-
-/**
- * One expressive accent tile: a real elevated square swatch (Modifier.shadow
- * — a true drop shadow, not Card's tonal-elevation color blend, which would
- * otherwise wash out the exact color a swatch is supposed to preview),
- * a spring scale/elevation lift on selection, a genuine ripple on tap, and
- * an animated check badge. [content] draws the tile's fill — a flat color
- * for presets, an icon-on-surface treatment for Mono/Custom.
- */
-@Composable
-private fun ColorTile(
- label: String,
- selected: Boolean,
- modifier: Modifier = Modifier,
- onClick: () -> Unit,
- content: @Composable (Boolean) -> Unit,
-) {
- val interactionSource = remember { MutableInteractionSource() }
- val pressed by interactionSource.collectIsPressedAsState()
- val scale by animateFloatAsState(
- targetValue = if (pressed) 0.94f else if (selected) 1.04f else 1f,
- animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
- label = "tileScale",
- )
- val elevation by animateDpAsState(
- targetValue = if (selected) 8.dp else 2.dp,
- animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
- label = "tileElevation",
- )
- val borderColor by androidx.compose.animation.animateColorAsState(
- targetValue = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
- animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
- label = "tileBorder",
- )
- val tileShape = RoundedCornerShape(20.dp)
-
- Column(
- modifier = modifier.scale(scale),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Box(
- Modifier
- .fillMaxWidth()
- .aspectRatio(1f)
- // Real shadow, not tonal elevation — keeps every tile's
- // color a true, undistorted preview of the accent it
- // represents (see GenerateScreen's ModeCard fix for why
- // Card's own elevation param is the wrong tool for this).
- .shadow(elevation = elevation, shape = tileShape, clip = false)
- .clip(tileShape)
- .clickable(
- interactionSource = interactionSource,
- indication = LocalIndication.current,
- onClick = onClick,
- )
- .border(2.5.dp, borderColor, tileShape),
- contentAlignment = Alignment.Center,
- ) {
- content(selected)
- androidx.compose.animation.AnimatedVisibility(
- visible = selected,
- enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.scaleIn(),
- exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut(),
- modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
- ) {
- Box(
- Modifier
- .size(20.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.Check,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(13.dp),
- )
- }
- }
- }
- Spacer(Modifier.height(6.dp))
- Text(
- label,
- style = MaterialTheme.typography.labelMedium,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
- color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- )
- }
-}
-
-@Composable
-private fun AboutCard(versionName: String) {
- Card(
- shape = CardOuterShape,
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- modifier = Modifier.fillMaxWidth(),
- ) {
- Column(
- Modifier.fillMaxWidth().padding(vertical = 28.dp, horizontal = 20.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Box(
- Modifier
- .size(72.dp)
- .clip(CircleShape)
- // The launcher icon is an XML on API 26+
- // (mipmap-anydpi-v26/ic_launcher_round.xml) — Compose's
- // painterResource() can only parse plain bitmap/vector
- // drawables, not that root element, and throws the
- // instant this composable enters composition. Rebuilding
- // the same mark from its two real layers (the lime
- // background color + the bars vector, both plain
- // resources) reproduces it exactly without touching the
- // adaptive icon resource at all.
- .background(colorResource(R.color.ic_launcher_background)),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_launcher_logo),
- contentDescription = "LastWave",
- tint = Color.Unspecified,
- modifier = Modifier.size(72.dp),
- )
- }
- Spacer(Modifier.height(14.dp))
- Text("LastWave", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
- Spacer(Modifier.height(8.dp))
- Surface(
- shape = ExpressivePillShape,
- color = MaterialTheme.colorScheme.primaryContainer,
- ) {
- Text(
- "Version $versionName",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- fontWeight = FontWeight.Medium,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
- )
- }
- Spacer(Modifier.height(12.dp))
- Text(
- "Built with the Last.fm API",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- )
- }
- }
-}
-
-private fun openTelegramChannel(context: android.content.Context, handleOrUrl: String): Boolean {
- val username = handleOrUrl
- .removePrefix("https://t.me/")
- .removePrefix("http://t.me/")
- .removePrefix("@")
- .trim()
- val tgIntent = Intent(Intent.ACTION_VIEW, Uri.parse("tg://resolve?domain=$username")).apply {
- setPackage("org.telegram.messenger")
- }
- val genericTgIntent = Intent(Intent.ACTION_VIEW, Uri.parse("tg://resolve?domain=$username"))
- val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$username"))
- return startActivitySafely(context, tgIntent) ||
- startActivitySafely(context, genericTgIntent) ||
- startActivitySafely(context, webIntent)
-}
-
-/** OEM Settings/browser components are optional and occasionally broken on
- * custom ROMs. Never let an external activity failure escape a click event. */
-private fun startActivitySafely(context: android.content.Context, intent: Intent): Boolean {
- val safeIntent = Intent(intent).apply {
- if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- return try {
- context.startActivity(safeIntent)
- true
- } catch (_: Exception) {
- false
- } catch (_: LinkageError) {
- false
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun ColorWheelSheet(onDismiss: () -> Unit, onApply: (Color) -> Unit) {
- val sheetState = rememberModalBottomSheetState()
- var hue by remember { mutableStateOf(4f) }
- var saturation by remember { mutableStateOf(0.75f) }
- var lightness by remember { mutableStateOf(0.5f) }
- val previewColor = Color.hsl(hue, saturation, lightness)
-
- ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 560.dp)
- .align(Alignment.CenterHorizontally)
- .padding(20.dp),
- ) {
- Text(stringResource(R.string.set_color_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
- Spacer(Modifier.height(16.dp))
- Box(
- Modifier
- .fillMaxWidth()
- .height(80.dp)
- .clip(RoundedCornerShape(20.dp))
- .background(previewColor),
- )
- Spacer(Modifier.height(20.dp))
- Text(stringResource(R.string.set_hue), style = MaterialTheme.typography.labelLarge)
- Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
- Text(stringResource(R.string.set_sat), style = MaterialTheme.typography.labelLarge)
- Slider(value = saturation, onValueChange = { saturation = it }, valueRange = 0f..1f)
- Text(stringResource(R.string.set_light), style = MaterialTheme.typography.labelLarge)
- Slider(value = lightness, onValueChange = { lightness = it }, valueRange = 0.15f..0.85f)
- Spacer(Modifier.height(12.dp))
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedButton(onClick = onDismiss, shape = ExpressivePillShape, modifier = Modifier.weight(1f).height(48.dp)) { Text(stringResource(R.string.common_cancel)) }
- Button(onClick = { onApply(previewColor) }, shape = ExpressivePillShape, modifier = Modifier.weight(1f).height(48.dp)) { Text(stringResource(R.string.common_apply)) }
- }
- Spacer(Modifier.height(8.dp))
- }
- }
-}
-
-// -- Experimental 15-band equalizer (Settings → Experimental → Equalizer) --
-
-private const val EQ_MAX_DB = EQ_MAX_GAIN_DB
-
-private fun eqBandCategory(hz: Int): String = when {
- hz <= 40 -> "SUB"
- hz <= 100 -> "BASS"
- hz <= 250 -> "LOW-MID"
- hz <= 1000 -> "MID"
- hz <= 2500 -> "HIGH-MID"
- hz <= 6300 -> "PRES"
- else -> "AIR"
-}
-
-@Composable
-private fun EqualizerCurveGraph(
- gains: FloatArray,
- enabled: Boolean,
- modifier: Modifier = Modifier,
-) {
- val primaryColor = MaterialTheme.colorScheme.primary
- val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
- val outlineVariant = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
- val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)
-
- Box(
- modifier = modifier
- .fillMaxWidth()
- .height(130.dp)
- .clip(RoundedCornerShape(20.dp))
- .background(MaterialTheme.colorScheme.surfaceContainerLowest)
- .padding(horizontal = 14.dp, vertical = 10.dp),
- ) {
- Canvas(modifier = Modifier.fillMaxSize()) {
- val w = size.width
- val h = size.height
- val paddingX = 14.dp.toPx()
- val availableW = w - paddingX * 2f
- val baselineY = h / 2f
- val maxDbPx = (h - 22.dp.toPx()) / 2f
-
- val topY = baselineY - maxDbPx
- val bottomY = baselineY + maxDbPx
-
- // Baseline (0 dB)
- drawLine(
- color = if (enabled) outlineVariant else outlineVariant.copy(alpha = 0.15f),
- start = Offset(paddingX, baselineY),
- end = Offset(w - paddingX, baselineY),
- strokeWidth = 1.5.dp.toPx(),
- pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 8f), 0f),
- )
- // +8 dB line
- drawLine(
- color = gridColor,
- start = Offset(paddingX, topY),
- end = Offset(w - paddingX, topY),
- strokeWidth = 1.dp.toPx(),
- pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 6f), 0f),
- )
- // -8 dB line
- drawLine(
- color = gridColor,
- start = Offset(paddingX, bottomY),
- end = Offset(w - paddingX, bottomY),
- strokeWidth = 1.dp.toPx(),
- pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 6f), 0f),
- )
-
- if (gains.isEmpty()) return@Canvas
-
- val count = gains.size
- val stepX = availableW / (count - 1).coerceAtLeast(1)
- val points = List(count) { i ->
- val x = paddingX + i * stepX
- val gain = if (enabled) gains[i].coerceIn(-EQ_MAX_DB, EQ_MAX_DB) else 0f
- val y = baselineY - (gain / EQ_MAX_DB) * maxDbPx
- Offset(x, y)
- }
-
- val path = Path()
- val fillPath = Path()
-
- path.moveTo(points.first().x, points.first().y)
- fillPath.moveTo(points.first().x, baselineY)
- fillPath.lineTo(points.first().x, points.first().y)
-
- for (i in 0 until points.size - 1) {
- val p0 = points[i]
- val p1 = points[i + 1]
- val controlX1 = p0.x + (p1.x - p0.x) / 2f
- val controlY1 = p0.y
- val controlX2 = p0.x + (p1.x - p0.x) / 2f
- val controlY2 = p1.y
- path.cubicTo(controlX1, controlY1, controlX2, controlY2, p1.x, p1.y)
- fillPath.cubicTo(controlX1, controlY1, controlX2, controlY2, p1.x, p1.y)
- }
-
- fillPath.lineTo(points.last().x, baselineY)
- fillPath.close()
-
- if (enabled) {
- drawPath(
- path = fillPath,
- brush = Brush.verticalGradient(
- colors = listOf(
- primaryColor.copy(alpha = 0.35f),
- primaryColor.copy(alpha = 0.05f),
- Color.Transparent,
- ),
- startY = topY,
- endY = bottomY,
- ),
- )
- }
-
- drawPath(
- path = path,
- color = if (enabled) primaryColor else onSurfaceVariant.copy(alpha = 0.4f),
- style = Stroke(
- width = 3.dp.toPx(),
- cap = StrokeCap.Round,
- join = StrokeJoin.Round,
- ),
- )
-
- for (p in points) {
- val hasBoostOrCut = Math.abs(p.y - baselineY) > 2f && enabled
- drawCircle(
- color = if (hasBoostOrCut) primaryColor else if (enabled) primaryColor.copy(alpha = 0.7f) else onSurfaceVariant.copy(alpha = 0.3f),
- radius = if (hasBoostOrCut) 4.5.dp.toPx() else 3.dp.toPx(),
- center = p,
- )
- if (hasBoostOrCut) {
- drawCircle(
- color = Color.White,
- radius = 2.dp.toPx(),
- center = p,
- )
- }
- }
- }
-
- Column(
- modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd).padding(end = 2.dp),
- verticalArrangement = Arrangement.SpaceBetween,
- horizontalAlignment = Alignment.End,
- ) {
- Text("+12dB", fontSize = 9.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
- Text("0dB", fontSize = 9.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
- Text("-12dB", fontSize = 9.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
- }
- }
-}
-
-/**
- * 100% natural, native Material 3 Equalizer:
- * - Edge-to-edge layout with status bar and navigation bar insets protection
- * - Live dynamic Bézier Spline frequency response visualizer
- * - Hardware acoustic fader board with real-time numeric dB readouts and 0 dB center detent haptics
- * - Standard ISO center frequencies
- */
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
-@Composable
-private fun EqualizerSheet(
- eq: EqualizerSettings,
- onDismiss: () -> Unit,
- onSetEnabled: (Boolean) -> Unit,
- onPickPreset: (String) -> Unit,
- onBandPreview: (Int, Float) -> Unit,
- onBandChange: (Int, Float) -> Unit,
-) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
-
- var gains by remember(eq.gainsDb) {
- mutableStateOf(
- FloatArray(EQ_BAND_FREQS_HZ.size) { index ->
- eq.gainsDb.getOrNull(index)
- ?.takeIf { it.isFinite() }
- ?.coerceIn(-EQ_MAX_DB, EQ_MAX_DB)
- ?: 0f
- },
- )
- }
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- dragHandle = {
- Surface(
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
- modifier = Modifier
- .padding(top = 12.dp, bottom = 8.dp)
- .size(width = 36.dp, height = 4.dp),
- ) {}
- },
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 640.dp)
- .align(Alignment.CenterHorizontally)
- .statusBarsPadding()
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 20.dp)
- .padding(bottom = 32.dp + safeDrawingBottomPadding()),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- // Header Row
- Row(
- modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier
- .size(44.dp)
- .clip(RoundedCornerShape(14.dp))
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.GraphicEq,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.size(24.dp),
- )
- }
- Spacer(Modifier.width(14.dp))
- Column {
- Text(
- "Equalizer",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- Text(
- "15-band hardware acoustic tuning",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- )
- }
- }
-
- TextButton(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- onPickPreset(EqualizerPresets.FLAT.name)
- },
- enabled = eq.enabled,
- ) {
- Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
- Spacer(Modifier.width(4.dp))
- Text(stringResource(R.string.set_eq_reset), style = MaterialTheme.typography.labelMedium)
- }
- }
-
- // Master On/Off Switch Card
- Surface(
- shape = RoundedCornerShape(20.dp),
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- tonalElevation = 2.dp,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp).fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(Modifier.weight(1f)) {
- Text(stringResource(R.string.set_eq_enable), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
- Text(
- if (eq.enabled) "Shaping your music in real-time" else "Off \u2014 original audio passes through",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 2,
- )
- }
- Switch(
- checked = eq.enabled,
- onCheckedChange = { enabled ->
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- onSetEnabled(enabled)
- },
- )
- }
- }
-
- // Real-Time Frequency Response Visualizer
- EqualizerCurveGraph(
- gains = gains,
- enabled = eq.enabled,
- )
-
- // Presets Horizontal Flow
- SectionLabel("Presets")
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
- modifier = Modifier.fillMaxWidth(),
- ) {
- EqualizerPresets.ALL.forEach { preset ->
- FilterChip(
- selected = eq.presetName.equals(preset.name, ignoreCase = true),
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- onPickPreset(preset.name)
- },
- label = { Text(preset.name, style = MaterialTheme.typography.labelMedium) },
- )
- }
- if (eq.presetName == EqualizerPresets.CUSTOM_NAME) {
- FilterChip(selected = true, onClick = {}, label = { Text(stringResource(R.string.set_eq_custom), style = MaterialTheme.typography.labelMedium) })
- }
- }
-
- // Native Equalizer Fader Board
- Surface(
- shape = RoundedCornerShape(24.dp),
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- tonalElevation = 2.dp,
- modifier = Modifier.fillMaxWidth(),
- ) {
- val curveAlpha by animateFloatAsState(
- targetValue = if (eq.enabled) 1f else 0.38f,
- animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
- label = "eqCurveAlpha",
- )
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp)
- .alpha(curveAlpha),
- ) {
- // Top Scale Label
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 2.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- "+8 dB (Boost)",
- fontSize = 11.sp,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.primary,
- )
- Text(
- "-8 dB (Cut)",
- fontSize = 11.sp,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- Spacer(Modifier.height(10.dp))
-
- // Scrollable Horizontal Row of Native Equalizer Faders
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .horizontalScroll(rememberScrollState())
- .padding(horizontal = 16.dp),
- horizontalArrangement = Arrangement.spacedBy(14.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- EQ_BAND_FREQS_HZ.forEachIndexed { index, hz ->
- EqNativeSlider(
- gainDb = gains[index],
- hz = hz,
- enabled = eq.enabled,
- onGainChange = { value ->
- gains = gains.copyOf().also { it[index] = value }
- onBandPreview(index, value)
- },
- onChangeFinished = { onBandChange(index, gains[index]) },
- )
- }
- }
- }
- }
-
- Text(
- text = when {
- !eq.enabled -> "Turn on to apply equalization live"
- eq.presetName == EqualizerPresets.CUSTOM_NAME -> "Custom profile \u2022 touch and drag any bar up/down"
- else -> "${eq.presetName} preset active \u2022 touch any bar to customize"
- },
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
- )
- }
- }
-}
-
-/**
- * Goated native hardware equalizer fader bar:
- * - Vertical capsule track with central 0 dB baseline notch and active level gradient beam
- * - Tactile hardware capsule thumb knob with double grip ridges
- * - Real-time continuous numeric dB badge on top with container coloring
- * - Standard frequency label + band category tag underneath
- */
-@Composable
-private fun EqNativeSlider(
- gainDb: Float,
- hz: Int,
- enabled: Boolean,
- modifier: Modifier = Modifier,
- onGainChange: (Float) -> Unit,
- onChangeFinished: () -> Unit,
-) {
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
- val normalized = ((gainDb + EQ_MAX_DB) / (EQ_MAX_DB * 2f)).coerceIn(0f, 1f)
- var isDragging by remember { mutableStateOf(false) }
- val currentGain by rememberUpdatedState(gainDb)
- val currentOnGainChange by rememberUpdatedState(onGainChange)
- val currentOnChangeFinished by rememberUpdatedState(onChangeFinished)
-
- Column(
- modifier = modifier,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- // Numeric Gain on top in a small pill container
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = when {
- !enabled -> MaterialTheme.colorScheme.surfaceContainer
- gainDb > 0f -> MaterialTheme.colorScheme.primaryContainer
- gainDb < 0f -> MaterialTheme.colorScheme.tertiaryContainer
- else -> MaterialTheme.colorScheme.surfaceContainerHighest
- },
- ) {
- Text(
- text = if (gainDb > 0f) "+${"%.1f".format(gainDb)}" else "${"%.1f".format(gainDb)}",
- fontSize = 11.sp,
- fontWeight = if (gainDb != 0f) FontWeight.Bold else FontWeight.Medium,
- color = when {
- !enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
- gainDb > 0f -> MaterialTheme.colorScheme.onPrimaryContainer
- gainDb < 0f -> MaterialTheme.colorScheme.onTertiaryContainer
- else -> MaterialTheme.colorScheme.onSurfaceVariant
- },
- maxLines = 1,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
- )
- }
-
- Spacer(Modifier.height(6.dp))
-
- // Vertical Track Box
- val trackHeight = 152.dp
- val thumbHeight = 24.dp
- val thumbWidth = 38.dp
-
- Box(
- modifier = Modifier
- .width(44.dp)
- .height(trackHeight)
- .clip(RoundedCornerShape(22.dp))
- .background(MaterialTheme.colorScheme.surfaceContainerHighest)
- .pointerInput(enabled) {
- if (!enabled) return@pointerInput
- detectVerticalDragGestures(
- onDragStart = { isDragging = true },
- onDragEnd = {
- isDragging = false
- currentOnChangeFinished()
- },
- onDragCancel = {
- isDragging = false
- currentOnChangeFinished()
- },
- ) { change, dragAmount ->
- change.consume()
- val deltaFraction = -dragAmount / size.height.toFloat()
- val currentFraction = ((currentGain + EQ_MAX_DB) / (EQ_MAX_DB * 2f))
- val newFraction = (currentFraction + deltaFraction).coerceIn(0f, 1f)
- val newGain = (newFraction * EQ_MAX_DB * 2f - EQ_MAX_DB).let {
- if (it in -0.3f..0.3f) 0f else (Math.round(it * 2f) / 2f)
- }
- if (newGain != currentGain) {
- if (newGain == 0f && currentGain != 0f) {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
- }
- currentOnGainChange(newGain)
- }
- }
- },
- contentAlignment = Alignment.Center,
- ) {
- // Center Baseline Line (0 dB)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(2.dp)
- .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
- )
-
- // Active Level Fill (from baseline to thumb)
- val baselineFraction = 0.5f
- val topFraction = if (normalized >= baselineFraction) 1f - normalized else 1f - baselineFraction
- val heightFraction = Math.abs(normalized - baselineFraction)
-
- if (heightFraction > 0.01f && enabled) {
- Box(
- modifier = Modifier
- .fillMaxWidth(0.55f)
- .fillMaxHeight(heightFraction)
- .align(Alignment.TopCenter)
- .graphicsLayer {
- translationY = size.height * topFraction
- }
- .clip(RoundedCornerShape(6.dp))
- .background(
- if (gainDb > 0f) {
- Brush.verticalGradient(
- listOf(
- MaterialTheme.colorScheme.primary,
- MaterialTheme.colorScheme.primaryContainer,
- ),
- )
- } else {
- Brush.verticalGradient(
- listOf(
- MaterialTheme.colorScheme.tertiaryContainer,
- MaterialTheme.colorScheme.tertiary,
- ),
- )
- },
- ),
- )
- }
-
- // Tactile Hardware Capsule Thumb Knob
- Box(
- modifier = Modifier
- .size(width = thumbWidth, height = thumbHeight)
- .align(Alignment.TopCenter)
- .graphicsLayer {
- val maxTravel = (trackHeight - thumbHeight).toPx()
- translationY = maxTravel * (1f - normalized)
- }
- .shadow(if (isDragging) 8.dp else 3.dp, RoundedCornerShape(12.dp))
- .clip(RoundedCornerShape(12.dp))
- .background(
- if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh,
- ),
- contentAlignment = Alignment.Center,
- ) {
- // Double grip ridges
- Column(
- verticalArrangement = Arrangement.spacedBy(3.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Box(
- modifier = Modifier
- .size(width = 14.dp, height = 2.dp)
- .clip(RoundedCornerShape(1.dp))
- .background(
- if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- ),
- )
- Box(
- modifier = Modifier
- .size(width = 14.dp, height = 2.dp)
- .clip(RoundedCornerShape(1.dp))
- .background(
- if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- ),
- )
- }
- }
- }
-
- Spacer(Modifier.height(8.dp))
-
- // Frequency Label
- Text(
- text = eqBandLabel(hz),
- fontSize = 12.sp,
- fontWeight = FontWeight.Bold,
- color = if (gainDb != 0f && enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- )
- Text(
- text = eqBandCategory(hz),
- fontSize = 9.sp,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
- maxLines = 1,
- )
- }
-}
-
-/** Controls which connected-account playlists appear in LastWave. This is
- * intentionally independent from importing and two-way sync. */
-/**
- * Lets the user pick which YouTube channel/profile answers inside the
- * current session. Cookies are identical for every channel (which is why a
- * cookie-only client always lands on the first one) — the choice is sent
- * per-request as the InnerTube delegation flag instead.
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun YouTubeChannelSheet(
- channels: List,
- isLoading: Boolean,
- selectedChannelId: String?,
- selectedAuthUser: Int?,
- selectedPageId: String = "",
- onReload: () -> Unit,
- onSelect: (com.lastwave.app.data.music.YtChannelOption) -> Unit,
- onDismiss: () -> Unit,
-) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 640.dp)
- .align(Alignment.CenterHorizontally)
- .statusBarsPadding()
- .padding(horizontal = 20.dp)
- .padding(bottom = 32.dp + safeDrawingBottomPadding()),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Column(Modifier.weight(1f)) {
- Text(stringResource(R.string.set_yt_channel), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
- Text(
- stringResource(R.string.set_yt_channel_sub),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-
- when {
- isLoading -> Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
- Text(
- stringResource(R.string.set_yt_loading),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-
- channels.isEmpty() -> {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Text(
- stringResource(R.string.set_yt_empty_title),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold,
- )
- Text(
- stringResource(R.string.set_yt_empty_sub),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- FilledTonalButton(onClick = onReload) {
- Text(stringResource(R.string.set_yt_retry))
- }
- }
- }
- }
-
- else -> {
- Text(
- "Each channel keeps its own sync mirrors — switching re-mirrors cleanly instead of mixing libraries.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 12.dp),
- )
- LazyColumn(
- modifier = Modifier.fillMaxWidth().heightIn(max = 440.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(channels.size, key = { index ->
- val c = channels[index]
- "${c.channelId}|${c.authUserIndex}|${c.pageId}|${c.accountName}"
- }) { index ->
- val channel = channels[index]
- val isSelected = channel.channelId == selectedChannelId &&
- channel.authUserIndex == selectedAuthUser &&
- channel.pageId == selectedPageId
- Surface(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- if (!isSelected) onSelect(channel)
- },
- shape = RoundedCornerShape(16.dp),
- color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceContainerHigh,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- RadioButton(
- selected = isSelected,
- onClick = { if (!isSelected) onSelect(channel) },
- )
- Spacer(Modifier.width(10.dp))
- Column(Modifier.weight(1f)) {
- Text(
- channel.accountName,
- fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
- maxLines = 1,
- )
- val detail = channel.channelHandle
- ?: if (channel.channelId != null) stringResource(R.string.set_channel_generic) else stringResource(R.string.set_channel_default)
- Text(
- detail,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- )
- }
- if (isSelected) {
- Text(
- "Active",
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- }
- }
- }
- }
- }
- }
-
- Spacer(Modifier.height(16.dp))
- Button(onClick = onDismiss, shape = CircleShape, modifier = Modifier.fillMaxWidth().height(48.dp)) {
- Text(stringResource(R.string.common_done), fontWeight = FontWeight.Bold)
- }
- }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun YouTubeLibraryVisibilitySheet(
- playlists: List,
- hiddenIds: Set,
- onSetVisible: (String, Boolean) -> Unit,
- onSetAllVisible: (Boolean) -> Unit,
- onDismiss: () -> Unit,
-) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
- val allShown = playlists.isNotEmpty() && playlists.all { it.id !in hiddenIds }
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 640.dp)
- .align(Alignment.CenterHorizontally)
- .statusBarsPadding()
- .padding(horizontal = 20.dp)
- .padding(bottom = 32.dp + safeDrawingBottomPadding()),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Column(Modifier.weight(1f)) {
- Text(stringResource(R.string.set_yt_shown_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
- Text(
- "Choose which account playlists appear in LastWave",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- TextButton(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- onSetAllVisible(!allShown)
- },
- ) {
- Text(if (allShown) "Hide All" else "Show All", fontWeight = FontWeight.Bold)
- }
- }
-
- if (playlists.isEmpty()) {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
- contentAlignment = Alignment.Center,
- ) {
- Text(stringResource(R.string.set_yt_none), color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(playlists.size, key = { playlists[it].id }) { index ->
- val playlist = playlists[index]
- val isShown = playlist.id !in hiddenIds
- Surface(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
- onSetVisible(playlist.id, !isShown)
- },
- shape = RoundedCornerShape(16.dp),
- color = if (isShown) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(RoundedCornerShape(10.dp))
- .background(if (isShown) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.QueueMusic,
- contentDescription = null,
- tint = if (isShown) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(playlist.title, fontWeight = FontWeight.SemiBold, maxLines = 1)
- Text(
- playlist.trackCountText ?: playlist.author ?: "YouTube Music playlist",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- )
- }
- Checkbox(
- checked = isShown,
- onCheckedChange = { checked -> onSetVisible(playlist.id, checked) },
- )
- }
- }
- }
- }
- }
-
- Spacer(Modifier.height(16.dp))
- Button(onClick = onDismiss, shape = CircleShape, modifier = Modifier.fillMaxWidth().height(48.dp)) {
- Text(stringResource(R.string.common_done), fontWeight = FontWeight.Bold)
- }
- }
- }
-}
-
-/** Bottom sheet allowing user to select which specific playlists to mirror to YouTube Music. */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun SyncPlaylistsSheet(
- playlists: List,
- syncedIds: Set?,
- onToggleSync: (Long, Boolean) -> Unit,
- onSelectAll: (Boolean) -> Unit,
- onDismiss: () -> Unit,
-) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
- val allSelected = playlists.isNotEmpty() && (syncedIds == null || (playlists.all { it.id in syncedIds } && syncedIds.isNotEmpty()))
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = sheetState,
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- dragHandle = {
- Surface(
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
- modifier = Modifier
- .padding(top = 12.dp, bottom = 8.dp)
- .size(width = 36.dp, height = 4.dp),
- ) {}
- },
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .adaptiveContentWidth(maxWidth = 640.dp)
- .align(Alignment.CenterHorizontally)
- .statusBarsPadding()
- .padding(horizontal = 20.dp)
- .padding(bottom = 32.dp + safeDrawingBottomPadding()),
- ) {
- // Header
- Row(
- modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Column(Modifier.weight(1f)) {
- Text(
- "Sync Playlists",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- Text(
- "Choose which playlists mirror to YouTube Music",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- TextButton(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
- onSelectAll(!allSelected)
- },
- ) {
- Text(if (allSelected) "Deselect All" else "Select All", fontWeight = FontWeight.Bold)
- }
- }
-
- if (playlists.isEmpty()) {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- "No playlists in your library yet",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxWidth().weight(1f, fill = false),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(playlists.size, key = { playlists[it].id }) { idx ->
- val playlist = playlists[idx]
- val isChecked = syncedIds == null || playlist.id in syncedIds
- Surface(
- onClick = {
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
- onToggleSync(playlist.id, !isChecked)
- },
- shape = RoundedCornerShape(16.dp),
- color = if (isChecked) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp).fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(RoundedCornerShape(10.dp))
- .background(
- if (isChecked) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
- ),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.QueueMusic,
- contentDescription = null,
- tint = if (isChecked) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(22.dp),
- )
- }
-
- Spacer(Modifier.width(14.dp))
-
- Column(Modifier.weight(1f)) {
- Text(
- playlist.title,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- )
- Text(
- "${playlist.tracks.size} tracks",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- Checkbox(
- checked = isChecked,
- onCheckedChange = { checked ->
- haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
- onToggleSync(playlist.id, checked)
- },
- )
- }
- }
- }
- }
- }
-
- Spacer(Modifier.height(16.dp))
-
- Button(
- onClick = onDismiss,
- shape = CircleShape,
- modifier = Modifier.fillMaxWidth().height(48.dp),
- ) {
- Text(stringResource(R.string.common_done), fontWeight = FontWeight.Bold)
- }
- }
- }
-}
-
-/**
- * Bottom sheet to pick from 8 experimental lyrics animation physics profiles.
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun LyricsProviderDialog(
- current: LyricsProvider,
- onSelect: (LyricsProvider) -> Unit,
- onDismiss: () -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.settings_lyrics_provider)) },
- text = {
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- stringResource(R.string.settings_lyrics_provider_desc),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(4.dp))
- LyricsProvider.entries.forEach { provider ->
- val selected = provider == current
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .clickable { onSelect(provider) }
- .padding(vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- RadioButton(selected = selected, onClick = { onSelect(provider) })
- Spacer(Modifier.width(8.dp))
- Column(Modifier.weight(1f)) {
- Text(
- provider.title,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
- )
- Text(
- provider.subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
- },
- )
-}
-
-@Composable
-private fun LyricsAutoHideDialog(
- currentSeconds: Int,
- onSelect: (Int) -> Unit,
- onDismiss: () -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.set_lyrics_autohide_title)) },
- text = {
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- stringResource(R.string.set_lyrics_autohide_desc),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(4.dp))
- SettingsRadioOption(
- label = stringResource(R.string.set_lyrics_autohide_never),
- selected = currentSeconds == 0,
- onClick = { onSelect(0) },
- )
- for (seconds in 2..10) {
- SettingsRadioOption(
- label = stringResource(R.string.set_lyrics_autohide_seconds, seconds),
- selected = currentSeconds == seconds,
- onClick = { onSelect(seconds) },
- )
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
- },
- )
-}
-
-@Composable
-private fun SettingsRadioOption(
- label: String,
- selected: Boolean,
- onClick: () -> Unit,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .clickable { onClick() }
- .padding(vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- RadioButton(selected = selected, onClick = onClick)
- Spacer(Modifier.width(8.dp))
- Text(
- label,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
- )
- }
-}
-
-@Composable
-private fun TranslationLanguageDialog(
- current: String,
- onSelect: (String) -> Unit,
- onDismiss: () -> Unit,
-) {
- val locale = remember { Locale.getDefault() }
- val languages = remember { TRANSLATION_LANGUAGES }
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.set_translation_language_title)) },
- text = {
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- stringResource(R.string.set_translation_language_desc),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(4.dp))
- SettingsRadioOption(
- label = stringResource(R.string.set_translation_language_app),
- selected = current.isBlank(),
- onClick = { onSelect("") },
- )
- for (language in languages) {
- SettingsRadioOption(
- label = translationLanguageName(language.code, locale),
- selected = current.equals(language.code, ignoreCase = true),
- onClick = { onSelect(language.code) },
- )
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
- },
- )
-}
-
-
-
-private fun loudnessModeTitle(context: android.content.Context, mode: LoudnessMode): String = when (mode) {
- LoudnessMode.TRACK -> context.getString(R.string.set_loud_track)
- LoudnessMode.ALBUM -> context.getString(R.string.set_loud_album)
- else -> context.getString(R.string.set_loud_off)
-}
-
-private fun loudnessModeSubtitle(context: android.content.Context, mode: LoudnessMode): String = when (mode) {
- LoudnessMode.TRACK -> context.getString(R.string.set_loud_track_sub)
- LoudnessMode.ALBUM -> context.getString(R.string.set_loud_album_sub)
- else -> context.getString(R.string.set_loud_off_sub)
-}
-
-@Composable
-private fun clarityPresetName(preset: com.lastwave.app.playback.ClarityPreset): String = stringResource(
- when (preset.key) {
- "speaker" -> R.string.set_preset_speaker
- "headphone" -> R.string.set_preset_headphone
- "dac" -> R.string.set_preset_dac
- else -> R.string.set_preset_reference
- },
-)
-
-@Composable
-private fun clarityPresetDescription(preset: com.lastwave.app.playback.ClarityPreset): String = stringResource(
- when (preset.key) {
- "speaker" -> R.string.set_preset_speaker_desc
- "headphone" -> R.string.set_preset_headphone_desc
- "dac" -> R.string.set_preset_dac_desc
- else -> R.string.set_preset_reference_desc
- },
-)
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun LoudnessModeDialog(
- current: LoudnessMode,
- onSelect: (LoudnessMode) -> Unit,
- onDismiss: () -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.set_loud_title)) },
- text = {
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- stringResource(R.string.set_loud_desc),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(4.dp))
- LoudnessMode.entries.forEach { mode ->
- val selected = mode == current
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .clickable { onSelect(mode) }
- .padding(vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- RadioButton(selected = selected, onClick = { onSelect(mode) })
- Spacer(Modifier.width(8.dp))
- Column(Modifier.weight(1f)) {
- Text(
- loudnessModeTitle(LocalContext.current, mode),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
- )
- Text(
- loudnessModeSubtitle(LocalContext.current, mode),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
- },
- )
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun ClarityPresetDialog(
- currentIndex: Int,
- onSelect: (Int) -> Unit,
- onDismiss: () -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.set_clarity_title)) },
- text = {
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- "Tunes the Studio Master Clarity chain to the output. Reference is the unmodified shipping curve.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(4.dp))
- ClarityPresets.ALL.forEach { preset ->
- val selected = preset.index == currentIndex
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .clickable { onSelect(preset.index) }
- .padding(vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- RadioButton(selected = selected, onClick = { onSelect(preset.index) })
- Spacer(Modifier.width(8.dp))
- Column(Modifier.weight(1f)) {
- Text(
- clarityPresetName(preset),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
- )
- Text(
- clarityPresetDescription(preset),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
- },
- )
-}
-
+package com.lastwave.app.ui.settings
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SearchOff
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.withStyle
+import com.lastwave.app.ui.common.HeaderActionIcon
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.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.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.ui.composed
+import androidx.compose.ui.graphics.Shape
+import kotlinx.coroutines.delay
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Album
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Backup
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.Check
+import com.lastwave.app.util.BatteryOptimizationHelper
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.CloudSync
+import androidx.compose.material.icons.filled.Colorize
+import androidx.compose.material.icons.filled.Contrast
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.BubbleChart
+import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.Dashboard
+import androidx.compose.material.icons.filled.Lyrics
+import androidx.compose.material.icons.filled.NotificationsActive
+import androidx.compose.material.icons.filled.Waves
+import com.lastwave.app.data.local.LyricsProvider
+import com.lastwave.app.ui.player.LyricsOffsetDialog
+import com.lastwave.app.ui.player.LyricsSizeDialog
+import androidx.compose.material.icons.filled.FormatSize
+import kotlin.math.roundToInt
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.filled.Translate
+import androidx.compose.material.icons.filled.Logout
+import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.filled.RestartAlt
+import androidx.compose.material.icons.filled.HighQuality
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.SpeakerPhone
+import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material.icons.filled.Language
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material.icons.filled.PlayCircle
+import androidx.compose.material.icons.filled.SmartDisplay
+import androidx.compose.material.icons.filled.SwitchAccount
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.QueueMusic
+import androidx.compose.material.icons.filled.FileDownload
+import androidx.compose.material.icons.filled.FormatListBulleted
+import androidx.compose.material.icons.filled.Extension
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+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.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import com.lastwave.app.data.local.AppLanguage
+import com.lastwave.app.data.local.nativeDisplayName
+import com.lastwave.app.data.lyrics.TRANSLATION_LANGUAGES
+import com.lastwave.app.data.lyrics.translationLanguageName
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.Canvas
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.input.pointer.pointerInput
+import java.util.Locale
+
+import com.lastwave.app.ui.player.LocalMiniPlayerScrollClearance
+import com.lastwave.app.R
+import com.lastwave.app.data.local.AccentMode
+import com.lastwave.app.data.local.ThemeMode
+import com.lastwave.app.data.local.EQ_BAND_FREQS_HZ
+import com.lastwave.app.data.local.EQ_MAX_GAIN_DB
+import com.lastwave.app.data.local.EqualizerPresets
+import com.lastwave.app.data.local.EqualizerSettings
+import com.lastwave.app.playback.ClarityPresets
+import com.lastwave.app.playback.LoudnessMode
+import com.lastwave.app.data.local.eqBandLabel
+import com.lastwave.app.ui.common.ConnectedButtonGroup
+import com.lastwave.app.ui.common.ConnectedButtonItem
+import com.lastwave.app.ui.common.ExpressiveHeader
+import com.lastwave.app.ui.common.safeDrawingBottomPadding
+import com.lastwave.app.ui.common.safeHorizontalContentPadding
+import com.lastwave.app.ui.common.adaptiveContentWidth
+import com.lastwave.app.ui.theme.ExpressivePillShape
+import androidx.compose.material.icons.filled.BrightnessAuto
+import androidx.compose.material.icons.filled.LightMode
+import androidx.compose.material.icons.filled.DarkMode
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+private data class AccentPreset(val name: String, val hex: String)
+private val ACCENT_PRESETS = listOf(
+ AccentPreset("Crimson", "#E03030"),
+ AccentPreset("Violet", "#7C4DFF"),
+ AccentPreset("Ocean", "#2196C6"),
+ AccentPreset("Sage", "#6B9E6B"),
+ AccentPreset("Amber", "#E0A030"),
+ AccentPreset("Rose", "#E0507A"),
+)
+
+// -- Expressive shape scale used only within this screen --
+private val CardOuterShape = RoundedCornerShape(28.dp)
+private val IconBadgeShape = RoundedCornerShape(14.dp)
+
+/** Where a row sits within a visually-connected group of settings rows —
+ * drives per-row corner radii so a multi-row group reads as one premium
+ * surface split into rows, not a stack of separate cards (see groupShape
+ * below and the GROUP_GAP spacing used between rows in a group's Column). */
+private enum class GroupPosition { SINGLE, TOP, MIDDLE, BOTTOM }
+
+private val GROUP_OUTER_RADIUS = 28.dp
+private val GROUP_INNER_RADIUS = 6.dp
+private val GROUP_GAP = 3.dp
+
+private fun groupShape(position: GroupPosition): RoundedCornerShape = when (position) {
+ GroupPosition.SINGLE -> RoundedCornerShape(GROUP_OUTER_RADIUS)
+ GroupPosition.TOP -> RoundedCornerShape(
+ topStart = GROUP_OUTER_RADIUS, topEnd = GROUP_OUTER_RADIUS,
+ bottomStart = GROUP_INNER_RADIUS, bottomEnd = GROUP_INNER_RADIUS,
+ )
+ GroupPosition.MIDDLE -> RoundedCornerShape(GROUP_INNER_RADIUS)
+ GroupPosition.BOTTOM -> RoundedCornerShape(
+ topStart = GROUP_INNER_RADIUS, topEnd = GROUP_INNER_RADIUS,
+ bottomStart = GROUP_OUTER_RADIUS, bottomEnd = GROUP_OUTER_RADIUS,
+ )
+}
+
+/** Wraps a fixed list of settings rows and assigns each one its
+ * GroupPosition automatically — SINGLE for a lone row, TOP/BOTTOM for the
+ * ends of a longer group, MIDDLE for everything between. Rows are stacked
+ * with a tiny GROUP_GAP rather than normal item spacing, so the group
+ * reads as one connected surface with rows peeking through a hairline gap
+ * rather than a list of separate cards. */
+@Composable
+private fun SettingsGroup(rowCount: Int, content: @Composable (index: Int, position: GroupPosition) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(GROUP_GAP)) {
+ for (i in 0 until rowCount) {
+ val position = when {
+ rowCount == 1 -> GroupPosition.SINGLE
+ i == 0 -> GroupPosition.TOP
+ i == rowCount - 1 -> GroupPosition.BOTTOM
+ else -> GroupPosition.MIDDLE
+ }
+ content(i, position)
+ }
+ }
+}
+
+/**
+ * Determines the target LazyColumn item index (section) for a searchable setting.
+ */
+private fun getTargetSectionIndex(settingId: String?): Int {
+ if (settingId == null) return 0
+ val entry = SettingsSearchIndex.allEntries.firstOrNull { it.id == settingId } ?: return 0
+ return when (entry.parentTab) {
+ SettingsTab.AUDIO -> if (entry.section == "Output & Loudness") 1 else 0
+ SettingsTab.APPEARANCE -> when (entry.section) {
+ "Accent Color" -> 1
+ "Experimental & Features", "Experimental & Player" -> 2
+ "Canvas" -> 3
+ else -> 0
+ }
+ SettingsTab.LIBRARY -> when (entry.section) {
+ "Imports" -> 1
+ "Modules & Addons" -> 2
+ else -> 0
+ }
+ SettingsTab.DATA_BACKUP -> if (entry.section == "Data Management") 1 else 0
+ SettingsTab.ABOUT -> if (entry.section == "About & Community") 1 else 0
+ else -> 0
+ }
+}
+
+/**
+ * Attaches a BringIntoViewRequester and renders an expressive pulsing highlight glow
+ * with outer colored shadow halo, accent border, and auto-scroll when isHighlighted is true.
+ */
+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
+private fun Modifier.settingHighlightGlow(
+ isHighlighted: Boolean,
+ shape: Shape = CardOuterShape,
+): Modifier = composed {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val primaryColor = MaterialTheme.colorScheme.primary
+
+ LaunchedEffect(isHighlighted) {
+ if (isHighlighted) {
+ delay(160L)
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+
+ if (!isHighlighted) {
+ return@composed this.bringIntoViewRequester(bringIntoViewRequester)
+ }
+
+ val infiniteTransition = rememberInfiniteTransition(label = "settingHighlightGlow")
+ val glowProgress by infiniteTransition.animateFloat(
+ initialValue = 0.35f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(650, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "glowProgress",
+ )
+
+ this
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .shadow(
+ elevation = (7 * glowProgress).dp,
+ shape = shape,
+ ambientColor = primaryColor.copy(alpha = 0.40f * glowProgress),
+ spotColor = primaryColor.copy(alpha = 0.55f * glowProgress),
+ )
+ .border(
+ width = 2.5.dp,
+ color = primaryColor.copy(alpha = glowProgress),
+ shape = shape,
+ )
+}
+
+/**
+ * Faithful port of settings.js (par 8): Last.fm account management, appearance
+ * (AMOLED / Dynamic Color / Monochrome / accent presets / custom color
+ * wheel), iTunes/ListenBrainz artwork toggles, data management (clear
+ * recommendation exclusions, clear all data), backup & restore, and app info.
+ *
+ * Visuals only: restyled into a Material 3 Expressive presentation
+ * (larger touch targets, per-row cards, tonal icon badges, spring-based
+ * press feedback). Every setting, callback, and piece of state below is
+ * unchanged from the original implementation.
+ */
+
+enum class SettingsTab(
+ @androidx.annotation.StringRes val titleRes: Int,
+ @androidx.annotation.StringRes val subtitleRes: Int,
+ val title: String,
+ val subtitle: String,
+ val icon: androidx.compose.ui.graphics.vector.ImageVector,
+) {
+ AUDIO(
+ titleRes = R.string.set_cat_audio,
+ subtitleRes = R.string.set_cat_audio_sub,
+ title = "Audio & Playback",
+ subtitle = "Streaming quality, Audio engine, Equalizer, Output & Loudness",
+ icon = Icons.Filled.GraphicEq,
+ ),
+ APPEARANCE(
+ titleRes = R.string.set_cat_appear,
+ subtitleRes = R.string.set_cat_appear_sub,
+ title = "Appearance & Visuals",
+ subtitle = "Themes, Colors, Artwork, Lyrics",
+ icon = Icons.Filled.Palette,
+ ),
+ YOUTUBE(
+ titleRes = R.string.set_cat_yt,
+ subtitleRes = R.string.set_cat_yt_sub,
+ title = "YouTube & Sync",
+ subtitle = "Account connection, 24/7 Library sync, Channels, History",
+ icon = Icons.Filled.CloudSync,
+ ),
+ LAST_FM(
+ titleRes = R.string.set_cat_lfm,
+ subtitleRes = R.string.set_cat_lfm_sub,
+ title = "Last.fm",
+ subtitle = "Account connection, Scrobbling sync & API credentials",
+ icon = Icons.Filled.Album,
+ ),
+ LIBRARY(
+ titleRes = R.string.set_cat_lib,
+ subtitleRes = R.string.set_cat_lib_sub,
+ title = "Library & Content",
+ subtitle = "Home layout, Playlist imports, Downloads, Exclusions",
+ icon = Icons.Filled.QueueMusic,
+ ),
+ DATA_BACKUP(
+ titleRes = R.string.set_cat_data,
+ subtitleRes = R.string.set_cat_data_sub,
+ title = "Data Management",
+ subtitle = "Cache cleanup, Export & Import settings, Database tools",
+ icon = Icons.Filled.Backup,
+ ),
+ ABOUT(
+ titleRes = R.string.set_cat_about,
+ subtitleRes = R.string.set_cat_about_sub,
+ title = "About & Community",
+ subtitle = "Version info, Telegram community, GitHub repository",
+ icon = Icons.Filled.AutoAwesome,
+ ),
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit = {},
+ onLoggedOut: () -> Unit = {},
+ onOpenDownloads: () -> Unit = {},
+ onOpenModules: () -> Unit = {},
+ onOpenHomeSections: () -> Unit = {},
+ onOpenExcludedSongs: () -> Unit = {},
+ onOpenYouTubeImport: () -> Unit = {},
+ onOpenYouTubeLogin: () -> Unit = {},
+ onOpenExternalImport: () -> Unit = {},
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ val session by viewModel.session.collectAsStateWithLifecycle()
+ val avatarUrl by viewModel.avatarUrl.collectAsStateWithLifecycle()
+ val theme by viewModel.theme.collectAsStateWithLifecycle()
+ val misc by viewModel.misc.collectAsStateWithLifecycle()
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val downloadCount by viewModel.downloadCount.collectAsStateWithLifecycle()
+ val downloadTotalBytes by viewModel.downloadTotalBytes.collectAsStateWithLifecycle()
+ val ytConnection by viewModel.ytConnection.collectAsStateWithLifecycle()
+ val ytSyncEnabled by viewModel.ytSyncEnabled.collectAsStateWithLifecycle()
+ val ytHistorySyncEnabled by viewModel.ytHistorySyncEnabled.collectAsStateWithLifecycle()
+ val ytSyncState by viewModel.ytSyncState.collectAsStateWithLifecycle()
+ val ytLastSyncAt by viewModel.ytLastSyncAt.collectAsStateWithLifecycle()
+ val syncedPlaylistIds by viewModel.syncedPlaylistIds.collectAsStateWithLifecycle()
+ val allPlaylists by viewModel.allPlaylists.collectAsStateWithLifecycle()
+ val ytAccountPlaylists by viewModel.ytAccountPlaylists.collectAsStateWithLifecycle()
+ val hiddenYtLibraryPlaylistIds by viewModel.hiddenYtLibraryPlaylistIds.collectAsStateWithLifecycle()
+ val ytChannels by viewModel.ytChannels.collectAsStateWithLifecycle()
+ val ytChannelsLoading by viewModel.ytChannelsLoading.collectAsStateWithLifecycle()
+ val eq by viewModel.equalizer.collectAsStateWithLifecycle()
+ val usbExclusiveEnabled by viewModel.usbExclusiveEnabled.collectAsStateWithLifecycle()
+ val internalDirectEnabled by viewModel.internalDirectEnabled.collectAsStateWithLifecycle()
+ val forceSoftwareVolume by viewModel.forceSoftwareVolume.collectAsStateWithLifecycle()
+ val internalDirectAvailability by viewModel.internalDirectAvailability.collectAsStateWithLifecycle()
+ val loudness by viewModel.loudness.collectAsStateWithLifecycle()
+ val updateInfo by viewModel.updateInfo.collectAsStateWithLifecycle()
+ val discordPresenceEnabled by viewModel.discordPresenceEnabled.collectAsStateWithLifecycle()
+ val isLastFmConnected by viewModel.isLastFmConnected.collectAsStateWithLifecycle()
+ val hasApiKey by viewModel.hasApiKey.collectAsStateWithLifecycle()
+ val lastFmAuthUrl by viewModel.lastFmAuthUrl.collectAsStateWithLifecycle()
+ val lastFmConnecting by viewModel.lastFmConnecting.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ // Last.fm web auth (Settings → Integrations): open the auth URL in Custom
+ // Tabs the moment SettingsViewModel produces one.
+ androidx.compose.runtime.LaunchedEffect(lastFmAuthUrl) {
+ lastFmAuthUrl?.let { url ->
+ runCatching {
+ androidx.browser.customtabs.CustomTabsIntent.Builder().build()
+ .launchUrl(context, android.net.Uri.parse(url))
+ }.onFailure {
+ viewModel.showToast(context.getString(R.string.toast_no_browser_lfm))
+ viewModel.cancelLastFmConnect()
+ }
+ }
+ }
+ var showQualityDialog by remember { mutableStateOf(false) }
+ var showDownloadQualityDialog by remember { mutableStateOf(false) }
+ var showEqSheet by remember { mutableStateOf(false) }
+ var showLyricsProviderDialog by remember { mutableStateOf(false) }
+ var showLyricsOffsetDialog by remember { mutableStateOf(false) }
+ var showLyricsSizeDialog by remember { mutableStateOf(false) }
+ var showLyricsAutoHideDialog by remember { mutableStateOf(false) }
+ var showTranslationLanguageDialog by remember { mutableStateOf(false) }
+ var showLoudnessDialog by remember { mutableStateOf(false) }
+ var showClarityPresetDialog by remember { mutableStateOf(false) }
+ var showSyncPlaylistsSheet by remember { mutableStateOf(false) }
+ var showYtLibraryVisibilitySheet by remember { mutableStateOf(false) }
+ var showYtChannelSheet by remember { mutableStateOf(false) }
+ var showYtDisconnectConfirm by remember { mutableStateOf(false) }
+ var showLanguageDialog by remember { mutableStateOf(false) }
+ val currentLanguage = remember(misc.appLanguageTag) { AppLanguage.fromTag(misc.appLanguageTag) }
+
+ // Sends the user to Android's own Notification Listener access screen
+ // — the one permission this feature needs that the app can never grant
+
+ // "*/*" rather than "application/json": many document providers (Drive,
+ // Downloads, some file managers) report a .json file as
+ // application/octet-stream or text/plain, and GetContent's mime filter
+ // hides anything that doesn't match — the user's own backup file would
+ // silently not show up. Real validation happens right after the file is
+ // read (stagePendingRestore), so being permissive here is safe.
+ val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ viewModel.handleRestorePicked(uri)
+ }
+ }
+
+ val csvPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ viewModel.handleCsvPicked(uri)
+ }
+ }
+
+ var showYouTubeImportSheet by remember { mutableStateOf(false) }
+
+ // Lets the user pick exactly where the backup file is saved (SAF), so
+ // it's guaranteed to be somewhere restoreLauncher's picker can browse
+ // back to later — unlike a silent write into app-private storage.
+ val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
+ if (uri != null) viewModel.exportBackup(uri, appVersionName(context))
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .adaptiveContentWidth(maxWidth = 760.dp),
+ ) {
+ var activeTab by remember { mutableStateOf(null) }
+ var isSearchBarVisible by rememberSaveable { mutableStateOf(false) }
+ var highlightedSettingId by remember { mutableStateOf(null) }
+ val focusManager = LocalFocusManager.current
+
+ val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
+ val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
+
+ LaunchedEffect(highlightedSettingId) {
+ if (highlightedSettingId != null) {
+ delay(3200L)
+ highlightedSettingId = null
+ }
+ }
+
+ BackHandler(enabled = searchQuery.isNotBlank() || isSearchBarVisible || activeTab != null) {
+ if (searchQuery.isNotBlank() || isSearchBarVisible) {
+ viewModel.clearSearch()
+ isSearchBarVisible = false
+ } else if (activeTab != null) {
+ activeTab = null
+ }
+ }
+
+ ExpressiveHeader(
+ title = if (searchQuery.isNotBlank()) stringResource(R.string.settings_search_title) else (activeTab?.title ?: stringResource(R.string.settings)),
+ onBack = {
+ if (searchQuery.isNotBlank() || isSearchBarVisible) {
+ viewModel.clearSearch()
+ isSearchBarVisible = false
+ } else if (activeTab != null) {
+ activeTab = null
+ } else {
+ onBack()
+ }
+ },
+ actions = {
+ HeaderActionIcon(
+ icon = if (isSearchBarVisible || searchQuery.isNotBlank()) Icons.Filled.Close else Icons.Filled.Search,
+ contentDescription = if (isSearchBarVisible || searchQuery.isNotBlank()) stringResource(R.string.settings_search_clear) else stringResource(R.string.settings_search_title),
+ onClick = {
+ if (isSearchBarVisible || searchQuery.isNotBlank()) {
+ viewModel.clearSearch()
+ isSearchBarVisible = false
+ } else {
+ isSearchBarVisible = true
+ }
+ },
+ )
+ },
+ )
+
+ AnimatedVisibility(
+ visible = isSearchBarVisible,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ SettingsSearchBar(
+ query = searchQuery,
+ onQueryChange = viewModel::onSearchQueryChange,
+ onClear = {
+ if (searchQuery.isNotBlank()) {
+ viewModel.clearSearch()
+ } else {
+ isSearchBarVisible = false
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 6.dp),
+ )
+ }
+
+ if (searchQuery.isNotBlank()) {
+ SettingsSearchResultsList(
+ query = searchQuery,
+ results = searchResults,
+ onResultClick = { entry ->
+ focusManager.clearFocus()
+ viewModel.clearSearch()
+ isSearchBarVisible = false
+ activeTab = entry.parentTab
+ highlightedSettingId = entry.id
+ },
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ )
+ } else {
+ AnimatedContent(
+ targetState = activeTab,
+ transitionSpec = {
+ if (targetState != null && initialState == null) {
+ (slideInHorizontally { width -> width / 4 } + fadeIn()).togetherWith(
+ slideOutHorizontally { width -> -width / 4 } + fadeOut(),
+ )
+ } else if (targetState == null && initialState != null) {
+ (slideInHorizontally { width -> -width / 4 } + fadeIn()).togetherWith(
+ slideOutHorizontally { width -> width / 4 } + fadeOut(),
+ )
+ } else {
+ fadeIn().togetherWith(fadeOut())
+ }
+ },
+ label = "SettingsTabTransition",
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ ) { tab ->
+ val listState = remember(tab) { LazyListState() }
+
+ LaunchedEffect(highlightedSettingId, tab) {
+ if (highlightedSettingId != null && tab != null) {
+ val targetIndex = getTargetSectionIndex(highlightedSettingId)
+ if (targetIndex > 0) {
+ delay(80L)
+ listState.animateScrollToItem(targetIndex)
+ } else if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
+ delay(80L)
+ listState.animateScrollToItem(0)
+ }
+ }
+ }
+
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 16.dp,
+ bottom = 32.dp + LocalMiniPlayerScrollClearance.current + safeDrawingBottomPadding(),
+ ),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.fillMaxSize().safeHorizontalContentPadding(),
+ ) {
+ when (tab) {
+ null -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel("Services & Add-ons")
+ SettingsGroup(rowCount = 3) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.Extension,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_modules_title),
+ subtitle = stringResource(R.string.settings_modules_sub),
+ onClick = onOpenModules,
+ position = position,
+ isHighlighted = (highlightedSettingId == "services.modules" || highlightedSettingId == "library.modules"),
+ )
+ 1 -> SettingsActionCard(
+ icon = Icons.Filled.Album,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_cat_lfm),
+ subtitle = if (isLastFmConnected) "Connected as ${session.username} \u2022 Scrobbling active" else "Connect account, scrobbles & statistics sync",
+ onClick = { activeTab = SettingsTab.LAST_FM },
+ position = position,
+ )
+ else -> SettingsActionCard(
+ icon = Icons.Filled.CloudSync,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_cat_yt),
+ subtitle = if (ytConnection.isConnected) "Connected as ${ytConnection.accountName} \u2022 24/7 sync" else "Connect account, 24/7 playlist sync, channels",
+ onClick = { activeTab = SettingsTab.YOUTUBE },
+ position = position,
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel("Playback & UI")
+ SettingsGroup(rowCount = 3) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.GraphicEq,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_cat_audio),
+ subtitle = stringResource(R.string.set_cat_audio_sub),
+ onClick = { activeTab = SettingsTab.AUDIO },
+ position = position,
+ )
+ 1 -> SettingsActionCard(
+ icon = Icons.Filled.Palette,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.set_cat_appear),
+ subtitle = stringResource(R.string.set_cat_appear_sub),
+ onClick = { activeTab = SettingsTab.APPEARANCE },
+ position = position,
+ )
+ else -> SettingsActionCard(
+ icon = Icons.Filled.QueueMusic,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_cat_lib),
+ subtitle = run {
+ val count = downloadCount
+ if (count > 0) "$count songs downloaded • Imports, Home layout" else "Home layout, Playlist imports, Downloads, Exclusions"
+ },
+ onClick = { activeTab = SettingsTab.LIBRARY },
+ position = position,
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel("System & About")
+ SettingsGroup(rowCount = 2) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.Backup,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.set_cat_data),
+ subtitle = stringResource(R.string.set_cat_data_sub),
+ onClick = { activeTab = SettingsTab.DATA_BACKUP },
+ position = position,
+ )
+ else -> SettingsActionCard(
+ icon = Icons.Filled.AutoAwesome,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_cat_about),
+ subtitle = if (updateInfo.isUpdateAvailable) "Update Available (${updateInfo.latestVersion}) \u2022 v${appVersionName(context)}" else "v${appVersionName(context)} \u2022 Language, Community, Source code",
+ onClick = { activeTab = SettingsTab.ABOUT },
+ position = position,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ SettingsTab.AUDIO -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_audio))
+ val qualitySubtitle = when (misc.losslessQuality) {
+ 28 -> "Dolby Atmos (Spatial Immersive Audio)"
+ 27 -> "Max (Up to 24-bit / 192 kHz)"
+ 7 -> "Hi-Res (24-bit / 96 kHz)"
+ 6 -> "CD Lossless (16-bit / 44.1 kHz FLAC)"
+ 5 -> "Standard (320 kbps MP3)"
+ 4 -> "Data Saver (96 kbps HE-AAC)"
+ -1 -> "YouTube Music (AAC / Opus)"
+ else -> "Max (Up to 24-bit / 192 kHz)"
+ }
+ val downloadQualitySubtitle = when (misc.downloadQuality) {
+ 28 -> "Dolby Atmos (Spatial Immersive Audio)"
+ 27 -> "Hi-Res (24-bit / 192 kHz FLAC)"
+ 7 -> "Hi-Res (24-bit / 96 kHz FLAC)"
+ 6 -> "CD Lossless (16-bit / 44.1 kHz FLAC)"
+ 5 -> "Standard (320 kbps MP3)"
+ 4 -> "Data Saver (96 kbps HE-AAC)"
+ -1 -> "YouTube Music (AAC / Opus)"
+ else -> "Hi-Res (24-bit / 192 kHz FLAC)"
+ }
+
+ val isIgnored = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
+ val totalAudioRows = if (misc.crossfadeEnabled) {
+ if (isIgnored) 7 else 8
+ } else {
+ if (isIgnored) 6 else 7
+ }
+ SettingsGroup(rowCount = totalAudioRows) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.HighQuality,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_streaming_quality),
+ subtitle = if (misc.losslessQuality == -1) "YouTube Music • Native stream" else "$qualitySubtitle • YouTube Music fallback",
+ onClick = { showQualityDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.streaming_quality"),
+ )
+ 1 -> SettingsActionCard(
+ icon = Icons.Filled.CloudDownload,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_download_quality),
+ subtitle = "$downloadQualitySubtitle \u2022 Lossless & YouTube",
+ onClick = { showDownloadQualityDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.download_quality"),
+ )
+ 2 -> SettingsToggleCard(
+ icon = Icons.Filled.Tune,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_bit_perfect),
+ subtitle = if (misc.isBitPerfectEnabled) {
+ stringResource(R.string.settings_bit_perfect_on_detail)
+ } else {
+ stringResource(R.string.settings_bit_perfect_off_detail)
+ },
+ checked = misc.isBitPerfectEnabled,
+ onCheckedChange = viewModel::setBitPerfectEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.bit_perfect"),
+ )
+ 3 -> SettingsToggleCard(
+ icon = Icons.Filled.GraphicEq,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_crossfade),
+ subtitle = if (misc.crossfadeEnabled && misc.isBitPerfectEnabled) {
+ "Paused while Bit-Perfect is enabled"
+ } else if (misc.crossfadeEnabled) {
+ "Smooth transition between tracks \u2022 ${misc.crossfadeSeconds} sec"
+ } else {
+ "Blend the end of a track into the next one"
+ },
+ checked = misc.crossfadeEnabled,
+ onCheckedChange = viewModel::setCrossfadeEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.crossfade"),
+ )
+ 4 -> if (misc.crossfadeEnabled) {
+ CrossfadeDurationRow(
+ seconds = misc.crossfadeSeconds,
+ onSecondsChange = viewModel::setCrossfadeSeconds,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.crossfade_duration"),
+ )
+ } else {
+ SettingsToggleCard(
+ icon = Icons.Filled.Lyrics,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_keep_lyrics_screen_awake),
+ subtitle = stringResource(R.string.settings_keep_lyrics_screen_awake_desc),
+ checked = misc.keepLyricsScreenAwake,
+ onCheckedChange = viewModel::setKeepLyricsScreenAwake,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.keep_lyrics_screen_awake"),
+ )
+ }
+ 5 -> if (misc.crossfadeEnabled) {
+ SettingsToggleCard(
+ icon = Icons.Filled.Lyrics,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_keep_lyrics_screen_awake),
+ subtitle = stringResource(R.string.settings_keep_lyrics_screen_awake_desc),
+ checked = misc.keepLyricsScreenAwake,
+ onCheckedChange = viewModel::setKeepLyricsScreenAwake,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.keep_lyrics_screen_awake"),
+ )
+ } else {
+ SettingsToggleCard(
+ icon = Icons.Filled.Lyrics,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_download_lyrics),
+ subtitle = if (misc.downloadLyrics) {
+ "Save .lrc companion files & embed lyrics in downloads"
+ } else {
+ "Do not fetch or save lyrics when downloading"
+ },
+ checked = misc.downloadLyrics,
+ onCheckedChange = viewModel::setDownloadLyrics,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.download_lyrics"),
+ )
+ }
+ 6 -> if (misc.crossfadeEnabled) {
+ SettingsToggleCard(
+ icon = Icons.Filled.Lyrics,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_download_lyrics),
+ subtitle = if (misc.downloadLyrics) {
+ "Save .lrc companion files & embed lyrics in downloads"
+ } else {
+ "Do not fetch or save lyrics when downloading"
+ },
+ checked = misc.downloadLyrics,
+ onCheckedChange = viewModel::setDownloadLyrics,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.download_lyrics"),
+ )
+ } else if (!isIgnored) {
+ SettingsActionCard(
+ icon = Icons.Filled.Bolt,
+ iconContainer = MaterialTheme.colorScheme.errorContainer,
+ iconTint = MaterialTheme.colorScheme.onErrorContainer,
+ title = stringResource(R.string.settings_battery_title),
+ subtitle = stringResource(R.string.set_battery_restricted),
+ onClick = { BatteryOptimizationHelper.requestIgnoreBatteryOptimizations(context) },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.battery_optimization"),
+ )
+ }
+ 7 -> if (!isIgnored) {
+ SettingsActionCard(
+ icon = Icons.Filled.Bolt,
+ iconContainer = MaterialTheme.colorScheme.errorContainer,
+ iconTint = MaterialTheme.colorScheme.onErrorContainer,
+ title = stringResource(R.string.settings_battery_title),
+ subtitle = stringResource(R.string.set_battery_restricted),
+ onClick = { BatteryOptimizationHelper.requestIgnoreBatteryOptimizations(context) },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.battery_optimization"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel("Output & Loudness")
+ val clarityPreset = remember(misc.clarityPreset) { ClarityPresets.fromIndex(misc.clarityPreset) }
+ val loudnessSubtitle = when (loudness.mode) {
+ LoudnessMode.TRACK -> "Track \u2022 Match every track to -14 LUFS"
+ LoudnessMode.ALBUM -> "Album \u2022 Keep intentional album dynamics"
+ else -> "Off \u2022 Play tagged tracks at original level"
+ }
+ SettingsGroup(rowCount = 9) { index, position ->
+ when (index) {
+ 0 -> SettingsToggleCard(
+ icon = Icons.Filled.Usb,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_usb_title),
+ subtitle = if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
+ stringResource(R.string.set_usb_old)
+ } else if (usbExclusiveEnabled || misc.isBitPerfectEnabled) {
+ stringResource(R.string.set_usb_on)
+ } else {
+ stringResource(R.string.set_usb_off)
+ },
+ checked = usbExclusiveEnabled,
+ onCheckedChange = viewModel::setUsbExclusiveEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.usb_exclusive"),
+ )
+ 1 -> {
+ // Probe what this output accepts only once the row is on screen.
+ LaunchedEffect(Unit) { viewModel.refreshInternalDirectAvailability() }
+ SettingsToggleCard(
+ icon = Icons.Filled.SpeakerPhone,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_internal_direct),
+ subtitle = when {
+ !internalDirectEnabled -> stringResource(R.string.settings_internal_direct_off)
+ internalDirectAvailability.available ->
+ stringResource(R.string.settings_internal_direct_on)
+ else -> stringResource(
+ R.string.settings_internal_direct_inactive,
+ stringResource(com.lastwave.app.playback.internalFailureDetail(internalDirectAvailability.reason)),
+ )
+ },
+ checked = internalDirectEnabled,
+ // Greyed out when this output cannot be bypassed, but an
+ // enabled toggle always stays reachable so it can be turned off.
+ enabled = internalDirectEnabled || internalDirectAvailability.available ||
+ internalDirectAvailability.reason == com.lastwave.app.playback.InternalDirectReason.CHECKING,
+ onCheckedChange = viewModel::setInternalDirectEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.internal_direct"),
+ )
+ }
+ 2 -> SettingsToggleCard(
+ icon = Icons.Filled.VolumeUp,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_force_sw_volume),
+ subtitle = if (forceSoftwareVolume) {
+ stringResource(R.string.settings_force_sw_volume_on)
+ } else {
+ stringResource(R.string.settings_force_sw_volume_off)
+ },
+ checked = forceSoftwareVolume,
+ onCheckedChange = viewModel::setForceSoftwareVolume,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.force_software_volume"),
+ )
+ 3 -> SettingsActionCard(
+ icon = Icons.Filled.VolumeUp,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_loud_title),
+ subtitle = loudnessSubtitle,
+ onClick = { showLoudnessDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.loudness_normalization"),
+ )
+ 4 -> SettingsActionCard(
+ icon = Icons.Filled.GraphicEq,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_equalizer),
+ subtitle = if (misc.isBitPerfectEnabled && eq.enabled) {
+ stringResource(R.string.set_eq_on_preset, eq.presetName)
+ } else if (eq.enabled) {
+ stringResource(R.string.set_eq_on_band, eq.presetName)
+ } else {
+ "Shape your sound across 15 frequencies"
+ },
+ onClick = { showEqSheet = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.equalizer"),
+ )
+ 5 -> SettingsToggleCard(
+ icon = Icons.Filled.VolumeUp,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.set_sys_fx_title),
+ subtitle = if (misc.systemEffectsMode) {
+ stringResource(R.string.set_sys_fx_on)
+ } else {
+ stringResource(R.string.set_sys_fx_off)
+ },
+ checked = misc.systemEffectsMode,
+ onCheckedChange = viewModel::setSystemEffectsMode,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.system_audio_effects"),
+ )
+ 6 -> SettingsToggleCard(
+ icon = Icons.Filled.Waves,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_studio_clarity),
+ subtitle = if (misc.isStudioMasterClarityEnabled) {
+ stringResource(R.string.settings_studio_clarity_on)
+ } else {
+ stringResource(R.string.settings_studio_clarity_off)
+ },
+ checked = misc.isStudioMasterClarityEnabled,
+ onCheckedChange = viewModel::setStudioMasterClarity,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.studio_clarity"),
+ )
+ 7 -> SettingsActionCard(
+ icon = Icons.Filled.Tune,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.set_clarity_title),
+ subtitle = stringResource(
+ R.string.set_clarity_row,
+ clarityPresetName(clarityPreset),
+ clarityPresetDescription(clarityPreset),
+ ),
+ onClick = { showClarityPresetDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.clarity_preset"),
+ )
+ 8 -> SettingsToggleCard(
+ icon = Icons.Filled.GraphicEq,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_clarity_bypass),
+ subtitle = if (misc.clarityAtmosBypass) {
+ "Clarity bypasses on multichannel/spatial sources"
+ } else {
+ "Off \u2022 Clarity processes every source"
+ },
+ checked = misc.clarityAtmosBypass,
+ onCheckedChange = viewModel::setClarityAtmosBypass,
+ position = position,
+ isHighlighted = (highlightedSettingId == "audio.clarity_spatial_bypass"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ SettingsTab.APPEARANCE -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_appearance))
+ SettingsGroup(rowCount = 8) { index, position ->
+ when (index) {
+ 0 -> ThemeModeSelectorCard(
+ currentThemeMode = theme?.themeMode ?: ThemeMode.SYSTEM,
+ onSelectThemeMode = viewModel::setThemeMode,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.theme_mode"),
+ )
+ 1 -> SettingsToggleCard(
+ icon = Icons.Filled.Contrast,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_amoled),
+ subtitle = stringResource(R.string.settings_amoled_sub),
+ checked = theme?.amoled ?: false,
+ enabled = theme?.themeMode != ThemeMode.LIGHT,
+ onCheckedChange = viewModel::setAmoled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.amoled"),
+ )
+ 2 -> SettingsToggleCard(
+ icon = Icons.Filled.Palette,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_dynamic_color),
+ subtitle = stringResource(R.string.settings_dynamic_color_sub),
+ checked = theme?.mode == AccentMode.DYNAMIC,
+ onCheckedChange = { enabled ->
+ viewModel.setAccentMode(if (enabled) AccentMode.DYNAMIC else AccentMode.MANUAL)
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.dynamic_color"),
+ )
+ 3 -> SettingsToggleCard(
+ icon = Icons.Filled.BubbleChart,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_liquid_glass),
+ subtitle = stringResource(R.string.settings_liquid_glass_sub),
+ checked = theme?.liquidGlass ?: true,
+ onCheckedChange = viewModel::setLiquidGlass,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.liquid_glass"),
+ )
+ 4 -> SettingsToggleCard(
+ icon = Icons.Filled.Apps,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_md3_nav_bar),
+ subtitle = stringResource(R.string.settings_md3_nav_bar_sub),
+ checked = theme?.md3NavBar ?: false,
+ onCheckedChange = viewModel::setMd3NavBar,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.md3_nav_bar"),
+ )
+ 5 -> SettingsToggleCard(
+ icon = Icons.Filled.Album,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_dynamic_now_playing),
+ subtitle = stringResource(R.string.settings_dynamic_now_playing_sub),
+ checked = misc.dynamicNowPlayingEnabled,
+ onCheckedChange = viewModel::setDynamicNowPlaying,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.dynamic_now_playing"),
+ )
+ 6 -> SettingsToggleCard(
+ icon = Icons.Filled.TextFields,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_app_font),
+ subtitle = stringResource(R.string.settings_app_font_sub),
+ checked = misc.useCustomFont,
+ onCheckedChange = viewModel::setUseCustomFont,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.app_font"),
+ )
+ 7 -> SettingsActionCard(
+ icon = Icons.Filled.Dashboard,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_home_sections),
+ subtitle = run {
+ val total = com.lastwave.app.data.local.HomeSection.entries.size
+ val visible = total - misc.hiddenHomeSections.size
+ stringResource(R.string.home_sections_visible, visible, total)
+ },
+ onClick = onOpenHomeSections,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.home_sections"),
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column {
+ SectionLabel(stringResource(R.string.settings_section_accent))
+ Spacer(Modifier.height(10.dp))
+ Card(
+ shape = CardOuterShape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (highlightedSettingId == "appearance.accent_preset") MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .settingHighlightGlow(
+ isHighlighted = (highlightedSettingId == "appearance.accent_preset"),
+ shape = CardOuterShape,
+ ),
+ ) {
+ Column(Modifier.padding(20.dp)) {
+ AccentPresetGrid(
+ currentMode = theme?.mode ?: AccentMode.MANUAL,
+ selectedHex = theme?.accentColorHex,
+ onPickPreset = { hex -> viewModel.setManualAccent(Color(android.graphics.Color.parseColor(hex))) },
+ onPickMono = { viewModel.setAccentMode(AccentMode.MONOCHROME) },
+ onPickCustom = viewModel::openColorWheel,
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_experimental))
+ SettingsGroup(rowCount = 9) { index, position ->
+ when (index) {
+ 0 -> SettingsToggleCard(
+ icon = Icons.Filled.Lyrics,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = "Word-by-word lyrics",
+ subtitle = if (misc.wordByWordLyrics) {
+ "Real-time syllable sync & karaoke highlight"
+ } else {
+ "Line-by-line synced lyrics"
+ },
+ checked = misc.wordByWordLyrics,
+ onCheckedChange = viewModel::setWordByWordLyrics,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.word_by_word_lyrics"),
+ )
+ 1 -> SettingsToggleCard(
+ icon = Icons.Filled.Waves,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_wavy_seekbar),
+ subtitle = if (misc.wavySeekbarEnabled) {
+ stringResource(R.string.settings_wavy_seekbar_on)
+ } else {
+ stringResource(R.string.settings_wavy_seekbar_off)
+ },
+ checked = misc.wavySeekbarEnabled,
+ onCheckedChange = viewModel::setWavySeekbarEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.wavy_seekbar"),
+ )
+ 2 -> SettingsToggleCard(
+ icon = Icons.Filled.RestartAlt,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_rotating_background),
+ subtitle = if (misc.rotatingBackgroundEnabled) {
+ stringResource(R.string.settings_rotating_background_on)
+ } else {
+ stringResource(R.string.settings_rotating_background_off)
+ },
+ checked = misc.rotatingBackgroundEnabled,
+ onCheckedChange = viewModel::setRotatingBackgroundEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.rotating_background"),
+ )
+ 3 -> SettingsToggleCard(
+ icon = Icons.Filled.RestartAlt,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_lyrics_rotating_background),
+ subtitle = if (misc.lyricsRotatingBackgroundEnabled) {
+ stringResource(R.string.settings_lyrics_rotating_background_on)
+ } else {
+ stringResource(R.string.settings_lyrics_rotating_background_off)
+ },
+ checked = misc.lyricsRotatingBackgroundEnabled,
+ onCheckedChange = viewModel::setLyricsRotatingBackgroundEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.lyrics_rotating_background"),
+ )
+ 4 -> SettingsActionCard(
+ icon = Icons.Filled.Lyrics,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_lyrics_provider),
+ subtitle = "${misc.lyricsProvider.title} \u2022 ${misc.lyricsProvider.subtitle}",
+ onClick = { showLyricsProviderDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.lyrics_provider"),
+ )
+ 5 -> SettingsActionCard(
+ icon = Icons.Filled.Timer,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_lyrics_offset_title),
+ subtitle = if (misc.lyricsOffsetMs == 0L) {
+ stringResource(R.string.set_offset_off)
+ } else {
+ stringResource(
+ R.string.set_offset_value,
+ if (misc.lyricsOffsetMs > 0) "+" else "",
+ misc.lyricsOffsetMs,
+ )
+ },
+ onClick = { showLyricsOffsetDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.lyrics_offset"),
+ )
+ 6 -> SettingsActionCard(
+ icon = Icons.Filled.FormatSize,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_lyrics_size_title),
+ subtitle = stringResource(
+ R.string.set_size_value,
+ (misc.lyricsFontScale * 100).roundToInt(),
+ when {
+ misc.lyricsFontScale < 0.85f -> stringResource(R.string.set_size_small)
+ misc.lyricsFontScale in 0.85f..1.15f -> stringResource(R.string.set_size_standard)
+ misc.lyricsFontScale in 1.16f..1.35f -> stringResource(R.string.set_size_large)
+ else -> stringResource(R.string.set_size_xlarge)
+ },
+ ),
+ onClick = { showLyricsSizeDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.lyrics_size"),
+ )
+ 7 -> SettingsActionCard(
+ icon = Icons.Filled.VisibilityOff,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_lyrics_autohide_title),
+ subtitle = if (misc.lyricsButtonsAutoHideSeconds == 0) {
+ stringResource(R.string.set_lyrics_autohide_never)
+ } else {
+ stringResource(
+ R.string.set_lyrics_autohide_value,
+ misc.lyricsButtonsAutoHideSeconds,
+ )
+ },
+ onClick = { showLyricsAutoHideDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.lyrics_autohide"),
+ )
+ 8 -> SettingsActionCard(
+ icon = Icons.Filled.Translate,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_translation_language_title),
+ subtitle = if (misc.translationLanguage.isBlank()) {
+ stringResource(R.string.set_translation_language_follows)
+ } else {
+ translationLanguageName(misc.translationLanguage, Locale.getDefault())
+ },
+ onClick = { showTranslationLanguageDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.translation_language"),
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_canvas_enabled))
+ val canvasRowCount = if (misc.canvasEnabled) 3 else 2
+ SettingsGroup(rowCount = canvasRowCount) { index, position ->
+ when (index) {
+ 0 -> SettingsToggleCard(
+ icon = Icons.Filled.SmartDisplay,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_canvas_enabled),
+ subtitle = stringResource(R.string.settings_canvas_enabled_sub),
+ checked = misc.canvasEnabled,
+ onCheckedChange = viewModel::setCanvasEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.canvas_enabled"),
+ )
+ 1 -> SettingsToggleCard(
+ icon = Icons.Filled.Visibility,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_canvas_full_bleed),
+ subtitle = stringResource(R.string.settings_canvas_full_bleed_sub),
+ checked = misc.canvasFullBleed,
+ onCheckedChange = viewModel::setCanvasFullBleed,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.canvas_full_bleed"),
+ )
+ 2 -> SettingsToggleCard(
+ icon = Icons.Filled.CloudDownload,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_canvas_cellular),
+ subtitle = stringResource(R.string.settings_canvas_cellular_sub),
+ checked = misc.canvasOverCellular,
+ onCheckedChange = viewModel::setCanvasOverCellular,
+ position = position,
+ isHighlighted = (highlightedSettingId == "appearance.canvas_cellular"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ SettingsTab.YOUTUBE -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_youtube))
+ val ytConnected = ytConnection.isConnected
+ val syncSubtitle = when (val sync = ytSyncState) {
+ is com.lastwave.app.data.ytmusic.YtSyncState.Running ->
+ "Syncing ${sync.current}/${sync.total} \u2022 ${sync.label}"
+ is com.lastwave.app.data.ytmusic.YtSyncState.Completed ->
+ "Playlists mirror to your account \u2022 synced ${relativeTime(sync.atMillis)}"
+ is com.lastwave.app.data.ytmusic.YtSyncState.Failed ->
+ "Last pass failed \u2014 will retry automatically"
+ else ->
+ if (!ytConnected) "Connect an account first"
+ else if (ytSyncEnabled) "Selected playlists mirror to your account, 24/7" + lastSyncSuffix(ytLastSyncAt)
+ else "Keep your YT Music library in sync with LastWave"
+ }
+ // Display name of the active channel: the picked entry when
+ // the channel list was loaded, otherwise the stored name.
+ val currentChannelName = ytChannels.firstOrNull {
+ it.channelId == ytConnection.onBehalfOfUser &&
+ it.authUserIndex == ytConnection.authUserIndex &&
+ it.pageId == ytConnection.pageId
+ }?.accountName ?: ytConnection.accountName
+ val ytRowCount = if (ytConnected) 7 else 2
+ SettingsGroup(rowCount = ytRowCount) { index, position ->
+ when (index) {
+ 0 -> if (ytConnected) {
+ YouTubeAccountRow(
+ accountName = ytConnection.accountName,
+ channelHandle = ytConnection.channelHandle,
+ onDisconnect = { showYtDisconnectConfirm = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.account"),
+ )
+ } else {
+ SettingsActionCard(
+ icon = Icons.Filled.CloudSync,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_connect_yt),
+ subtitle = stringResource(R.string.settings_connect_yt_sub),
+ onClick = onOpenYouTubeLogin,
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.account"),
+ )
+ }
+ 1 -> SettingsToggleCard(
+ icon = Icons.Filled.CloudSync,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_yt_sync),
+ subtitle = syncSubtitle,
+ checked = ytConnected && ytSyncEnabled,
+ onCheckedChange = viewModel::setYtSyncEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.sync"),
+ )
+ 2 -> if (ytConnected) {
+ SettingsActionCard(
+ icon = Icons.Filled.SwitchAccount,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_yt_channel_row),
+ subtitle = currentChannelName.ifBlank { stringResource(R.string.set_channel_default) },
+ onClick = {
+ viewModel.loadYtChannels()
+ showYtChannelSheet = true
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.channel"),
+ )
+ }
+ 3 -> if (ytConnected) {
+ val selectedCount = syncedPlaylistIds?.size ?: allPlaylists.size
+ val syncCountText = if (syncedPlaylistIds == null || selectedCount == allPlaylists.size) {
+ stringResource(R.string.set_sync_all, allPlaylists.size)
+ } else {
+ stringResource(R.string.set_sync_partial, selectedCount, allPlaylists.size)
+ }
+ SettingsActionCard(
+ icon = Icons.Filled.FormatListBulleted,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_select_playlists),
+ subtitle = syncCountText,
+ onClick = { showSyncPlaylistsSheet = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.select_playlists"),
+ )
+ } else {
+ SettingsActionCard(
+ icon = Icons.Filled.QueueMusic,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.set_import_yt),
+ subtitle = stringResource(R.string.set_import_yt_sub),
+ onClick = onOpenYouTubeImport,
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.import"),
+ )
+ }
+ 4 -> if (ytConnected) {
+ val shownCount = ytAccountPlaylists.count { it.id !in hiddenYtLibraryPlaylistIds }
+ val visibilitySubtitle = if (shownCount == ytAccountPlaylists.size) {
+ stringResource(R.string.set_yt_shown_all, ytAccountPlaylists.size)
+ } else {
+ stringResource(R.string.set_yt_shown_partial, shownCount, ytAccountPlaylists.size)
+ }
+ SettingsActionCard(
+ icon = Icons.Filled.Visibility,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_yt_shown),
+ subtitle = visibilitySubtitle,
+ onClick = { showYtLibraryVisibilitySheet = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.shown_playlists"),
+ )
+ }
+ 5 -> SettingsActionCard(
+ icon = Icons.Filled.QueueMusic,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_make_local),
+ subtitle = stringResource(R.string.settings_make_local_sub),
+ onClick = onOpenYouTubeImport,
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.import"),
+ )
+ 6 -> if (ytConnected) {
+ SettingsToggleCard(
+ icon = Icons.Filled.History,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_yt_history),
+ subtitle = if (ytHistorySyncEnabled) {
+ stringResource(R.string.set_yt_hist_on)
+ } else {
+ stringResource(R.string.set_yt_hist_off)
+ },
+ checked = ytHistorySyncEnabled,
+ onCheckedChange = viewModel::setYtHistorySyncEnabled,
+ position = position,
+ isHighlighted = (highlightedSettingId == "youtube.history"),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ SettingsTab.LAST_FM -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel("Integrations / Scrobbling")
+ // Last.fm is optional here — never a gate. Connected:
+ // global scrobbles + stats sync. Disconnected: Stats and
+ // recommendations run local-first from Room.
+ LastFmIntegrationCard(
+ isConnected = isLastFmConnected,
+ username = session.username,
+ avatarUrl = avatarUrl,
+ connecting = lastFmConnecting,
+ awaitingApproval = lastFmAuthUrl != null,
+ hasApiKey = hasApiKey,
+ isHighlighted = (highlightedSettingId in listOf("lastfm.connect", "lastfm.api_credentials")),
+ onConnect = { viewModel.beginLastFmConnect() },
+ onCancel = viewModel::cancelLastFmConnect,
+ onDisconnect = viewModel::disconnectLastFm,
+ onSaveKeys = viewModel::saveApiCredentials,
+ onRemoveKey = viewModel::clearApiKey,
+ onOpenCreateKeyPage = {
+ runCatching {
+ androidx.browser.customtabs.CustomTabsIntent.Builder().build()
+ .launchUrl(context, android.net.Uri.parse(LAST_FM_CREATE_KEY_URL))
+ }.onFailure {
+ viewModel.showToast(context.getString(R.string.toast_no_browser_open_lfm))
+ }
+ },
+ )
+ // Discord presence, next to Last.fm because both are "tell
+ // other services what I'm listening to" integrations and
+ // neither is required for playback. Unlike Last.fm this
+ // needs no account in LastWave — it publishes as the
+ // LastWave application through the Discord app itself, so
+ // the copy says exactly that.
+ SettingsToggleCard(
+ icon = Icons.Filled.Public,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_discord_title),
+ subtitle = if (discordPresenceEnabled) {
+ stringResource(R.string.set_discord_on)
+ } else {
+ stringResource(R.string.set_discord_off)
+ },
+ checked = discordPresenceEnabled,
+ onCheckedChange = viewModel::setDiscordPresenceEnabled,
+ isHighlighted = (highlightedSettingId == "discord.presence"),
+ )
+ SectionLabel("Global Live Telemetry & Map")
+ SettingsToggleCard(
+ icon = Icons.Filled.Public,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = "Global Live Map Telemetry",
+ subtitle = if (misc.liveTelemetryEnabled) {
+ "On • Listening ticks broadcast anonymously to the live world map"
+ } else {
+ "Off • Playback telemetry stays completely local"
+ },
+ checked = misc.liveTelemetryEnabled,
+ onCheckedChange = viewModel::setLiveTelemetryEnabled,
+ isHighlighted = (highlightedSettingId == "telemetry.enabled"),
+ )
+ SettingsGroup(rowCount = 1) { _, position ->
+ SettingsActionCard(
+ icon = Icons.Filled.Public,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = "Open Global Live Map",
+ subtitle = "telemetry.clashprojects.qd.je • Real-time vector map of global listeners",
+ onClick = {
+ runCatching {
+ androidx.browser.customtabs.CustomTabsIntent.Builder().build()
+ .launchUrl(context, Uri.parse("https://telemetry.clashprojects.qd.je"))
+ }.onFailure {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://telemetry.clashprojects.qd.je")).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ if (!startActivitySafely(context, intent)) {
+ viewModel.showToast("No compatible browser is available")
+ }
+ }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "telemetry.open_map"),
+ )
+ }
+ }
+ }
+ }
+
+
+
+ SettingsTab.LIBRARY -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel("Home & Organization")
+ SettingsGroup(rowCount = 2) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.Dashboard,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_home_sections),
+ subtitle = run {
+ val total = com.lastwave.app.data.local.HomeSection.entries.size
+ val visible = total - misc.hiddenHomeSections.size
+ stringResource(R.string.home_sections_visible, visible, total)
+ },
+ onClick = onOpenHomeSections,
+ position = position,
+ isHighlighted = (highlightedSettingId == "library.home_sections"),
+ )
+ else -> SettingsActionCard(
+ icon = Icons.Filled.RestartAlt,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_excluded_songs),
+ subtitle = stringResource(R.string.set_excl_count, state.recommendationExclusionCount),
+ onClick = onOpenExcludedSongs,
+ position = position,
+ isHighlighted = (highlightedSettingId == "library.excluded_songs"),
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_imports))
+ SettingsGroup(rowCount = 3) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.QueueMusic,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.set_import_yt),
+ subtitle = stringResource(R.string.set_import_yt_sub),
+ onClick = onOpenYouTubeImport,
+ position = position,
+ isHighlighted = (highlightedSettingId == "library.import_yt"),
+ )
+ 1 -> SettingsActionCard(
+ icon = Icons.Filled.QueueMusic,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.set_import_spotify),
+ subtitle = stringResource(R.string.set_import_spotify_sub),
+ onClick = onOpenExternalImport,
+ position = position,
+ isHighlighted = (highlightedSettingId == "library.import_external"),
+ )
+ else -> SettingsActionCard(
+ icon = Icons.Filled.FileDownload,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.settings_import_file),
+ subtitle = stringResource(R.string.settings_import_file_sub),
+ onClick = {
+ runCatching {
+ csvPickerLauncher.launch(arrayOf("text/*", "text/csv", "application/csv", "audio/x-mpegurl", "application/x-mpegurl", "application/vnd.apple.mpegurl", "*/*"))
+ }.onFailure { viewModel.showToast(context.getString(R.string.toast_no_picker)) }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "library.import_file"),
+ )
+ }
+ }
+ }
+ }
+
+
+
+ }
+
+ SettingsTab.DATA_BACKUP -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_backup))
+ SettingsGroup(rowCount = 2) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.Backup,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_backup),
+ subtitle = stringResource(R.string.settings_backup_sub),
+ onClick = {
+ runCatching { backupLauncher.launch("lastwave-backup-${System.currentTimeMillis()}.json") }
+ .onFailure { viewModel.showToast(context.getString(R.string.toast_no_picker)) }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "backup.create"),
+ )
+ 1 -> SettingsActionCard(
+ icon = Icons.Filled.CloudDownload,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_restore),
+ subtitle = stringResource(R.string.settings_restore_sub),
+ onClick = {
+ runCatching { restoreLauncher.launch(arrayOf("*/*")) }
+ .onFailure { viewModel.showToast(context.getString(R.string.toast_no_picker)) }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "backup.restore"),
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_data))
+ SettingsGroup(rowCount = 1) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.Delete,
+ iconContainer = MaterialTheme.colorScheme.errorContainer,
+ iconTint = MaterialTheme.colorScheme.onErrorContainer,
+ title = stringResource(R.string.settings_clear_all),
+ subtitle = stringResource(R.string.settings_clear_all_sub),
+ danger = true,
+ onClick = viewModel::requestClearAllData,
+ position = position,
+ isHighlighted = (highlightedSettingId == "backup.clear_all"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ SettingsTab.ABOUT -> {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_language))
+ SettingsGroup(rowCount = 1) { _, position ->
+ SettingsActionCard(
+ icon = Icons.Filled.Language,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_language_title),
+ subtitle = currentLanguage.nativeDisplayName(),
+ onClick = { showLanguageDialog = true },
+ position = position,
+ isHighlighted = (highlightedSettingId == "about.language"),
+ )
+ }
+ }
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionLabel(stringResource(R.string.settings_section_about))
+ SettingsGroup(rowCount = 5) { index, position ->
+ when (index) {
+ 0 -> SettingsActionCard(
+ icon = Icons.Filled.Public,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = "Global Live Map",
+ subtitle = "telemetry.clashprojects.qd.je • Real-time listener telemetry",
+ onClick = {
+ runCatching {
+ androidx.browser.customtabs.CustomTabsIntent.Builder().build()
+ .launchUrl(context, Uri.parse("https://telemetry.clashprojects.qd.je"))
+ }.onFailure {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://telemetry.clashprojects.qd.je")).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ if (!startActivitySafely(context, intent)) {
+ viewModel.showToast("No compatible browser is available")
+ }
+ }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "about.live_map"),
+ )
+ 1 -> SettingsActionCard(
+ icon = Icons.AutoMirrored.Filled.Send,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_updates_support),
+ subtitle = stringResource(R.string.set_tg_sub),
+ onClick = {
+ if (!openTelegramChannel(context, "clashprojects")) {
+ viewModel.showToast(context.getString(R.string.toast_no_browser_tg))
+ }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "about.telegram_support"),
+ )
+ 2 -> SettingsActionCard(
+ icon = Icons.Filled.Group,
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ title = stringResource(R.string.set_discord_support),
+ subtitle = stringResource(R.string.set_discord_support_sub),
+ onClick = {
+ val discordIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://discord.gg/TMCEPSUNk2"))
+ if (!startActivitySafely(context, discordIntent)) {
+ viewModel.showToast(context.getString(R.string.toast_no_browser))
+ }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "about.discord_support"),
+ )
+ 3 -> SettingsActionCard(
+ icon = Icons.Filled.AutoAwesome,
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
+ title = stringResource(R.string.settings_more_from_us),
+ subtitle = stringResource(R.string.set_more_apps_sub),
+ onClick = {
+ if (!openTelegramChannel(context, "MaterialYouApp")) {
+ viewModel.showToast(context.getString(R.string.toast_no_browser_tg))
+ }
+ },
+ position = position,
+ isHighlighted = (highlightedSettingId == "about.more_apps"),
+ )
+ 4 -> SettingsActionCard(
+ icon = Icons.Filled.Code,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_diagnostics),
+ subtitle = stringResource(R.string.settings_diagnostics_sub),
+ onClick = { viewModel.exportDiagnostics() },
+ position = position,
+ isHighlighted = (highlightedSettingId == "about.diagnostics"),
+ )
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+
+ // Prominent Update Available Banner Card (if newer version detected)
+ if (updateInfo.isUpdateAvailable) {
+ Surface(
+ shape = CardOuterShape,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shadowElevation = 3.dp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(CardOuterShape)
+ .clickable { viewModel.openUpdate(context) },
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(46.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.CloudDownload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Update Available!",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Text(
+ text = "Version ${updateInfo.latestVersion} is ready to install",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
+ )
+ }
+ Surface(
+ shape = RoundedCornerShape(14.dp),
+ color = MaterialTheme.colorScheme.primary,
+ ) {
+ Text(
+ text = "Update",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+
+ AboutCard(versionName = appVersionName(context))
+
+ SettingsActionCard(
+ icon = Icons.Filled.CloudDownload,
+ iconContainer = if (updateInfo.isUpdateAvailable) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = if (updateInfo.isUpdateAvailable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSecondaryContainer,
+ title = if (updateInfo.isUpdateAvailable) "Update Ready (${updateInfo.latestVersion})" else "Check for Updates",
+ subtitle = when {
+ updateInfo.isChecking -> "Checking GitHub releases..."
+ updateInfo.isUpdateAvailable -> {
+ val fileName = updateInfo.downloadFileName
+ if (!fileName.isNullOrBlank()) "Tap to download $fileName" else "Tap to download new version"
+ }
+ !updateInfo.message.isNullOrBlank() -> updateInfo.message.orEmpty()
+ else -> "Current version: ${appVersionName(context)}"
+ },
+ onClick = {
+ if (updateInfo.isUpdateAvailable) {
+ viewModel.openUpdate(context)
+ } else {
+ viewModel.checkForUpdates()
+ }
+ },
+ isHighlighted = (highlightedSettingId == "about.check_updates"),
+ )
+
+ SettingsActionCard(
+ icon = Icons.Filled.Code,
+ iconContainer = MaterialTheme.colorScheme.secondaryContainer,
+ iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
+ title = stringResource(R.string.settings_source_code),
+ subtitle = "github.com/Clash-Projects/LastWave-native",
+ onClick = {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Clash-Projects/LastWave-native"))
+ if (!startActivitySafely(context, intent)) {
+ viewModel.showToast(context.getString(R.string.toast_no_browser_plain))
+ }
+ },
+ isHighlighted = (highlightedSettingId == "about.source_code"),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // -- Custom color wheel dialog (par 8.4) --
+ if (state.showColorWheel) {
+ ColorWheelSheet(onDismiss = viewModel::dismissColorWheel, onApply = viewModel::applyCustomColor)
+ }
+
+ // -- Clear-all-data confirm --
+ if (state.showClearAllConfirm) {
+ AlertDialog(
+ onDismissRequest = viewModel::dismissClearAllConfirm,
+ title = { Text(stringResource(R.string.dialog_clear_all_title)) },
+ text = { Text(stringResource(R.string.dialog_clear_all_text)) },
+ confirmButton = { TextButton(onClick = { viewModel.confirmClearAllData(onLoggedOut) }) { Text(stringResource(R.string.dialog_clear_all_confirm)) } },
+ dismissButton = { TextButton(onClick = viewModel::dismissClearAllConfirm) { Text(stringResource(R.string.common_cancel)) } },
+ )
+ }
+
+ // -- YouTube Music disconnect confirm --
+ if (showYtDisconnectConfirm) {
+ AlertDialog(
+ onDismissRequest = { showYtDisconnectConfirm = false },
+ title = { Text(stringResource(R.string.dialog_disconnect_yt_title)) },
+ text = { Text(stringResource(R.string.dialog_disconnect_yt_text)) },
+ confirmButton = {
+ TextButton(onClick = {
+ showYtDisconnectConfirm = false
+ viewModel.disconnectYouTube()
+ }) { Text(stringResource(R.string.common_disconnect)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showYtDisconnectConfirm = false }) { Text(stringResource(R.string.common_cancel)) }
+ },
+ )
+ }
+
+ // -- Restore confirm --
+ if (state.showRestoreConfirm) { val isPlaylistMirror = state.pendingRestoreKind == PendingRestoreKind.PLAYLIST_MIRROR
+ AlertDialog(
+ onDismissRequest = viewModel::dismissRestoreConfirm,
+ title = { Text(if (isPlaylistMirror) "Sync playlist JSON?" else "Restore backup?") },
+ text = {
+ Text(
+ if (isPlaylistMirror) {
+ "This will merge ${state.pendingRestorePlaylistCount ?: 0} playlist(s) from the local JSON file and reconnect automatic syncing."
+ } else {
+ "This will replace your current data with ${state.pendingRestorePlaylistCount ?: 0} playlist(s) and all settings from the backup file."
+ },
+ )
+ },
+ confirmButton = { TextButton(onClick = { viewModel.confirmRestore(onBack) }) { Text(if (isPlaylistMirror) stringResource(R.string.common_sync) else stringResource(R.string.common_restore)) } },
+ dismissButton = { TextButton(onClick = viewModel::dismissRestoreConfirm) { Text(stringResource(R.string.common_cancel)) } },
+ )
+ }
+
+ // -- App language picker (Settings -> Language) --
+ if (showLanguageDialog) {
+ AlertDialog(
+ onDismissRequest = { showLanguageDialog = false },
+ title = { Text(stringResource(R.string.settings_language_dialog_title)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
+ AppLanguage.SELECTABLE.forEach { language ->
+ val selected = language == currentLanguage
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .clickable {
+ viewModel.setAppLanguage(language)
+ showLanguageDialog = false
+ }
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ language.nativeDisplayName(),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
+ )
+ if (language == AppLanguage.SYSTEM) {
+ Text(
+ stringResource(R.string.settings_language_system_sub),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (selected) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = stringResource(R.string.common_selected),
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { showLanguageDialog = false }) {
+ Text(stringResource(R.string.common_done))
+ }
+ },
+ )
+ }
+
+ // -- Enable Scrobbling password dialog --
+ if (state.showSessionKeyDialog) {
+ var password by remember { mutableStateOf("") }
+ AlertDialog(
+ onDismissRequest = viewModel::dismissSessionKeyDialog,
+ title = { Text(stringResource(R.string.set_scrobble_title)) },
+ text = {
+ Column {
+ Text(
+ "Last.fm only allows scrobbling through a signed session, and the only way to get one without a browser is with your password. It's sent once, directly to Last.fm over HTTPS, and never stored.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Spacer(Modifier.height(16.dp))
+ androidx.compose.material3.OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.set_scrobble_pass)) },
+ singleLine = true,
+ visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
+ isError = state.sessionKeyError != null,
+ supportingText = state.sessionKeyError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { viewModel.submitPassword(password) },
+ enabled = !state.sessionKeyLoading,
+ ) {
+ if (state.sessionKeyLoading) {
+ com.lastwave.app.ui.common.ExpressiveInlineLoadingIndicator(size = 18.dp)
+ } else {
+ Text(stringResource(R.string.set_scrobble_enable))
+ }
+ }
+ },
+ dismissButton = { TextButton(onClick = viewModel::dismissSessionKeyDialog) { Text(stringResource(R.string.common_cancel)) } },
+ )
+ }
+
+ // -- Experimental 15-band equalizer --
+ if (showEqSheet) {
+ EqualizerSheet(
+ eq = eq,
+ onDismiss = { showEqSheet = false },
+ onSetEnabled = viewModel::setEqualizerEnabled,
+ onPickPreset = viewModel::applyEqPreset,
+ onBandPreview = viewModel::previewEqBandGain,
+ onBandChange = viewModel::setEqBandGain,
+ )
+ }
+
+
+
+ // -- Lyrics provider picker: preferred source first, automatic
+ // fallback to the rest when it returns nothing --
+ if (showLyricsProviderDialog) {
+ LyricsProviderDialog(
+ current = misc.lyricsProvider,
+ onSelect = {
+ viewModel.setLyricsProvider(it)
+ showLyricsProviderDialog = false
+ },
+ onDismiss = { showLyricsProviderDialog = false },
+ )
+ }
+
+ // -- Lyrics sync offset stepper: shifts highlight/focus only --
+ if (showLyricsOffsetDialog) {
+ LyricsOffsetDialog(
+ currentMs = misc.lyricsOffsetMs,
+ onSelect = { viewModel.setLyricsOffsetMs(it) },
+ onDismiss = { showLyricsOffsetDialog = false },
+ )
+ }
+
+ // -- Lyrics text size dialog: scales font size with live preview --
+ if (showLyricsSizeDialog) {
+ LyricsSizeDialog(
+ currentScale = misc.lyricsFontScale,
+ onSelect = { viewModel.setLyricsFontScale(it) },
+ onDismiss = { showLyricsSizeDialog = false },
+ )
+ }
+
+ // -- Floating lyrics buttons: idle time before they fade out --
+ if (showLyricsAutoHideDialog) {
+ LyricsAutoHideDialog(
+ currentSeconds = misc.lyricsButtonsAutoHideSeconds,
+ onSelect = {
+ viewModel.setLyricsButtonsAutoHideSeconds(it)
+ showLyricsAutoHideDialog = false
+ },
+ onDismiss = { showLyricsAutoHideDialog = false },
+ )
+ }
+
+ // -- Language the lyrics translate button turns songs into --
+ if (showTranslationLanguageDialog) {
+ TranslationLanguageDialog(
+ current = misc.translationLanguage,
+ onSelect = {
+ viewModel.setTranslationLanguage(it)
+ showTranslationLanguageDialog = false
+ },
+ onDismiss = { showTranslationLanguageDialog = false },
+ )
+ }
+
+ if (showLoudnessDialog) {
+ LoudnessModeDialog(
+ current = loudness.mode,
+ onSelect = {
+ viewModel.setLoudnessMode(it)
+ showLoudnessDialog = false
+ },
+ onDismiss = { showLoudnessDialog = false },
+ )
+ }
+
+ if (showClarityPresetDialog) {
+ ClarityPresetDialog(
+ currentIndex = misc.clarityPreset,
+ onSelect = {
+ viewModel.setClarityPreset(it)
+ showClarityPresetDialog = false
+ },
+ onDismiss = { showClarityPresetDialog = false },
+ )
+ }
+
+ // -- Selective Playlist Sync sheet --
+ if (showSyncPlaylistsSheet) {
+ SyncPlaylistsSheet(
+ playlists = allPlaylists,
+ syncedIds = syncedPlaylistIds,
+ onToggleSync = viewModel::togglePlaylistSync,
+ onSelectAll = viewModel::selectAllPlaylistsForSync,
+ onDismiss = { showSyncPlaylistsSheet = false },
+ )
+ }
+
+ if (showYtLibraryVisibilitySheet) {
+ YouTubeLibraryVisibilitySheet(
+ playlists = ytAccountPlaylists,
+ hiddenIds = hiddenYtLibraryPlaylistIds,
+ onSetVisible = viewModel::setYtLibraryPlaylistVisible,
+ onSetAllVisible = viewModel::setAllYtLibraryPlaylistsVisible,
+ onDismiss = { showYtLibraryVisibilitySheet = false },
+ )
+ }
+
+ if (showYtChannelSheet) {
+ YouTubeChannelSheet(
+ channels = ytChannels,
+ isLoading = ytChannelsLoading,
+ selectedChannelId = ytConnection.onBehalfOfUser,
+ selectedAuthUser = ytConnection.authUserIndex,
+ selectedPageId = ytConnection.pageId,
+ onReload = viewModel::loadYtChannels,
+ onSelect = {
+ viewModel.selectYtChannel(it)
+ showYtChannelSheet = false
+ },
+ onDismiss = { showYtChannelSheet = false },
+ )
+ }
+
+ if (showQualityDialog) {
+ val tiers = listOf(
+ Triple(28, "Dolby Atmos", "Spatial Immersive Audio • Master Audio" to "ATMOS"),
+ Triple(27, "Hi-Res Audio (Max)", "Up to 24-bit / 192 kHz • Lossless Studio FLAC" to "24-BIT / 192k"),
+ Triple(7, "Hi-Res Audio", "24-bit / 96 kHz • Lossless Studio FLAC" to "24-BIT / 96k"),
+ Triple(6, "CD Lossless", "16-bit / 44.1 kHz • Lossless CD FLAC" to "16-BIT / 44.1k"),
+ Triple(5, "Standard Quality", "320 kbps • MP3 / AAC" to "320 kbps"),
+ Triple(4, "Data Saver", "96 kbps • High Efficiency AAC" to "96 kbps"),
+ Triple(-1, "YouTube Music", "128-256 kbps • YouTube Music AAC / Opus stream" to "YOUTUBE"),
+ )
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+
+ ModalBottomSheet(
+ onDismissRequest = { showQualityDialog = false },
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ dragHandle = {
+ Surface(
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
+ modifier = Modifier
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 36.dp, height = 4.dp),
+ ) {}
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 640.dp)
+ .align(Alignment.CenterHorizontally)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 24.dp + safeDrawingBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 4.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.HighQuality,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(
+ "Streaming Quality",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ "Select preferred audio resolution & bit depth",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Text(
+ "YouTube Music plays strictly as-is (never upgrades). Dolby Atmos plays only when selected — otherwise it never plays. Atmos falls back to Hi-Res → CD → 320 kbps → YouTube. Hi-Res steps down to lower lossless tiers, then YouTube last.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ tiers.forEach { (qualityId, title, meta) ->
+ val (subtitle, badge) = meta
+ val isSelected = misc.losslessQuality == qualityId
+ Surface(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ viewModel.setLosslessQuality(qualityId)
+ showQualityDialog = false
+ },
+ shape = RoundedCornerShape(20.dp),
+ color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
+ border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary) else null,
+ shadowElevation = if (isSelected) 3.dp else 0.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.width(8.dp))
+ Surface(
+ shape = RoundedCornerShape(6.dp),
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest,
+ ) {
+ Text(
+ badge,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = stringResource(R.string.set_selected_cd),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showDownloadQualityDialog) {
+ val downloadTiers = listOf(
+ Triple(28, "Dolby Atmos", "Spatial Immersive Audio • Master Audio" to "ATMOS"),
+ Triple(27, "Hi-Res Audio (Max)", "Up to 24-bit / 192 kHz • Studio Master FLAC" to "24-BIT / 192k"),
+ Triple(7, "Hi-Res Audio", "24-bit / 96 kHz • Studio FLAC" to "24-BIT / 96k"),
+ Triple(6, "CD Lossless", "16-bit / 44.1 kHz • Bit-Exact CD FLAC" to "16-BIT / 44.1k"),
+ Triple(5, "Standard Quality", "320 kbps • High-Bitrate MP3 / AAC" to "320 kbps"),
+ Triple(4, "Data Saver", "96 kbps • High Efficiency AAC" to "96 kbps"),
+ Triple(-1, "YouTube Music", "128-256 kbps • YouTube Music AAC / Opus stream" to "YOUTUBE"),
+ )
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+
+ ModalBottomSheet(
+ onDismissRequest = { showDownloadQualityDialog = false },
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ dragHandle = {
+ Surface(
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
+ modifier = Modifier
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 36.dp, height = 4.dp),
+ ) {}
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 640.dp)
+ .align(Alignment.CenterHorizontally)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 24.dp + safeDrawingBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 4.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.CloudDownload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(
+ "Download Quality",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ "Select offline audio resolution & bit depth",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Text(
+ "YouTube Music downloads strictly as-is (never upgrades). Dolby Atmos downloads only when selected — otherwise it never downloads. Atmos falls back to Hi-Res → CD → 320 kbps → YouTube. Hi-Res steps down to lower tiers, then YouTube last.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ downloadTiers.forEach { (qualityId, title, meta) ->
+ val (subtitle, badge) = meta
+ val isSelected = misc.downloadQuality == qualityId
+ Surface(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ viewModel.setDownloadQuality(qualityId)
+ showDownloadQualityDialog = false
+ },
+ shape = RoundedCornerShape(20.dp),
+ color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
+ border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, MaterialTheme.colorScheme.primary) else null,
+ shadowElevation = if (isSelected) 3.dp else 0.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.width(8.dp))
+ Surface(
+ shape = RoundedCornerShape(6.dp),
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest,
+ ) {
+ Text(
+ badge,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = stringResource(R.string.set_selected_cd),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showYouTubeImportSheet) {
+ YouTubeImportSheet(
+ onDismiss = { showYouTubeImportSheet = false },
+ innerTube = viewModel.innerTube,
+ importManager = viewModel.playlistImportManager,
+ onImportSuccess = { saved ->
+ viewModel.showToast(context.getString(R.string.toast_imported, saved.title, saved.tracks.size))
+ },
+ )
+ }
+
+ state.toastMessage?.let { msg ->
+ LaunchedEffect(msg) {
+ kotlinx.coroutines.delay(3000)
+ viewModel.dismissToast()
+ }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .safeHorizontalContentPadding()
+ .padding(
+ bottom = 24.dp +
+ LocalMiniPlayerScrollClearance.current +
+ safeDrawingBottomPadding(),
+ ),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ Surface(shape = ExpressivePillShape, color = MaterialTheme.colorScheme.inverseSurface, tonalElevation = 6.dp) {
+ Text(msg, color = MaterialTheme.colorScheme.inverseOnSurface, modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp))
+ }
+ }
+ }
+}
+
+private fun appVersionName(context: android.content.Context): String = try {
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.3.0"
+} catch (error: Exception) {
+ "4.3.0"
+} catch (error: LinkageError) {
+ "4.3.0"
+}
+
+/** Small tap-scale used across the row-style cards on this screen for a
+ * softer, springier press response than the plain ripple alone gives. */
+@Composable
+private fun rememberPressScale(interactionSource: MutableInteractionSource): Float {
+ val pressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) 0.97f else 1f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
+ label = "pressScale",
+ )
+ return scale
+}
+
+@Composable
+private fun SectionLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 4.dp, bottom = 2.dp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Composable
+private fun IconBadge(icon: ImageVector, container: Color, tint: Color, modifier: Modifier = Modifier) {
+ Box(
+ modifier
+ .size(44.dp)
+ .clip(IconBadgeShape)
+ .background(container),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
+ }
+}
+
+@Composable
+private fun ThemeModeSelectorCard(
+ currentThemeMode: ThemeMode,
+ onSelectThemeMode: (ThemeMode) -> Unit,
+ position: GroupPosition = GroupPosition.SINGLE,
+ isHighlighted: Boolean = false,
+) {
+ val shape = groupShape(position)
+
+ Card(
+ shape = shape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
+ ) {
+ Column(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconBadge(
+ Icons.Filled.BrightnessAuto,
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.settings_theme_mode),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ stringResource(R.string.settings_theme_mode_sub),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Spacer(Modifier.height(14.dp))
+ val items = listOf(
+ ConnectedButtonItem(stringResource(R.string.theme_mode_system), Icons.Filled.BrightnessAuto),
+ ConnectedButtonItem(stringResource(R.string.theme_mode_light), Icons.Filled.LightMode),
+ ConnectedButtonItem(stringResource(R.string.theme_mode_dark), Icons.Filled.DarkMode),
+ )
+ val selectedIdx = when (currentThemeMode) {
+ ThemeMode.SYSTEM -> 0
+ ThemeMode.LIGHT -> 1
+ ThemeMode.DARK -> 2
+ }
+ ConnectedButtonGroup(
+ items = items,
+ selectedIndex = selectedIdx,
+ onSelect = { idx ->
+ val mode = when (idx) {
+ 0 -> ThemeMode.SYSTEM
+ 1 -> ThemeMode.LIGHT
+ else -> ThemeMode.DARK
+ }
+ onSelectThemeMode(mode)
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingsToggleCard(
+ icon: ImageVector,
+ iconContainer: Color,
+ iconTint: Color,
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ position: GroupPosition = GroupPosition.SINGLE,
+ enabled: Boolean = true,
+ isHighlighted: Boolean = false,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val scale = rememberPressScale(interactionSource)
+ val shape = groupShape(position)
+
+ Card(
+ onClick = { if (enabled) onCheckedChange(!checked) },
+ shape = shape,
+ enabled = enabled,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f),
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ interactionSource = interactionSource,
+ modifier = Modifier
+ .fillMaxWidth()
+ .scale(if (enabled) scale else 1f)
+ .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconBadge(
+ icon,
+ if (enabled) iconContainer else iconContainer.copy(alpha = 0.5f),
+ if (enabled) iconTint else iconTint.copy(alpha = 0.5f),
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f),
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Switch(
+ checked = checked,
+ enabled = enabled,
+ onCheckedChange = if (enabled) onCheckedChange else null,
+ thumbContent = if (checked) {
+ {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = null,
+ modifier = Modifier.size(SwitchDefaults.IconSize),
+ )
+ }
+ } else null,
+ )
+ }
+ }
+}
+
+
+
+@Composable
+private fun CrossfadeDurationRow(
+ seconds: Int,
+ onSecondsChange: (Int) -> Unit,
+ position: GroupPosition = GroupPosition.SINGLE,
+ isHighlighted: Boolean = false,
+) {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+ var sliderValue by remember { mutableStateOf(seconds.coerceIn(1, 12).toFloat()) }
+ var isDragging by remember { mutableStateOf(false) }
+ LaunchedEffect(seconds) {
+ if (!isDragging) sliderValue = seconds.coerceIn(1, 12).toFloat()
+ }
+ val shape = groupShape(position)
+
+ val blendStyle = when (sliderValue.roundToInt()) {
+ in 1..2 -> "Quick DJ overlap"
+ in 3..5 -> "Standard smooth blend"
+ in 6..8 -> "Long musical transition"
+ else -> "Extended cinematic crossfade"
+ }
+
+ Card(
+ shape = shape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
+ ) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconBadge(
+ Icons.Filled.Tune,
+ MaterialTheme.colorScheme.secondaryContainer,
+ MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.set_xfade_dur), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
+ Text(
+ "${sliderValue.roundToInt()}s \u2022 $blendStyle",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Text(
+ "${sliderValue.roundToInt()}s",
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
+ )
+ }
+ }
+ Slider(
+ value = sliderValue,
+ onValueChange = { value ->
+ isDragging = true
+ val rounded = value.roundToInt().coerceIn(1, 12)
+ if (rounded.toFloat() != sliderValue) {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
+ sliderValue = rounded.toFloat()
+ onSecondsChange(rounded)
+ }
+ },
+ onValueChangeFinished = {
+ isDragging = false
+ },
+ valueRange = 1f..12f,
+ steps = 10,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 6.dp),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(stringResource(R.string.set_xfade_1), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
+ Text(stringResource(R.string.set_xfade_6), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
+ Text(stringResource(R.string.set_xfade_12), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
+ }
+ }
+ }
+}
+
+@Composable
+private fun SettingsActionCard(
+ icon: ImageVector,
+ iconContainer: Color,
+ iconTint: Color,
+ title: String,
+ subtitle: String,
+ danger: Boolean = false,
+ onClick: () -> Unit,
+ position: GroupPosition = GroupPosition.SINGLE,
+ isHighlighted: Boolean = false,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val scale = rememberPressScale(interactionSource)
+ val titleColor = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
+ val shape = groupShape(position)
+
+ Card(
+ onClick = onClick,
+ shape = shape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ interactionSource = interactionSource,
+ modifier = Modifier
+ .fillMaxWidth()
+ .scale(scale)
+ .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconBadge(icon, iconContainer, iconTint)
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = titleColor, maxLines = 1, softWrap = false, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
+ }
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+// ==========================================
+// SETTINGS SEARCH COMPOSABLES
+// ==========================================
+
+/**
+ * Builds an [AnnotatedString] from [text], highlighting ranges from [ranges]
+ * with primary bold styling for search query matches.
+ */
+private fun buildHighlightedText(
+ text: String,
+ ranges: List,
+ highlightColor: Color,
+): AnnotatedString {
+ if (ranges.isEmpty() || text.isEmpty()) {
+ return AnnotatedString(text)
+ }
+ return buildAnnotatedString {
+ var lastIndex = 0
+ for (range in ranges) {
+ val start = range.first.coerceIn(0, text.length)
+ val end = (range.last + 1).coerceIn(0, text.length)
+ if (start > lastIndex) {
+ append(text.substring(lastIndex, start))
+ }
+ if (end > start) {
+ withStyle(SpanStyle(color = highlightColor, fontWeight = FontWeight.Bold)) {
+ append(text.substring(start, end))
+ }
+ lastIndex = end
+ }
+ }
+ if (lastIndex < text.length) {
+ append(text.substring(lastIndex))
+ }
+ }
+}
+
+@Composable
+private fun SettingsSearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onClear: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = modifier.focusRequester(focusRequester),
+ shape = RoundedCornerShape(20.dp),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ ),
+ placeholder = {
+ Text(
+ stringResource(R.string.settings_search_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Filled.Search,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingIcon = {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = onClear) {
+ Icon(
+ Icons.Filled.Close,
+ contentDescription = stringResource(R.string.settings_search_clear),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
+ )
+}
+
+@Composable
+private fun SettingsSearchResultsList(
+ query: String,
+ results: List,
+ onResultClick: (SettingsEntry) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 8.dp,
+ bottom = 32.dp + LocalMiniPlayerScrollClearance.current + safeDrawingBottomPadding(),
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier.fillMaxSize().safeHorizontalContentPadding(),
+ ) {
+ if (results.isEmpty()) {
+ item {
+ SettingsSearchEmptyState(query = query)
+ }
+ } else {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "${results.size} setting${if (results.size == 1) "" else "s"} found",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ items(results, key = { it.entry.id }) { result ->
+ SearchResultCard(
+ result = result,
+ onClick = { onResultClick(result.entry) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchResultCard(
+ result: MatchResult,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val scale = rememberPressScale(interactionSource)
+ val highlightColor = MaterialTheme.colorScheme.primary
+
+ val annotatedTitle = remember(result.entry.title, result.titleMatchedRanges, highlightColor) {
+ buildHighlightedText(result.entry.title, result.titleMatchedRanges, highlightColor)
+ }
+
+ val annotatedSubtitle = remember(result.entry.subtitle, result.subtitleMatchedRanges, highlightColor) {
+ buildHighlightedText(result.entry.subtitle, result.subtitleMatchedRanges, highlightColor)
+ }
+
+ Card(
+ onClick = onClick,
+ shape = CardOuterShape,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ interactionSource = interactionSource,
+ modifier = Modifier
+ .fillMaxWidth()
+ .scale(scale),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ ) {
+ Text(
+ text = "${result.entry.parentTab.title} › ${result.entry.section}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconBadge(
+ result.entry.icon,
+ result.entry.iconContainer(),
+ result.entry.iconTint(),
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = annotatedTitle,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = annotatedSubtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ Icons.Filled.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SettingsSearchEmptyState(query: String) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 48.dp, horizontal = 24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ modifier = Modifier.size(72.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = Icons.Filled.SearchOff,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.size(36.dp),
+ )
+ }
+ }
+ Text(
+ text = stringResource(R.string.settings_search_no_results, query),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(R.string.settings_search_no_results_sub),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun YouTubeAccountRow(
+ accountName: String,
+ channelHandle: String?,
+ onDisconnect: () -> Unit,
+ position: GroupPosition,
+ isHighlighted: Boolean = false,
+) {
+ val shape = groupShape(position)
+ Card(
+ shape = shape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize()
+ .settingHighlightGlow(isHighlighted = isHighlighted, shape = shape),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconBadge(
+ Icons.Filled.SmartDisplay,
+ MaterialTheme.colorScheme.errorContainer,
+ MaterialTheme.colorScheme.onErrorContainer,
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ "YouTube Music Account",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ accountName.ifBlank { "Connected" },
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ if (!channelHandle.isNullOrBlank()) {
+ Text(
+ channelHandle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Spacer(Modifier.width(8.dp))
+ FilledTonalIconButton(
+ onClick = onDisconnect,
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Icon(Icons.Filled.Logout, contentDescription = stringResource(R.string.set_disconnect_cd))
+ }
+ }
+ }
+}
+
+private fun lastSyncSuffix(lastSyncAtMillis: Long): String {
+ if (lastSyncAtMillis <= 0L) return ""
+ return " \u2022 synced ${relativeTime(lastSyncAtMillis)}"
+}
+
+private fun relativeTime(timestampMillis: Long): String {
+ if (timestampMillis <= 0L) return "never"
+ val delta = System.currentTimeMillis() - timestampMillis
+ val minutes = delta / 60_000L
+ return when {
+ minutes < 1 -> "just now"
+ minutes < 60 -> "${minutes}m ago"
+ minutes < 60 * 24 -> "${minutes / 60}h ago"
+ else -> "${minutes / (60 * 24)}d ago"
+ }
+}
+
+private const val LAST_FM_CREATE_KEY_URL = "https://www.last.fm/api/account/create"
+
+@Composable
+private fun LastFmIntegrationCard(
+ isConnected: Boolean,
+ username: String,
+ avatarUrl: String?,
+ connecting: Boolean,
+ awaitingApproval: Boolean,
+ hasApiKey: Boolean,
+ onConnect: () -> Unit,
+ onCancel: () -> Unit,
+ onDisconnect: () -> Unit,
+ onSaveKeys: (String, String) -> Unit,
+ onRemoveKey: () -> Unit,
+ onOpenCreateKeyPage: () -> Unit,
+ isHighlighted: Boolean = false,
+) {
+ var showDisconnectConfirm by remember { mutableStateOf(false) }
+ // No shared key exists, so the form starts open until a key is saved.
+ var showKeyForm by remember(hasApiKey) { mutableStateOf(!hasApiKey) }
+ var keyInput by remember { mutableStateOf("") }
+ var secretInput by remember { mutableStateOf("") }
+
+ Card(
+ shape = CardOuterShape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize()
+ .settingHighlightGlow(isHighlighted = isHighlighted, shape = CardOuterShape),
+ ) {
+ Column(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = if (isConnected) 14.dp else 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isConnected) {
+ if (avatarUrl != null) {
+ coil.compose.AsyncImage(
+ model = avatarUrl,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
+ contentScale = androidx.compose.ui.layout.ContentScale.Crop
+ )
+ } else {
+ IconBadge(
+ Icons.Filled.CloudSync,
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ } else {
+ Box(
+ Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ if (isConnected && username.isNotBlank()) username.take(1).uppercase() else "L",
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ }
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ "Last.fm",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ if (isConnected && username.isNotBlank()) username else "Not connected",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ if (!isConnected) {
+ Text(
+ "Optional • Stats use local listening when disconnected",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (isConnected) {
+ Spacer(Modifier.width(8.dp))
+ FilledTonalIconButton(
+ onClick = { showDisconnectConfirm = true },
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Icon(Icons.Filled.Logout, contentDescription = stringResource(R.string.set_disconnect_cd))
+ }
+ }
+ }
+ if (!isConnected) {
+ when {
+ awaitingApproval || connecting -> {
+ com.lastwave.app.ui.common.ExpressiveLoadingIndicator(
+ message = if (connecting) "Connecting to Last.fm…" else "Waiting for approval in the browser…",
+ )
+ Spacer(Modifier.height(4.dp))
+ TextButton(onClick = onCancel) { Text(stringResource(R.string.common_cancel)) }
+ }
+ else -> {
+ Button(
+ onClick = onConnect,
+ enabled = hasApiKey,
+ shape = ExpressivePillShape,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(stringResource(R.string.set_lfm_connect)) }
+ Text(
+ if (hasApiKey) "Approve in your browser. You can disconnect anytime — Stats keep working locally."
+ else "Add your API key below first, then connect.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ if (!isConnected) {
+ // ── Bring-your-own-key (required, no shared key): Last.fm
+ // rate-limits per API key, so each person adds their own key
+ // here before connecting.
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.set_lfm_key_label),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ if (hasApiKey) stringResource(R.string.set_lfm_key_saved)
+ else stringResource(R.string.set_lfm_key_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ TextButton(onClick = { showKeyForm = !showKeyForm }) {
+ Text(if (showKeyForm) "Hide" else if (hasApiKey) "Change" else "Add key")
+ }
+ }
+ if (showKeyForm) {
+ androidx.compose.material3.OutlinedTextField(
+ value = keyInput,
+ onValueChange = { keyInput = it.trim() },
+ label = { Text(stringResource(R.string.set_lfm_apikey)) },
+ singleLine = true,
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ androidx.compose.material3.OutlinedTextField(
+ value = secretInput,
+ onValueChange = { secretInput = it.trim() },
+ label = { Text(stringResource(R.string.set_lfm_secret)) },
+ singleLine = true,
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ Button(
+ onClick = {
+ onSaveKeys(keyInput, secretInput)
+ keyInput = ""
+ secretInput = ""
+ showKeyForm = false
+ },
+ enabled = keyInput.length >= 16 && secretInput.length >= 16,
+ shape = ExpressivePillShape,
+ modifier = Modifier.weight(1f),
+ ) { Text(stringResource(R.string.set_lfm_save)) }
+ if (hasApiKey) {
+ OutlinedButton(
+ onClick = onRemoveKey,
+ shape = ExpressivePillShape,
+ ) { Text(stringResource(R.string.set_lfm_remove)) }
+ }
+ }
+ TextButton(onClick = onOpenCreateKeyPage) {
+ Text(stringResource(R.string.set_lfm_get))
+ }
+ } else {
+ TextButton(onClick = onOpenCreateKeyPage) {
+ Text(stringResource(R.string.set_lfm_how))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showDisconnectConfirm) {
+ AlertDialog(
+ onDismissRequest = { showDisconnectConfirm = false },
+ title = { Text(stringResource(R.string.set_lfm_disc_title)) },
+ text = { Text(stringResource(R.string.set_lfm_disc_text)) },
+ confirmButton = {
+ TextButton(onClick = {
+ showDisconnectConfirm = false
+ onDisconnect()
+ }) { Text(stringResource(R.string.set_lfm_disc_btn)) }
+ },
+ dismissButton = { TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.common_cancel)) } },
+ )
+ }
+}
+
+@Composable
+private fun AccentPresetGrid(
+ currentMode: AccentMode,
+ selectedHex: String?,
+ onPickPreset: (String) -> Unit,
+ onPickMono: () -> Unit,
+ onPickCustom: () -> Unit,
+) {
+ // A preset only reads as "selected" while the user is actually in
+ // manual mode — Dynamic/Monochrome shouldn't light up whichever preset
+ // happens to hex-match by coincidence.
+ fun isPresetSelected(hex: String) =
+ currentMode == AccentMode.MANUAL && selectedHex?.equals(hex, ignoreCase = true) == true
+ val customSelected = currentMode == AccentMode.MANUAL &&
+ selectedHex != null &&
+ ACCENT_PRESETS.none { it.hex.equals(selectedHex, ignoreCase = true) }
+ val monoSelected = currentMode == AccentMode.MONOCHROME
+
+ // One unified 4-column tile grid — six color presets plus Mono and
+ // Custom as tiles of their own, not a separate row of pill buttons.
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
+ ACCENT_PRESETS.take(4).forEach { preset ->
+ ColorTile(
+ label = preset.name,
+ selected = isPresetSelected(preset.hex),
+ modifier = Modifier.weight(1f),
+ onClick = { onPickPreset(preset.hex) },
+ ) {
+ PaletteTilePreview(preset.hex)
+ }
+ }
+ }
+ Spacer(Modifier.height(14.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
+ ACCENT_PRESETS.drop(4).forEach { preset ->
+ ColorTile(
+ label = preset.name,
+ selected = isPresetSelected(preset.hex),
+ modifier = Modifier.weight(1f),
+ onClick = { onPickPreset(preset.hex) },
+ ) {
+ PaletteTilePreview(preset.hex)
+ }
+ }
+ ColorTile(
+ label = "Mono",
+ selected = monoSelected,
+ modifier = Modifier.weight(1f),
+ onClick = onPickMono,
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Contrast,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ ColorTile(
+ label = stringResource(R.string.set_eq_custom),
+ selected = customSelected,
+ modifier = Modifier.weight(1f),
+ onClick = onPickCustom,
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ androidx.compose.ui.graphics.Brush.sweepGradient(
+ listOf(Color(0xFFE03030), Color(0xFFE0A030), Color(0xFF6B9E6B), Color(0xFF2196C6), Color(0xFF7C4DFF), Color(0xFFE03030)),
+ ),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Colorize,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Four coordinated shades derived from one preset hex via HSV — Dark,
+ * Medium, Light, and a punchier Accent tone — used to render a real
+ * multi-tone palette preview inside each Accent tile instead of one flat
+ * swatch. Order returned: [dark, medium, light, accent].
+ *
+ * Tuned for a muted, Material You / Monet feel rather than raw HSV
+ * vibrance: saturation is capped well below 100% even for the "accent"
+ * shade (Monet's HCT-derived tonal palettes rarely reach full chroma —
+ * that's what read as neon here), and the value range is narrower so
+ * "dark" and "light" stay closer to the preset's own character instead of
+ * swinging to near-black/near-white extremes.
+ */
+private fun accentShades(hex: String): List {
+ val argb = android.graphics.Color.parseColor(hex)
+ val hsv = FloatArray(3)
+ android.graphics.Color.colorToHSV(argb, hsv)
+ val (h, s, v) = hsv
+ val mutedBase = s * 0.72f // the single biggest lever for "less neon"
+
+ fun shade(saturation: Float, value: Float): Color {
+ val arr = floatArrayOf(h, saturation.coerceIn(0f, 0.82f), value.coerceIn(0.2f, 0.92f))
+ return Color(android.graphics.Color.HSVToColor(arr))
+ }
+
+ return listOf(
+ shade(mutedBase.coerceAtLeast(0.42f), v * 0.62f), // dark
+ shade(mutedBase, v * 0.80f), // medium — closest to the preset's own tone
+ shade((mutedBase * 0.55f), (v + (1f - v) * 0.45f).coerceAtLeast(0.78f)), // light
+ shade((mutedBase * 1.15f), (v * 0.95f)), // accent — a touch richer, never maxed out
+ )
+}
+
+/** Renders a preset's four shades as a 2x2 block grid filling the tile,
+ * so selecting a preset previews its whole coordinated palette rather
+ * than one flat color. */
+@Composable
+private fun PaletteTilePreview(hex: String) {
+ val shades = remember(hex) { accentShades(hex) }
+ Column(Modifier.fillMaxSize()) {
+ Row(Modifier.weight(1f).fillMaxWidth()) {
+ Box(Modifier.weight(1f).fillMaxHeight().background(shades[0]))
+ Box(Modifier.weight(1f).fillMaxHeight().background(shades[1]))
+ }
+ Row(Modifier.weight(1f).fillMaxWidth()) {
+ Box(Modifier.weight(1f).fillMaxHeight().background(shades[2]))
+ Box(Modifier.weight(1f).fillMaxHeight().background(shades[3]))
+ }
+ }
+}
+
+
+/**
+ * One expressive accent tile: a real elevated square swatch (Modifier.shadow
+ * — a true drop shadow, not Card's tonal-elevation color blend, which would
+ * otherwise wash out the exact color a swatch is supposed to preview),
+ * a spring scale/elevation lift on selection, a genuine ripple on tap, and
+ * an animated check badge. [content] draws the tile's fill — a flat color
+ * for presets, an icon-on-surface treatment for Mono/Custom.
+ */
+@Composable
+private fun ColorTile(
+ label: String,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ content: @Composable (Boolean) -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) 0.94f else if (selected) 1.04f else 1f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
+ label = "tileScale",
+ )
+ val elevation by animateDpAsState(
+ targetValue = if (selected) 8.dp else 2.dp,
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
+ label = "tileElevation",
+ )
+ val borderColor by androidx.compose.animation.animateColorAsState(
+ targetValue = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
+ label = "tileBorder",
+ )
+ val tileShape = RoundedCornerShape(20.dp)
+
+ Column(
+ modifier = modifier.scale(scale),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ // Real shadow, not tonal elevation — keeps every tile's
+ // color a true, undistorted preview of the accent it
+ // represents (see GenerateScreen's ModeCard fix for why
+ // Card's own elevation param is the wrong tool for this).
+ .shadow(elevation = elevation, shape = tileShape, clip = false)
+ .clip(tileShape)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = LocalIndication.current,
+ onClick = onClick,
+ )
+ .border(2.5.dp, borderColor, tileShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ content(selected)
+ androidx.compose.animation.AnimatedVisibility(
+ visible = selected,
+ enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.scaleIn(),
+ exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut(),
+ modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
+ ) {
+ Box(
+ Modifier
+ .size(20.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(13.dp),
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
+ color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun AboutCard(versionName: String) {
+ Card(
+ shape = CardOuterShape,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ Modifier.fillMaxWidth().padding(vertical = 28.dp, horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ Modifier
+ .size(72.dp)
+ .clip(CircleShape)
+ // The launcher icon is an XML on API 26+
+ // (mipmap-anydpi-v26/ic_launcher_round.xml) — Compose's
+ // painterResource() can only parse plain bitmap/vector
+ // drawables, not that root element, and throws the
+ // instant this composable enters composition. Rebuilding
+ // the same mark from its two real layers (the lime
+ // background color + the bars vector, both plain
+ // resources) reproduces it exactly without touching the
+ // adaptive icon resource at all.
+ .background(colorResource(R.color.ic_launcher_background)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_launcher_logo),
+ contentDescription = "LastWave",
+ tint = Color.Unspecified,
+ modifier = Modifier.size(72.dp),
+ )
+ }
+ Spacer(Modifier.height(14.dp))
+ Text("LastWave", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(8.dp))
+ Surface(
+ shape = ExpressivePillShape,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Text(
+ "Version $versionName",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
+ )
+ }
+ Spacer(Modifier.height(12.dp))
+ Text(
+ "Built with the Last.fm API",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+private fun openTelegramChannel(context: android.content.Context, handleOrUrl: String): Boolean {
+ val username = handleOrUrl
+ .removePrefix("https://t.me/")
+ .removePrefix("http://t.me/")
+ .removePrefix("@")
+ .trim()
+ val tgIntent = Intent(Intent.ACTION_VIEW, Uri.parse("tg://resolve?domain=$username")).apply {
+ setPackage("org.telegram.messenger")
+ }
+ val genericTgIntent = Intent(Intent.ACTION_VIEW, Uri.parse("tg://resolve?domain=$username"))
+ val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$username"))
+ return startActivitySafely(context, tgIntent) ||
+ startActivitySafely(context, genericTgIntent) ||
+ startActivitySafely(context, webIntent)
+}
+
+/** OEM Settings/browser components are optional and occasionally broken on
+ * custom ROMs. Never let an external activity failure escape a click event. */
+private fun startActivitySafely(context: android.content.Context, intent: Intent): Boolean {
+ val safeIntent = Intent(intent).apply {
+ if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ return try {
+ context.startActivity(safeIntent)
+ true
+ } catch (_: Exception) {
+ false
+ } catch (_: LinkageError) {
+ false
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ColorWheelSheet(onDismiss: () -> Unit, onApply: (Color) -> Unit) {
+ val sheetState = rememberModalBottomSheetState()
+ var hue by remember { mutableStateOf(4f) }
+ var saturation by remember { mutableStateOf(0.75f) }
+ var lightness by remember { mutableStateOf(0.5f) }
+ val previewColor = Color.hsl(hue, saturation, lightness)
+
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 560.dp)
+ .align(Alignment.CenterHorizontally)
+ .padding(20.dp),
+ ) {
+ Text(stringResource(R.string.set_color_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(16.dp))
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(previewColor),
+ )
+ Spacer(Modifier.height(20.dp))
+ Text(stringResource(R.string.set_hue), style = MaterialTheme.typography.labelLarge)
+ Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
+ Text(stringResource(R.string.set_sat), style = MaterialTheme.typography.labelLarge)
+ Slider(value = saturation, onValueChange = { saturation = it }, valueRange = 0f..1f)
+ Text(stringResource(R.string.set_light), style = MaterialTheme.typography.labelLarge)
+ Slider(value = lightness, onValueChange = { lightness = it }, valueRange = 0.15f..0.85f)
+ Spacer(Modifier.height(12.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedButton(onClick = onDismiss, shape = ExpressivePillShape, modifier = Modifier.weight(1f).height(48.dp)) { Text(stringResource(R.string.common_cancel)) }
+ Button(onClick = { onApply(previewColor) }, shape = ExpressivePillShape, modifier = Modifier.weight(1f).height(48.dp)) { Text(stringResource(R.string.common_apply)) }
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+}
+
+// -- Experimental 15-band equalizer (Settings → Experimental → Equalizer) --
+
+private const val EQ_MAX_DB = EQ_MAX_GAIN_DB
+
+private fun eqBandCategory(hz: Int): String = when {
+ hz <= 40 -> "SUB"
+ hz <= 100 -> "BASS"
+ hz <= 250 -> "LOW-MID"
+ hz <= 1000 -> "MID"
+ hz <= 2500 -> "HIGH-MID"
+ hz <= 6300 -> "PRES"
+ else -> "AIR"
+}
+
+@Composable
+private fun EqualizerCurveGraph(
+ gains: FloatArray,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val primaryColor = MaterialTheme.colorScheme.primary
+ val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
+ val outlineVariant = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
+ val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(130.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerLowest)
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ ) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val w = size.width
+ val h = size.height
+ val paddingX = 14.dp.toPx()
+ val availableW = w - paddingX * 2f
+ val baselineY = h / 2f
+ val maxDbPx = (h - 22.dp.toPx()) / 2f
+
+ val topY = baselineY - maxDbPx
+ val bottomY = baselineY + maxDbPx
+
+ // Baseline (0 dB)
+ drawLine(
+ color = if (enabled) outlineVariant else outlineVariant.copy(alpha = 0.15f),
+ start = Offset(paddingX, baselineY),
+ end = Offset(w - paddingX, baselineY),
+ strokeWidth = 1.5.dp.toPx(),
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 8f), 0f),
+ )
+ // +8 dB line
+ drawLine(
+ color = gridColor,
+ start = Offset(paddingX, topY),
+ end = Offset(w - paddingX, topY),
+ strokeWidth = 1.dp.toPx(),
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 6f), 0f),
+ )
+ // -8 dB line
+ drawLine(
+ color = gridColor,
+ start = Offset(paddingX, bottomY),
+ end = Offset(w - paddingX, bottomY),
+ strokeWidth = 1.dp.toPx(),
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 6f), 0f),
+ )
+
+ if (gains.isEmpty()) return@Canvas
+
+ val count = gains.size
+ val stepX = availableW / (count - 1).coerceAtLeast(1)
+ val points = List(count) { i ->
+ val x = paddingX + i * stepX
+ val gain = if (enabled) gains[i].coerceIn(-EQ_MAX_DB, EQ_MAX_DB) else 0f
+ val y = baselineY - (gain / EQ_MAX_DB) * maxDbPx
+ Offset(x, y)
+ }
+
+ val path = Path()
+ val fillPath = Path()
+
+ path.moveTo(points.first().x, points.first().y)
+ fillPath.moveTo(points.first().x, baselineY)
+ fillPath.lineTo(points.first().x, points.first().y)
+
+ for (i in 0 until points.size - 1) {
+ val p0 = points[i]
+ val p1 = points[i + 1]
+ val controlX1 = p0.x + (p1.x - p0.x) / 2f
+ val controlY1 = p0.y
+ val controlX2 = p0.x + (p1.x - p0.x) / 2f
+ val controlY2 = p1.y
+ path.cubicTo(controlX1, controlY1, controlX2, controlY2, p1.x, p1.y)
+ fillPath.cubicTo(controlX1, controlY1, controlX2, controlY2, p1.x, p1.y)
+ }
+
+ fillPath.lineTo(points.last().x, baselineY)
+ fillPath.close()
+
+ if (enabled) {
+ drawPath(
+ path = fillPath,
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ primaryColor.copy(alpha = 0.35f),
+ primaryColor.copy(alpha = 0.05f),
+ Color.Transparent,
+ ),
+ startY = topY,
+ endY = bottomY,
+ ),
+ )
+ }
+
+ drawPath(
+ path = path,
+ color = if (enabled) primaryColor else onSurfaceVariant.copy(alpha = 0.4f),
+ style = Stroke(
+ width = 3.dp.toPx(),
+ cap = StrokeCap.Round,
+ join = StrokeJoin.Round,
+ ),
+ )
+
+ for (p in points) {
+ val hasBoostOrCut = Math.abs(p.y - baselineY) > 2f && enabled
+ drawCircle(
+ color = if (hasBoostOrCut) primaryColor else if (enabled) primaryColor.copy(alpha = 0.7f) else onSurfaceVariant.copy(alpha = 0.3f),
+ radius = if (hasBoostOrCut) 4.5.dp.toPx() else 3.dp.toPx(),
+ center = p,
+ )
+ if (hasBoostOrCut) {
+ drawCircle(
+ color = Color.White,
+ radius = 2.dp.toPx(),
+ center = p,
+ )
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd).padding(end = 2.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ horizontalAlignment = Alignment.End,
+ ) {
+ Text("+12dB", fontSize = 9.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
+ Text("0dB", fontSize = 9.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
+ Text("-12dB", fontSize = 9.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
+ }
+ }
+}
+
+/**
+ * 100% natural, native Material 3 Equalizer:
+ * - Edge-to-edge layout with status bar and navigation bar insets protection
+ * - Live dynamic Bézier Spline frequency response visualizer
+ * - Hardware acoustic fader board with real-time numeric dB readouts and 0 dB center detent haptics
+ * - Standard ISO center frequencies
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+private fun EqualizerSheet(
+ eq: EqualizerSettings,
+ onDismiss: () -> Unit,
+ onSetEnabled: (Boolean) -> Unit,
+ onPickPreset: (String) -> Unit,
+ onBandPreview: (Int, Float) -> Unit,
+ onBandChange: (Int, Float) -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+
+ var gains by remember(eq.gainsDb) {
+ mutableStateOf(
+ FloatArray(EQ_BAND_FREQS_HZ.size) { index ->
+ eq.gainsDb.getOrNull(index)
+ ?.takeIf { it.isFinite() }
+ ?.coerceIn(-EQ_MAX_DB, EQ_MAX_DB)
+ ?: 0f
+ },
+ )
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ dragHandle = {
+ Surface(
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
+ modifier = Modifier
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 36.dp, height = 4.dp),
+ ) {}
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 640.dp)
+ .align(Alignment.CenterHorizontally)
+ .statusBarsPadding()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 32.dp + safeDrawingBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Header Row
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.GraphicEq,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(
+ "Equalizer",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ "15-band hardware acoustic tuning",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+
+ TextButton(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onPickPreset(EqualizerPresets.FLAT.name)
+ },
+ enabled = eq.enabled,
+ ) {
+ Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(stringResource(R.string.set_eq_reset), style = MaterialTheme.typography.labelMedium)
+ }
+ }
+
+ // Master On/Off Switch Card
+ Surface(
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ tonalElevation = 2.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.set_eq_enable), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
+ Text(
+ if (eq.enabled) "Shaping your music in real-time" else "Off \u2014 original audio passes through",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ )
+ }
+ Switch(
+ checked = eq.enabled,
+ onCheckedChange = { enabled ->
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onSetEnabled(enabled)
+ },
+ )
+ }
+ }
+
+ // Real-Time Frequency Response Visualizer
+ EqualizerCurveGraph(
+ gains = gains,
+ enabled = eq.enabled,
+ )
+
+ // Presets Horizontal Flow
+ SectionLabel("Presets")
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ EqualizerPresets.ALL.forEach { preset ->
+ FilterChip(
+ selected = eq.presetName.equals(preset.name, ignoreCase = true),
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onPickPreset(preset.name)
+ },
+ label = { Text(preset.name, style = MaterialTheme.typography.labelMedium) },
+ )
+ }
+ if (eq.presetName == EqualizerPresets.CUSTOM_NAME) {
+ FilterChip(selected = true, onClick = {}, label = { Text(stringResource(R.string.set_eq_custom), style = MaterialTheme.typography.labelMedium) })
+ }
+ }
+
+ // Native Equalizer Fader Board
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ tonalElevation = 2.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ val curveAlpha by animateFloatAsState(
+ targetValue = if (eq.enabled) 1f else 0.38f,
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
+ label = "eqCurveAlpha",
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ .alpha(curveAlpha),
+ ) {
+ // Top Scale Label
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "+8 dB (Boost)",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ "-8 dB (Cut)",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(Modifier.height(10.dp))
+
+ // Scrollable Horizontal Row of Native Equalizer Faders
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ EQ_BAND_FREQS_HZ.forEachIndexed { index, hz ->
+ EqNativeSlider(
+ gainDb = gains[index],
+ hz = hz,
+ enabled = eq.enabled,
+ onGainChange = { value ->
+ gains = gains.copyOf().also { it[index] = value }
+ onBandPreview(index, value)
+ },
+ onChangeFinished = { onBandChange(index, gains[index]) },
+ )
+ }
+ }
+ }
+ }
+
+ Text(
+ text = when {
+ !eq.enabled -> "Turn on to apply equalization live"
+ eq.presetName == EqualizerPresets.CUSTOM_NAME -> "Custom profile \u2022 touch and drag any bar up/down"
+ else -> "${eq.presetName} preset active \u2022 touch any bar to customize"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
+ )
+ }
+ }
+}
+
+/**
+ * Goated native hardware equalizer fader bar:
+ * - Vertical capsule track with central 0 dB baseline notch and active level gradient beam
+ * - Tactile hardware capsule thumb knob with double grip ridges
+ * - Real-time continuous numeric dB badge on top with container coloring
+ * - Standard frequency label + band category tag underneath
+ */
+@Composable
+private fun EqNativeSlider(
+ gainDb: Float,
+ hz: Int,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+ onGainChange: (Float) -> Unit,
+ onChangeFinished: () -> Unit,
+) {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+ val normalized = ((gainDb + EQ_MAX_DB) / (EQ_MAX_DB * 2f)).coerceIn(0f, 1f)
+ var isDragging by remember { mutableStateOf(false) }
+ val currentGain by rememberUpdatedState(gainDb)
+ val currentOnGainChange by rememberUpdatedState(onGainChange)
+ val currentOnChangeFinished by rememberUpdatedState(onChangeFinished)
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Numeric Gain on top in a small pill container
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = when {
+ !enabled -> MaterialTheme.colorScheme.surfaceContainer
+ gainDb > 0f -> MaterialTheme.colorScheme.primaryContainer
+ gainDb < 0f -> MaterialTheme.colorScheme.tertiaryContainer
+ else -> MaterialTheme.colorScheme.surfaceContainerHighest
+ },
+ ) {
+ Text(
+ text = if (gainDb > 0f) "+${"%.1f".format(gainDb)}" else "${"%.1f".format(gainDb)}",
+ fontSize = 11.sp,
+ fontWeight = if (gainDb != 0f) FontWeight.Bold else FontWeight.Medium,
+ color = when {
+ !enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ gainDb > 0f -> MaterialTheme.colorScheme.onPrimaryContainer
+ gainDb < 0f -> MaterialTheme.colorScheme.onTertiaryContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 1,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+
+ Spacer(Modifier.height(6.dp))
+
+ // Vertical Track Box
+ val trackHeight = 152.dp
+ val thumbHeight = 24.dp
+ val thumbWidth = 38.dp
+
+ Box(
+ modifier = Modifier
+ .width(44.dp)
+ .height(trackHeight)
+ .clip(RoundedCornerShape(22.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .pointerInput(enabled) {
+ if (!enabled) return@pointerInput
+ detectVerticalDragGestures(
+ onDragStart = { isDragging = true },
+ onDragEnd = {
+ isDragging = false
+ currentOnChangeFinished()
+ },
+ onDragCancel = {
+ isDragging = false
+ currentOnChangeFinished()
+ },
+ ) { change, dragAmount ->
+ change.consume()
+ val deltaFraction = -dragAmount / size.height.toFloat()
+ val currentFraction = ((currentGain + EQ_MAX_DB) / (EQ_MAX_DB * 2f))
+ val newFraction = (currentFraction + deltaFraction).coerceIn(0f, 1f)
+ val newGain = (newFraction * EQ_MAX_DB * 2f - EQ_MAX_DB).let {
+ if (it in -0.3f..0.3f) 0f else (Math.round(it * 2f) / 2f)
+ }
+ if (newGain != currentGain) {
+ if (newGain == 0f && currentGain != 0f) {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
+ }
+ currentOnGainChange(newGain)
+ }
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ // Center Baseline Line (0 dB)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(2.dp)
+ .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
+ )
+
+ // Active Level Fill (from baseline to thumb)
+ val baselineFraction = 0.5f
+ val topFraction = if (normalized >= baselineFraction) 1f - normalized else 1f - baselineFraction
+ val heightFraction = Math.abs(normalized - baselineFraction)
+
+ if (heightFraction > 0.01f && enabled) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.55f)
+ .fillMaxHeight(heightFraction)
+ .align(Alignment.TopCenter)
+ .graphicsLayer {
+ translationY = size.height * topFraction
+ }
+ .clip(RoundedCornerShape(6.dp))
+ .background(
+ if (gainDb > 0f) {
+ Brush.verticalGradient(
+ listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.primaryContainer,
+ ),
+ )
+ } else {
+ Brush.verticalGradient(
+ listOf(
+ MaterialTheme.colorScheme.tertiaryContainer,
+ MaterialTheme.colorScheme.tertiary,
+ ),
+ )
+ },
+ ),
+ )
+ }
+
+ // Tactile Hardware Capsule Thumb Knob
+ Box(
+ modifier = Modifier
+ .size(width = thumbWidth, height = thumbHeight)
+ .align(Alignment.TopCenter)
+ .graphicsLayer {
+ val maxTravel = (trackHeight - thumbHeight).toPx()
+ translationY = maxTravel * (1f - normalized)
+ }
+ .shadow(if (isDragging) 8.dp else 3.dp, RoundedCornerShape(12.dp))
+ .clip(RoundedCornerShape(12.dp))
+ .background(
+ if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ // Double grip ridges
+ Column(
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(width = 14.dp, height = 2.dp)
+ .clip(RoundedCornerShape(1.dp))
+ .background(
+ if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .size(width = 14.dp, height = 2.dp)
+ .clip(RoundedCornerShape(1.dp))
+ .background(
+ if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ ),
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ // Frequency Label
+ Text(
+ text = eqBandLabel(hz),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = if (gainDb != 0f && enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ )
+ Text(
+ text = eqBandCategory(hz),
+ fontSize = 9.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
+ maxLines = 1,
+ )
+ }
+}
+
+/** Controls which connected-account playlists appear in LastWave. This is
+ * intentionally independent from importing and two-way sync. */
+/**
+ * Lets the user pick which YouTube channel/profile answers inside the
+ * current session. Cookies are identical for every channel (which is why a
+ * cookie-only client always lands on the first one) — the choice is sent
+ * per-request as the InnerTube delegation flag instead.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun YouTubeChannelSheet(
+ channels: List,
+ isLoading: Boolean,
+ selectedChannelId: String?,
+ selectedAuthUser: Int?,
+ selectedPageId: String = "",
+ onReload: () -> Unit,
+ onSelect: (com.lastwave.app.data.music.YtChannelOption) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 640.dp)
+ .align(Alignment.CenterHorizontally)
+ .statusBarsPadding()
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 32.dp + safeDrawingBottomPadding()),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.set_yt_channel), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+ Text(
+ stringResource(R.string.set_yt_channel_sub),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ when {
+ isLoading -> Box(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ Text(
+ stringResource(R.string.set_yt_loading),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ channels.isEmpty() -> {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ stringResource(R.string.set_yt_empty_title),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ stringResource(R.string.set_yt_empty_sub),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ FilledTonalButton(onClick = onReload) {
+ Text(stringResource(R.string.set_yt_retry))
+ }
+ }
+ }
+ }
+
+ else -> {
+ Text(
+ "Each channel keeps its own sync mirrors — switching re-mirrors cleanly instead of mixing libraries.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().heightIn(max = 440.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(channels.size, key = { index ->
+ val c = channels[index]
+ "${c.channelId}|${c.authUserIndex}|${c.pageId}|${c.accountName}"
+ }) { index ->
+ val channel = channels[index]
+ val isSelected = channel.channelId == selectedChannelId &&
+ channel.authUserIndex == selectedAuthUser &&
+ channel.pageId == selectedPageId
+ Surface(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ if (!isSelected) onSelect(channel)
+ },
+ shape = RoundedCornerShape(16.dp),
+ color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceContainerHigh,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = isSelected,
+ onClick = { if (!isSelected) onSelect(channel) },
+ )
+ Spacer(Modifier.width(10.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ channel.accountName,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ val detail = channel.channelHandle
+ ?: if (channel.channelId != null) stringResource(R.string.set_channel_generic) else stringResource(R.string.set_channel_default)
+ Text(
+ detail,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ if (isSelected) {
+ Text(
+ "Active",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ Button(onClick = onDismiss, shape = CircleShape, modifier = Modifier.fillMaxWidth().height(48.dp)) {
+ Text(stringResource(R.string.common_done), fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun YouTubeLibraryVisibilitySheet(
+ playlists: List,
+ hiddenIds: Set,
+ onSetVisible: (String, Boolean) -> Unit,
+ onSetAllVisible: (Boolean) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+ val allShown = playlists.isNotEmpty() && playlists.all { it.id !in hiddenIds }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 640.dp)
+ .align(Alignment.CenterHorizontally)
+ .statusBarsPadding()
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 32.dp + safeDrawingBottomPadding()),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.set_yt_shown_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+ Text(
+ "Choose which account playlists appear in LastWave",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ TextButton(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onSetAllVisible(!allShown)
+ },
+ ) {
+ Text(if (allShown) "Hide All" else "Show All", fontWeight = FontWeight.Bold)
+ }
+ }
+
+ if (playlists.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(stringResource(R.string.set_yt_none), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(playlists.size, key = { playlists[it].id }) { index ->
+ val playlist = playlists[index]
+ val isShown = playlist.id !in hiddenIds
+ Surface(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
+ onSetVisible(playlist.id, !isShown)
+ },
+ shape = RoundedCornerShape(16.dp),
+ color = if (isShown) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(if (isShown) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.QueueMusic,
+ contentDescription = null,
+ tint = if (isShown) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(playlist.title, fontWeight = FontWeight.SemiBold, maxLines = 1)
+ Text(
+ playlist.trackCountText ?: playlist.author ?: "YouTube Music playlist",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ Checkbox(
+ checked = isShown,
+ onCheckedChange = { checked -> onSetVisible(playlist.id, checked) },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ Button(onClick = onDismiss, shape = CircleShape, modifier = Modifier.fillMaxWidth().height(48.dp)) {
+ Text(stringResource(R.string.common_done), fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+/** Bottom sheet allowing user to select which specific playlists to mirror to YouTube Music. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SyncPlaylistsSheet(
+ playlists: List,
+ syncedIds: Set?,
+ onToggleSync: (Long, Boolean) -> Unit,
+ onSelectAll: (Boolean) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
+ val allSelected = playlists.isNotEmpty() && (syncedIds == null || (playlists.all { it.id in syncedIds } && syncedIds.isNotEmpty()))
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ dragHandle = {
+ Surface(
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
+ modifier = Modifier
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 36.dp, height = 4.dp),
+ ) {}
+ },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .adaptiveContentWidth(maxWidth = 640.dp)
+ .align(Alignment.CenterHorizontally)
+ .statusBarsPadding()
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 32.dp + safeDrawingBottomPadding()),
+ ) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ "Sync Playlists",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ "Choose which playlists mirror to YouTube Music",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ TextButton(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onSelectAll(!allSelected)
+ },
+ ) {
+ Text(if (allSelected) "Deselect All" else "Select All", fontWeight = FontWeight.Bold)
+ }
+ }
+
+ if (playlists.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ "No playlists in your library yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().weight(1f, fill = false),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(playlists.size, key = { playlists[it].id }) { idx ->
+ val playlist = playlists[idx]
+ val isChecked = syncedIds == null || playlist.id in syncedIds
+ Surface(
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
+ onToggleSync(playlist.id, !isChecked)
+ },
+ shape = RoundedCornerShape(16.dp),
+ color = if (isChecked) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(
+ if (isChecked) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.QueueMusic,
+ contentDescription = null,
+ tint = if (isChecked) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+
+ Spacer(Modifier.width(14.dp))
+
+ Column(Modifier.weight(1f)) {
+ Text(
+ playlist.title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ Text(
+ "${playlist.tracks.size} tracks",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Checkbox(
+ checked = isChecked,
+ onCheckedChange = { checked ->
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
+ onToggleSync(playlist.id, checked)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ Button(
+ onClick = onDismiss,
+ shape = CircleShape,
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ ) {
+ Text(stringResource(R.string.common_done), fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+/**
+ * Bottom sheet to pick from 8 experimental lyrics animation physics profiles.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LyricsProviderDialog(
+ current: LyricsProvider,
+ onSelect: (LyricsProvider) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.settings_lyrics_provider)) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ stringResource(R.string.settings_lyrics_provider_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ LyricsProvider.entries.forEach { provider ->
+ val selected = provider == current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onSelect(provider) }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = { onSelect(provider) })
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ provider.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
+ )
+ Text(
+ provider.subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
+ },
+ )
+}
+
+@Composable
+private fun LyricsAutoHideDialog(
+ currentSeconds: Int,
+ onSelect: (Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.set_lyrics_autohide_title)) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ stringResource(R.string.set_lyrics_autohide_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ SettingsRadioOption(
+ label = stringResource(R.string.set_lyrics_autohide_never),
+ selected = currentSeconds == 0,
+ onClick = { onSelect(0) },
+ )
+ for (seconds in 2..10) {
+ SettingsRadioOption(
+ label = stringResource(R.string.set_lyrics_autohide_seconds, seconds),
+ selected = currentSeconds == seconds,
+ onClick = { onSelect(seconds) },
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
+ },
+ )
+}
+
+@Composable
+private fun SettingsRadioOption(
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onClick() }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = onClick)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ label,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
+ )
+ }
+}
+
+@Composable
+private fun TranslationLanguageDialog(
+ current: String,
+ onSelect: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val locale = remember { Locale.getDefault() }
+ val languages = remember { TRANSLATION_LANGUAGES }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.set_translation_language_title)) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ stringResource(R.string.set_translation_language_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ SettingsRadioOption(
+ label = stringResource(R.string.set_translation_language_app),
+ selected = current.isBlank(),
+ onClick = { onSelect("") },
+ )
+ for (language in languages) {
+ SettingsRadioOption(
+ label = translationLanguageName(language.code, locale),
+ selected = current.equals(language.code, ignoreCase = true),
+ onClick = { onSelect(language.code) },
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
+ },
+ )
+}
+
+
+
+private fun loudnessModeTitle(context: android.content.Context, mode: LoudnessMode): String = when (mode) {
+ LoudnessMode.TRACK -> context.getString(R.string.set_loud_track)
+ LoudnessMode.ALBUM -> context.getString(R.string.set_loud_album)
+ else -> context.getString(R.string.set_loud_off)
+}
+
+private fun loudnessModeSubtitle(context: android.content.Context, mode: LoudnessMode): String = when (mode) {
+ LoudnessMode.TRACK -> context.getString(R.string.set_loud_track_sub)
+ LoudnessMode.ALBUM -> context.getString(R.string.set_loud_album_sub)
+ else -> context.getString(R.string.set_loud_off_sub)
+}
+
+@Composable
+private fun clarityPresetName(preset: com.lastwave.app.playback.ClarityPreset): String = stringResource(
+ when (preset.key) {
+ "speaker" -> R.string.set_preset_speaker
+ "headphone" -> R.string.set_preset_headphone
+ "dac" -> R.string.set_preset_dac
+ else -> R.string.set_preset_reference
+ },
+)
+
+@Composable
+private fun clarityPresetDescription(preset: com.lastwave.app.playback.ClarityPreset): String = stringResource(
+ when (preset.key) {
+ "speaker" -> R.string.set_preset_speaker_desc
+ "headphone" -> R.string.set_preset_headphone_desc
+ "dac" -> R.string.set_preset_dac_desc
+ else -> R.string.set_preset_reference_desc
+ },
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LoudnessModeDialog(
+ current: LoudnessMode,
+ onSelect: (LoudnessMode) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.set_loud_title)) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ stringResource(R.string.set_loud_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ LoudnessMode.entries.forEach { mode ->
+ val selected = mode == current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onSelect(mode) }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = { onSelect(mode) })
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ loudnessModeTitle(LocalContext.current, mode),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
+ )
+ Text(
+ loudnessModeSubtitle(LocalContext.current, mode),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ClarityPresetDialog(
+ currentIndex: Int,
+ onSelect: (Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.set_clarity_title)) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ "Tunes the Studio Master Clarity chain to the output. Reference is the unmodified shipping curve.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ ClarityPresets.ALL.forEach { preset ->
+ val selected = preset.index == currentIndex
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onSelect(preset.index) }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = { onSelect(preset.index) })
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ clarityPresetName(preset),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
+ )
+ Text(
+ clarityPresetDescription(preset),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
+ },
+ )
+}
+
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index ad1898c05..654b544d2 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -1035,6 +1035,12 @@
- تم تخطي ملف %1$d (تنسيق غير مدعوم: %2$s)
- تم تخطي ملفات %1$d (تنسيق غير مدعوم: %2$s)
+ تغيير مزود كلمات الأغاني
+ مزود كلمات الأغاني
+ الحالي: %1$s
+ تجربة مصدر آخر
+ استبعاد المزود الحالي والبحث عن كلمات الأغاني من مصادر أخرى
+ أو اختر مزوداً مباشرةً
مشاركة الكلمات
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 59f68a0a8..38c0e9e8a 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1035,6 +1035,12 @@
- %1$d-Datei übersprungen (nicht unterstütztes Format: %2$s)
- %1$d Dateien übersprungen (nicht unterstütztes Format: %2$s)
+ Songtext-Anbieter ändern
+ Songtext-Anbieter
+ Aktuell: %1$s
+ Andere Quelle versuchen
+ Schließt den aktuellen Anbieter aus und sucht Songtexte aus anderen Quellen
+ Oder einen Anbieter direkt wählen
Songtext teilen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 60d5c8411..88aeb985d 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1035,6 +1035,12 @@
- Archivo %1$d omitido (formato no compatible: %2$s)
- %1$d archivos omitidos (formato no compatible: %2$s)
+ Cambiar proveedor de letras
+ Proveedor de letras
+ Actual: %1$s
+ Probar otra fuente
+ Excluye el proveedor actual y busca letras de otras fuentes
+ O elige un proveedor directamente
Compartir letras
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index ee6e15393..171c0df34 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1038,6 +1038,12 @@
Le mode Invité fonctionne entièrement hors ligne avec des recommandations locales. Connectez Last.fm à tout moment dans Paramètres pour synchroniser les scrobbles.
Restaurer la sauvegarde
Ouvrir les téléchargements hors ligne
+ Changer de fournisseur de paroles
+ Fournisseur de paroles
+ Actuel : %1$s
+ Essayer une autre source
+ Exclut le fournisseur actuel et recherche les paroles auprès d\'autres sources
+ Ou choisir directement un fournisseur
Partager les paroles
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 7140e2de2..08d2bb446 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -1035,6 +1035,12 @@
- %1$d फ़ाइल छोड़ दी गई (असमर्थित प्रारूप: %2$s)
- %1$d फ़ाइलें छोड़ दी गईं (असमर्थित प्रारूप: %2$s)
+ गीत के बोल प्रदाता बदलें
+ गीत के बोल प्रदाता
+ वर्तमान: %1$s
+ दूसरा स्रोत आज़माएं
+ वर्तमान प्रदाता को छोड़कर अन्य स्रोतों से बोल खोजता है
+ या सीधे कोई प्रदाता चुनें
बोल साझा करें
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index f516e11b4..1b74e63b1 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -1035,6 +1035,12 @@
- File %1$d dilewati (format tidak didukung: %2$s)
- %1$d file dilewati (format tidak didukung: %2$s)
+ Ubah penyedia lirik
+ Penyedia Lirik
+ Saat ini: %1$s
+ Coba Sumber Lain
+ Kecualikan penyedia saat ini dan cari lirik dari sumber lain
+ Atau pilih penyedia langsung
Bagikan Lirik
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 02da23c52..7a7773d11 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -1035,6 +1035,12 @@
- %1$d ファイルはスキップされました (サポートされていない形式: %2$s)
- %1$d ファイルがスキップされました (サポートされていない形式: %2$s)
+ 歌詞プロバイダーを変更
+ 歌詞プロバイダー
+ 現在: %1$s
+ 別のソースを試す
+ 現在のプロバイダーを除外して他のソースから歌詞を検索
+ またはプロバイダーを直接選択
歌詞を共有
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 961a55f6e..bc50c5551 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1035,6 +1035,12 @@
- %1$d 파일을 건너뛰었습니다(지원되지 않는 형식: %2$s).
- %1$d 파일을 건너뛰었습니다(지원되지 않는 형식: %2$s).
+ 가사 제공자 변경
+ 가사 제공자
+ 현재: %1$s
+ 다른 출처 시도
+ 현재 제공자를 제외하고 다른 출처에서 가사를 검색합니다
+ 또는 제공자를 직접 선택
가사 공유
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index fa7205015..1c667ae32 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -1035,6 +1035,12 @@
- Arquivo %1$d ignorado (formato não suportado: %2$s)
- %1$d arquivos ignorados (formato não suportado: %2$s)
+ Alterar provedor de letras
+ Provedor de letras
+ Atual: %1$s
+ Tentar outra fonte
+ Exclui o provedor atual e busca letras de outras fontes
+ Ou escolha um provedor diretamente
Compartilhar letra
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 27e4d3cdc..a9c5781ba 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1035,6 +1035,12 @@
- Файл %1$d пропущен (неподдерживаемый формат: %2$s)
- Файлы %1$d пропущены (неподдерживаемый формат: %2$s).
+ Сменить источник текста песни
+ Источник текста песни
+ Текущий: %1$s
+ Попробовать другой источник
+ Исключить текущий источник и искать текст в других
+ Или выберите источник напрямую
Поделиться текстом
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index e161bfe2e..0fad14fe3 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1088,6 +1088,12 @@
Bu şarkı sözlerinde zaten Latin karakterleri kullanılıyor
Romanizasyon kullanılamıyor
Çeviri kullanılamıyor
+ Şarkı sözü sağlayıcısını değiştir
+ Şarkı Sözü Sağlayıcısı
+ Geçerli: %1$s
+ Başka Kaynak Dene
+ Mevcut sağlayıcıyı hariç tutar ve diğer kaynaklardan sözleri arar
+ Veya doğrudan bir sağlayıcı seçin
Şarkı sözü paylaş
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index cfd095ef8..780412555 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1038,6 +1038,12 @@
- 跳过 %1$d 文件(不支持的格式:%2$s)
- 跳过 %1$d 文件(不支持的格式:%2$s)
+ 更改歌词源
+ 歌词源
+ 当前:%1$s
+ 尝试其他来源
+ 排除当前来源并从其他来源查找歌词
+ 或直接选择歌词源
分享歌词
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 33ab76155..253076775 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1090,6 +1090,13 @@
Guest mode works fully offline with local recommendations. Connect Last.fm anytime in Settings to sync scrobbles.
Restore backup
Open Offline Downloads
+ Change lyrics provider
+ Lyrics Provider
+ Current: %1$s
+ Try Another Source
+ Excludes current provider and finds lyrics from other sources
+ Or choose a provider directly
+
Share lyrics
diff --git a/builds/README.md b/builds/README.md
index 35bb2ee54..7d9d63123 100644
--- a/builds/README.md
+++ b/builds/README.md
@@ -1,11 +1,11 @@
-# Builds
-
-- **Release Artifact:** `LastWave-v4.2.4-release.apk` (**22.7 MB**)
- - Variant: `release` (`:app:assembleRelease`)
- - Optimizations: R8 code shrinking, dead-code elimination, resource shrinking (`isMinifyEnabled = true`, `isShrinkResources = true`)
-- **Debug Artifact:** `LastWave-v4.2.4-debug.apk` (**120.4 MB**)
- - Variant: `debug` (`:app:assembleDebug`)
- - Uncompressed full DEX with all debug symbols, no R8 shrinking
-- **Version:** `4.2.4` (versionCode `24`)
-- **Feature:** Full Screen Cover Art implementation (static & animated canvas full-bleed hero with dynamic bottom fade and scrim protection)
-- **Toolchain:** Gradle 9.3.1, OpenJDK 17, Android compileSdk 37, targetSdk 35, minSdk 29
+# Builds
+
+- **Release Artifact:** `LastWave-v4.3.0-release.apk` (**22.7 MB**)
+ - Variant: `release` (`:app:assembleRelease`)
+ - Optimizations: R8 code shrinking, dead-code elimination, resource shrinking (`isMinifyEnabled = true`, `isShrinkResources = true`)
+- **Debug Artifact:** `LastWave-v4.3.0-debug.apk` (**120.4 MB**)
+ - Variant: `debug` (`:app:assembleDebug`)
+ - Uncompressed full DEX with all debug symbols, no R8 shrinking
+- **Version:** `4.3.0` (versionCode `25`)
+- **Feature:** Full Screen Cover Art implementation (static & animated canvas full-bleed hero with dynamic bottom fade and scrim protection)
+- **Toolchain:** Gradle 9.3.1, OpenJDK 17, Android compileSdk 37, targetSdk 35, minSdk 29