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 Logo - -# LastWave - -**Next-Gen YouTube Music Client with Algorithmic Smart Playlist Generator, Real-Time Synced Lyrics & Universal Last.fm Scrobbler for Android.** - -

- - Stars - - - Forks - - - YouTube Music Client - - - Last.fm Scrobbler - - - Platform - -

- -

- - Clash-Projects%2FLastWave-Native | Trendshift - -   - - Clash-Projects%2FLastWave-Native | Trendshift - -

- -

- - Telegram Support - - - More From Us - - - Discord Community - -

- -

- - UPI (Buy Me A Chai) - -   - - Buy Me A Coffee - -   - - GitHub Sponsors - -

- -
- -
- -
- - - -
-
- - - -
- -
- -## 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. - ---- - -
-

LastWave is built with ❤️ by Duxtami & Ajisth.

-
- -[![GitGem](https://gitgem.org/api/badge/github/Clash-Projects/LastWave-Native.svg)](https://gitgem.org/github/Clash-Projects/LastWave-Native) +
+ +LastWave Logo + +# LastWave + +**Next-Gen YouTube Music Client with Algorithmic Smart Playlist Generator, Real-Time Synced Lyrics & Universal Last.fm Scrobbler for Android.** + +

+ + Stars + + + Forks + + + YouTube Music Client + + + Last.fm Scrobbler + + + Platform + +

+ +

+ + Clash-Projects%2FLastWave-Native | Trendshift + +   + + Clash-Projects%2FLastWave-Native | Trendshift + +

+ +

+ + Telegram Support + + + More From Us + + + Discord Community + +

+ +

+ + UPI (Buy Me A Chai) + +   + + Buy Me A Coffee + +   + + GitHub Sponsors + +

+ +
+ +
+ +
+ + + +
+
+ + + +
+ +
+ +## 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. + +--- + +
+

LastWave is built with ❤️ by Duxtami & Ajisth.

+
+ +[![GitGem](https://gitgem.org/api/badge/github/Clash-Projects/LastWave-Native.svg)](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