diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..85707c1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +gradlew text eol=lf +*.bat text eol=crlf +*.jar binary +*.png binary +*.webp binary diff --git a/.gitignore b/.gitignore index 634100f..6c97a72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,24 @@ +# Gradle / Android Studio +.gradle/ +build/ +local.properties +.idea/ +*.iml +.kotlin/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Signing keys - never commit these +*.jks +*.keystore + # OS .DS_Store Thumbs.db # Editors .vscode/ -.idea/ *.swp # Local scratch diff --git a/README.md b/README.md index c74bf03..8d1ca1c 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,30 @@ Compare the SHA-256 checksum against the value listed in [CHANGELOG.md](CHANGELO sha256sum Fogwawe-v1.0.10.apk ``` +## Building from source + +Requires JDK 17+ and Android Studio with Android SDK 37. + +```sh +./gradlew assembleDebug +``` + +Release builds are signed only when a keystore is supplied through the `KEYSTORE_PATH`, `STORE_PASSWORD`, `KEY_ALIAS` and `KEY_PASSWORD` environment variables. Without them the release build is unsigned, which is what F-Droid expects. Keystores are excluded by `.gitignore` — never commit them. + +For every release: bump `versionCode` and `versionName` in `app/build.gradle.kts`, add `fastlane/metadata/android//changelogs/.txt`, and tag the commit as `v` (for example `v1.0.11`). + ## Repository structure ``` . -├── assets/ # Icon images +├── app/ # Android app source (Kotlin, Jetpack Compose) +├── assets/ # README images and logos │ └── screenshots/ # App screenshots +├── fastlane/ # F-Droid store listing, screenshots and changelogs +├── fdroid/ # Build recipe for fdroiddata +├── gradle/ # Gradle wrapper and version catalog ├── CHANGELOG.md # Per-version sizes and checksums (links to GitHub Releases) ├── LICENSE # GNU GPLv3 └── README.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..b66c6fa --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,107 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.roborazzi) +} + +android { + namespace = "cc.skysparkle.fogwave" + + compileSdk { version = release(37) } + + defaultConfig { + applicationId = "cc.skysparkle.fogwave" + minSdk = 24 + targetSdk = 36 + versionCode = 11 + versionName = "1.0.11" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Library versions shown under About -> Licenses, taken from the version catalog so they + // always match what is actually bundled. + buildConfigField("String", "COMPOSE_BOM_VERSION", "\"${libs.versions.composeBom.get()}\"") + buildConfigField("String", "ACTIVITY_VERSION", "\"${libs.versions.activityCompose.get()}\"") + buildConfigField("String", "CORE_KTX_VERSION", "\"${libs.versions.coreKtx.get()}\"") + buildConfigField("String", "LIFECYCLE_VERSION", "\"${libs.versions.lifecycleRuntimeCompose.get()}\"") + buildConfigField("String", "MEDIA3_VERSION", "\"${libs.versions.media3.get()}\"") + buildConfigField("String", "COROUTINES_VERSION", "\"${libs.versions.kotlinxCoroutinesAndroid.get()}\"") + buildConfigField("String", "KOTLIN_VERSION", "\"${libs.versions.kotlin.get()}\"") + buildConfigField("String", "COIL_VERSION", "\"${libs.versions.coilCompose.get()}\"") + } + + // Release signing is applied only when a keystore is available, so anyone (including F-Droid, + // which signs with its own key) can build an unsigned release from a fresh clone. + val releaseKeystore = file(System.getenv("KEYSTORE_PATH") ?: "${rootDir}/my-upload-key.jks") + val canSignRelease = releaseKeystore.exists() && System.getenv("STORE_PASSWORD") != null + + signingConfigs { + if (canSignRelease) { + create("release") { + storeFile = releaseKeystore + storePassword = System.getenv("STORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") ?: "upload" + keyPassword = System.getenv("KEY_PASSWORD") ?: System.getenv("STORE_PASSWORD") + } + } + } + + buildTypes { + release { + isCrunchPngs = false + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + // Keeps release builds reproducible (F-Droid). + vcsInfo.include = false + if (canSignRelease) signingConfig = signingConfigs.getByName("release") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = true + } + testOptions { unitTests { isIncludeAndroidResources = true } } + dependenciesInfo { + includeInApk = false + includeInBundle = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(libs.coil.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.session) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.runner) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..1e9f966 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/cc/skysparkle/fogwave/ExampleInstrumentedTest.kt b/app/src/androidTest/java/cc/skysparkle/fogwave/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c3ff041 --- /dev/null +++ b/app/src/androidTest/java/cc/skysparkle/fogwave/ExampleInstrumentedTest.kt @@ -0,0 +1,16 @@ +package cc.skysparkle.fogwave + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals(BuildConfig.APPLICATION_ID, appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e60bfef --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/cc/skysparkle/fogwave/FogwaveApp.kt b/app/src/main/java/cc/skysparkle/fogwave/FogwaveApp.kt new file mode 100644 index 0000000..b90d0fa --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/FogwaveApp.kt @@ -0,0 +1,20 @@ +package cc.skysparkle.fogwave + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.memory.MemoryCache + +class FogwaveApp : Application(), ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.20) + .build() + } + .crossfade(true) + .respectCacheHeaders(false) + .build() + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/MainActivity.kt b/app/src/main/java/cc/skysparkle/fogwave/MainActivity.kt new file mode 100644 index 0000000..97c4366 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/MainActivity.kt @@ -0,0 +1,34 @@ +package cc.skysparkle.fogwave + +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import cc.skysparkle.fogwave.ui.screens.HomeScreen +import cc.skysparkle.fogwave.ui.theme.FogwaveTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) + ) + setContent { + FogwaveTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + HomeScreen() + } + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/data/model/RadioStation.kt b/app/src/main/java/cc/skysparkle/fogwave/data/model/RadioStation.kt new file mode 100644 index 0000000..fda7bf5 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/data/model/RadioStation.kt @@ -0,0 +1,54 @@ +package cc.skysparkle.fogwave.data.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import cc.skysparkle.fogwave.R + +@Immutable +data class RadioStation( + val id: String, + val name: String, + @param:StringRes val descriptionRes: Int, + val streamUrl: String, + @param:DrawableRes val iconResId: Int, + val genres: List, + val bitrate: String, + val lightBackdrop: Boolean = false +) + +enum class Genre(@param:StringRes val labelRes: Int) { + HITS(R.string.genre_hits), + POP(R.string.genre_pop), + DANCE(R.string.genre_dance), + OLDIES(R.string.genre_oldies), + ROCK(R.string.genre_rock), + EASY_LISTENING(R.string.genre_easy_listening), + LITHUANIAN(R.string.genre_lithuanian) +} + +enum class ViewMode { + ICONS, + TILES, + LIST, + DETAILS +} + +enum class IconSizeStep(val sizeDp: Int) { + S(64), + M(96), + L(132), + XL(176) +} + +enum class AppTab(@param:StringRes val titleRes: Int) { + RADIO(R.string.tab_radio), + FAVORITES(R.string.tab_favorites), + ABOUT(R.string.tab_about) +} + +enum class ExtraPlayerTab(@param:StringRes val titleRes: Int) { + RECENT(R.string.extra_tab_recent), + SIMILAR(R.string.extra_tab_similar), + INFO(R.string.extra_tab_info) +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/data/repository/RadioRepository.kt b/app/src/main/java/cc/skysparkle/fogwave/data/repository/RadioRepository.kt new file mode 100644 index 0000000..03b4ffe --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/data/repository/RadioRepository.kt @@ -0,0 +1,263 @@ +package cc.skysparkle.fogwave.data.repository + +import android.content.Context +import androidx.core.content.edit +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.Genre +import cc.skysparkle.fogwave.data.model.IconSizeStep +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.data.model.ViewMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray + +class RadioRepository(context: Context) { + private val prefs = context.getSharedPreferences("fogwave_prefs", Context.MODE_PRIVATE) + + val stations: List = listOf( + RadioStation( + id = "zip", + name = "ZIP FM", + descriptionRes = R.string.station_desc_zip, + streamUrl = "https://stream4.radijas.lt/zipfm", + iconResId = R.drawable.ic_station_zipfm, + genres = listOf(Genre.HITS, Genre.POP, Genre.LITHUANIAN), + bitrate = "MP3" + ), + RadioStation( + id = "powerhit", + name = "Power Hit Radio", + descriptionRes = R.string.station_desc_powerhit, + // Stable revma entry point: it redirects to a fresh node URL with a short-lived token. + streamUrl = "https://stream.rcs.revma.com/f31w7e0fveuvv", + iconResId = R.drawable.ic_station_powerhit, + genres = listOf(Genre.DANCE, Genre.POP, Genre.HITS), + bitrate = "AAC" + ), + RadioStation( + id = "powerhitgold", + name = "Power Hit Radio Gold", + descriptionRes = R.string.station_desc_powerhitgold, + streamUrl = "https://stream.rcs.revma.com/asdpz3qfbhuvv", + iconResId = R.drawable.ic_station_powerhitgold, + genres = listOf(Genre.DANCE, Genre.OLDIES, Genre.HITS), + bitrate = "AAC" + ), + RadioStation( + id = "rock", + name = "Rock FM", + descriptionRes = R.string.station_desc_rock, + streamUrl = "https://stream4.radijas.lt/rockfm", + iconResId = R.drawable.ic_station_rockfm, + genres = listOf(Genre.ROCK, Genre.LITHUANIAN), + bitrate = "MP3", + lightBackdrop = true + ), + RadioStation( + id = "relax", + name = "Relax FM", + descriptionRes = R.string.station_desc_relax, + streamUrl = "https://stream4.radijas.lt/relaxfm", + iconResId = R.drawable.ic_station_relaxfm, + genres = listOf(Genre.EASY_LISTENING), + bitrate = "MP3" + ), + RadioStation( + id = "lietus", + name = "Lietus", + descriptionRes = R.string.station_desc_lietus, + streamUrl = "https://stream.m-1.fm/lietus/aacp64", + iconResId = R.drawable.ic_station_lietus, + genres = listOf(Genre.LITHUANIAN, Genre.POP), + bitrate = "AAC 64 kbps", + lightBackdrop = true + ), + RadioStation( + id = "m1", + name = "M-1", + descriptionRes = R.string.station_desc_m1, + streamUrl = "https://stream.m-1.fm/m1/aacp64", + iconResId = R.drawable.ic_station_m1, + genres = listOf(Genre.POP, Genre.HITS), + bitrate = "AAC 64 kbps" + ), + RadioStation( + id = "plus", + name = "M-1 Plius", + descriptionRes = R.string.station_desc_plus, + streamUrl = "https://radio.m-1.fm/m1plius/aacp64", + iconResId = R.drawable.ic_station_m1plus, + genres = listOf(Genre.POP, Genre.OLDIES), + bitrate = "AAC 64 kbps", + lightBackdrop = true + ), + RadioStation( + id = "goldfm", + name = "Gold FM", + descriptionRes = R.string.station_desc_goldfm, + streamUrl = "https://stream.goldfm.lt/goldfm.aac", + iconResId = R.drawable.ic_station_goldfm, + genres = listOf(Genre.OLDIES, Genre.HITS, Genre.LITHUANIAN), + bitrate = "AAC" + ), + RadioStation( + id = "rc", + name = "RC", + descriptionRes = R.string.station_desc_rc, + streamUrl = "https://stream1.rc.lt/rc128.mp3", + iconResId = R.drawable.ic_station_rc, + genres = listOf(Genre.HITS, Genre.LITHUANIAN), + bitrate = "MP3 128 kbps", + lightBackdrop = true + ) + ) + + private val stationIds: Set = stations.mapTo(HashSet()) { it.id } + + private val _favoritesFlow = MutableStateFlow>(loadFavorites()) + val favoritesFlow: StateFlow> = _favoritesFlow.asStateFlow() + + private val _recentsFlow = MutableStateFlow>(loadRecents()) + val recentsFlow: StateFlow> = _recentsFlow.asStateFlow() + + private val _viewModeFlow = MutableStateFlow(loadViewMode()) + val viewModeFlow: StateFlow = _viewModeFlow.asStateFlow() + + private val _sizeStepFlow = MutableStateFlow(loadSizeStep()) + val sizeStepFlow: StateFlow = _sizeStepFlow.asStateFlow() + + private val _seenFavoritesFlow = MutableStateFlow(loadSeenFavorites()) + val seenFavoritesFlow: StateFlow> = _seenFavoritesFlow.asStateFlow() + + private fun loadSeenFavorites(): Set { + prefs.getStringSet("seen_favorites", null)?.let { return it.toSet() } + return loadFavorites() + } + + fun markFavoritesSeen(ids: Set) { + val copy = ids.toSet() + _seenFavoritesFlow.value = copy + prefs.edit { putStringSet("seen_favorites", copy); remove("last_seen_favorites_count") } + } + + fun getLastStationId(): String? { + return prefs.getString("last_station_id", null) + } + + fun setLastStationId(stationId: String) { + prefs.edit { putString("last_station_id", stationId) } + } + + private fun loadPlayCounts(): MutableMap { + val raw = prefs.getString("play_counts", null) ?: return mutableMapOf() + val map = mutableMapOf() + raw.split(",").forEach { entry -> + val parts = entry.split(":") + if (parts.size == 2) { + val count = parts[1].toIntOrNull() + if (count != null) map[parts[0]] = count + } + } + return map + } + + private fun savePlayCounts(counts: Map) { + val raw = counts.entries.joinToString(",") { (id, count) -> "$id:$count" } + prefs.edit { putString("play_counts", raw) } + } + + fun recordStationPlayed(stationId: String) { + val counts = loadPlayCounts() + counts[stationId] = (counts[stationId] ?: 0) + 1 + savePlayCounts(counts) + } + + fun stationsSortedByPopularity(): List { + val counts = loadPlayCounts() + if (counts.isEmpty()) return stations + return stations.sortedByDescending { counts[it.id] ?: 0 } + } + + private fun loadFavorites(): Set { + // SharedPreferences hands out its internal set, so always work on a copy. + return prefs.getStringSet("favorites", null)?.filterTo(HashSet()) { it in stationIds } ?: emptySet() + } + + fun toggleFavorite(stationId: String): Boolean { + val current = _favoritesFlow.value.toMutableSet() + val isNowFav = if (current.contains(stationId)) { + current.remove(stationId) + false + } else { + current.add(stationId) + true + } + prefs.edit { putStringSet("favorites", current) } + _favoritesFlow.value = current + return isNowFav + } + + private fun loadRecents(): List { + val raw = prefs.getString("recents", null) ?: return emptyList() + return try { + val arr = JSONArray(raw) + val list = mutableListOf() + for (i in 0 until arr.length()) { + val id = arr.getString(i) + if (id in stationIds && id !in list) list.add(id) + } + list + } catch (_: Exception) { + emptyList() + } + } + + fun pushRecent(stationId: String) { + val current = _recentsFlow.value.toMutableList() + current.remove(stationId) + current.add(0, stationId) + val trimmed = current.take(10) + _recentsFlow.value = trimmed + + val arr = JSONArray() + trimmed.forEach { arr.put(it) } + prefs.edit { putString("recents", arr.toString()) } + } + + private fun loadViewMode(): ViewMode { + val mode = prefs.getString("view_mode", ViewMode.ICONS.name) ?: ViewMode.ICONS.name + return try { + ViewMode.valueOf(mode) + } catch (_: Exception) { + ViewMode.ICONS + } + } + + fun setViewMode(mode: ViewMode) { + _viewModeFlow.value = mode + prefs.edit { putString("view_mode", mode.name) } + } + + private fun loadSizeStep(): IconSizeStep { + val step = prefs.getString("size_step", IconSizeStep.M.name) ?: IconSizeStep.M.name + return try { + IconSizeStep.valueOf(step) + } catch (_: Exception) { + IconSizeStep.M + } + } + + fun setSizeStep(step: IconSizeStep) { + _sizeStepFlow.value = step + prefs.edit { putString("size_step", step.name) } + } + + fun hasSeenBatteryOptimizationPrompt(): Boolean { + return prefs.getBoolean("seen_battery_optimization_prompt", false) + } + + fun markBatteryOptimizationPromptSeen() { + prefs.edit { putBoolean("seen_battery_optimization_prompt", true) } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/player/AudioEffectsController.kt b/app/src/main/java/cc/skysparkle/fogwave/player/AudioEffectsController.kt new file mode 100644 index 0000000..7cb74d9 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/player/AudioEffectsController.kt @@ -0,0 +1,58 @@ +package cc.skysparkle.fogwave.player + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object AudioEffectsController { + private val _state = MutableStateFlow(AudioEffectsState()) + val state: StateFlow = _state.asStateFlow() + + private var engine: AudioEffectsEngine? = null + + fun attach(engine: AudioEffectsEngine) { + this.engine = engine + refresh() + } + + fun detach(engine: AudioEffectsEngine) { + if (this.engine === engine) { + this.engine = null + _state.value = AudioEffectsState() + } + } + + fun setEqualizerEnabled(enabled: Boolean) { + engine?.setEqualizerEnabled(enabled) + refresh() + } + + fun setBandLevel(band: Int, levelMillibel: Int) { + engine?.setBandLevel(band, levelMillibel) + refresh() + } + + fun setPreset(preset: Int) { + engine?.setPreset(preset) + refresh() + } + + fun setLoudnessEnabled(enabled: Boolean) { + engine?.setLoudnessEnabled(enabled) + refresh() + } + + fun setLoudnessGain(gainMillibel: Float) { + engine?.setLoudnessGain(gainMillibel) + refresh() + } + + fun setDynamicsProcessingEnabled(enabled: Boolean) { + engine?.setDynamicsProcessingEnabled(enabled) + refresh() + } + + private fun refresh() { + engine?.let { _state.value = it.currentState() } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/player/AudioEffectsEngine.kt b/app/src/main/java/cc/skysparkle/fogwave/player/AudioEffectsEngine.kt new file mode 100644 index 0000000..fa065ff --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/player/AudioEffectsEngine.kt @@ -0,0 +1,247 @@ +package cc.skysparkle.fogwave.player + +import android.content.Context +import android.content.SharedPreferences +import android.media.audiofx.DynamicsProcessing +import android.media.audiofx.Equalizer +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.util.Log +import androidx.core.content.edit + +data class EqualizerBand( + val index: Int, + val centerFreqHz: Int, + val levelMillibel: Int, + val minLevelMillibel: Int, + val maxLevelMillibel: Int +) + +data class AudioEffectsState( + val equalizerAvailable: Boolean = false, + val equalizerEnabled: Boolean = false, + val bands: List = emptyList(), + val presets: List = emptyList(), + val currentPreset: Int = -1, + val loudnessAvailable: Boolean = false, + val loudnessEnabled: Boolean = false, + val loudnessGainMillibel: Float = 0f, + val loudnessMaxGainMillibel: Float = 2000f, + val dynamicsProcessingAvailable: Boolean = false, + val dynamicsProcessingEnabled: Boolean = false +) + +class AudioEffectsEngine(context: Context, audioSessionId: Int) { + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences("fogwave_audio_effects", Context.MODE_PRIVATE) + + // Tracked manually: Equalizer.currentPreset is unreliable after bands are moved by hand. + private var currentPresetIndex: Int = prefs.getInt(KEY_PRESET, -1) + + private var equalizer: Equalizer? = null + private var loudnessEnhancer: LoudnessEnhancer? = null + private var dynamicsProcessing: DynamicsProcessing? = null + + private val presetNames = mutableListOf() + + init { + try { + equalizer = Equalizer(0, audioSessionId).apply { + for (p in 0 until numberOfPresets) { + presetNames.add(getPresetName(p.toShort())) + } + + val preset = currentPresetIndex + if (preset in 0 until numberOfPresets) { + usePreset(preset.toShort()) + } else { + for (b in 0 until numberOfBands) { + val saved = prefs.getInt(KEY_BAND_PREFIX + b, Int.MIN_VALUE) + if (saved != Int.MIN_VALUE) setBandLevel(b.toShort(), saved.toShort()) + } + } + enabled = prefs.getBoolean(KEY_EQ_ENABLED, false) + } + } catch (e: Exception) { + Log.e(TAG, "Equalizer unavailable", e) + } + + try { + loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply { + setTargetGain(prefs.getInt(KEY_LOUDNESS_GAIN, 0)) + enabled = prefs.getBoolean(KEY_LOUDNESS_ENABLED, false) + } + } catch (e: Exception) { + Log.e(TAG, "LoudnessEnhancer unavailable", e) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + dynamicsProcessing = buildDynamicsProcessing(audioSessionId) + } catch (e: Exception) { + Log.e(TAG, "DynamicsProcessing unavailable", e) + } + } + } + + private fun buildDynamicsProcessing(audioSessionId: Int): DynamicsProcessing { + // Upper edge of each multiband-compressor band: bass / mids / highs. + val crossovers = floatArrayOf(200f, 4_000f, 20_000f) + val bandCount = crossovers.size + + val config = DynamicsProcessing.Config.Builder( + DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION, + 1, + false, 0, + true, bandCount, + false, 0, + true + ).build() + + val dp = DynamicsProcessing(0, audioSessionId, config) + dp.setInputGainAllChannelsTo(3.0f) + + // The effect expands the config to the real channel count of the session (usually stereo), + // so every channel has to be tuned, not only the first one. + for (ch in 0 until dp.channelCount) { + val mbc = dp.getMbcByChannelIndex(ch) ?: continue + mbc.isEnabled = true + for (b in 0 until bandCount) { + val band = DynamicsProcessing.MbcBand( + true, + crossovers[b], + 8f, + 60f, + 2.0f, + -24f, + 6f, + -90f, + 1f, + 0f, + 0f + ) + mbc.setBand(b, band) + } + dp.setMbcByChannelIndex(ch, mbc) + + val limiter = dp.getLimiterByChannelIndex(ch) ?: continue + limiter.isEnabled = true + limiter.attackTime = 3f + limiter.releaseTime = 60f + limiter.ratio = 10f + limiter.threshold = -2f + limiter.postGain = 0f + dp.setLimiterByChannelIndex(ch, limiter) + } + + dp.enabled = prefs.getBoolean(KEY_DYNAMICS_ENABLED, false) + return dp + } + + fun currentState(): AudioEffectsState { + val eq = equalizer + val bands = if (eq != null) { + (0 until eq.numberOfBands).map { i -> + val idx = i.toShort() + val range = eq.bandLevelRange + EqualizerBand( + index = i, + centerFreqHz = eq.getCenterFreq(idx) / 1000, + levelMillibel = eq.getBandLevel(idx).toInt(), + minLevelMillibel = range[0].toInt(), + maxLevelMillibel = range[1].toInt() + ) + } + } else emptyList() + + return AudioEffectsState( + equalizerAvailable = eq != null, + equalizerEnabled = eq?.enabled ?: false, + bands = bands, + presets = presetNames, + currentPreset = if (eq != null) currentPresetIndex else -1, + loudnessAvailable = loudnessEnhancer != null, + loudnessEnabled = loudnessEnhancer?.enabled ?: false, + loudnessGainMillibel = try { + loudnessEnhancer?.targetGain ?: 0f + } catch (_: Exception) { + 0f + }, + dynamicsProcessingAvailable = dynamicsProcessing != null, + dynamicsProcessingEnabled = dynamicsProcessing?.enabled ?: false + ) + } + + fun setEqualizerEnabled(enabled: Boolean) { + try { + equalizer?.enabled = enabled + prefs.edit { putBoolean(KEY_EQ_ENABLED, enabled) } + } catch (_: Exception) {} + } + + fun setBandLevel(band: Int, levelMillibel: Int) { + try { + equalizer?.setBandLevel(band.toShort(), levelMillibel.toShort()) + currentPresetIndex = -1 + prefs.edit { + putInt(KEY_BAND_PREFIX + band, levelMillibel) + putInt(KEY_PRESET, -1) + } + } catch (_: Exception) {} + } + + fun setPreset(preset: Int) { + try { + val eq = equalizer ?: return + eq.usePreset(preset.toShort()) + currentPresetIndex = preset + prefs.edit { + putInt(KEY_PRESET, preset) + for (b in 0 until eq.numberOfBands) { + putInt(KEY_BAND_PREFIX + b, eq.getBandLevel(b.toShort()).toInt()) + } + } + } catch (_: Exception) {} + } + + fun setLoudnessEnabled(enabled: Boolean) { + try { + loudnessEnhancer?.enabled = enabled + prefs.edit { putBoolean(KEY_LOUDNESS_ENABLED, enabled) } + } catch (_: Exception) {} + } + + fun setLoudnessGain(gainMillibel: Float) { + try { + val gain = gainMillibel.coerceIn(0f, 2000f).toInt() + loudnessEnhancer?.setTargetGain(gain) + prefs.edit { putInt(KEY_LOUDNESS_GAIN, gain) } + } catch (_: Exception) {} + } + + fun setDynamicsProcessingEnabled(enabled: Boolean) { + try { + dynamicsProcessing?.enabled = enabled + prefs.edit { putBoolean(KEY_DYNAMICS_ENABLED, enabled) } + } catch (_: Exception) {} + } + + fun release() { + try { equalizer?.release() } catch (_: Exception) {} + try { loudnessEnhancer?.release() } catch (_: Exception) {} + try { dynamicsProcessing?.release() } catch (_: Exception) {} + equalizer = null + loudnessEnhancer = null + dynamicsProcessing = null + } + + private companion object { + const val TAG = "AudioEffectsEngine" + const val KEY_EQ_ENABLED = "eq_enabled" + const val KEY_PRESET = "eq_preset" + const val KEY_BAND_PREFIX = "eq_band_" + const val KEY_LOUDNESS_ENABLED = "loudness_enabled" + const val KEY_LOUDNESS_GAIN = "loudness_gain" + const val KEY_DYNAMICS_ENABLED = "dynamics_enabled" + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/player/FogwavePlaybackService.kt b/app/src/main/java/cc/skysparkle/fogwave/player/FogwavePlaybackService.kt new file mode 100644 index 0000000..c3d597a --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/player/FogwavePlaybackService.kt @@ -0,0 +1,354 @@ +package cc.skysparkle.fogwave.player + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.os.SystemClock +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.LibraryParams +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import cc.skysparkle.fogwave.BuildConfig +import cc.skysparkle.fogwave.MainActivity +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.repository.RadioRepository +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(UnstableApi::class) +class FogwavePlaybackService : MediaLibraryService() { + private var mediaLibrarySession: MediaLibrarySession? = null + private lateinit var repository: RadioRepository + private lateinit var player: ExoPlayer + private var audioEffectsEngine: AudioEffectsEngine? = null + + private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + + private val retryDelaysMs = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000, 30_000) + private var retryAttempt = 0 + private var retryJob: Job? = null + + // Elapsed-realtime timestamp of the last pause, used to jump back to the live edge on resume. + private var pausedAtMs = 0L + + private var lastRequestedItem: MediaItem? = null + + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + private val httpDataSourceFactory: DefaultHttpDataSource.Factory by lazy { + DefaultHttpDataSource.Factory() + .setUserAgent("FogwaveRadio/${BuildConfig.VERSION_NAME} (Android; Icecast/Shoutcast Client)") + .setDefaultRequestProperties(mapOf("Icy-MetaData" to "1")) + .setConnectTimeoutMs(10_000) + .setReadTimeoutMs(10_000) + .setAllowCrossProtocolRedirects(true) + } + + private val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs(5_000, 15_000, 500, 1_000) + .setPrioritizeTimeOverSizeThresholds(true) + .build() + + private val librarySessionCallback = object : MediaLibrarySession.Callback { + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> { + val rootItem = MediaItem.Builder() + .setMediaId(ROOT_ID) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(R.string.app_name)) + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build() + ) + .build() + return Futures.immediateFuture(LibraryResult.ofItem(rootItem, params)) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + if (parentId != ROOT_ID) { + return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)) + } + val items = repository.stations.map { it.toMediaItem(this@FogwavePlaybackService) } + return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.copyOf(items), params)) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val station = repository.stations.find { it.id == mediaId } + ?: return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)) + return Futures.immediateFuture(LibraryResult.ofItem(station.toMediaItem(this@FogwavePlaybackService), null)) + } + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList + ): ListenableFuture> { + val resolved = mediaItems.map { item -> + if (item.localConfiguration != null) { + item + } else { + repository.stations.find { it.id == item.mediaId } + ?.toMediaItem(this@FogwavePlaybackService) + ?: item + } + }.toMutableList() + + resolved.firstOrNull()?.let { item -> + Log.d(TAG, "MediaItem resolved: mediaId=${item.mediaId} uri=${item.localConfiguration?.uri}") + lastRequestedItem = item + retryJob?.cancel() + retryAttempt = 0 + } + + return Futures.immediateFuture(resolved) + } + } + + private val resiliencePlayerListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + val stateName = when (playbackState) { + Player.STATE_IDLE -> "IDLE" + Player.STATE_BUFFERING -> "BUFFERING" + Player.STATE_READY -> "READY" + Player.STATE_ENDED -> "ENDED" + else -> "UNKNOWN($playbackState)" + } + Log.d(TAG, "Player state changed: $stateName (playWhenReady=${player.playWhenReady})") + + // A live stream never really ends: STATE_ENDED means the server dropped the connection. + if (playbackState == Player.STATE_ENDED && player.playWhenReady) { + scheduleReconnect() + } + + if (playbackState == Player.STATE_READY && player.playWhenReady) { + if (retryAttempt != 0) Log.d(TAG, "Reconnect succeeded - resetting backoff") + retryAttempt = 0 + retryJob?.cancel() + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(TAG, if (isPlaying) "Playing" else "Not playing (paused/buffering/stopped)") + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (!playWhenReady) { + if (retryJob?.isActive == true) { + Log.d(TAG, "Reconnect cancelled - playWhenReady=false (reason=$reason)") + } + retryJob?.cancel() + pausedAtMs = SystemClock.elapsedRealtime() + return + } + val pausedFor = SystemClock.elapsedRealtime() - pausedAtMs + val resumingOldBuffer = pausedAtMs != 0L && pausedFor > LIVE_RESYNC_AFTER_MS && + player.playbackState == Player.STATE_READY + pausedAtMs = 0L + if (resumingOldBuffer) { + // After a long pause the buffer is stale and the server has usually dropped the + // connection, so reopen the stream at the live edge instead of replaying old audio. + Log.d(TAG, "Resumed after ${pausedFor}ms - reloading live stream") + // Posted (not immediate) so the player is not re-entered from its own callback. + serviceScope.launch(Dispatchers.Main) { restartStream() } + } + } + + override fun onPlayerError(error: PlaybackException) { + Log.e( + TAG, + "Playback error: errorCode=${error.errorCode} errorCodeName=${error.errorCodeName} " + + "message=${error.message} cause=${error.cause} " + + "mediaItem=${lastRequestedItem?.localConfiguration?.uri}", + error + ) + scheduleReconnect() + } + } + + private fun scheduleReconnect(immediate: Boolean = false) { + retryJob?.cancel() + if (!immediate && retryAttempt >= MAX_RETRY_ATTEMPTS) { + // Give up instead of keeping the device busy forever; the error stays visible and a tap + // on play starts over. + Log.w(TAG, "Reconnect abandoned after $retryAttempt attempts") + retryAttempt = 0 + player.pause() + return + } + val delayMs = if (immediate) 0L else retryDelaysMs.getOrElse(retryAttempt) { retryDelaysMs.last() } + retryAttempt++ + Log.d(TAG, "Reconnect scheduled in ${delayMs}ms (attempt=$retryAttempt)") + retryJob = serviceScope.launch { + delay(delayMs) + if (!player.playWhenReady) { + Log.d(TAG, "Reconnect skipped - no longer playWhenReady") + return@launch + } + restartStream() + } + } + + private fun restartStream() { + val item = player.currentMediaItem ?: lastRequestedItem + if (item == null) { + Log.d(TAG, "Restart skipped - nothing to play") + return + } + Log.d(TAG, "Restarting stream: uri=${item.localConfiguration?.uri}") + try { + player.stop() + player.setMediaItem(item) + player.prepare() + player.play() + } catch (e: Exception) { + Log.e(TAG, "Restart failed", e) + } + } + + private fun registerNetworkCallback() { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return + connectivityManager = cm + val callback = object : ConnectivityManager.NetworkCallback() { + // Called on a ConnectivityManager thread; ExoPlayer must only be accessed on the main thread. + override fun onAvailable(network: Network) { + serviceScope.launch { + if (retryJob?.isActive == true && player.playWhenReady && player.playbackState != Player.STATE_READY) { + Log.d(TAG, "Network available again - reconnecting immediately") + scheduleReconnect(immediate = true) + } + } + } + } + networkCallback = callback + try { + cm.registerDefaultNetworkCallback(callback) + } catch (_: Exception) { + } + } + + override fun onCreate() { + super.onCreate() + repository = RadioRepository(applicationContext) + + val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager + val audioSessionId = audioManager?.generateAudioSessionId()?.takeIf { it != 0 } + ?: C.AUDIO_SESSION_ID_UNSET + + player = ExoPlayer.Builder(this) + .setMediaSourceFactory(DefaultMediaSourceFactory(this).setDataSourceFactory(httpDataSourceFactory)) + .setLoadControl(loadControl) + .build() + .apply { + if (audioSessionId != C.AUDIO_SESSION_ID_UNSET) { + setAudioSessionId(audioSessionId) + } + + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build(), + true + ) + setHandleAudioBecomingNoisy(true) + + // Keeps CPU and Wi-Fi awake while streaming; without it playback stops with the screen off. + setWakeMode(C.WAKE_MODE_NETWORK) + addListener(resiliencePlayerListener) + } + + Log.d(TAG, "Audio session id for effects: ${player.audioSessionId}") + audioEffectsEngine = AudioEffectsEngine(applicationContext, player.audioSessionId).also { + AudioEffectsController.attach(it) + } + + registerNetworkCallback() + + // Tapping the media notification brings the app back to the front. + val sessionActivity = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) + .setSessionActivity(sessionActivity) + .build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = mediaLibrarySession + + override fun onTaskRemoved(rootIntent: Intent?) { + if (!player.playWhenReady || player.mediaItemCount == 0) { + player.pause() + stopSelf() + } + } + + override fun onDestroy() { + retryJob?.cancel() + serviceScope.cancel() + networkCallback?.let { cb -> + try { connectivityManager?.unregisterNetworkCallback(cb) } catch (_: Exception) {} + } + audioEffectsEngine?.let { + AudioEffectsController.detach(it) + it.release() + } + player.removeListener(resiliencePlayerListener) + player.release() + mediaLibrarySession?.release() + mediaLibrarySession = null + super.onDestroy() + } + + companion object { + private const val ROOT_ID = "fogwave_root" + private const val TAG = "FogwavePlaybackService" + private const val MAX_RETRY_ATTEMPTS = 12 + private const val LIVE_RESYNC_AFTER_MS = 30_000L + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/player/RadioPlayerManager.kt b/app/src/main/java/cc/skysparkle/fogwave/player/RadioPlayerManager.kt new file mode 100644 index 0000000..ceff0d7 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/player/RadioPlayerManager.kt @@ -0,0 +1,166 @@ +package cc.skysparkle.fogwave.player + +import android.content.ComponentName +import android.content.Context +import android.util.Log +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.RadioStation +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class PlayerState { + data object Idle : PlayerState() + data object Connecting : PlayerState() + data object Playing : PlayerState() + data object Paused : PlayerState() + data class Error(val message: String) : PlayerState() +} + +class RadioPlayerManager(private val context: Context) { + private val _playerState = MutableStateFlow(PlayerState.Idle) + val playerState: StateFlow = _playerState.asStateFlow() + + private val _currentMediaId = MutableStateFlow(null) + val currentMediaId: StateFlow = _currentMediaId.asStateFlow() + + private var currentStation: RadioStation? = null + private var pendingStation: RadioStation? = null + + private var controllerFuture: ListenableFuture? = null + private var controller: MediaController? = null + private var released = false + + private val playerListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + _playerState.value = deriveState(player) + _currentMediaId.value = player.currentMediaItem?.mediaId + } + + override fun onPlayerError(error: PlaybackException) { + Log.e(TAG, "Playback error: ${error.errorCodeName} (${error.errorCode}) ${error.message}", error) + } + } + + // Declared after every property it touches so that nothing is read before initialization. + init { + connectToPlaybackService() + } + + private fun connectToPlaybackService() { + if (released || controllerFuture != null) return + val sessionToken = SessionToken(context, ComponentName(context, FogwavePlaybackService::class.java)) + val future = MediaController.Builder(context, sessionToken).buildAsync() + controllerFuture = future + future.addListener({ + if (released) return@addListener + try { + val c = future.get() + controller = c + c.addListener(playerListener) + Log.d(TAG, "MediaController connected") + val station = pendingStation + pendingStation = null + if (station != null) { + playStation(station) + } else { + _playerState.value = deriveState(c) + _currentMediaId.value = c.currentMediaItem?.mediaId + } + } catch (e: Exception) { + Log.e(TAG, "Failed to connect to FogwavePlaybackService", e) + // Allow the next play request to try again. + controllerFuture = null + pendingStation = null + _playerState.value = PlayerState.Error(context.getString(R.string.stream_unavailable)) + } + }, MoreExecutors.directExecutor()) + } + + private fun deriveState(p: Player): PlayerState { + p.playerError?.let { return PlayerState.Error(errorMessage(it)) } + return when (p.playbackState) { + Player.STATE_READY -> if (p.playWhenReady) PlayerState.Playing else PlayerState.Paused + Player.STATE_BUFFERING, Player.STATE_ENDED -> + if (p.playWhenReady) PlayerState.Connecting else PlayerState.Paused + else -> when { + p.mediaItemCount == 0 -> PlayerState.Idle + p.playWhenReady -> PlayerState.Connecting + else -> PlayerState.Paused + } + } + } + + private fun errorMessage(error: PlaybackException): String = context.getString( + when (error.errorCode) { + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> R.string.network_unavailable + else -> R.string.stream_unavailable + } + ) + + fun playStation(station: RadioStation) { + Log.d(TAG, "Station requested: ${station.id}") + currentStation = station + val c = controller + if (c == null) { + pendingStation = station + _playerState.value = PlayerState.Connecting + connectToPlaybackService() + return + } + _playerState.value = PlayerState.Connecting + try { + c.setMediaItem(station.toMediaItem(context)) + c.prepare() + c.play() + } catch (e: Exception) { + Log.e(TAG, "Failed to start ${station.streamUrl}", e) + _playerState.value = PlayerState.Error(context.getString(R.string.stream_unavailable)) + } + } + + fun play() { + val c = controller + when { + c == null || c.mediaItemCount == 0 -> currentStation?.let { playStation(it) } + // A failed, stopped or finished live stream has to be reopened rather than resumed. + c.playerError != null || c.playbackState == Player.STATE_IDLE || c.playbackState == Player.STATE_ENDED -> { + c.seekToDefaultPosition() + c.prepare() + c.play() + } + else -> c.play() + } + } + + fun pause() { + controller?.pause() + } + + fun togglePlay() { + when (_playerState.value) { + is PlayerState.Playing, is PlayerState.Connecting -> pause() + is PlayerState.Paused, is PlayerState.Idle, is PlayerState.Error -> play() + } + } + + fun release() { + released = true + pendingStation = null + controller?.removeListener(playerListener) + controller = null + controllerFuture?.let { MediaController.releaseFuture(it) } + controllerFuture = null + } + + private companion object { + const val TAG = "RadioPlayerManager" + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/player/StationMediaItems.kt b/app/src/main/java/cc/skysparkle/fogwave/player/StationMediaItems.kt new file mode 100644 index 0000000..7fd1278 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/player/StationMediaItems.kt @@ -0,0 +1,27 @@ +package cc.skysparkle.fogwave.player + +import android.content.ContentResolver +import android.content.Context +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import cc.skysparkle.fogwave.data.model.RadioStation + +fun RadioStation.toMediaItem(context: Context): MediaItem { + val artworkUri = "${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/$iconResId".toUri() + return MediaItem.Builder() + .setUri(streamUrl) + .setMediaId(id) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(name) + .setArtist(context.getString(descriptionRes)) + .setStation(name) + .setArtworkUri(artworkUri) + .setIsBrowsable(false) + .setIsPlayable(true) + .setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION) + .build() + ) + .build() +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/AboutPanel.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/AboutPanel.kt new file mode 100644 index 0000000..09bfdfc --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/AboutPanel.kt @@ -0,0 +1,226 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.BuildConfig +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +private const val REPOSITORY_URL = "https://git.skysparkle.cc/kalarina/Fogwave" +private const val PRIVACY_POLICY_URL = "https://skysparkle.cc/privacy?lang=en" + +@Composable +fun AboutPanel(modifier: Modifier = Modifier) { + val context = LocalContext.current + var showLicenses by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(24.dp)) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_fogwave_logo, + contentDescription = stringResource(R.string.fogwave_logo_content_desc), + size = 52.dp + ) + } + + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.ExtraBold, + fontSize = 22.sp, + letterSpacing = (-0.5).sp, + color = LocalGeoColors.current.deepPurple + ) + ) + + // Comes straight from versionName in app/build.gradle.kts, so it can never drift. + Text( + text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + color = LocalGeoColors.current.primary + ), + modifier = Modifier.testTag("about_version") + ) + + Text( + text = stringResource(R.string.about_description), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.5.sp, + lineHeight = 20.sp, + color = LocalGeoColors.current.onSurfaceVariant, + textAlign = TextAlign.Center + ), + modifier = Modifier.padding(top = 4.dp) + ) + } + + AboutLinkCard( + iconRes = R.drawable.ic_repo, + iconTint = null, + title = stringResource(R.string.about_my_repository), + subtitle = REPOSITORY_URL.removePrefix("https://git.skysparkle.cc/"), + trailingIconRes = R.drawable.ic_open_in_new, + onClick = { context.openUrl(REPOSITORY_URL) }, + modifier = Modifier.testTag("github_repo_card") + ) + + AboutLinkCard( + iconRes = R.drawable.ic_copyright, + title = stringResource(R.string.licenses_title), + subtitle = stringResource(R.string.licenses_subtitle), + trailingIconRes = R.drawable.ic_chevron_right, + onClick = { showLicenses = true }, + modifier = Modifier.testTag("licenses_card") + ) + + AboutLinkCard( + iconRes = R.drawable.ic_privacy, + title = stringResource(R.string.privacy_title), + subtitle = stringResource(R.string.privacy_subtitle), + trailingIconRes = R.drawable.ic_open_in_new, + onClick = { context.openUrl(PRIVACY_POLICY_URL) }, + modifier = Modifier.testTag("privacy_card") + ) + } + + if (showLicenses) { + LicensesDialog(onDismiss = { showLicenses = false }) + } +} + +@Composable +private fun AboutLinkCard( + @DrawableRes iconRes: Int, + title: String, + subtitle: String, + @DrawableRes trailingIconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + iconTint: Color? = LocalGeoColors.current.deepPurple +) { + val opensBrowser = trailingIconRes == R.drawable.ic_open_in_new + Row( + modifier = modifier + .fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.2.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(20.dp)) + .clickable( + onClickLabel = if (opensBrowser) stringResource(R.string.about_open_in_browser) else null, + role = Role.Button, + onClick = onClick + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(14.dp)) + .background(LocalGeoColors.current.primaryContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = iconRes, + tint = iconTint ?: Color.Unspecified, + size = if (iconTint == null) 32.dp else 26.dp + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = LocalGeoColors.current.deepPurple + ) + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + color = LocalGeoColors.current.primary + ) + ) + } + + if (opensBrowser) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.primaryContainer), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = trailingIconRes, + tint = LocalGeoColors.current.deepPurple, + size = 18.dp + ) + } + } else { + AppIcon( + resId = trailingIconRes, + tint = LocalGeoColors.current.onSurfaceVariant, + size = 18.dp + ) + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/AppIcon.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/AppIcon.kt new file mode 100644 index 0000000..d76ee2a --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/AppIcon.kt @@ -0,0 +1,29 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun AppIcon( + resId: Int, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, + size: Dp = 24.dp, + contentDescription: String? = null +) { + Image( + painter = painterResource(id = resId), + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint), + modifier = modifier.size(size) + ) +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/BatteryOptimizationDialog.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/BatteryOptimizationDialog.kt new file mode 100644 index 0000000..8f5cb0f --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/BatteryOptimizationDialog.kt @@ -0,0 +1,39 @@ +package cc.skysparkle.fogwave.ui.components + +import android.content.Intent +import android.provider.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import cc.skysparkle.fogwave.R + +@Composable +// Opens the general battery-optimization list instead of ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: +// that action requires a permission Google Play rejects for ordinary media apps. +fun BatteryOptimizationDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.battery_opt_title)) }, + text = { Text(stringResource(R.string.battery_opt_message)) }, + confirmButton = { + TextButton(onClick = { + try { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (_: Exception) { + } + onDismiss() + }) { + Text(stringResource(R.string.battery_opt_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.battery_opt_dismiss)) + } + } + ) +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/EqualizerSheet.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/EqualizerSheet.kt new file mode 100644 index 0000000..c9fb381 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/EqualizerSheet.kt @@ -0,0 +1,383 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.player.AudioEffectsState +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors +import java.util.Locale +import kotlin.math.roundToInt + +@Composable +fun EqualizerSheet( + audioEffects: AudioEffectsState, + onClose: () -> Unit, + onSetEqualizerEnabled: (Boolean) -> Unit, + onSetEqualizerBand: (Int, Int) -> Unit, + onSetPreset: (Int) -> Unit, + onResetBands: () -> Unit, + onSetLoudnessEnabled: (Boolean) -> Unit, + onSetDynamicsProcessingEnabled: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(LocalGeoColors.current.surfaceContainer) + // Full-screen overlay: keep taps on empty areas from reaching the player underneath. + .pointerInput(Unit) {} + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClose, modifier = Modifier.testTag("eq_back_btn")) { + AppIcon( + resId = R.drawable.ic_chevron_left, + contentDescription = stringResource(R.string.back_content_desc), + tint = LocalGeoColors.current.onSurface + ) + } + Text( + text = stringResource(R.string.equalizer_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.deepPurple + ), + modifier = Modifier.weight(1f) + ) + Text( + text = if (audioEffects.equalizerEnabled) stringResource(R.string.equalizer_on) else stringResource(R.string.equalizer_off), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = if (audioEffects.equalizerEnabled) LocalGeoColors.current.liveGreen else LocalGeoColors.current.onSurfaceVariant + ), + modifier = Modifier.padding(end = 6.dp) + ) + Switch( + checked = audioEffects.equalizerEnabled, + onCheckedChange = onSetEqualizerEnabled, + colors = SwitchDefaults.colors(checkedTrackColor = LocalGeoColors.current.primary), + modifier = Modifier.testTag("eq_enable_switch") + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(LocalGeoColors.current.outlineVariant) + ) + Text( + text = stringResource(R.string.equalizer_sound_section), + style = MaterialTheme.typography.labelMedium.copy( + color = LocalGeoColors.current.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + ) + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(LocalGeoColors.current.outlineVariant) + ) + } + + if (audioEffects.bands.isNotEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + .alpha(if (audioEffects.equalizerEnabled) 1f else 0.45f), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(LocalGeoColors.current.outlineVariant) + ) + + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + audioEffects.bands.forEach { band -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxHeight() + ) { + val db = (band.levelMillibel / 100f).roundToInt() + Text( + text = if (db > 0) "+$db dB" else "$db dB", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = if (db == 0) LocalGeoColors.current.onSurfaceVariant + else LocalGeoColors.current.primary + ) + ) + Box( + modifier = Modifier + .weight(1f) + .width(40.dp), + contentAlignment = Alignment.Center + ) { + Slider( + value = band.levelMillibel.toFloat(), + onValueChange = { onSetEqualizerBand(band.index, it.roundToInt()) }, + valueRange = band.minLevelMillibel.toFloat()..band.maxLevelMillibel.toFloat(), + enabled = audioEffects.equalizerEnabled, + colors = SliderDefaults.colors( + thumbColor = LocalGeoColors.current.primary, + activeTrackColor = LocalGeoColors.current.primary, + inactiveTrackColor = LocalGeoColors.current.outlineVariant + ), + modifier = Modifier + // requiredWidth (not width): the parent is narrower, and width() would be clamped before rotation. + .requiredWidth(160.dp) + .graphicsLayer { + rotationZ = 270f + transformOrigin = TransformOrigin(0.5f, 0.5f) + } + ) + } + Text( + text = frequencyLabel(band.centerFreqHz), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + color = LocalGeoColors.current.onSurfaceVariant + ) + ) + } + } + } + } + } else { + Row( + modifier = Modifier.padding(vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppIcon( + resId = R.drawable.ic_alert, + tint = LocalGeoColors.current.onSurfaceVariant, + size = 18.dp + ) + Text( + text = stringResource(R.string.equalizer_unavailable), + style = MaterialTheme.typography.bodyMedium.copy(color = LocalGeoColors.current.onSurfaceVariant) + ) + } + } + + if (audioEffects.presets.isNotEmpty()) { + val currentPresetName = audioEffects.presets.getOrNull(audioEffects.currentPreset) + ?.let { presetLabel(it) } + ?: stringResource(R.string.equalizer_custom_preset) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppIcon( + resId = R.drawable.ic_sparkle, + contentDescription = null, + tint = LocalGeoColors.current.primary, + size = 18.dp + ) + Text( + text = currentPresetName, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + color = LocalGeoColors.current.onSurface + ) + ) + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(audioEffects.presets) { index, name -> + val selected = audioEffects.currentPreset == index + Text( + text = presetLabel(name), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = if (selected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant + ), + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (selected) LocalGeoColors.current.primaryContainer else LocalGeoColors.current.surfaceContainerHigh) + .clickable { onSetPreset(index) } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + } + + if (audioEffects.loudnessAvailable) { + ToggleRow( + label = stringResource(R.string.loudness_row_label), + checked = audioEffects.loudnessEnabled, + onCheckedChange = onSetLoudnessEnabled + ) + } + if (audioEffects.dynamicsProcessingAvailable) { + ToggleRow( + label = stringResource(R.string.dynamics_row_label), + checked = audioEffects.dynamicsProcessingEnabled, + onCheckedChange = onSetDynamicsProcessingEnabled + ) + } + + if (audioEffects.bands.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedButton( + onClick = onResetBands, + modifier = Modifier.testTag("eq_reset_btn") + ) { + AppIcon( + resId = R.drawable.ic_refresh, + contentDescription = null, + tint = LocalGeoColors.current.primary, + size = 18.dp + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.equalizer_reset)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + // The whole row is the switch, so the label is a bigger touch target and is read with it. + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(LocalGeoColors.current.surfaceContainerHigh) + .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + color = LocalGeoColors.current.onSurface + ), + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = null, + colors = SwitchDefaults.colors(checkedTrackColor = LocalGeoColors.current.primary) + ) + } +} + +private fun frequencyLabel(hz: Int): String = when { + hz < 1000 -> "$hz Hz" + hz % 1000 == 0 -> "${hz / 1000} kHz" + else -> String.format(Locale.getDefault(), "%.1f kHz", hz / 1000f) +} + +// Preset names come from the device's audio framework in English; translate the standard ones. +@Composable +@ReadOnlyComposable +private fun presetLabel(name: String): String { + val res = when (name.trim().lowercase(Locale.ROOT)) { + "normal" -> R.string.eq_preset_normal + "classical" -> R.string.eq_preset_classical + "dance" -> R.string.eq_preset_dance + "flat" -> R.string.eq_preset_flat + "folk" -> R.string.eq_preset_folk + "heavy metal" -> R.string.eq_preset_heavy_metal + "hip hop" -> R.string.eq_preset_hip_hop + "jazz" -> R.string.eq_preset_jazz + "pop" -> R.string.eq_preset_pop + "rock" -> R.string.eq_preset_rock + else -> return name + } + return stringResource(res) +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/FavoritesPanel.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/FavoritesPanel.kt new file mode 100644 index 0000000..295ba23 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/FavoritesPanel.kt @@ -0,0 +1,223 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.IconSizeStep +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.data.model.ViewMode +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +@Composable +fun FavoritesPanel( + favorites: List, + currentStation: RadioStation?, + isPlaying: Boolean, + viewMode: ViewMode, + sizeStep: IconSizeStep, + onSelectStation: (RadioStation) -> Unit, + onBrowseStations: () -> Unit, + modifier: Modifier = Modifier, + bottomContentPadding: Dp = 140.dp +) { + if (favorites.isEmpty()) { + EmptyFavorites( + onBrowseStations = onBrowseStations, + bottomContentPadding = bottomContentPadding, + modifier = modifier + ) + return + } + + StationCollection( + stations = favorites, + currentStation = currentStation, + isPlaying = isPlaying, + viewMode = viewMode, + sizeStep = sizeStep, + onSelectStation = onSelectStation, + bottomContentPadding = bottomContentPadding, + modifier = modifier + ) +} + +/** The station grid (Icons mode) or list (other modes), shared by the Radio and Favorites tabs. */ +@Composable +fun StationCollection( + stations: List, + currentStation: RadioStation?, + isPlaying: Boolean, + viewMode: ViewMode, + sizeStep: IconSizeStep, + onSelectStation: (RadioStation) -> Unit, + bottomContentPadding: Dp, + modifier: Modifier = Modifier +) { + val contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = bottomContentPadding) + when (viewMode) { + ViewMode.ICONS -> LazyVerticalGrid( + columns = GridCells.Adaptive((sizeStep.sizeDp + 16).dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = contentPadding, + modifier = modifier + ) { + items(stations, key = { it.id }) { station -> + val isCur = station.id == currentStation?.id + StationItem( + station = station, + isCurrent = isCur, + isPlaying = isCur && isPlaying, + viewMode = viewMode, + sizeStep = sizeStep, + onClick = { onSelectStation(station) } + ) + } + } + + ViewMode.TILES, ViewMode.LIST, ViewMode.DETAILS -> LazyColumn( + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + items(stations, key = { it.id }) { station -> + val isCur = station.id == currentStation?.id + StationItem( + station = station, + isCurrent = isCur, + isPlaying = isCur && isPlaying, + viewMode = viewMode, + sizeStep = sizeStep, + onClick = { onSelectStation(station) } + ) + } + } + } +} + +@Composable +private fun EmptyFavorites( + onBrowseStations: () -> Unit, + bottomContentPadding: Dp, + modifier: Modifier = Modifier +) { + // Scrollable and at least as tall as the screen, so the card stays centered on phones and is + // still fully reachable in landscape. + BoxWithConstraints(modifier = modifier) { + Box( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .fillMaxWidth() + .padding(top = 8.dp, bottom = bottomContentPadding), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .shadow(4.dp, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(24.dp)) + .padding(24.dp) + .testTag("empty_favorites_card"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.redFavorite.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_favorite, + tint = LocalGeoColors.current.redFavorite, + size = 28.dp + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = stringResource(R.string.favorites_empty_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.5.sp, + color = LocalGeoColors.current.deepPurple, + textAlign = TextAlign.Center + ) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.favorites_empty_subtitle), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 18.sp, + color = LocalGeoColors.current.onSurfaceVariant, + textAlign = TextAlign.Center + ) + ) + + Spacer(modifier = Modifier.height(18.dp)) + + Button( + onClick = onBrowseStations, + colors = ButtonDefaults.buttonColors( + containerColor = LocalGeoColors.current.primaryContainer, + contentColor = LocalGeoColors.current.onPrimaryContainer + ) + ) { + AppIcon( + resId = R.drawable.ic_radio, + tint = LocalGeoColors.current.onPrimaryContainer, + size = 18.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.browse_stations)) + } + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/LicensesDialog.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/LicensesDialog.kt new file mode 100644 index 0000000..c3a8548 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/LicensesDialog.kt @@ -0,0 +1,176 @@ +package cc.skysparkle.fogwave.ui.components + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import cc.skysparkle.fogwave.BuildConfig +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +private data class LicenseRow( + val name: String, + val attribution: String, + val license: String, + val url: String, + val note: String? = null +) + +private const val APACHE = "Apache License 2.0" +private const val AOSP = "The Android Open Source Project" +private const val BROADCASTER = "\u00A9 broadcaster" + +// Versions come from gradle/libs.versions.toml through BuildConfig, so this list cannot go stale. +private val LIBRARIES = listOf( + LicenseRow("Jetpack Compose (UI, Foundation, Material 3)", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/compose", "BOM ${BuildConfig.COMPOSE_BOM_VERSION}"), + LicenseRow("AndroidX Activity Compose", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/activity", BuildConfig.ACTIVITY_VERSION), + LicenseRow("AndroidX Core KTX", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/core", BuildConfig.CORE_KTX_VERSION), + LicenseRow("AndroidX Lifecycle (ViewModel + Runtime Compose)", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/lifecycle", BuildConfig.LIFECYCLE_VERSION), + LicenseRow("AndroidX Media3 (ExoPlayer, MediaSession)", AOSP, APACHE, "https://developer.android.com/jetpack/androidx/releases/media3", BuildConfig.MEDIA3_VERSION), + LicenseRow("AndroidX \u2014 other libraries pulled in by the ones above", AOSP, APACHE, "https://developer.android.com/jetpack/androidx"), + LicenseRow("Guava (used by Media3)", "Google", APACHE, "https://github.com/google/guava"), + LicenseRow("kotlinx.coroutines", "JetBrains", APACHE, "https://github.com/Kotlin/kotlinx.coroutines", BuildConfig.COROUTINES_VERSION), + LicenseRow("Kotlin Standard Library", "JetBrains", APACHE, "https://github.com/JetBrains/kotlin", BuildConfig.KOTLIN_VERSION), + LicenseRow("Coil", "Coil Contributors", APACHE, "https://github.com/coil-kt/coil", BuildConfig.COIL_VERSION), + LicenseRow("OkHttp & Okio (used by Coil)", "Square, Inc.", APACHE, "https://square.github.io/okhttp/") +) + +private val ASSETS = listOf( + LicenseRow("MingCute Icon (interface icons)", "MingCute Design, via IconBuddy", APACHE, "https://iconbuddy.com/mingcute"), + LicenseRow("Misty mountain background photo", "Federico Bottos, on Unsplash", "Unsplash License", "https://unsplash.com/es/fotos/arboles-verdes-en-la-montana-bajo-nubes-blancas-durante-el-dia-obQacWYxB1I") +) + +private val STATIONS = listOf( + LicenseRow("ZIP FM", "Radiocentras group", BROADCASTER, "https://www.zipfm.lt/"), + LicenseRow("Power Hit Radio", "TV3 Group", BROADCASTER, "https://powerhitradio.tv3.lt/"), + LicenseRow("Power Hit Radio Gold", "TV3 Group", BROADCASTER, "https://powerhitradio.tv3.lt/"), + LicenseRow("Rock FM", "Radiocentras group", BROADCASTER, "https://www.radijas.lt/rockfm"), + LicenseRow("Relax FM", "Radiocentras group", BROADCASTER, "https://www.radijas.lt/relaxfm"), + LicenseRow("Lietus", "M-1 group", BROADCASTER, "https://www.m-1.fm/"), + LicenseRow("M-1", "M-1 group", BROADCASTER, "https://www.m-1.fm/"), + LicenseRow("M-1 Plius", "M-1 group", BROADCASTER, "https://www.m-1.fm/"), + LicenseRow("Gold FM", "Gold FM", BROADCASTER, "https://www.goldfm.lt/"), + LicenseRow("RC", "Radiocentras group", BROADCASTER, "https://www.rc.lt/") +) + +@Composable +fun LicensesDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .padding(20.dp) + ) { + Text( + text = stringResource(R.string.licenses_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.deepPurple + ) + ) + Text( + text = stringResource(R.string.licenses_intro), + style = MaterialTheme.typography.bodySmall.copy(color = LocalGeoColors.current.onSurfaceVariant), + modifier = Modifier.padding(top = 4.dp, bottom = 12.dp) + ) + Column( + modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + LicenseGroup(stringResource(R.string.licenses_group_libraries), LIBRARIES, context) + LicenseGroup(stringResource(R.string.licenses_group_assets), ASSETS, context) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + LicenseGroup(stringResource(R.string.licenses_group_stations), STATIONS, context) + Text( + text = stringResource(R.string.licenses_stations_hint), + style = MaterialTheme.typography.labelSmall.copy(color = LocalGeoColors.current.onSurfaceVariant), + modifier = Modifier.padding(top = 4.dp) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalArrangement = Arrangement.End + ) { + Text( + text = stringResource(R.string.close_content_desc), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + color = LocalGeoColors.current.deepPurple + ), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable(role = Role.Button, onClick = onDismiss) + .padding(horizontal = 14.dp, vertical = 8.dp) + ) + } + } + } +} + +@Composable +private fun LicenseGroup(title: String, rows: List, context: Context) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.primary + ) + ) + rows.forEach { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(role = Role.Button) { context.openUrl(row.url) } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.name, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium) + ) + val meta = listOfNotNull(row.attribution, row.note).joinToString(" \u00B7 ") + Text( + text = meta, + style = MaterialTheme.typography.labelSmall.copy(color = LocalGeoColors.current.onSurfaceVariant) + ) + Text( + text = row.license, + style = MaterialTheme.typography.labelSmall.copy(color = LocalGeoColors.current.primary) + ) + } + AppIcon( + resId = R.drawable.ic_open_in_new, + tint = LocalGeoColors.current.onSurfaceVariant, + size = 14.dp + ) + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/LiveBars.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/LiveBars.kt new file mode 100644 index 0000000..2813b6e --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/LiveBars.kt @@ -0,0 +1,62 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +private val BarDurations = intArrayOf(520, 740, 610, 830, 570) + +private val IdleLevels = floatArrayOf(0.3f, 0.45f, 0.35f, 0.5f, 0.3f) + +@Composable +fun LiveBars(active: Boolean, color: Color, modifier: Modifier = Modifier) { + if (active) { + val transition = rememberInfiniteTransition(label = "live_bars") + val levels: List> = BarDurations.mapIndexed { i, d -> + transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(d, delayMillis = i * 60, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "bar_$i" + ) + } + Bars(modifier, color) { i -> levels[i].value } + } else { + Bars(modifier, color.copy(alpha = 0.45f)) { i -> IdleLevels[i] } + } +} + +@Composable +private fun Bars(modifier: Modifier, color: Color, level: (Int) -> Float) { + Canvas(modifier = modifier.width(46.dp).height(22.dp)) { + val count = BarDurations.size + val gap = size.width * 0.08f + val barW = (size.width - gap * (count - 1)) / count + for (i in 0 until count) { + val h = size.height * level(i) + drawRoundRect( + color = color, + topLeft = Offset(i * (barW + gap), size.height - h), + size = Size(barW, h), + cornerRadius = CornerRadius(barW / 2f) + ) + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/MiniPlayer.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/MiniPlayer.kt new file mode 100644 index 0000000..b2089fa --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/MiniPlayer.kt @@ -0,0 +1,260 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.player.PlayerState +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors +import kotlin.math.roundToInt + +private val TABLET_BREAKPOINT_DP = 600.dp +private val TABLET_MINI_PLAYER_MAX_WIDTH_DP = 640.dp + +@Composable +fun MiniPlayer( + station: RadioStation?, + playerState: PlayerState, + onTogglePlay: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onOpenNowPlaying: () -> Unit, + onHide: () -> Unit, + modifier: Modifier = Modifier +) { + if (station == null) return + + val isPlaying = playerState is PlayerState.Playing + val isConnecting = playerState is PlayerState.Connecting + val isError = playerState is PlayerState.Error + + val subText = when { + isError -> playerState.message + isConnecting -> stringResource(R.string.player_connecting) + isPlaying -> stringResource(R.string.player_on_air) + else -> stationDescription(station) + } + + val density = LocalDensity.current + var dragOffsetPx by remember { mutableFloatStateOf(0f) } + val dismissThresholdPx = with(density) { 44.dp.toPx() } + val flingVelocityThresholdPx = with(density) { 1200.dp.toPx() } + val hideTargetPx = with(density) { 160.dp.toPx() } + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 6.dp), + contentAlignment = Alignment.TopCenter + ) { + val isTablet = maxWidth > TABLET_BREAKPOINT_DP + + Box( + modifier = (if (isTablet) Modifier.widthIn(max = TABLET_MINI_PLAYER_MAX_WIDTH_DP) else Modifier.fillMaxWidth()) + .offset { IntOffset(0, dragOffsetPx.roundToInt()) } + .graphicsLayer { + alpha = 1f - (dragOffsetPx / hideTargetPx).coerceIn(0f, 1f) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .shadow(6.dp, RoundedCornerShape(32.dp)) + .clip(RoundedCornerShape(32.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(32.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true) + ) { + onOpenNowPlaying() + } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + dragOffsetPx = (dragOffsetPx + delta).coerceAtLeast(0f) + }, + onDragStopped = { velocity -> + val shouldHide = + dragOffsetPx > dismissThresholdPx || velocity > flingVelocityThresholdPx + if (shouldHide) { + animate( + initialValue = dragOffsetPx, + targetValue = hideTargetPx, + initialVelocity = velocity, + animationSpec = tween( + durationMillis = 180, + easing = FastOutLinearInEasing + ) + ) { value, _ -> dragOffsetPx = value } + onHide() + dragOffsetPx = 0f + } else { + animate( + initialValue = dragOffsetPx, + targetValue = 0f, + initialVelocity = velocity, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) { value, _ -> dragOffsetPx = value } + } + } + ) + .padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp) + .testTag("mini_player"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(46.dp) + .clip(RoundedCornerShape(14.dp)) + .background(stationTileColor(station)) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)) + ) { + Image( + painter = painterResource(id = station.iconResId), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = station.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.5.sp, + color = LocalGeoColors.current.deepPurple + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = subText, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = if (isError) LocalGeoColors.current.redFavorite else (if (isPlaying) LocalGeoColors.current.liveGreen else LocalGeoColors.current.onSurfaceVariant) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + IconButton( + onClick = onPrevious, + modifier = Modifier + .size(36.dp) + .testTag("mini_prev_btn") + ) { + AppIcon( + resId = R.drawable.ic_chevron_left, + contentDescription = stringResource(R.string.previous_station_content_desc), + tint = LocalGeoColors.current.onSurfaceVariant, + size = 24.dp + ) + } + + Box( + modifier = Modifier + .size(42.dp) + .shadow(2.dp, CircleShape) + .clip(CircleShape) + .background(LocalGeoColors.current.accentLavender) + .clickable { onTogglePlay() } + .testTag("mini_play_btn"), + contentAlignment = Alignment.Center + ) { + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = LocalGeoColors.current.onAccentLavender, + strokeWidth = 2.dp + ) + } else { + AppIcon( + resId = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play, + contentDescription = if (isPlaying) stringResource(R.string.pause_content_desc) else stringResource(R.string.play_content_desc), + tint = LocalGeoColors.current.onAccentLavender, + size = 22.dp + ) + } + } + + IconButton( + onClick = onNext, + modifier = Modifier + .size(36.dp) + .testTag("mini_next_btn") + ) { + AppIcon( + resId = R.drawable.ic_chevron_right, + contentDescription = stringResource(R.string.next_station_content_desc), + tint = LocalGeoColors.current.onSurfaceVariant, + size = 24.dp + ) + } + } + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/MistyMountainBackground.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/MistyMountainBackground.kt new file mode 100644 index 0000000..498fee6 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/MistyMountainBackground.kt @@ -0,0 +1,53 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Scale +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +@Composable +fun MistyMountainBackground(modifier: Modifier = Modifier) { + val context = LocalContext.current + val request = remember(context) { + ImageRequest.Builder(context) + .data(R.drawable.bg_misty_mountain) + .scale(Scale.FILL) + .crossfade(200) + .build() + } + + Box(modifier = modifier.fillMaxSize().background(LocalGeoColors.current.background)) { + AsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + val scrimBase = LocalGeoColors.current.background + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + scrimBase.copy(alpha = 0.62f), + scrimBase.copy(alpha = 0.48f), + scrimBase.copy(alpha = 0.52f), + scrimBase.copy(alpha = 0.78f) + ) + ) + ) + ) + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/NavigationTabs.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/NavigationTabs.kt new file mode 100644 index 0000000..bceb7b3 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/NavigationTabs.kt @@ -0,0 +1,181 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.AppTab +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +@Composable +fun NavigationTabs( + selectedTab: AppTab, + favoritesCount: Int, + onTabSelected: (AppTab) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .shadow(6.dp, RoundedCornerShape(26.dp)) + .clip(RoundedCornerShape(26.dp)) + .background(LocalGeoColors.current.surfaceContainer.copy(alpha = 0.96f)) + .border( + width = 1.dp, + color = LocalGeoColors.current.outlineVariant, + shape = RoundedCornerShape(26.dp) + ) + .padding(horizontal = 6.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + FitTabsRow(modifier = Modifier.fillMaxWidth()) { + AppTab.entries.forEach { tab -> + val isSelected = tab == selectedTab + val bgColor by animateColorAsState( + targetValue = if (isSelected) LocalGeoColors.current.primaryContainer else Color.Transparent, + label = "tab_bg" + ) + val tintColor by animateColorAsState( + targetValue = if (isSelected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant, + label = "tab_tint" + ) + + val tabIcon: Int = when (tab) { + AppTab.RADIO -> R.drawable.ic_radio + AppTab.FAVORITES -> R.drawable.ic_favorite + AppTab.ABOUT -> R.drawable.ic_info + } + + Row( + modifier = Modifier + .testTag("tab_${tab.name.lowercase()}") + .clip(RoundedCornerShape(20.dp)) + .background(bgColor) + .selectable( + selected = isSelected, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true), + role = Role.Tab, + onClick = { onTabSelected(tab) } + ) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally) + ) { + AppIcon( + resId = tabIcon, + tint = if (tab == AppTab.FAVORITES && isSelected) LocalGeoColors.current.redFavorite else tintColor, + size = 17.dp + ) + + Text( + text = stringResource(tab.titleRes), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp, + color = tintColor + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // Weighted so the icon and badge keep their size when a long label is squeezed. + modifier = Modifier.weight(1f, fill = false) + ) + + if (tab == AppTab.FAVORITES && favoritesCount > 0 && !isSelected) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.primary.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + Text( + text = if (favoritesCount > 9) "9+" else "$favoritesCount", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = LocalGeoColors.current.onPrimary + ) + ) + } + } + } + } + } + } +} + +/** + * Lays tabs out like `Arrangement.SpaceEvenly`, but when the labels are too long for the screen + * (narrow phones, long translations) it shrinks only the widest tabs until everything fits. + */ +@Composable +private fun FitTabsRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val natural = measurables.map { it.maxIntrinsicWidth(constraints.maxHeight) } + val available = if (constraints.hasBoundedWidth) constraints.maxWidth else natural.sum() + + val widths = if (natural.sum() <= available) { + natural + } else { + // Water-filling: find the largest cap so that sum(min(width, cap)) fits. + val sorted = natural.sorted() + var remaining = available + var cap = 0 + for ((index, width) in sorted.withIndex()) { + val left = sorted.size - index + if (width * left <= remaining) { + remaining -= width + } else { + cap = remaining / left + break + } + } + natural.map { minOf(it, cap) } + } + + val placeables = measurables.mapIndexed { i, measurable -> + measurable.measure(Constraints.fixedWidth(widths[i]).copy(maxHeight = constraints.maxHeight)) + } + val height = placeables.maxOfOrNull { it.height } ?: 0 + val gap = ((available - placeables.sumOf { it.width }) / (placeables.size + 1)).coerceAtLeast(0) + + layout(available, height) { + var x = gap + placeables.forEach { placeable -> + placeable.placeRelative(x, (height - placeable.height) / 2) + x += placeable.width + gap + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/NowPlayingSheet.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/NowPlayingSheet.kt new file mode 100644 index 0000000..d150034 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/NowPlayingSheet.kt @@ -0,0 +1,765 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.ExtraPlayerTab +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.player.AudioEffectsState +import cc.skysparkle.fogwave.player.PlayerState +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors +import kotlin.math.roundToInt + +@Composable +fun NowPlayingSheet( + station: RadioStation?, + playerState: PlayerState, + isFavorite: Boolean, + onToggleFavorite: () -> Unit, + onTogglePlay: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onClose: () -> Unit, + onOpenEqualizer: () -> Unit, + audioEffects: AudioEffectsState, + activeExtraTab: ExtraPlayerTab, + onSelectExtraTab: (ExtraPlayerTab) -> Unit, + recents: List, + similarStations: List, + onSelectStation: (RadioStation) -> Unit, + modifier: Modifier = Modifier, + sheetModifier: Modifier = Modifier +) { + if (station == null) return + + val isPlaying = playerState is PlayerState.Playing + val isConnecting = playerState is PlayerState.Connecting + val isError = playerState is PlayerState.Error + + val density = LocalDensity.current + val currentOnClose by rememberUpdatedState(onClose) + val drag = remember(density) { + SheetDragState( + dismissThresholdPx = with(density) { 110.dp.toPx() }, + flingThresholdPx = with(density) { 1400.dp.toPx() }, + offscreenPx = with(density) { 900.dp.toPx() }, + onDismiss = { currentOnClose() } + ) + } + val artScale by animateFloatAsState( + targetValue = if (isPlaying) 1.02f else 0.98f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "art_scale" + ) + + Box( + modifier = modifier + .fillMaxSize() + // Read in the draw phase so pulling the sheet does not recompose the whole player. + .drawBehind { + val progress = (drag.offsetPx / drag.dismissThresholdPx).coerceIn(0f, 1f) + drawRect(Color.Black.copy(alpha = 0.55f * (1f - progress))) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + onClose() + } + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val isTablet = maxWidth > 600.dp + val sheetWidthModifier = if (isTablet) Modifier.widthIn(max = 560.dp) else Modifier.fillMaxWidth() + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .then(sheetModifier) + .offset { IntOffset(0, drag.offsetPx.roundToInt()) } + .then(sheetWidthModifier) + .fillMaxHeight(0.94f) + .shadow(16.dp, RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp)) + .clip(RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp)) + .background(LocalGeoColors.current.background) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp)) + // Swallow taps so they do not fall through to the scrim (which closes the sheet), + // without exposing the sheet itself as a button to accessibility services. + .pointerInput(Unit) { detectTapGestures { } } + // The whole sheet scrolls; drags that the content cannot use pull the sheet down. + .nestedScroll(drag.nestedScrollConnection) + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(bottom = 28.dp) + .testTag("now_playing_sheet"), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(top = 12.dp, bottom = 4.dp) + .size(width = 48.dp, height = 5.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.outlineVariant) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.now_playing_label), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.5.sp, + letterSpacing = 1.2.sp, + color = LocalGeoColors.current.primary + ) + ) + + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape) + .clickable { onClose() } + .testTag("np_close_btn"), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_close, + contentDescription = stringResource(R.string.close_content_desc), + tint = LocalGeoColors.current.deepPurple, + size = 18.dp + ) + } + } + + Box( + modifier = Modifier + .padding(horizontal = 44.dp, vertical = 10.dp) + .fillMaxWidth() + .aspectRatio(1f) + .scale(artScale) + .shadow(8.dp, RoundedCornerShape(32.dp)) + .clip(RoundedCornerShape(32.dp)) + .background(stationTileColor(station)) + .border(1.5.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(32.dp)) + ) { + Image( + painter = painterResource(id = station.iconResId), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = station.name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold, + fontSize = 25.sp, + letterSpacing = (-0.5).sp, + color = LocalGeoColors.current.deepPurple, + textAlign = TextAlign.Center + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stationDescription(station), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + color = LocalGeoColors.current.onSurfaceVariant, + textAlign = TextAlign.Center + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .clip(CircleShape) + .background(if (isPlaying) LocalGeoColors.current.primaryContainer else LocalGeoColors.current.surfaceContainer) + .border( + width = 1.dp, + color = if (isPlaying) LocalGeoColors.current.primary.copy(alpha = 0.4f) else LocalGeoColors.current.outlineVariant, + shape = CircleShape + ) + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + when { + isPlaying -> LiveOnAirDot() + isError -> AppIcon( + resId = R.drawable.ic_alert, + tint = LocalGeoColors.current.redFavorite, + size = 12.dp + ) + !isConnecting -> AppIcon( + resId = R.drawable.ic_sleep, + tint = LocalGeoColors.current.onSurfaceVariant, + size = 12.dp + ) + else -> Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.onSurfaceVariant) + ) + } + Text( + text = when { + isError -> playerState.message + isConnecting -> stringResource(R.string.player_connecting) + isPlaying -> stringResource(R.string.now_playing_live) + else -> stringResource(R.string.player_stopped) + }, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = if (isPlaying) LocalGeoColors.current.deepPurple else (if (isError) LocalGeoColors.current.redFavorite else LocalGeoColors.current.onSurfaceVariant) + ) + ) + } + } + + LiveBars( + active = isPlaying, + color = LocalGeoColors.current.primary, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 10.dp) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape) + .clickable { onOpenEqualizer() } + .testTag("np_equalizer_toggle"), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_equalizer, + contentDescription = stringResource(R.string.equalizer_content_desc), + tint = if (audioEffects.equalizerEnabled) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant.copy(alpha = 0.5f), + size = 20.dp + ) + } + + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape) + .clickable { onPrevious() } + .testTag("np_prev_btn"), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_skip_previous, + contentDescription = stringResource(R.string.previous_station_content_desc), + tint = LocalGeoColors.current.deepPurple, + size = 24.dp + ) + } + + Box( + modifier = Modifier + .size(72.dp) + .shadow(4.dp, RoundedCornerShape(26.dp)) + .clip(RoundedCornerShape(26.dp)) + .background(LocalGeoColors.current.accentLavender) + .clickable { onTogglePlay() } + .testTag("np_play_btn"), + contentAlignment = Alignment.Center + ) { + if (isConnecting) { + val connecting = stringResource(R.string.player_connecting) + CircularProgressIndicator( + modifier = Modifier + .size(32.dp) + .semantics { contentDescription = connecting }, + color = LocalGeoColors.current.onAccentLavender, + strokeWidth = 3.dp + ) + } else { + AppIcon( + resId = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play, + contentDescription = if (isPlaying) stringResource(R.string.pause_content_desc) else stringResource(R.string.play_content_desc), + tint = LocalGeoColors.current.onAccentLavender, + size = 34.dp + ) + } + } + + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, CircleShape) + .clickable { onNext() } + .testTag("np_next_btn"), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_skip_next, + contentDescription = stringResource(R.string.next_station_content_desc), + tint = LocalGeoColors.current.deepPurple, + size = 24.dp + ) + } + + val favScale by animateFloatAsState( + targetValue = if (isFavorite) 1.12f else 1.0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "fav_scale" + ) + Box( + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .background(if (isFavorite) LocalGeoColors.current.redFavorite.copy(alpha = 0.15f) else LocalGeoColors.current.surfaceContainer) + .border( + width = 1.2.dp, + color = if (isFavorite) LocalGeoColors.current.redFavorite else LocalGeoColors.current.outlineVariant, + shape = CircleShape + ) + .scale(favScale) + .clickable { onToggleFavorite() } + .testTag("np_favorite_toggle"), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = if (isFavorite) R.drawable.ic_heart_fill else R.drawable.ic_heart_outline, + contentDescription = if (isFavorite) stringResource(R.string.remove_from_favorites) else stringResource(R.string.add_to_favorites), + tint = if (isFavorite) LocalGeoColors.current.redFavorite else LocalGeoColors.current.deepPurple, + size = 22.dp + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ExtraPlayerTab.entries.forEach { tab -> + val isSelected = tab == activeExtraTab + val bg by animateColorAsState( + targetValue = if (isSelected) LocalGeoColors.current.primaryContainer else Color.Transparent, + label = "extra_tab_bg" + ) + val textColor by animateColorAsState( + targetValue = if (isSelected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant, + label = "extra_tab_text" + ) + + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background(bg) + .clickable { onSelectExtraTab(tab) } + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(tab.titleRes), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 12.sp, + color = textColor + ) + ) + } + } + } + + when (activeExtraTab) { + ExtraPlayerTab.RECENT -> { + if (recents.isEmpty()) { + Text( + text = stringResource(R.string.recent_empty), + style = MaterialTheme.typography.bodySmall.copy( + color = LocalGeoColors.current.onSurfaceVariant, + textAlign = TextAlign.Center + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + recents.forEach { s -> + val isCur = s.id == station.id + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (isCur) LocalGeoColors.current.primaryContainer.copy(alpha = 0.6f) else LocalGeoColors.current.surfaceContainer) + .border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)) + .clickable { onSelectStation(s) } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(stationTileColor(s)) + ) { + Image( + painter = painterResource(id = s.iconResId), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isCur && isPlaying) { + LiveOnAirDot() + } + Text( + text = s.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = if (isCur) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = stationDescription(s), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = LocalGeoColors.current.onSurfaceVariant + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + AppIcon( + resId = R.drawable.ic_play, + contentDescription = stringResource(R.string.play_content_desc), + tint = LocalGeoColors.current.deepPurple, + size = 18.dp + ) + } + } + } + } + } + + ExtraPlayerTab.SIMILAR -> { + if (similarStations.isEmpty()) { + Text( + text = stringResource(R.string.similar_empty), + style = MaterialTheme.typography.bodySmall.copy( + color = LocalGeoColors.current.onSurfaceVariant, + textAlign = TextAlign.Center + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + similarStations.forEach { s -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)) + .clickable { onSelectStation(s) } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(stationTileColor(s)) + ) { + Image( + painter = painterResource(id = s.iconResId), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = s.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.deepPurple + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${stationDescription(s)} • ${stationGenres(s)}", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = LocalGeoColors.current.onSurfaceVariant + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + AppIcon( + resId = R.drawable.ic_play, + contentDescription = stringResource(R.string.play_content_desc), + tint = LocalGeoColors.current.deepPurple, + size = 18.dp + ) + } + } + } + } + } + + ExtraPlayerTab.INFO -> { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(16.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + InfoRow(key = stringResource(R.string.info_station), value = station.name) + InfoRow(key = stringResource(R.string.info_description), value = stationDescription(station)) + InfoRow(key = stringResource(R.string.info_genre), value = stationGenres(station)) + InfoRow(key = stringResource(R.string.info_quality), value = station.bitrate) + InfoRow( + key = stringResource(R.string.info_status), + value = when (playerState) { + is PlayerState.Playing -> stringResource(R.string.status_live) + is PlayerState.Connecting -> stringResource(R.string.player_connecting) + is PlayerState.Error -> playerState.message + else -> stringResource(R.string.player_stopped) + } + ) + } + } + } + } + } + } + } +} + +@Composable +private fun InfoRow(key: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = key, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.SemiBold, + color = LocalGeoColors.current.onSurfaceVariant + ) + ) + // Long values (descriptions, genre lists) wrap on the right instead of pushing the key out. + Text( + text = value, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.deepPurple, + textAlign = TextAlign.End + ), + modifier = Modifier.weight(1f) + ) + } +} + +/** + * Offset of the now-playing sheet while it is being pulled down. Driven through nested scroll so + * the gesture works anywhere on the (scrollable) sheet once its content is scrolled to the top. + */ +@Stable +private class SheetDragState( + val dismissThresholdPx: Float, + private val flingThresholdPx: Float, + private val offscreenPx: Float, + private val onDismiss: () -> Unit +) { + var offsetPx by mutableFloatStateOf(0f) + private set + + private fun dragBy(delta: Float): Float { + val old = offsetPx + offsetPx = (offsetPx + delta).coerceAtLeast(0f) + return offsetPx - old + } + + private suspend fun settle(velocity: Float) { + if (offsetPx > dismissThresholdPx || velocity > flingThresholdPx) { + animate( + initialValue = offsetPx, + targetValue = offscreenPx, + initialVelocity = velocity, + animationSpec = tween(durationMillis = 220, easing = FastOutLinearInEasing) + ) { value, _ -> offsetPx = value } + onDismiss() + } else { + animate( + initialValue = offsetPx, + targetValue = 0f, + initialVelocity = velocity, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) { value, _ -> offsetPx = value } + } + } + + val nestedScrollConnection = object : NestedScrollConnection { + // Dragging back up first collapses the pull-down offset before the content scrolls. + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = + if (available.y < 0f && offsetPx > 0f) Offset(0f, dragBy(available.y)) else Offset.Zero + + // Whatever downward drag the content could not consume (it is at the top) moves the sheet. + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = + if (available.y > 0f && source == NestedScrollSource.UserInput) { + Offset(0f, dragBy(available.y)) + } else { + Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (offsetPx <= 0f) return Velocity.Zero + settle(available.y) + return available + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationItem.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationItem.kt new file mode 100644 index 0000000..e8fea0f --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationItem.kt @@ -0,0 +1,462 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.data.model.IconSizeStep +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.data.model.ViewMode +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors +import coil.compose.AsyncImage + +@Composable +fun LiveOnAirDot(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val scale by infiniteTransition.animateFloat( + initialValue = 0.8f, + targetValue = 1.25f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "live_pulse" + ) + + Box( + modifier = modifier + .size(7.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(CircleShape) + .background(LocalGeoColors.current.liveGreen) + ) +} + +private val GridTextShadow = Shadow(color = Color.Black.copy(alpha = 0.55f), blurRadius = 8f) + +@Composable +fun StationItem( + station: RadioStation, + isCurrent: Boolean, + isPlaying: Boolean, + viewMode: ViewMode, + sizeStep: IconSizeStep, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val pressScale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMediumLow), + label = "station_press" + ) + val cardModifier = modifier.graphicsLayer { + scaleX = pressScale + scaleY = pressScale + } + + when (viewMode) { + ViewMode.ICONS -> { + StationIconsCard( + station = station, + isCurrent = isCurrent, + isPlaying = isPlaying, + sizeDp = sizeStep.sizeDp.dp, + onClick = onClick, + interactionSource = interactionSource, + modifier = cardModifier + ) + } + ViewMode.TILES -> { + StationTilesCard( + station = station, + isCurrent = isCurrent, + isPlaying = isPlaying, + sizeDp = (sizeStep.sizeDp * 0.7f).dp, + onClick = onClick, + interactionSource = interactionSource, + modifier = cardModifier + ) + } + ViewMode.LIST -> { + StationListRow( + station = station, + isCurrent = isCurrent, + isPlaying = isPlaying, + sizeDp = (sizeStep.sizeDp * 0.45f).coerceAtLeast(36f).dp, + onClick = onClick, + interactionSource = interactionSource, + modifier = cardModifier + ) + } + ViewMode.DETAILS -> { + StationDetailsRow( + station = station, + isCurrent = isCurrent, + isPlaying = isPlaying, + sizeDp = (sizeStep.sizeDp * 0.45f).coerceAtLeast(36f).dp, + onClick = onClick, + interactionSource = interactionSource, + modifier = cardModifier + ) + } + } +} + +@Composable +private fun StationIconsCard( + station: RadioStation, + isCurrent: Boolean, + isPlaying: Boolean, + sizeDp: Dp, + onClick: () -> Unit, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(sizeDp) + .clip(RoundedCornerShape(20.dp)) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) + .padding(4.dp) + .testTag("station_${station.id}"), + horizontalAlignment = Alignment.Start + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .shadow(if (isCurrent) 4.dp else 1.dp, RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(22.dp)) + .background(stationTileColor(station)) + .border( + width = if (isCurrent) 2.dp else 1.dp, + color = if (isCurrent) LocalGeoColors.current.primary else LocalGeoColors.current.outlineVariant, + shape = RoundedCornerShape(22.dp) + ) + ) { + AsyncImage( + model = station.iconResId, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isCurrent && isPlaying) { + LiveOnAirDot() + } + Text( + text = station.name, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.5.sp, + lineHeight = 17.sp, + color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface, + shadow = GridTextShadow + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stationDescription(station), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = LocalGeoColors.current.onSurface.copy(alpha = 0.82f), + shadow = GridTextShadow + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun StationTilesCard( + station: RadioStation, + isCurrent: Boolean, + isPlaying: Boolean, + sizeDp: Dp, + onClick: () -> Unit, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(if (isCurrent) LocalGeoColors.current.primaryContainer.copy(alpha = 0.7f) else LocalGeoColors.current.surfaceContainer.copy(alpha = 0.9f)) + .border( + width = if (isCurrent) 1.5.dp else 1.dp, + color = if (isCurrent) LocalGeoColors.current.primary else LocalGeoColors.current.outlineVariant, + shape = RoundedCornerShape(18.dp) + ) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) + .padding(10.dp) + .testTag("station_${station.id}"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(sizeDp) + .clip(RoundedCornerShape(14.dp)) + .background(stationTileColor(station)) + .border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(14.dp)) + ) { + AsyncImage( + model = station.iconResId, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isCurrent && isPlaying) { + LiveOnAirDot() + } + Text( + text = station.name, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = stationDescription(station), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + color = LocalGeoColors.current.onSurfaceVariant + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun StationListRow( + station: RadioStation, + isCurrent: Boolean, + isPlaying: Boolean, + sizeDp: Dp, + onClick: () -> Unit, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (isCurrent) LocalGeoColors.current.primaryContainer.copy(alpha = 0.6f) else Color.Transparent) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) + .padding(horizontal = 10.dp, vertical = 7.dp) + .testTag("station_${station.id}"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(sizeDp) + .clip(RoundedCornerShape(10.dp)) + .background(stationTileColor(station)) + .border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(10.dp)) + ) { + AsyncImage( + model = station.iconResId, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isCurrent && isPlaying) { + LiveOnAirDot() + } + Text( + text = station.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun StationDetailsRow( + station: RadioStation, + isCurrent: Boolean, + isPlaying: Boolean, + sizeDp: Dp, + onClick: () -> Unit, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (isCurrent) LocalGeoColors.current.primaryContainer.copy(alpha = 0.6f) else Color.Transparent) + .border( + width = 0.8.dp, + color = if (isCurrent) LocalGeoColors.current.primary else LocalGeoColors.current.outlineVariant.copy(alpha = 0.6f), + shape = RoundedCornerShape(14.dp) + ) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) + .padding(horizontal = 10.dp, vertical = 8.dp) + .testTag("station_${station.id}"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(sizeDp) + .clip(RoundedCornerShape(10.dp)) + .background(stationTileColor(station)) + .border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(10.dp)) + ) { + AsyncImage( + model = station.iconResId, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } + + Column(modifier = Modifier.weight(1.3f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isCurrent && isPlaying) { + LiveOnAirDot() + } + Text( + text = station.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = if (isCurrent) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurface + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = stationDescription(station), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = LocalGeoColors.current.onSurfaceVariant + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stationGenres(station), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = LocalGeoColors.current.primary + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.9f) + ) + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationLabels.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationLabels.kt new file mode 100644 index 0000000..3f4bde7 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationLabels.kt @@ -0,0 +1,27 @@ +package cc.skysparkle.fogwave.ui.components + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import cc.skysparkle.fogwave.data.model.RadioStation + +@Composable +@ReadOnlyComposable +fun stationDescription(station: RadioStation): String = stringResource(station.descriptionRes) + +@Composable +@ReadOnlyComposable +fun stationGenres(station: RadioStation): String = + station.genres.map { stringResource(it.labelRes) }.joinToString(", ") + +/** Opens [url] in the user's browser; silently does nothing when no browser is installed. */ +fun Context.openUrl(url: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (_: ActivityNotFoundException) { + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationLogo.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationLogo.kt new file mode 100644 index 0000000..0abffa8 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/StationLogo.kt @@ -0,0 +1,15 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +private val LightLogoBackdrop = Color(0xFFECE8F1) + +// Dark station logos are unreadable on the dark tile, so they get a light backdrop. +@Composable +@ReadOnlyComposable +fun stationTileColor(station: RadioStation): Color = + if (station.lightBackdrop) LightLogoBackdrop else LocalGeoColors.current.surfaceContainer diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/TopBrandBar.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/TopBrandBar.kt new file mode 100644 index 0000000..46c348e --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/TopBrandBar.kt @@ -0,0 +1,82 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors + +@Composable +fun TopBrandBar(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .background(LocalGeoColors.current.surfaceContainer.copy(alpha = 0.92f)) + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .fillMaxWidth() + .testTag("top_brand_bar") + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_fogwave_logo, + size = 34.dp + ) + } + + Column( + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + letterSpacing = (-0.4).sp, + color = LocalGeoColors.current.deepPurple + ), + modifier = Modifier.semantics { heading() } + ) + Text( + text = stringResource(R.string.app_subtitle), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.5.sp, + color = LocalGeoColors.current.onSurfaceVariant, + letterSpacing = 0.2.sp + ) + ) + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/components/ViewOptionsPopover.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/components/ViewOptionsPopover.kt new file mode 100644 index 0000000..b4b9848 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/components/ViewOptionsPopover.kt @@ -0,0 +1,287 @@ +package cc.skysparkle.fogwave.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.IconSizeStep +import cc.skysparkle.fogwave.data.model.ViewMode +import cc.skysparkle.fogwave.ui.theme.LocalGeoColors +import kotlin.math.roundToInt + +@Composable +fun StationsToolbar( + title: String, + isOpen: Boolean, + onToggleOpen: () -> Unit, + viewMode: ViewMode, + onSelectViewMode: (ViewMode) -> Unit, + sizeStep: IconSizeStep, + onSelectSizeStep: (IconSizeStep) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + letterSpacing = (-0.4).sp, + color = LocalGeoColors.current.deepPurple + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + ) + + Box( + modifier = Modifier + .size(38.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isOpen) LocalGeoColors.current.primaryContainer else LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(12.dp)) + .clickable(role = Role.Button) { onToggleOpen() } + .testTag("view_options_trigger"), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = R.drawable.ic_table, + contentDescription = stringResource(R.string.view_mode_content_desc), + tint = if (isOpen) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant, + size = 19.dp + ) + } + } + + AnimatedVisibility( + visible = isOpen, + enter = expandVertically( + animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow), + expandFrom = Alignment.Top + ) + fadeIn(tween(200)) + scaleIn( + initialScale = 0.96f, + transformOrigin = TransformOrigin(1f, 0f), + animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow) + ), + exit = shrinkVertically( + animationSpec = tween(180, easing = FastOutLinearInEasing), + shrinkTowards = Alignment.Top + ) + fadeOut(tween(140)) + scaleOut( + targetScale = 0.96f, + transformOrigin = TransformOrigin(1f, 0f), + animationSpec = tween(180) + ) + ) { + Column( + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(LocalGeoColors.current.surfaceContainer) + .border(1.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(24.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(R.string.display_mode_label), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.5.sp, + letterSpacing = 0.8.sp, + color = LocalGeoColors.current.primary + ) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(LocalGeoColors.current.surface) + .border(0.8.dp, LocalGeoColors.current.outlineVariant, RoundedCornerShape(12.dp)) + .padding(3.dp) + .selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + ViewModeSegment( + title = stringResource(R.string.view_mode_list), + icon = R.drawable.ic_list, + isSelected = viewMode == ViewMode.LIST, + onClick = { onSelectViewMode(ViewMode.LIST) }, + modifier = Modifier.weight(1f) + ) + ViewModeSegment( + title = stringResource(R.string.view_mode_table), + icon = R.drawable.ic_layout_columns, + isSelected = viewMode == ViewMode.DETAILS, + onClick = { onSelectViewMode(ViewMode.DETAILS) }, + modifier = Modifier.weight(1f) + ) + ViewModeSegment( + title = stringResource(R.string.view_mode_tiles), + icon = R.drawable.ic_layout_rows, + isSelected = viewMode == ViewMode.TILES, + onClick = { onSelectViewMode(ViewMode.TILES) }, + modifier = Modifier.weight(1f) + ) + ViewModeSegment( + title = stringResource(R.string.view_mode_icons), + icon = R.drawable.ic_grid, + isSelected = viewMode == ViewMode.ICONS, + onClick = { onSelectViewMode(ViewMode.ICONS) }, + modifier = Modifier.weight(1f) + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(R.string.size_label), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.5.sp, + letterSpacing = 0.8.sp, + color = LocalGeoColors.current.primary + ) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "S", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.onSurfaceVariant + ) + ) + + val steps = IconSizeStep.entries + val currentIndex = steps.indexOf(sizeStep).toFloat() + val sizeLabel = stringResource(R.string.size_label) + + Slider( + value = currentIndex, + onValueChange = { indexFloat -> + val step = steps[indexFloat.roundToInt().coerceIn(0, steps.size - 1)] + // The slider reports every drag frame; only persist real changes. + if (step != sizeStep) onSelectSizeStep(step) + }, + valueRange = 0f..(steps.size - 1).toFloat(), + steps = steps.size - 2, + colors = SliderDefaults.colors( + thumbColor = LocalGeoColors.current.primary, + activeTrackColor = LocalGeoColors.current.primary, + inactiveTrackColor = LocalGeoColors.current.outlineVariant + ), + modifier = Modifier + .weight(1f) + .semantics { contentDescription = sizeLabel } + ) + + Text( + text = "XL", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = LocalGeoColors.current.onSurfaceVariant + ) + ) + } + } + } + } + } +} + +@Composable +private fun ViewModeSegment( + title: String, + icon: Int, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val bg by animateColorAsState( + targetValue = if (isSelected) LocalGeoColors.current.primaryContainer else Color.Transparent, + animationSpec = tween(220), + label = "segment_bg" + ) + val tint by animateColorAsState( + targetValue = if (isSelected) LocalGeoColors.current.deepPurple else LocalGeoColors.current.onSurfaceVariant, + animationSpec = tween(220), + label = "segment_tint" + ) + Box( + modifier = modifier + .height(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(bg) + .selectable(selected = isSelected, role = Role.RadioButton, onClick = onClick), + contentAlignment = Alignment.Center + ) { + AppIcon( + resId = icon, + contentDescription = title, + tint = tint, + size = 18.dp + ) + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/screens/HomeScreen.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..abe21e5 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/screens/HomeScreen.kt @@ -0,0 +1,305 @@ +package cc.skysparkle.fogwave.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import cc.skysparkle.fogwave.R +import cc.skysparkle.fogwave.data.model.AppTab +import cc.skysparkle.fogwave.player.PlayerState +import cc.skysparkle.fogwave.ui.components.AboutPanel +import cc.skysparkle.fogwave.ui.components.BatteryOptimizationDialog +import cc.skysparkle.fogwave.ui.components.EqualizerSheet +import cc.skysparkle.fogwave.ui.components.FavoritesPanel +import cc.skysparkle.fogwave.ui.components.MiniPlayer +import cc.skysparkle.fogwave.ui.components.MistyMountainBackground +import cc.skysparkle.fogwave.ui.components.NavigationTabs +import cc.skysparkle.fogwave.ui.components.NowPlayingSheet +import cc.skysparkle.fogwave.ui.components.StationCollection +import cc.skysparkle.fogwave.ui.components.StationsToolbar +import cc.skysparkle.fogwave.ui.components.TopBrandBar +import cc.skysparkle.fogwave.ui.viewmodel.RadioViewModel + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun HomeScreen( + viewModel: RadioViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isPlaying = uiState.playerState is PlayerState.Playing + + val favoriteStations = remember(uiState.stations, uiState.favorites) { + uiState.stations.filter { it.id in uiState.favorites } + } + val similarStations = remember(uiState.stations, uiState.currentStation) { + viewModel.getSimilarStations() + } + val recentStations = remember(uiState.recents, uiState.currentStation) { + uiState.recents.filter { it.id != uiState.currentStation?.id } + } + + BackHandler(enabled = uiState.isEqualizerPopOpen) { viewModel.closeEqualizerPop() } + BackHandler(enabled = uiState.isNowPlayingOpen && !uiState.isEqualizerPopOpen) { viewModel.closeNowPlaying() } + BackHandler(enabled = uiState.isViewPopOpen && !uiState.isNowPlayingOpen) { viewModel.closeViewPop() } + BackHandler( + enabled = uiState.selectedTab != AppTab.RADIO && !uiState.isNowPlayingOpen && !uiState.isViewPopOpen + ) { viewModel.setSelectedTab(AppTab.RADIO) } + val showBatteryOptimizationPrompt by viewModel.showBatteryOptimizationPrompt.collectAsStateWithLifecycle() + + if (showBatteryOptimizationPrompt) { + BatteryOptimizationDialog(onDismiss = { viewModel.dismissBatteryOptimizationPrompt() }) + } + + val density = LocalDensity.current + var dockHeight by remember { mutableStateOf(140.dp) } + val scrollBottomPadding = dockHeight + 16.dp + + Box(modifier = Modifier.fillMaxSize()) { + MistyMountainBackground() + + Column( + modifier = Modifier.fillMaxSize() + ) { + TopBrandBar() + + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = uiState.selectedTab, + transitionSpec = { + val dir = if (targetState.ordinal > initialState.ordinal) 1 else -1 + (fadeIn(tween(220, delayMillis = 50)) + + slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it / 10 * dir }) + .togetherWith( + fadeOut(tween(140)) + + slideOutHorizontally(tween(200)) { -it / 10 * dir } + ) + }, + label = "tabs" + ) { tab -> + when (tab) { + AppTab.RADIO, AppTab.FAVORITES -> { + val isFavorites = tab == AppTab.FAVORITES + Column(modifier = Modifier.fillMaxSize()) { + StationsToolbar( + title = stringResource( + if (isFavorites) R.string.favorite_stations_title else R.string.stations_title + ), + isOpen = uiState.isViewPopOpen, + onToggleOpen = { viewModel.toggleViewPop() }, + viewMode = uiState.viewMode, + onSelectViewMode = { viewModel.setViewMode(it) }, + sizeStep = uiState.sizeStep, + onSelectSizeStep = { viewModel.setSizeStep(it) } + ) + + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = uiState.viewMode, + transitionSpec = { + (fadeIn(tween(220, delayMillis = 60)) + + scaleIn(initialScale = 0.985f, animationSpec = tween(260))) + .togetherWith(fadeOut(tween(120))) + }, + label = "view_mode" + ) { mode -> + if (isFavorites) { + FavoritesPanel( + favorites = favoriteStations, + currentStation = uiState.currentStation, + isPlaying = isPlaying, + viewMode = mode, + sizeStep = uiState.sizeStep, + onSelectStation = { viewModel.onStationClick(it) }, + onBrowseStations = { viewModel.setSelectedTab(AppTab.RADIO) }, + bottomContentPadding = scrollBottomPadding, + modifier = Modifier.fillMaxSize() + ) + } else { + StationCollection( + stations = uiState.stations, + currentStation = uiState.currentStation, + isPlaying = isPlaying, + viewMode = mode, + sizeStep = uiState.sizeStep, + onSelectStation = { viewModel.onStationClick(it) }, + bottomContentPadding = scrollBottomPadding, + modifier = Modifier.fillMaxSize() + ) + } + } + PopoverScrim( + visible = uiState.isViewPopOpen, + onDismiss = { viewModel.closeViewPop() } + ) + } + } + } + + AppTab.ABOUT -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = scrollBottomPadding) + ) { + AboutPanel() + } + } + } + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + // Measured before the inset padding so list content clears the system bar too. + .onGloballyPositioned { coordinates -> + val measured = with(density) { coordinates.size.height.toDp() } + if (measured > 0.dp && measured != dockHeight) { + dockHeight = measured + } + } + // Insets live on the dock itself so the tabs stay clear of the gesture bar + // even when the mini player is swiped away. + .navigationBarsPadding() + ) { + NavigationTabs( + selectedTab = uiState.selectedTab, + favoritesCount = uiState.unseenFavoritesCount, + onTabSelected = { viewModel.setSelectedTab(it) }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp) + ) + + AnimatedVisibility( + visible = !uiState.isMiniPlayerHidden, + enter = fadeIn() + slideInVertically(initialOffsetY = { it }), + exit = ExitTransition.None + ) { + MiniPlayer( + station = uiState.currentStation, + playerState = uiState.playerState, + onTogglePlay = { viewModel.togglePlay() }, + onPrevious = { viewModel.previousStation() }, + onNext = { viewModel.nextStation() }, + onOpenNowPlaying = { viewModel.openNowPlaying() }, + onHide = { viewModel.hideMiniPlayer() } + ) + } + } + + AnimatedVisibility( + visible = uiState.isNowPlayingOpen, + enter = fadeIn(tween(220)), + exit = fadeOut(tween(220)) + ) { + val isCurFav = uiState.currentStation?.let { uiState.favorites.contains(it.id) } == true + NowPlayingSheet( + station = uiState.currentStation, + playerState = uiState.playerState, + isFavorite = isCurFav, + onToggleFavorite = { viewModel.toggleFavorite() }, + onTogglePlay = { viewModel.togglePlay() }, + onPrevious = { viewModel.previousStation() }, + onNext = { viewModel.nextStation() }, + onClose = { viewModel.closeNowPlaying() }, + onOpenEqualizer = { viewModel.toggleEqualizerPop() }, + audioEffects = uiState.audioEffects, + activeExtraTab = uiState.activeExtraTab, + onSelectExtraTab = { viewModel.setActiveExtraTab(it) }, + recents = recentStations, + similarStations = similarStations, + onSelectStation = { viewModel.selectStation(it) }, + sheetModifier = Modifier.animateEnterExit( + enter = slideInVertically( + animationSpec = spring(dampingRatio = 0.86f, stiffness = Spring.StiffnessMediumLow) + ) { it }, + exit = slideOutVertically( + animationSpec = tween(220, easing = FastOutLinearInEasing) + ) { it } + ) + ) + } + + AnimatedVisibility( + visible = uiState.isEqualizerPopOpen, + enter = fadeIn(tween(200)) + slideInVertically( + animationSpec = spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow) + ) { it / 3 }, + exit = fadeOut(tween(180)) + slideOutVertically( + animationSpec = tween(200, easing = FastOutLinearInEasing) + ) { it / 3 } + ) { + EqualizerSheet( + audioEffects = uiState.audioEffects, + onClose = { viewModel.closeEqualizerPop() }, + onSetEqualizerEnabled = { viewModel.setEqualizerEnabled(it) }, + onSetEqualizerBand = { band, level -> viewModel.setEqualizerBand(band, level) }, + onSetPreset = { viewModel.setEqualizerPreset(it) }, + onResetBands = { viewModel.resetEqualizerBands() }, + onSetLoudnessEnabled = { viewModel.setLoudnessEnabled(it) }, + onSetDynamicsProcessingEnabled = { viewModel.setDynamicsProcessingEnabled(it) } + ) + } + } +} + +@Composable +private fun PopoverScrim(visible: Boolean, onDismiss: () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(160)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.38f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Color.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Color.kt new file mode 100644 index 0000000..61c607e --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Color.kt @@ -0,0 +1,58 @@ +package cc.skysparkle.fogwave.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class GeoColors( + val background: Color, + val onBackground: Color, + val surface: Color, + val onSurface: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val onSurfaceVariant: Color, + val outline: Color, + val outlineVariant: Color, + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val accentLavender: Color, + val onAccentLavender: Color, + val deepPurple: Color, + val liveGreen: Color, + val redFavorite: Color +) + +val DarkGeoColors = GeoColors( + background = Color(0xFF141218), + onBackground = Color(0xFFE6E0E9), + surface = Color(0xFF141218), + onSurface = Color(0xFFE6E0E9), + surfaceContainer = Color(0xFF211F26), + surfaceContainerHigh = Color(0xFF2B2930), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF49454F), + primary = Color(0xFFD0BCFF), + onPrimary = Color(0xFF381E72), + primaryContainer = Color(0xFF4F378B), + onPrimaryContainer = Color(0xFFEADDFF), + secondary = Color(0xFFCCC2DC), + onSecondary = Color(0xFF332D41), + secondaryContainer = Color(0xFF4A4458), + onSecondaryContainer = Color(0xFFE8DEF8), + accentLavender = Color(0xFF4F378B), + onAccentLavender = Color(0xFFEADDFF), + deepPurple = Color(0xFFD0BCFF), + liveGreen = Color(0xFF81C995), + redFavorite = Color(0xFFF2B8B5) +) + +val LocalGeoColors = staticCompositionLocalOf { DarkGeoColors } diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Theme.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Theme.kt new file mode 100644 index 0000000..c4d6d00 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Theme.kt @@ -0,0 +1,33 @@ +package cc.skysparkle.fogwave.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider + +private val DarkColorScheme = with(DarkGeoColors) { + darkColorScheme( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = outlineVariant, + onSurfaceVariant = onSurfaceVariant, + outline = outline + ) +} + +@Composable +fun FogwaveTheme(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalGeoColors provides DarkGeoColors) { + MaterialTheme(colorScheme = DarkColorScheme, typography = Typography, content = content) + } +} diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Type.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Type.kt new file mode 100644 index 0000000..c596d3a --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/theme/Type.kt @@ -0,0 +1,19 @@ +package cc.skysparkle.fogwave.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ) + ) diff --git a/app/src/main/java/cc/skysparkle/fogwave/ui/viewmodel/RadioViewModel.kt b/app/src/main/java/cc/skysparkle/fogwave/ui/viewmodel/RadioViewModel.kt new file mode 100644 index 0000000..65ac4d3 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/fogwave/ui/viewmodel/RadioViewModel.kt @@ -0,0 +1,323 @@ +package cc.skysparkle.fogwave.ui.viewmodel + +import android.app.Application +import android.content.Context +import android.os.PowerManager +import androidx.compose.runtime.Immutable +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import cc.skysparkle.fogwave.data.model.AppTab +import cc.skysparkle.fogwave.data.model.ExtraPlayerTab +import cc.skysparkle.fogwave.data.model.IconSizeStep +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.data.model.ViewMode +import cc.skysparkle.fogwave.data.repository.RadioRepository +import cc.skysparkle.fogwave.player.AudioEffectsController +import cc.skysparkle.fogwave.player.AudioEffectsState +import cc.skysparkle.fogwave.player.PlayerState +import cc.skysparkle.fogwave.player.RadioPlayerManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@Immutable +data class RadioUiState( + val stations: List = emptyList(), + val currentStation: RadioStation? = null, + val playerState: PlayerState = PlayerState.Idle, + val favorites: Set = emptySet(), + val recents: List = emptyList(), + val selectedTab: AppTab = AppTab.RADIO, + val viewMode: ViewMode = ViewMode.ICONS, + val sizeStep: IconSizeStep = IconSizeStep.M, + val isNowPlayingOpen: Boolean = false, + val isEqualizerPopOpen: Boolean = false, + val isViewPopOpen: Boolean = false, + val activeExtraTab: ExtraPlayerTab = ExtraPlayerTab.RECENT, + val audioEffects: AudioEffectsState = AudioEffectsState(), + val isMiniPlayerHidden: Boolean = false, + val unseenFavoritesCount: Int = 0 +) + +class RadioViewModel(application: Application) : AndroidViewModel(application) { + private val repository = RadioRepository(application.applicationContext) + private val playerManager = RadioPlayerManager(application.applicationContext) + + // Sorted once per launch so the grid and next/previous stay stable during a session. + private val _stations = MutableStateFlow(repository.stationsSortedByPopularity()) + private val stations: List get() = _stations.value + + private val initialStation: RadioStation? = run { + val lastId = repository.getLastStationId() + stations.find { it.id == lastId } ?: stations.firstOrNull() + } + + private val _currentStation = MutableStateFlow(initialStation) + private val _selectedTab = MutableStateFlow(AppTab.RADIO) + private val _isNowPlayingOpen = MutableStateFlow(false) + private val _isEqualizerPopOpen = MutableStateFlow(false) + private val _isViewPopOpen = MutableStateFlow(false) + private val _activeExtraTab = MutableStateFlow(ExtraPlayerTab.RECENT) + private val _isMiniPlayerHidden = MutableStateFlow(false) + + private val _showBatteryOptimizationPrompt = MutableStateFlow(false) + val showBatteryOptimizationPrompt: StateFlow = _showBatteryOptimizationPrompt.asStateFlow() + + private fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return true + return pm.isIgnoringBatteryOptimizations(context.packageName) + } + + fun dismissBatteryOptimizationPrompt() { + repository.markBatteryOptimizationPromptSeen() + _showBatteryOptimizationPrompt.value = false + } + + val uiState: StateFlow = combine( + _currentStation, + playerManager.playerState, + repository.favoritesFlow, + repository.recentsFlow, + _selectedTab, + repository.viewModeFlow, + repository.sizeStepFlow, + _isNowPlayingOpen, + _isEqualizerPopOpen, + _isViewPopOpen, + _activeExtraTab, + AudioEffectsController.state, + _isMiniPlayerHidden, + repository.seenFavoritesFlow, + _stations + ) { params -> + val current = params[0] as? RadioStation + val pState = params[1] as PlayerState + @Suppress("UNCHECKED_CAST") + val favs = params[2] as Set + @Suppress("UNCHECKED_CAST") + val recentsIds = params[3] as List + val tab = params[4] as AppTab + val mode = params[5] as ViewMode + val size = params[6] as IconSizeStep + val nowPlaying = params[7] as Boolean + val eqPop = params[8] as Boolean + val viewPop = params[9] as Boolean + val extraTab = params[10] as ExtraPlayerTab + val effects = params[11] as AudioEffectsState + val miniHidden = params[12] as Boolean + @Suppress("UNCHECKED_CAST") + val seenFavs = params[13] as Set + @Suppress("UNCHECKED_CAST") + val orderedStations = params[14] as List + + val byId = orderedStations.associateBy { it.id } + val recentsList = recentsIds.mapNotNull { byId[it] } + + RadioUiState( + stations = orderedStations, + currentStation = current, + playerState = pState, + favorites = favs, + recents = recentsList, + selectedTab = tab, + viewMode = mode, + sizeStep = size, + isNowPlayingOpen = nowPlaying, + isEqualizerPopOpen = eqPop, + isViewPopOpen = viewPop, + activeExtraTab = extraTab, + audioEffects = effects, + isMiniPlayerHidden = miniHidden, + unseenFavoritesCount = favs.count { it !in seenFavs } + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = RadioUiState( + stations = stations, + currentStation = initialStation, + favorites = repository.favoritesFlow.value, + viewMode = repository.viewModeFlow.value, + sizeStep = repository.sizeStepFlow.value, + audioEffects = AudioEffectsController.state.value, + unseenFavoritesCount = repository.favoritesFlow.value.count { it !in repository.seenFavoritesFlow.value } + ) + ) + + init { + viewModelScope.launch { + playerManager.currentMediaId.collect { id -> + if (id != null && id != _currentStation.value?.id) { + stations.find { it.id == id }?.let { onAir -> + _currentStation.value = onAir + repository.setLastStationId(onAir.id) + } + } + } + } + } + + /** Tap on a station tile: open the player for the station already on air, otherwise tune in. */ + fun onStationClick(station: RadioStation) { + val state = playerManager.playerState.value + val onAir = station.id == _currentStation.value?.id && + (state is PlayerState.Playing || state is PlayerState.Connecting) + if (onAir) { + _isMiniPlayerHidden.value = false + openNowPlaying() + } else { + selectStation(station) + } + } + + fun selectStation(station: RadioStation) { + _currentStation.value = station + _isMiniPlayerHidden.value = false + repository.setLastStationId(station.id) + repository.pushRecent(station.id) + repository.recordStationPlayed(station.id) + + if (!repository.hasSeenBatteryOptimizationPrompt() && + !isIgnoringBatteryOptimizations(getApplication()) + ) { + _showBatteryOptimizationPrompt.value = true + } + playerManager.playStation(station) + } + + fun hideMiniPlayer() { + _isMiniPlayerHidden.value = true + } + + fun togglePlay() { + val current = _currentStation.value ?: return + when (playerManager.playerState.value) { + // Start from the station shown in the UI: it is kept in sync with whatever the + // session is playing, including stations picked from Android Auto or the notification. + is PlayerState.Idle, is PlayerState.Error -> playerManager.playStation(current) + else -> playerManager.togglePlay() + } + } + + fun nextStation() { + val list = stations + if (list.isEmpty()) return + val currentIndex = list.indexOfFirst { it.id == _currentStation.value?.id } + val nextIndex = if (currentIndex in 0 until list.size - 1) currentIndex + 1 else 0 + selectStation(list[nextIndex]) + } + + fun previousStation() { + val list = stations + if (list.isEmpty()) return + val currentIndex = list.indexOfFirst { it.id == _currentStation.value?.id } + val prevIndex = if (currentIndex > 0) currentIndex - 1 else list.size - 1 + selectStation(list[prevIndex]) + } + + fun toggleFavorite(stationId: String? = _currentStation.value?.id) { + if (stationId != null) { + repository.toggleFavorite(stationId) + if (_selectedTab.value == AppTab.FAVORITES) { + repository.markFavoritesSeen(repository.favoritesFlow.value) + } + } + } + + fun getSimilarStations(): List { + val cur = _currentStation.value ?: return emptyList() + val curGenres = cur.genres + val others = stations.filter { it.id != cur.id } + + val similar = others + .map { s -> s to s.genres.count { it in curGenres } } + .filter { it.second > 0 } + .sortedByDescending { it.second } + .map { it.first } + return similar.ifEmpty { others } + } + + fun setEqualizerEnabled(enabled: Boolean) { + AudioEffectsController.setEqualizerEnabled(enabled) + } + + fun setEqualizerBand(band: Int, levelMillibel: Int) { + AudioEffectsController.setBandLevel(band, levelMillibel) + } + + fun setEqualizerPreset(preset: Int) { + AudioEffectsController.setPreset(preset) + } + + fun resetEqualizerBands() { + AudioEffectsController.state.value.bands.forEach { band -> + AudioEffectsController.setBandLevel(band.index, 0) + } + } + + fun setLoudnessEnabled(enabled: Boolean) { + AudioEffectsController.setLoudnessEnabled(enabled) + } + + fun setLoudnessGain(gainMillibel: Float) { + AudioEffectsController.setLoudnessGain(gainMillibel) + } + + fun setDynamicsProcessingEnabled(enabled: Boolean) { + AudioEffectsController.setDynamicsProcessingEnabled(enabled) + } + + fun setSelectedTab(tab: AppTab) { + _selectedTab.value = tab + _isViewPopOpen.value = false + if (tab == AppTab.FAVORITES) { + repository.markFavoritesSeen(repository.favoritesFlow.value) + } + } + + fun setViewMode(mode: ViewMode) { + repository.setViewMode(mode) + } + + fun setSizeStep(step: IconSizeStep) { + repository.setSizeStep(step) + } + + fun openNowPlaying() { + _isNowPlayingOpen.value = true + } + + fun closeNowPlaying() { + _isNowPlayingOpen.value = false + _isEqualizerPopOpen.value = false + } + + fun toggleEqualizerPop() { + _isEqualizerPopOpen.value = !_isEqualizerPopOpen.value + } + + fun closeEqualizerPop() { + _isEqualizerPopOpen.value = false + } + + fun toggleViewPop() { + _isViewPopOpen.value = !_isViewPopOpen.value + } + + fun closeViewPop() { + _isViewPopOpen.value = false + } + + fun setActiveExtraTab(tab: ExtraPlayerTab) { + _activeExtraTab.value = tab + } + + override fun onCleared() { + super.onCleared() + playerManager.release() + } +} diff --git a/app/src/main/res/drawable-nodpi/bg_misty_mountain.webp b/app/src/main/res/drawable-nodpi/bg_misty_mountain.webp new file mode 100644 index 0000000..8e5b905 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/bg_misty_mountain.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_alert.png b/app/src/main/res/drawable-nodpi/ic_alert.png new file mode 100755 index 0000000..b82692b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_alert.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_chevron_left.png b/app/src/main/res/drawable-nodpi/ic_chevron_left.png new file mode 100644 index 0000000..ec4807f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_chevron_left.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_chevron_right.png b/app/src/main/res/drawable-nodpi/ic_chevron_right.png new file mode 100644 index 0000000..4309e47 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_chevron_right.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_close.png b/app/src/main/res/drawable-nodpi/ic_close.png new file mode 100644 index 0000000..1391448 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_close.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_copyright.png b/app/src/main/res/drawable-nodpi/ic_copyright.png new file mode 100755 index 0000000..49fb222 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_copyright.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_equalizer.png b/app/src/main/res/drawable-nodpi/ic_equalizer.png new file mode 100755 index 0000000..d458554 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_equalizer.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_favorite.png b/app/src/main/res/drawable-nodpi/ic_favorite.png new file mode 100755 index 0000000..543761a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_favorite.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_fogwave_logo.png b/app/src/main/res/drawable-nodpi/ic_fogwave_logo.png new file mode 100644 index 0000000..1b58edc Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_fogwave_logo.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_grid.png b/app/src/main/res/drawable-nodpi/ic_grid.png new file mode 100755 index 0000000..492a0ad Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_grid.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_heart_fill.png b/app/src/main/res/drawable-nodpi/ic_heart_fill.png new file mode 100644 index 0000000..23c8a4d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_heart_fill.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_heart_outline.png b/app/src/main/res/drawable-nodpi/ic_heart_outline.png new file mode 100644 index 0000000..fef32c9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_heart_outline.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_info.png b/app/src/main/res/drawable-nodpi/ic_info.png new file mode 100755 index 0000000..7db3657 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_info.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_layout_columns.png b/app/src/main/res/drawable-nodpi/ic_layout_columns.png new file mode 100755 index 0000000..629a099 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_layout_columns.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_layout_rows.png b/app/src/main/res/drawable-nodpi/ic_layout_rows.png new file mode 100755 index 0000000..08466c1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_layout_rows.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_list.png b/app/src/main/res/drawable-nodpi/ic_list.png new file mode 100755 index 0000000..7e4c660 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_list.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_open_in_new.png b/app/src/main/res/drawable-nodpi/ic_open_in_new.png new file mode 100755 index 0000000..503ba75 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_open_in_new.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_pause.png b/app/src/main/res/drawable-nodpi/ic_pause.png new file mode 100755 index 0000000..eea664c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_play.png b/app/src/main/res/drawable-nodpi/ic_play.png new file mode 100755 index 0000000..b75caa4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_play.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_privacy.png b/app/src/main/res/drawable-nodpi/ic_privacy.png new file mode 100644 index 0000000..a57e407 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_privacy.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_radio.png b/app/src/main/res/drawable-nodpi/ic_radio.png new file mode 100755 index 0000000..d4a766e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_radio.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_refresh.png b/app/src/main/res/drawable-nodpi/ic_refresh.png new file mode 100644 index 0000000..1236adf Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_refresh.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_repo.png b/app/src/main/res/drawable-nodpi/ic_repo.png new file mode 100755 index 0000000..c6a68db Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_repo.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_skip_next.png b/app/src/main/res/drawable-nodpi/ic_skip_next.png new file mode 100755 index 0000000..72a5a30 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_skip_next.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_skip_previous.png b/app/src/main/res/drawable-nodpi/ic_skip_previous.png new file mode 100755 index 0000000..9e878ca Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_skip_previous.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_sleep.png b/app/src/main/res/drawable-nodpi/ic_sleep.png new file mode 100755 index 0000000..2e1a9ee Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_sleep.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_sparkle.png b/app/src/main/res/drawable-nodpi/ic_sparkle.png new file mode 100644 index 0000000..932b38b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_sparkle.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_goldfm.webp b/app/src/main/res/drawable-nodpi/ic_station_goldfm.webp new file mode 100644 index 0000000..855cd24 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_goldfm.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_lietus.webp b/app/src/main/res/drawable-nodpi/ic_station_lietus.webp new file mode 100644 index 0000000..d993e09 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_lietus.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_m1.webp b/app/src/main/res/drawable-nodpi/ic_station_m1.webp new file mode 100644 index 0000000..989e936 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_m1.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_m1plus.webp b/app/src/main/res/drawable-nodpi/ic_station_m1plus.webp new file mode 100644 index 0000000..76efaea Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_m1plus.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_powerhit.webp b/app/src/main/res/drawable-nodpi/ic_station_powerhit.webp new file mode 100644 index 0000000..b1ab5cb Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_powerhit.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_powerhitgold.webp b/app/src/main/res/drawable-nodpi/ic_station_powerhitgold.webp new file mode 100644 index 0000000..0696575 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_powerhitgold.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_rc.webp b/app/src/main/res/drawable-nodpi/ic_station_rc.webp new file mode 100644 index 0000000..23e1010 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_rc.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_relaxfm.webp b/app/src/main/res/drawable-nodpi/ic_station_relaxfm.webp new file mode 100644 index 0000000..b99ffab Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_relaxfm.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_rockfm.webp b/app/src/main/res/drawable-nodpi/ic_station_rockfm.webp new file mode 100644 index 0000000..b9cfbb7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_rockfm.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_station_zipfm.webp b/app/src/main/res/drawable-nodpi/ic_station_zipfm.webp new file mode 100644 index 0000000..36e366f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_station_zipfm.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_table.png b/app/src/main/res/drawable-nodpi/ic_table.png new file mode 100755 index 0000000..da2ce5a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_table.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..cf79c3d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..cf79c3d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c3f42d6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..2726cba Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..aba9057 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..7a72ad6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..025183c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..d972885 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..e5ac162 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..0561766 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..5b84e1c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3df1971 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..1c0e20c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..b0a7a09 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c50aae0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..33634a5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8ec82cb Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..87b75a4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..9b08230 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..804a238 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..586ead4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..0d83f30 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..9b5eb43 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..7a22f29 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d164b65 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..9fbb283 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..0b6f3fb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..5e11e79 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,109 @@ + + Fogwave + Internetradio + + Version %1$s + Ein schneller, eleganter Internetradio-Player mit klarem Klang und ausgewogenem geometrischem Design. + Mein Repository + Im Browser öffnen + Fogwave-Logo + + Verbindung wird hergestellt… + Auf Sendung + Gestoppt + Läuft gerade • LIVE + LÄUFT GERADE + Schließen + Equalizer + Equalizer + AN + AUS + Klang + Benutzerdefiniert + Equalizer ist auf diesem Gerät nicht verfügbar. + Zurücksetzen + Lautstärkeverstärkung + Dynamikverarbeitung + Zurück + Vorheriger Sender + Nächster Sender + Abspielen + Pause + Aus Favoriten entfernen + Zu Favoriten hinzufügen + Sender, die du gehört hast, erscheinen hier. + Keine ähnlichen Sender gefunden. + Sender + Beschreibung + Genre + Qualität + Status + Auf Sendung • Livestream + + Anzeigemodus + ANZEIGEMODUS + Liste + Tabelle + Kacheln + Symbole + GRÖSSE + + Noch keine Favoriten + Öffne den Player eines beliebigen Senders und tippe auf das Herz, um ihn zu den Favoriten hinzuzufügen. + + Sender + Lieblingssender + + Radio + Favoriten + Info + + Zuletzt gehört + Ähnlich + Info + Stream vorübergehend nicht verfügbar + Wiedergabe im Hintergrund stabil halten + Manche Handys pausieren Hintergrund-Apps wenige Minuten nach dem Ausschalten des Bildschirms. Öffne die Akku-Einstellungen und wähle Fogwave aus, damit dein Sender ohne Unterbrechung weiterläuft. + Einstellungen öffnen + Jetzt nicht + Lizenzen + Bibliotheken, Icons, Foto & Radiosender + Fogwave basiert auf den unten aufgeführten Open-Source-Komponenten sowie dem Icon-Set, dem Hintergrundfoto und den Radiosendern, deren Streams die App abspielt. Tippe auf einen Eintrag, um die Seite zu öffnen. + Bibliotheken + Icons & Foto + Radiosender + Die Sender werden direkt vom jeweiligen Sendeunternehmen gestreamt. Fogwave speichert und verbreitet deren Audio nicht und besitzt keine Rechte daran. + Sender durchsuchen + Datenschutzerklärung + Wie Fogwave mit deinen Daten umgeht + + Keine Internetverbindung + Hits • Litauen + Dance • Pop • Top-Hits + Gold-Hits • Dance-Klassiker + Rockmusik • Litauen + Entspannte Musik zum Zurücklehnen + Litauische Musik + Populäre Musik + Musik & Hit-Klassiker + Goldene Hits + Radiocentras • Litauen + Hits + Pop + Dance + Oldies + Rock + Easy Listening + Litauische Musik + + Normal + Klassik + Dance + Linear + Folk + Heavy Metal + Hip-Hop + Jazz + Pop + Rock + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..185c214 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,109 @@ + + Fogwave + インターネットラジオ + + バージョン %1$s + クリアなサウンドと調和のとれた幾何学的デザインを備えた、高速でエレガントなインターネットラジオプレーヤー。 + マイリポジトリ + ブラウザで開く + Fogwave ロゴ + + 接続中… + オンエア中 + 停止中 + 再生中 • ライブ + 再生中 + 閉じる + イコライザー + イコライザー + オン + オフ + サウンド + カスタム + このデバイスではイコライザーを使用できません。 + リセット + 音量ブースト + ダイナミクス処理 + 戻る + 前の局 + 次の局 + 再生 + 一時停止 + お気に入りから削除 + お気に入りに追加 + これまでに聴いた局がここに表示されます。 + 類似の局は見つかりませんでした。 + 局名 + 説明 + ジャンル + 音質 + 状態 + オンエア中 • ライブストリーム + + 表示モード + 表示モード + リスト + テーブル + タイル + アイコン + サイズ + + お気に入りはまだありません + 好きな局のプレーヤーを開き、ハートをタップしてお気に入りに追加してください。 + + ラジオ局 + お気に入りの局 + + ラジオ + お気に入り + アプリについて + + 最近の再生 + 類似 + 情報 + ストリームは一時的に利用できません + バックグラウンド再生を安定させる + 一部のスマホは画面オフから数分でバックグラウンドアプリを停止します。バッテリー設定を開いてFogwaveを選ぶと、再生が途切れにくくなります。 + 設定を開く + 後で + ライセンス + ライブラリ、アイコン、写真、ラジオ局 + Fogwaveは以下のオープンソースのコンポーネントに加え、アイコンセット、背景写真、そして再生しているラジオ局をもとに作られています。項目をタップするとページが開きます。 + ライブラリ + アイコンと写真 + ラジオ局 + 各局のストリームは放送局から直接再生しており、Fogwaveはその音声を保存・再配布せず、権利も保有していません。 + ラジオ局を見る + プライバシーポリシー + Fogwave でのデータの取り扱いについて + + インターネットに接続されていません + ヒット曲 • リトアニア + ダンス • ポップ • トップヒット + ゴールドヒット • ダンスクラシック + ロック • リトアニア + くつろげるイージーリスニング + リトアニアの音楽 + 人気の音楽 + 音楽と往年のヒット曲 + ゴールデンヒット + Radiocentras • リトアニア + ヒット曲 + ポップ + ダンス + オールディーズ + ロック + イージーリスニング + リトアニア音楽 + + ノーマル + クラシック + ダンス + フラット + フォーク + ヘヴィメタル + ヒップホップ + ジャズ + ポップ + ロック + diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..67c5289 --- /dev/null +++ b/app/src/main/res/values-lt/strings.xml @@ -0,0 +1,109 @@ + + Fogwave + Interneto radijas + + Versija %1$s + Greitas ir elegantiškas interneto radijo grotuvas su švariu garsu ir darniu geometriniu dizainu. + Mano saugykla + Atidaryti naršyklėje + Fogwave logotipas + + Jungiamasi… + Eteryje + Sustabdyta + Dabar skamba • tiesiogiai + DABAR SKAMBA + Uždaryti + Ekvalaizeris + Ekvalaizeris + ĮJ. + IŠJ. + Garsas + Pasirinktinis + Ekvalaizeris šiame įrenginyje nepasiekiamas. + Atstatyti + Garsumo stiprinimas + Dinamikos apdorojimas + Atgal + Ankstesnė stotis + Kita stotis + Leisti + Pristabdyti + Šalinti iš mėgstamų + Pridėti prie mėgstamų + Čia atsiras stotys, kurių klausėtės. + Panašių stočių nerasta. + Stotis + Aprašymas + Žanras + Kokybė + Būsena + Eteryje • tiesioginė transliacija + + Rodymo būdas + RODYMO BŪDAS + Sąrašas + Lentelė + Plytelės + Piktogramos + DYDIS + + Mėgstamų kol kas nėra + Atverkite bet kurios stoties grotuvą ir bakstelėkite širdelę, kad pridėtumėte ją prie mėgstamų. + + Stotys + Mėgstamos stotys + + Radijas + Mėgstami + Apie + + Neseniai klausytos + Panašios + Info + Srautas laikinai nepasiekiamas + Stabilesnis grojimas fone + Kai kurie telefonai sustabdo fone veikiančias programas praėjus kelioms minutėms po ekrano išjungimo. Atidarykite baterijos nustatymus ir pasirinkite „Fogwave“, kad stotis grotų be pertrūkių. + Atidaryti nustatymus + Ne dabar + Licencijos + Bibliotekos, piktogramos, nuotrauka ir radijo stotys + „Fogwave“ sukurta iš žemiau išvardytų atvirojo kodo komponentų, taip pat piktogramų rinkinio, fono nuotraukos ir radijo stočių, kurių transliacijas groja programa. Palieskite elementą, kad atidarytumėte jo puslapį. + Bibliotekos + Piktogramos ir nuotrauka + Radijo stotys + Stotys transliuojamos tiesiai iš transliuotojų serverių; „Fogwave“ jų garso turinio nesaugo, neplatina ir neturi jam teisių. + Naršyti stotis + Privatumo politika + Kaip „Fogwave“ tvarko jūsų duomenis + + Nėra interneto ryšio + Hitai • Lietuva + Šokių • Pop • Populiariausi hitai + Auksiniai hitai • Šokių klasika + Roko muzika • Lietuva + Rami, atpalaiduojanti muzika + Lietuviška muzika + Populiari muzika + Muzika ir klasikiniai hitai + Auksiniai hitai + Radiocentras • Lietuva + Hitai + Pop + Šokių + Senieji hitai + Rokas + Lengvoji muzika + Lietuviška muzika + + Įprastas + Klasika + Šokių + Lygus + Folk + Sunkusis metalas + Hip hopas + Džiazas + Pop + Rokas + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..2b7986e --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,109 @@ + + Fogwave + Интернет-радио + + Версия %1$s + Быстрый и элегантный плеер интернет-радио с чистым звуком и гармоничным геометрическим дизайном. + Мой репозиторий + Открыть в браузере + Логотип Fogwave + + Подключение… + В эфире + Остановлено + Сейчас играет • прямой эфир + СЕЙЧАС ИГРАЕТ + Закрыть + Эквалайзер + Эквалайзер + ВКЛ + ВЫКЛ + Звук + Пользовательский + Эквалайзер недоступен на этом устройстве. + Сбросить + Усиление громкости + Обработка динамики + Назад + Предыдущая станция + Следующая станция + Воспроизвести + Пауза + Удалить из любимых + Добавить в любимые + Здесь появятся станции, которые вы слушали. + Похожие станции не найдены. + Станция + Описание + Жанр + Качество + Статус + В эфире • прямая трансляция + + Вид отображения + РЕЖИМ ОТОБРАЖЕНИЯ + Список + Таблица + Плитки + Значки + РАЗМЕР + + В любимых пока ничего нет + Откройте плеер любой станции и нажмите на сердечко, чтобы добавить её в список любимых. + + Станции + Любимые станции + + Радио + Любимые + О программе + + Недавние + Похожие + Инфо + Поток временно недоступен + Стабильное воспроизведение в фоне + На некоторых телефонах фоновые приложения останавливаются через пару минут после выключения экрана. Откройте настройки батареи и выберите Fogwave, чтобы станция играла без перерывов. + Открыть настройки + Не сейчас + Лицензии + Библиотеки, значки, фото и радиостанции + Fogwave построен на открытых компонентах ниже, а также на наборе значков, фоновой фотографии и радиостанциях, чьи потоки он воспроизводит. Нажмите на пункт, чтобы открыть его страницу. + Библиотеки + Значки и фото + Радиостанции + Станции воспроизводятся напрямую с серверов вещателей; Fogwave не хранит, не распространяет и не имеет прав на их эфир. + К станциям + Политика конфиденциальности + Как Fogwave обращается с вашими данными + + Нет подключения к интернету + Хиты • Литва + Танцевальная • Поп • Топ-хиты + Золотые хиты • Танцевальная классика + Рок-музыка • Литва + Спокойная лёгкая музыка + Литовская музыка + Популярная музыка + Музыка и хиты прошлых лет + Золотые хиты + Radiocentras • Литва + Хиты + Поп + Танцевальная + Хиты прошлых лет + Рок + Лёгкая музыка + Литовская музыка + + Обычный + Классика + Танцевальный + Ровный + Фолк + Хэви-метал + Хип-хоп + Джаз + Поп + Рок + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..99d1b0d --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,109 @@ + + Fogwave + 网络电台 + + 版本 %1$s + 音质清晰、几何设计和谐统一的快速优雅网络电台播放器。 + 我的仓库 + 在浏览器中打开 + Fogwave 标志 + + 正在连接… + 正在直播 + 已停止 + 正在播放 • 直播 + 正在播放 + 关闭 + 均衡器 + 均衡器 + 开 + 关 + 音效 + 自定义 + 此设备不支持均衡器。 + 重置 + 响度增强 + 动态处理 + 返回 + 上一个电台 + 下一个电台 + 播放 + 暂停 + 从收藏中移除 + 添加到收藏 + 你收听过的电台将显示在这里。 + 未找到相似的电台。 + 电台 + 简介 + 类型 + 音质 + 状态 + 正在直播 • 实时音频流 + + 显示模式 + 显示模式 + 列表 + 表格 + 磁贴 + 图标 + 大小 + + 收藏夹还是空的 + 打开任意电台的播放器,点击心形图标即可将其加入收藏。 + + 电台 + 收藏的电台 + + 电台 + 收藏 + 关于 + + 最近播放 + 相似电台 + 信息 + 音频流暂时不可用 + 让后台播放更稳定 + 部分手机会在息屏几分钟后暂停后台应用。打开电池设置并选择 Fogwave,即可让电台不间断播放。 + 打开设置 + 暂不设置 + 许可证 + 库、图标、照片与电台 + Fogwave 基于下列开源组件构建,并使用了图标集、背景照片,以及它播放的这些电台。点按任意条目即可打开对应页面。 + 库 + 图标与照片 + 电台 + 电台内容直接来自各广播机构的流媒体;Fogwave 不托管、不转发,也不拥有其音频内容的任何权利。 + 浏览电台 + 隐私政策 + Fogwave 如何处理你的数据 + + 无网络连接 + 热门歌曲 • 立陶宛 + 舞曲 • 流行 • 热门榜单 + 金曲 • 经典舞曲 + 摇滚乐 • 立陶宛 + 舒缓轻音乐 + 立陶宛音乐 + 流行音乐 + 音乐与经典金曲 + 黄金金曲 + Radiocentras • 立陶宛 + 热门 + 流行 + 舞曲 + 怀旧金曲 + 摇滚 + 轻音乐 + 立陶宛音乐 + + 正常 + 古典 + 舞曲 + 平直 + 民谣 + 重金属 + 嘻哈 + 爵士 + 流行 + 摇滚 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4219aed --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF141218 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7a6a124 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,109 @@ + + Fogwave + Internet Radio + + Version %1$s + A fast, elegant internet radio player with clean sound and a balanced geometric design. + My Repository + Open in browser + Fogwave logo + + Connecting… + On air + Stopped + Now playing • LIVE + NOW PLAYING + Close + Equalizer + Equalizer + ON + OFF + Sound + Custom + Equalizer isn\'t available on this device. + Reset + Loudness Enhancer + Dynamics Processing + Back + Keep playback running smoothly + Some phones pause background apps a few minutes after the screen turns off. Opening battery settings and choosing Fogwave lets you exempt it from that, so your station keeps playing uninterrupted. + Open settings + Not now + Previous station + Next station + Play + Pause + Remove from favorites + Add to favorites + Stations you\'ve listened to will show up here. + No similar stations found. + Station + Description + Genre + Quality + Status + On air • Live Stream + + Display mode + DISPLAY MODE + List + Table + Tiles + Icons + SIZE + + Nothing in favorites yet + Open any station\'s player and tap the heart to add it to favorites. + + Stations + Favorite Stations + + Radio + Favorites + About + + Recently Played + Similar + Info + Stream temporarily unavailable + Licenses + Libraries, icons, photo & radio stations + Fogwave is built on the open-source components below, plus the icon set, background photo, and the radio stations it streams from. Tap anything to open its page. + Libraries + Icons & photo + Radio stations + Stations are streamed directly from each broadcaster; Fogwave does not host, redistribute, or otherwise hold rights to their audio. + Browse stations + Privacy Policy + How Fogwave handles your data + + No internet connection + Hit music • Lithuania + Dance • Pop • Top Hits + Gold hits • Dance classics + Rock music • Lithuania + Relaxing easy listening + Lithuanian music + Popular music + Music & classic hits + Golden hits + Radiocentras • Lithuania + Hits + Pop + Dance + Oldies + Rock + Easy listening + Lithuanian music + + Normal + Classical + Dance + Flat + Folk + Heavy Metal + Hip Hop + Jazz + Pop + Rock + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..94f2f33 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..eaeb1e9 --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..0729482 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..e7d2d50 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..61b019d --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..75292f4 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/cc/skysparkle/fogwave/AppInfoTest.kt b/app/src/test/java/cc/skysparkle/fogwave/AppInfoTest.kt new file mode 100644 index 0000000..bed2ef1 --- /dev/null +++ b/app/src/test/java/cc/skysparkle/fogwave/AppInfoTest.kt @@ -0,0 +1,54 @@ +package cc.skysparkle.fogwave + +import android.content.Context +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.core.app.ApplicationProvider +import cc.skysparkle.fogwave.data.repository.RadioRepository +import cc.skysparkle.fogwave.ui.components.AboutPanel +import cc.skysparkle.fogwave.ui.theme.FogwaveTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class AppInfoTest { + @get:Rule val composeTestRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun appNameIsFogwave() { + assertEquals("Fogwave", context.getString(R.string.app_name)) + } + + @Test + fun aboutScreenShowsTheBuildVersion() { + composeTestRule.setContent { FogwaveTheme { AboutPanel() } } + + composeTestRule + .onNodeWithTag("about_version") + .assertTextEquals(context.getString(R.string.about_version, BuildConfig.VERSION_NAME)) + } + + @Test + fun stationsAreUniqueSecureAndDoNotUseExpiringTokens() { + val stations = RadioRepository(context).stations + + assertEquals(stations.size, stations.map { it.id }.toSet().size) + stations.forEach { station -> + assertTrue(station.streamUrl, station.streamUrl.startsWith("https://")) + // Short-lived tokens (e.g. revma's rj-tok) stop working within minutes. + assertFalse(station.streamUrl, station.streamUrl.contains("rj-tok")) + assertTrue(context.getString(station.descriptionRes).isNotBlank()) + assertTrue(station.genres.isNotEmpty()) + } + } +} diff --git a/app/src/test/java/cc/skysparkle/fogwave/StationItemScreenshotTest.kt b/app/src/test/java/cc/skysparkle/fogwave/StationItemScreenshotTest.kt new file mode 100644 index 0000000..b21ba44 --- /dev/null +++ b/app/src/test/java/cc/skysparkle/fogwave/StationItemScreenshotTest.kt @@ -0,0 +1,53 @@ +package cc.skysparkle.fogwave + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import cc.skysparkle.fogwave.data.model.Genre +import cc.skysparkle.fogwave.data.model.IconSizeStep +import cc.skysparkle.fogwave.data.model.RadioStation +import cc.skysparkle.fogwave.data.model.ViewMode +import cc.skysparkle.fogwave.ui.components.StationItem +import cc.skysparkle.fogwave.ui.theme.FogwaveTheme +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.Pixel8, sdk = [36]) +class StationItemScreenshotTest { + @get:Rule val composeTestRule = createComposeRule() + + private val station = RadioStation( + id = "zipfm", + name = "ZIP FM", + descriptionRes = R.string.station_desc_zip, + streamUrl = "https://example.com/stream", + iconResId = R.drawable.ic_station_zipfm, + genres = listOf(Genre.HITS, Genre.POP), + bitrate = "128 kbps" + ) + + @Test + fun stationItem_list_screenshot() { + composeTestRule.setContent { + FogwaveTheme { + StationItem( + station = station, + isCurrent = true, + isPlaying = false, + viewMode = ViewMode.LIST, + sizeStep = IconSizeStep.M, + onClick = {} + ) + } + } + + composeTestRule.onRoot().captureRoboImage(filePath = "src/test/screenshots/station_item.png") + } +} diff --git a/assets/fogwave_logo.png b/assets/fogwave_logo.png new file mode 100644 index 0000000..f83fe2e Binary files /dev/null and b/assets/fogwave_logo.png differ diff --git a/assets/play_store_icon_512.png b/assets/play_store_icon_512.png new file mode 100644 index 0000000..2b1794c Binary files /dev/null and b/assets/play_store_icon_512.png differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..082c85a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.roborazzi) apply false +} diff --git a/fastlane/metadata/android/de/changelogs/11.txt b/fastlane/metadata/android/de/changelogs/11.txt new file mode 100644 index 0000000..dc0b491 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/11.txt @@ -0,0 +1,6 @@ +• Stabile Stream-Adressen für Power Hit Radio und Power Hit Radio Gold +• Senderbeschreibungen, Genres und Equalizer-Voreinstellungen sind übersetzt +• Ein Tippen auf die Benachrichtigung öffnet die App; nach einer langen Pause geht es live weiter +• Neuverbindungsversuche enden nach einigen Minuten und schonen den Akku +• Der Player lässt sich überall nach unten wischen, um ihn zu schließen +• Verbesserte Barrierefreiheit und Darstellung auf kleinen Bildschirmen diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt new file mode 100644 index 0000000..48720ba --- /dev/null +++ b/fastlane/metadata/android/de/full_description.txt @@ -0,0 +1,20 @@ +Fogwave ist ein schlichter, werbefreier Internetradio-Player für litauische Sender. + +Sender +ZIP FM, Power Hit Radio, Power Hit Radio Gold, Rock FM, Relax FM, Lietus, M-1, M-1 Plius, Gold FM und RC. + +Funktionen +
    +
  • Wiedergabe im Hintergrund mit Steuerung über Benachrichtigung, Sperrbildschirm, Headset und Bluetooth
  • +
  • Unterstützung für Android Auto
  • +
  • Automatische Neuverbindung, wenn der Stream abbricht oder das Netz zurückkommt
  • +
  • Equalizer mit Voreinstellungen und Bandregelung, Lautheitsverstärker und Dynamikbearbeitung
  • +
  • Favoriten, zuletzt gehörte und ähnliche Sender
  • +
  • Vier Ansichten – Liste, Tabelle, Kacheln und Symbole – mit einstellbarer Größe
  • +
  • Englisch, Russisch, Deutsch, Litauisch, Japanisch und Chinesisch
  • +
+ +Datenschutz +Keine Konten, keine Werbung, keine Analyse. Der Ton kommt direkt von den Servern der Sender; Fogwave speichert oder verbreitet ihn nicht. + +Sendernamen und Logos gehören ihren jeweiligen Inhabern. diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt new file mode 100644 index 0000000..610bcff --- /dev/null +++ b/fastlane/metadata/android/de/short_description.txt @@ -0,0 +1 @@ +Schlichtes, werbefreies Internetradio mit litauischen Sendern diff --git a/fastlane/metadata/android/de/title.txt b/fastlane/metadata/android/de/title.txt new file mode 100644 index 0000000..77f1297 --- /dev/null +++ b/fastlane/metadata/android/de/title.txt @@ -0,0 +1 @@ +Fogwave diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 0000000..108fb7b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11.txt @@ -0,0 +1,6 @@ +• Stable stream addresses for Power Hit Radio and Power Hit Radio Gold +• Station descriptions, genres and equalizer presets are now translated +• Tapping the notification opens the app; resuming after a long pause jumps back to live +• Reconnection stops after a few minutes instead of draining the battery +• Swipe down anywhere on the player to close it +• Accessibility fixes and better layout on small screens diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..4bdefca --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,20 @@ +Fogwave is a minimal, ad-free internet radio player for Lithuanian stations. + +Stations +ZIP FM, Power Hit Radio, Power Hit Radio Gold, Rock FM, Relax FM, Lietus, M-1, M-1 Plius, Gold FM and RC. + +Features +
    +
  • Background playback with notification, lock-screen, headset and Bluetooth controls
  • +
  • Android Auto support
  • +
  • Automatic reconnection when the stream drops or the network comes back
  • +
  • Equalizer with presets and per-band control, loudness enhancer and dynamics processing
  • +
  • Favorites, recently played and similar stations
  • +
  • Four layouts — list, table, tiles and icons — with adjustable size
  • +
  • English, Russian, German, Lithuanian, Japanese and Chinese
  • +
+ +Privacy +No accounts, no ads, no analytics. Audio is streamed directly from each broadcaster; Fogwave does not host or redistribute it. + +Station names and logos belong to their respective owners. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..de1da80 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..2b1794c Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..6d7accf Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..2c6c4a7 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..333979e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..c5d5c41 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..a673058 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..14ce9d0 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Minimal ad-free internet radio for Lithuanian stations diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..77f1297 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Fogwave diff --git a/fastlane/metadata/android/ja/changelogs/11.txt b/fastlane/metadata/android/ja/changelogs/11.txt new file mode 100644 index 0000000..36ac77e --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/11.txt @@ -0,0 +1,6 @@ +• Power Hit Radio と Power Hit Radio Gold のストリーム URL を安定化 +• 放送局の説明、ジャンル、イコライザーのプリセットを翻訳 +• 通知をタップするとアプリが開き、長い一時停止の後はライブから再開 +• 再接続は数分で停止し、バッテリーを消耗しません +• プレーヤーはどこからでも下にスワイプして閉じられます +• アクセシビリティと小さな画面でのレイアウトを改善 diff --git a/fastlane/metadata/android/ja/full_description.txt b/fastlane/metadata/android/ja/full_description.txt new file mode 100644 index 0000000..d395f2c --- /dev/null +++ b/fastlane/metadata/android/ja/full_description.txt @@ -0,0 +1,20 @@ +Fogwave は、リトアニアのラジオ局を聴くための広告なしのシンプルなインターネットラジオプレーヤーです。 + +放送局 +ZIP FM、Power Hit Radio、Power Hit Radio Gold、Rock FM、Relax FM、Lietus、M-1、M-1 Plius、Gold FM、RC。 + +機能 +
    +
  • 通知、ロック画面、ヘッドセット、Bluetooth から操作できるバックグラウンド再生
  • +
  • Android Auto に対応
  • +
  • ストリームが途切れたときやネットワーク復帰時に自動で再接続
  • +
  • プリセットと帯域ごとの調整ができるイコライザー、ラウドネスエンハンサー、ダイナミクス処理
  • +
  • お気に入り、最近の再生、類似の放送局
  • +
  • リスト・テーブル・タイル・アイコンの 4 つの表示とサイズ調整
  • +
  • 英語、ロシア語、ドイツ語、リトアニア語、日本語、中国語に対応
  • +
+ +プライバシー +アカウント、広告、解析は一切ありません。音声は各放送局から直接配信され、Fogwave が保存・再配布することはありません。 + +放送局の名称とロゴは各権利者に帰属します。 diff --git a/fastlane/metadata/android/ja/short_description.txt b/fastlane/metadata/android/ja/short_description.txt new file mode 100644 index 0000000..5e5ebf8 --- /dev/null +++ b/fastlane/metadata/android/ja/short_description.txt @@ -0,0 +1 @@ +リトアニアのラジオ局を聴ける、広告なしのシンプルなネットラジオ diff --git a/fastlane/metadata/android/ja/title.txt b/fastlane/metadata/android/ja/title.txt new file mode 100644 index 0000000..77f1297 --- /dev/null +++ b/fastlane/metadata/android/ja/title.txt @@ -0,0 +1 @@ +Fogwave diff --git a/fastlane/metadata/android/lt/changelogs/11.txt b/fastlane/metadata/android/lt/changelogs/11.txt new file mode 100644 index 0000000..6b0458c --- /dev/null +++ b/fastlane/metadata/android/lt/changelogs/11.txt @@ -0,0 +1,6 @@ +• Stabilūs Power Hit Radio ir Power Hit Radio Gold srautų adresai +• Išversti stočių aprašymai, žanrai ir ekvalaizerio nustatymai +• Paspaudus pranešimą atsidaro programa; po ilgos pauzės grįžtama į tiesioginį eterį +• Prisijungimo bandymai po kelių minučių sustoja ir neeikvoja baterijos +• Grotuvą galima uždaryti braukiant žemyn bet kurioje vietoje +• Geresnis prieinamumas ir išdėstymas mažuose ekranuose diff --git a/fastlane/metadata/android/lt/full_description.txt b/fastlane/metadata/android/lt/full_description.txt new file mode 100644 index 0000000..23d60eb --- /dev/null +++ b/fastlane/metadata/android/lt/full_description.txt @@ -0,0 +1,20 @@ +Fogwave – paprastas internetinio radijo grotuvas be reklamų, skirtas Lietuvos radijo stotims. + +Stotys +ZIP FM, Power Hit Radio, Power Hit Radio Gold, Rock FM, Relax FM, Lietus, M-1, M-1 Plius, Gold FM ir RC. + +Galimybės +
    +
  • Grojimas fone su valdymu iš pranešimo, užrakinto ekrano, ausinių ir per Bluetooth
  • +
  • Android Auto palaikymas
  • +
  • Automatinis prisijungimas iš naujo, kai srautas nutrūksta arba grįžta ryšys
  • +
  • Ekvalaizeris su nustatymais ir juostų valdymu, garsumo stiprintuvas ir dinamikos apdorojimas
  • +
  • Mėgstamos, neseniai klausytos ir panašios stotys
  • +
  • Keturi vaizdai – sąrašas, lentelė, plytelės ir piktogramos – su keičiamu dydžiu
  • +
  • Anglų, rusų, vokiečių, lietuvių, japonų ir kinų kalbos
  • +
+ +Privatumas +Jokių paskyrų, reklamų ar analitikos. Garsas transliuojamas tiesiai iš radijo stočių serverių; Fogwave jo nesaugo ir neplatina. + +Stočių pavadinimai ir logotipai priklauso jų savininkams. diff --git a/fastlane/metadata/android/lt/short_description.txt b/fastlane/metadata/android/lt/short_description.txt new file mode 100644 index 0000000..aea9cc7 --- /dev/null +++ b/fastlane/metadata/android/lt/short_description.txt @@ -0,0 +1 @@ +Paprastas internetinis radijas su Lietuvos stotimis, be reklamų diff --git a/fastlane/metadata/android/lt/title.txt b/fastlane/metadata/android/lt/title.txt new file mode 100644 index 0000000..77f1297 --- /dev/null +++ b/fastlane/metadata/android/lt/title.txt @@ -0,0 +1 @@ +Fogwave diff --git a/fastlane/metadata/android/ru/changelogs/11.txt b/fastlane/metadata/android/ru/changelogs/11.txt new file mode 100644 index 0000000..91b7e38 --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/11.txt @@ -0,0 +1,6 @@ +• Стабильные адреса потоков Power Hit Radio и Power Hit Radio Gold +• Описания станций, жанры и пресеты эквалайзера переведены +• Нажатие на уведомление открывает приложение; после долгой паузы эфир продолжается с прямого включения +• Переподключение прекращается через несколько минут и не разряжает батарею +• Плеер закрывается свайпом вниз в любом месте +• Улучшена доступность и вёрстка на маленьких экранах diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt new file mode 100644 index 0000000..5bf4dd0 --- /dev/null +++ b/fastlane/metadata/android/ru/full_description.txt @@ -0,0 +1,20 @@ +Fogwave — минималистичный интернет-радиоплеер без рекламы для литовских радиостанций. + +Станции +ZIP FM, Power Hit Radio, Power Hit Radio Gold, Rock FM, Relax FM, Lietus, M-1, M-1 Plius, Gold FM и RC. + +Возможности +
    +
  • Фоновое воспроизведение с управлением из уведомления, с экрана блокировки, с гарнитуры и по Bluetooth
  • +
  • Поддержка Android Auto
  • +
  • Автоматическое переподключение при обрыве потока и при появлении сети
  • +
  • Эквалайзер с пресетами и настройкой полос, усилитель громкости и обработка динамики
  • +
  • Избранное, недавно прослушанные и похожие станции
  • +
  • Четыре вида — список, таблица, плитки и значки — с настройкой размера
  • +
  • Английский, русский, немецкий, литовский, японский и китайский языки
  • +
+ +Конфиденциальность +Без аккаунтов, рекламы и аналитики. Звук передаётся напрямую с серверов радиостанций; Fogwave его не хранит и не распространяет. + +Названия и логотипы станций принадлежат их владельцам. diff --git a/fastlane/metadata/android/ru/short_description.txt b/fastlane/metadata/android/ru/short_description.txt new file mode 100644 index 0000000..d453b5a --- /dev/null +++ b/fastlane/metadata/android/ru/short_description.txt @@ -0,0 +1 @@ +Минималистичное интернет-радио с литовскими станциями, без рекламы diff --git a/fastlane/metadata/android/ru/title.txt b/fastlane/metadata/android/ru/title.txt new file mode 100644 index 0000000..77f1297 --- /dev/null +++ b/fastlane/metadata/android/ru/title.txt @@ -0,0 +1 @@ +Fogwave diff --git a/fastlane/metadata/android/zh-CN/changelogs/11.txt b/fastlane/metadata/android/zh-CN/changelogs/11.txt new file mode 100644 index 0000000..a04e325 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/11.txt @@ -0,0 +1,6 @@ +• Power Hit Radio 与 Power Hit Radio Gold 使用稳定的音频流地址 +• 电台介绍、流派和均衡器预设现已翻译 +• 点按通知即可打开应用;长时间暂停后恢复播放会回到直播 +• 重新连接几分钟后自动停止,不再耗电 +• 在播放器任意位置下滑即可关闭 +• 改进无障碍功能和小屏幕布局 diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 0000000..dac3318 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1,20 @@ +Fogwave 是一款简洁、无广告的网络电台播放器,收录立陶宛电台。 + +电台 +ZIP FM、Power Hit Radio、Power Hit Radio Gold、Rock FM、Relax FM、Lietus、M-1、M-1 Plius、Gold FM 和 RC。 + +功能 +
    +
  • 后台播放,可通过通知、锁屏、耳机和蓝牙控制
  • +
  • 支持 Android Auto
  • +
  • 音频流中断或网络恢复时自动重新连接
  • +
  • 带预设和分频段调节的均衡器、响度增强和动态处理
  • +
  • 收藏、最近播放和相似电台
  • +
  • 列表、表格、卡片和图标四种视图,尺寸可调
  • +
  • 支持英语、俄语、德语、立陶宛语、日语和中文
  • +
+ +隐私 +无需账号,无广告,无统计分析。音频直接来自各电台服务器,Fogwave 不存储也不转发。 + +电台名称和标志归其各自所有者所有。 diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 0000000..a06a12a --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +收听立陶宛电台的简洁无广告网络电台 diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt new file mode 100644 index 0000000..77f1297 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/title.txt @@ -0,0 +1 @@ +Fogwave diff --git a/fdroid/cc.skysparkle.fogwave.yml b/fdroid/cc.skysparkle.fogwave.yml new file mode 100644 index 0000000..f9f7bab --- /dev/null +++ b/fdroid/cc.skysparkle.fogwave.yml @@ -0,0 +1,30 @@ +# Build recipe for the F-Droid main repository. +# Submit it as metadata/cc.skysparkle.fogwave.yml in a merge request to +# https://gitlab.com/fdroid/fdroiddata. Descriptions, screenshots and changelogs are read from +# fastlane/metadata/android in this repository, so they are not repeated here. + +Categories: + - Multimedia +License: GPL-3.0-only +SourceCode: https://git.skysparkle.cc/kalarina/Fogwave +IssueTracker: https://git.skysparkle.cc/kalarina/Fogwave/issues + +AntiFeatures: + NonFreeAssets: + en-US: Station logos are trademarks of their broadcasters and the background photo is under the Unsplash License. + +RepoType: git +Repo: https://git.skysparkle.cc/kalarina/Fogwave.git + +Builds: + - versionName: 1.0.11 + versionCode: 11 + commit: v1.0.11 + subdir: app + gradle: + - yes + +AutoUpdateMode: Version +UpdateCheckMode: Tags +CurrentVersion: 1.0.11 +CurrentVersionCode: 11 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..e888e6c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +kotlin.code.style=official +android.nonTransitiveRClass=true +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.workers.max=4 +kotlin.compiler.execution.strategy=in-process diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..09ad242 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,52 @@ +[versions] +agp = "9.2.0" +coreKtx = "1.18.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleViewmodelCompose = "2.11.0" +lifecycleRuntimeCompose = "2.11.0" +activityCompose = "1.13.0" +kotlin = "2.4.20" +composeBom = "2026.09.00" +core = "1.6.1" +runner = "1.6.2" +coilCompose = "2.7.0" +kotlinxCoroutinesAndroid = "1.11.0" +kotlinxCoroutinesCore = "1.11.0" +robolectric = "4.16.1" +roborazzi = "1.59.0" +media3 = "1.11.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } +androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } +roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c42672d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..38f59fa --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Fogwave" + +include(":app")