15 Commits

Author SHA1 Message Date
nk
55a380d34b chore(app): bump 9 / 1.8 (фикс восстановления потока после смены сети) 2026-06-11 23:25:36 +03:00
nk
75d256eda5 fix(player): восстановление потока после смены/пропажи сети (машина, туннели)
Причина «падений через 10-15 мин в машине»: при смене сети (Wi-Fi↔LTE) или долгом
обрыве поток рвался, а переподключение СДАВАЛОСЬ навсегда после 10 попыток (~100с),
и не было реакции на возврат сети → радио не оживало, foreground-сервис отваливался,
процесс убивала система (это выглядело как «падение», хотя крэша не было).

- scheduleReconnect больше не сдаётся: переподключается, пока пользователь хочет
  играть (флаг intendedToPlay; пауза/стоп его снимают).
- Добавлен ConnectivityManager.registerDefaultNetworkCallback: при возврате/смене
  сети мгновенно re-prepare потока, не дожидаясь бэк-оффа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:22:39 +03:00
nk
1771a5b975 chore(repo): игнор tempfiles/, фикс дизайн-файла и ассетов RuStore
- tempfiles/ в .gitignore (скрэтч: картинки, HTML-эксперименты, мокапы),
  кроме tempfiles/radiOLA.pen — дизайн-файл остаётся под версией
- коммит изменений radiOLA.pen
- docs/rustore-listing.md и design/logos/*.html — ассеты карточки RuStore

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:28:48 +03:00
nk
91777fc459 feat(background): подсказка ColorOS «Разрешить работу в фоне» + bump 8/1.7
На Oppo/OnePlus/Realme стандартного исключения из оптимизации батареи мало —
система схлопывает приложение в фоне. Один раз показываем пояснение и открываем
настройки приложения, чтобы юзер включил «Разрешить работу в фоновом режиме».
2026-06-11 14:46:06 +03:00
nk
7a00f53b20 fix(now-playing): мгновенный рефреш эфира при возврате из фона + восстановление сессии
Опрос now-playing был привязан к play()/жизни ViewModel — при заморозке ColorOS
в фоне или пересоздании ViewModel трек «застывал». Теперь: startNowPlaying()
с мгновенным refresh, восстановление привязки к играющей станции из
PlayerController.currentStationId, и onAppForeground() на ON_RESUME.
2026-06-10 18:05:32 +03:00
nk
3c7ae1eb4c fix(deeplink): объявить видимость пакета re.sova.five (Android 11+ queries) 2026-06-08 14:52:05 +03:00
nk
d31e5d1119 chore(app): bump версии до 7 / 1.6 (RuStore-релиз) 2026-06-08 14:45:36 +03:00
nk
ab09d92b0d feat(deeplink): кнопка поиска в SOVA (re.sova.five, только sideload) 2026-06-08 14:41:42 +03:00
nk
c75ff8cb9a build(app): релизная подпись из keystore.properties (Задача 6) 2026-06-08 14:26:02 +03:00
nk
9e729512e9 build: игнорировать keystore.properties и *.jks (релизные секреты) 2026-06-08 14:06:26 +03:00
nk
92a7c614c1 feat(deeplink): прямое открытие в пакете стороннего сервиса (packageName) 2026-06-08 14:05:59 +03:00
nk
cbd6451ee0 refactor(settings): убрать осиротевший тумблер записи; тестер под SHOW_DEV_TOOLS 2026-06-08 14:05:11 +03:00
nk
6a21a84b86 build(app): REQUEST_INSTALL_PACKAGES только в sideload 2026-06-08 14:03:21 +03:00
nk
8dc0d46c40 feat(app): апдейтер только в sideload; API/BASE_URL на https 2026-06-08 14:03:21 +03:00
nk
34bd6ab02e build(app): product flavors store/sideload + BuildConfig флаги 2026-06-08 14:03:21 +03:00
23 changed files with 5339 additions and 364 deletions

10
.gitignore vendored
View File

@@ -26,6 +26,16 @@ app/build/
# Kotlin
.kotlin/
# Релизная подпись (секреты — никогда в git)
keystore.properties
*.jks
*.keystore
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
design/logos/gen/
design/logos/ref_*.png
# Скрэтч-папка (картинки, HTML-эксперименты, мокапы RuStore) — не версионируем
tempfiles/
# ...кроме дизайн-файла Pencil — он остаётся под версией
!tempfiles/radiOLA.pen

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -7,6 +9,12 @@ plugins {
alias(libs.plugins.hilt)
}
// Релизная подпись: пароли/путь к keystore — в keystore.properties (в .gitignore).
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) }
}
android {
namespace = "com.radiola"
compileSdk = 34
@@ -15,8 +23,8 @@ android {
applicationId = "com.radiola"
minSdk = 26
targetSdk = 34
versionCode = 6
versionName = "1.5"
versionCode = 9
versionName = "1.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -24,8 +32,20 @@ android {
}
}
signingConfigs {
create("release") {
if (keystorePropsFile.exists()) {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -33,6 +53,22 @@ android {
)
}
}
// Каналы дистрибуции: store — для RuStore (без авто-апдейтера, без dev-тестера
// и кнопки SOVA); sideload — прямой APK с авто-обновлением.
flavorDimensions += "distribution"
productFlavors {
create("store") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "false")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "false")
}
create("sideload") {
dimension = "distribution"
buildConfigField("boolean", "ENABLE_SELF_UPDATE", "true")
buildConfigField("boolean", "SHOW_DEV_TOOLS", "true")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17

View File

@@ -11,8 +11,6 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />

View File

@@ -109,6 +109,19 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
// Возврат на передний план → мгновенно освежаем now-playing
// (фоновая заморозка ColorOS может останавливать опрос эфира).
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
playerViewModel.onAppForeground()
}
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
// --- Авто-обновление: проверяем версию на старте, показываем диалог ---
var pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) }
var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) }
@@ -117,6 +130,8 @@ class MainActivity : ComponentActivity() {
var downloadedApk by remember { mutableStateOf<java.io.File?>(null) }
val updateScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Авто-обновление только в sideload-сборке (в store обновляет RuStore).
if (!BuildConfig.ENABLE_SELF_UPDATE) return@LaunchedEffect
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
}
@@ -355,18 +370,64 @@ class MainActivity : ComponentActivity() {
private fun maybeRequestBatteryExemption() {
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
// Спрашиваем один раз на установку, чтобы не надоедать.
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("battery_opt_asked", false)) return
prefs.edit().putBoolean("battery_opt_asked", true).apply()
runCatching {
startActivity(
Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
android.net.Uri.parse("package:$packageName")
// Спрашиваем один раз на установку, чтобы не надоедать.
if (!pm.isIgnoringBatteryOptimizations(packageName) &&
!prefs.getBoolean("battery_opt_asked", false)
) {
prefs.edit().putBoolean("battery_opt_asked", true).apply()
runCatching {
startActivity(
Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
android.net.Uri.parse("package:$packageName")
)
)
)
}
}
maybeGuideColorOsBackground()
}
/**
* ColorOS/OxygenOS (Oppo, OnePlus, Realme) агрессивно «схлопывают» приложение в
* фоне при выключённом экране — стандартного исключения из оптимизации батареи
* мало. Реально помогает галочка «Разрешить работу в фоновом режиме» в разделе
* «Использование батареи» приложения. Прямого API для неё нет, поэтому один раз
* показываем пояснение и открываем экран настроек приложения, чтобы юзер включил.
*/
private fun maybeGuideColorOsBackground() {
val m = android.os.Build.MANUFACTURER.lowercase()
val isColorOs = m.contains("oppo") || m.contains("oneplus") || m.contains("realme")
if (!isColorOs) return
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("bg_activity_guided", false)) return
prefs.edit().putBoolean("bg_activity_guided", true).apply()
runCatching {
android.app.AlertDialog.Builder(this)
.setTitle("Фоновое воспроизведение")
.setMessage(
"На вашем устройстве система может выгружать приложение при " +
"выключённом экране, и радио прерывается.\n\n" +
"Чтобы этого не происходило, откройте «Использование батареи» " +
"и включите «Разрешить работу в фоновом режиме»."
)
.setPositiveButton("Открыть настройки") { _, _ -> openAppBatterySettings() }
.setNegativeButton("Позже", null)
.show()
}
}
/** Экран «Использование батареи» приложения; фолбэк — страница «О приложении». */
private fun openAppBatterySettings() {
val uri = android.net.Uri.parse("package:$packageName")
val candidates = listOf(
// На части ColorOS открывает прямо управление батареей приложения.
Intent("android.settings.APP_BATTERY_SETTINGS").apply { data = uri },
// Универсальный фолбэк — «О приложении», оттуда «Использование батареи».
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
)
for (intent in candidates) {
if (runCatching { startActivity(intent); true }.getOrDefault(false)) return
}
}
}

View File

@@ -28,7 +28,6 @@ class SettingsRepositoryImpl @Inject constructor(
private val SLEEP_TIMER = intPreferencesKey("sleep_timer_minutes")
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
private val COUNTRY_CODE = stringPreferencesKey("country_code")
private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style")
@@ -57,9 +56,6 @@ class SettingsRepositoryImpl @Inject constructor(
override fun getEqualizerPreset(): Flow<String> = dataStore.data.map { it[EQUALIZER_PRESET] ?: "Flat" }
override suspend fun setEqualizerPreset(preset: String) { dataStore.edit { it[EQUALIZER_PRESET] = preset } }
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
override fun getPreferredBitrate(): Flow<Int> = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 }
override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } }

View File

@@ -15,6 +15,32 @@ object DeeplinkNavigator {
val url = service.buildSearchUrl(track.artist, track.song)
Log.d("DeeplinkNavigator", "url=$url")
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val pkg = service.packageName
if (pkg != null) {
// Сторонний клиент: открыть напрямую в его пакете, если установлен.
val installed = try {
context.packageManager.getPackageInfo(pkg, 0)
true
} catch (e: Exception) {
false
}
if (installed) {
intent.setPackage(pkg)
try {
context.startActivity(intent)
return
} catch (e: Exception) {
Log.e("DeeplinkNavigator", "Не удалось открыть в $pkg", e)
// упадём в общий путь ниже (системный выбор / браузер)
}
} else {
Toast.makeText(context, "${service.displayName} не установлено", Toast.LENGTH_SHORT).show()
return
}
}
// Обычные сервисы (или фолбэк) — системный выбор приложения.
try {
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
} catch (e: Exception) {

View File

@@ -123,7 +123,7 @@ object AppModule {
@Named("radiolaClient") okHttpClient: OkHttpClient,
json: Json
): Retrofit = Retrofit.Builder()
.baseUrl("http://121.127.37.212:3000/")
.baseUrl("https://api.radiola.nexaweb.su/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()

View File

@@ -3,7 +3,10 @@ package com.radiola.domain.model
enum class DeeplinkService(
val serviceId: String,
val displayName: String,
val searchUrlTemplate: String
val searchUrlTemplate: String,
// Пакет стороннего приложения: если задан — открываем поиск прямо в нём
// (setPackage), иначе через системный выбор «Открыть в...».
val packageName: String? = null
) {
YANDEX("yandex", "Яндекс Музыка", "https://music.yandex.ru/search?text=%s"),
VK("vk", "ВК Музыка", "https://vk.com/audio?q=%s"),
@@ -12,7 +15,10 @@ enum class DeeplinkService(
APPLE_MUSIC("apple", "Apple Music", "https://music.apple.com/search?term=%s"),
YOUTUBE_MUSIC("youtube", "YouTube Music", "https://music.youtube.com/search?q=%s"),
TIDAL("tidal", "Tidal", "https://listen.tidal.com/search?q=%s"),
DEEZER("deezer", "Deezer", "https://www.deezer.com/search/%s");
DEEZER("deezer", "Deezer", "https://www.deezer.com/search/%s"),
// Сторонний клиент ВК (мод VK 6.12). Открываем поиск музыки напрямую в его
// пакете через LinkRedirActivity. Доступен только в sideload-сборке.
SOVA("sova", "SOVA", "https://vk.com/audio?q=%s", packageName = "re.sova.five");
fun buildSearchUrl(artist: String, song: String): String {
val query = java.net.URLEncoder.encode("$artist $song", "UTF-8")

View File

@@ -12,8 +12,6 @@ interface SettingsRepository {
suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>)
fun getEqualizerPreset(): Flow<String>
suspend fun setEqualizerPreset(preset: String)
fun isRecordingEnabled(): Flow<Boolean>
suspend fun setRecordingEnabled(enabled: Boolean)
// Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции).
fun getPreferredBitrate(): Flow<Int>
suspend fun setPreferredBitrate(bitrate: Int)

View File

@@ -4,6 +4,8 @@ import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.Uri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
@@ -90,6 +92,31 @@ class PlayerController @Inject constructor(
private var retryCount = 0
private var reconnectJob: Job? = null
// Намерение играть: пользователь включил станцию и не ставил паузу/стоп.
// По нему решаем, переподключаться ли после обрыва — НЕ сдаёмся навсегда.
@Volatile private var intendedToPlay = false
// Слушатель сети: возврат/смена сети (Wi-Fi↔LTE в машине, выход из туннеля)
// мгновенно переподключает поток, не дожидаясь бэк-оффа. Главная причина, по
// которой радио раньше «не оживало» после смены сети.
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (!intendedToPlay) return
// Колбэк — на системном потоке; ExoPlayer трогаем на main (timerScope).
timerScope.launch {
if (!exoPlayer.isPlaying) {
retryCount = 0
runCatching {
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
}
}
}
private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
@@ -212,7 +239,10 @@ class PlayerController @Inject constructor(
*/
private fun scheduleReconnect() {
reconnectJob?.cancel()
if (retryCount >= 10) return
// Пока пользователь хочет играть — пробуем переподключаться бесконечно
// (с бэк-оффом до 15с). Раньше сдавались навсегда после 10 попыток (~100с) →
// длинный туннель/смена сети глушили радио до ручного перезапуска.
if (!intendedToPlay) return
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
retryCount++
reconnectJob = timerScope.launch {
@@ -244,6 +274,7 @@ class PlayerController @Inject constructor(
init {
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
runCatching { connectivityManager?.registerDefaultNetworkCallback(networkCallback) }
}
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
@@ -251,6 +282,7 @@ class PlayerController @Inject constructor(
// Новая станция — сбрасываем переподключение предыдущего потока.
reconnectJob?.cancel()
retryCount = 0
intendedToPlay = true
_currentStationId.value = stationId
_icyTitle.value = null
val mediaItem = MediaItem.Builder()
@@ -412,17 +444,19 @@ class PlayerController @Inject constructor(
}
fun pause() {
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
// позже само возобновит воспроизведение.
// Пауза пользователем — больше не хотим играть, отменяем переподключение.
intendedToPlay = false
reconnectJob?.cancel()
exoPlayer.pause()
}
fun play() {
intendedToPlay = true
exoPlayer.play()
}
fun stop() {
intendedToPlay = false
reconnectJob?.cancel()
exoPlayer.stop()
_currentStationPrefix.value = null
@@ -432,6 +466,7 @@ class PlayerController @Inject constructor(
fun release() {
timerScope.cancel()
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
runCatching { connectivityManager?.unregisterNetworkCallback(networkCallback) }
sleepSoundPlayer.stop()
exoPlayer.release()
}

View File

@@ -108,12 +108,30 @@ class PlayerViewModel @Inject constructor(
}
viewModelScope.launch {
settingsRepository.getEnabledDeeplinkServices().collect { ids ->
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
_enabledServices.value = DeeplinkService.entries.filter {
it.serviceId in ids &&
// SOVA (сторонний мод ВК) — только в sideload-сборке.
(com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA)
}
}
}
viewModelScope.launch {
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
}
// Восстановление сессии: если процесс/Activity пересоздались, а станция уже
// играет в фоновом сервисе (PlayerController помнит id) — заново привязываемся
// и запускаем опрос now-playing. Иначе мини-плеер/эфир «застывают».
viewModelScope.launch {
combine(playerController.currentStationId, _stations) { id, list ->
id?.let { sid -> list.firstOrNull { it.id == sid } }
}.collect { station ->
if (station != null && _currentStation.value == null) {
_currentStation.value = station
_playlist.value = _stations.value
startNowPlaying(station)
}
}
}
viewModelScope.launch {
_currentTrack
.filterNotNull()
@@ -146,8 +164,20 @@ class PlayerViewModel @Inject constructor(
playerController.play(url, station.prefix, station.name, station.id)
}
viewModelScope.launch { pushHistoryUseCase(station.id) }
startNowPlaying(station)
}
/**
* Запускает опрос now-playing для станции: мгновенный рефреш + цикл раз в 5с
* (пока играем) + сбор трека из API (приоритет) и ICY (фолбэк). Вынесено из
* play(), чтобы переиспользовать при восстановлении сессии (возврат из фона /
* пересоздание ViewModel) — иначе эфир «застывает» на последнем значении.
*/
private fun startNowPlaying(station: Station) {
nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch {
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
launch { nowPlayingRepository.refreshNowPlaying() }
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
// каждые 5с → батарея + лишняя нагрузка на бэкенд).
@@ -204,6 +234,17 @@ class PlayerViewModel @Inject constructor(
}
}
/**
* Возврат приложения на передний план: мгновенно освежаем эфир (чтобы юзер не
* видел залипший трек после фоновой заморозки) и, если опрос почему-то не идёт,
* перезапускаем его для текущей станции.
*/
fun onAppForeground() {
val station = _currentStation.value ?: return
viewModelScope.launch { nowPlayingRepository.refreshNowPlaying() }
if (nowPlayingJob?.isActive != true) startNowPlaying(station)
}
/** Стартовое качество станции с учётом предпочтения пользователя. */
private fun pickInitialQuality(station: Station): StreamQuality? {
val list = station.qualities

View File

@@ -50,7 +50,6 @@ fun SettingsScreen(
val enabledServices by viewModel.enabledServices.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val themePalette by viewModel.themePalette.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState()
@@ -408,7 +407,11 @@ fun SettingsScreen(
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) {
DeeplinkService.entries.forEachIndexed { index, service ->
// В store-сборке скрываем SOVA (сторонний мод ВК) — только sideload.
val services = DeeplinkService.entries.filter {
com.radiola.BuildConfig.SHOW_DEV_TOOLS || it != DeeplinkService.SOVA
}
services.forEachIndexed { index, service ->
val checked = service.serviceId in enabledServices
val trackColor by animateColorAsState(
targetValue = if (checked) colors.accent else colors.surface2,
@@ -439,7 +442,7 @@ fun SettingsScreen(
)
)
}
if (index < DeeplinkService.entries.size - 1) {
if (index < services.size - 1) {
HorizontalDivider(
color = colors.border,
modifier = Modifier.padding(horizontal = 16.dp)
@@ -449,51 +452,8 @@ fun SettingsScreen(
}
}
// --- Запись эфира ---
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Запись эфира",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "Сохранять в файл при воспроизведении",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Switch(
checked = isRecordingEnabled,
onCheckedChange = { viewModel.setRecordingEnabled(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
}
}
// --- Тестирование станций ---
item {
// --- Тестирование станций (dev-инструмент, только в sideload) ---
if (com.radiola.BuildConfig.SHOW_DEV_TOOLS) item {
SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ")
Spacer(Modifier.height(8.dp))
Column(

View File

@@ -38,9 +38,6 @@ class SettingsViewModel @Inject constructor(
val themePalette: StateFlow<String> = settingsRepository.getThemePalette()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest")
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
@@ -91,10 +88,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setThemePalette(id) }
}
fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
}
fun startTesting() {
viewModelScope.launch {
_isTesting.value = true

View File

@@ -32,7 +32,7 @@ data class VersionInfo(
*/
object UpdateManager {
private const val TAG = "radiOLA/Update"
private const val BASE_URL = "http://121.127.37.212:3000"
private const val BASE_URL = "https://api.radiola.nexaweb.su"
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Установка скачанного APK — только в sideload-сборке (авто-апдейтер).
В store-сборке этого разрешения нет (требование RuStore). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Видимость пакета SOVA (Android 11+ package visibility): без этого
getPackageInfo/launch в re.sova.five блокируется и кнопка считает,
что приложение не установлено. Только sideload (в store кнопки SOVA нет). -->
<queries>
<package android:name="re.sova.five" />
</queries>
</manifest>

27
design/logos/icon_01.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_02.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_03.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_04.html Normal file

File diff suppressed because one or more lines are too long

27
design/logos/icon_05.html Normal file

File diff suppressed because one or more lines are too long

87
design/logos/sheet.html Normal file

File diff suppressed because one or more lines are too long

83
docs/rustore-listing.md Normal file
View File

@@ -0,0 +1,83 @@
# Карточка radiOLA для RuStore
Дата: 2026-06-08. Черновик контента и ассетов для публикации в RuStore.
Связано: [дизайн релиза](superpowers/specs/2026-06-08-rustore-release-design.md), раздел F.
## Основное
| Поле | Значение |
|---|---|
| Название в Консоли (внутреннее, не меняется) | `radiOLA` |
| Название для пользователей | `radiOLA — радио онлайн` |
| Тип приложения | Универсальное |
| Тип монетизации | Бесплатное |
| Категория | Музыка и аудио |
| Возрастной рейтинг | 12+ (возможна ненормативная лирика в потоках) |
| Политика конфиденциальности | `https://api.radiola.nexaweb.su/privacy` (⏳ захостить) |
## Страны и регионы
Россия, Беларусь, Казахстан, Кыргызстан, Армения (в каталоге есть белорусские
станции — Unistar, Новое Радио BY).
## Краткое описание
```
Онлайн-радио: сотни станций, тексты песен, распознавание треков, запись эфира
```
## Полное описание
```
radiOLA — удобный плеер интернет-радио с сотнями станций и умными функциями.
• Сотни радиостанций — музыка, новости, разговорные
• Что играет сейчас: трек, исполнитель и обложка в реальном времени
• Чарты популярных треков на радио — что крутят чаще всего, с фильтром
по жанрам и периодам, трендами роста и графиком популярности
• Тексты песен прямо во время эфира
• Распознавание треков (даже без метаданных станции)
• Быстрый поиск играющего трека в Яндекс Музыке, ВК Музыке, Spotify,
Apple Music, YouTube Music, BOOM, Tidal и Deezer — одним касанием
• История прослушанного и распознанных песен
• Запись эфира с перемоткой и тайм-кодами
• Эквалайзер и улучшайзеры звука
• Таймер сна и будильник с радио
• Выбор качества потока, 8 цветовых тем
• Фоновое воспроизведение, управление с локскрина
```
## Ассеты (готовы)
Папка: `tempfiles/screensforRuStore/out/`
| Файл | Назначение |
|---|---|
| `icon_512.png` | Иконка 512×512 (тема forest, композит ic_bg/ic_fg_forest) |
| `rustore_1.png` | Каталог станций |
| `rustore_2.png` | Плеер + поиск трека в сервисах |
| `rustore_3.png` | Чарты |
| `rustore_4.png` | Тексты песен |
| `rustore_5.png` | Записи эфира |
| `rustore_6.png` | Эквалайзер |
| `rustore_7.png` | Таймер сна |
| `rustore_8.png` | Будильник / настройки |
Пересборка скриншотов: `tempfiles/screensforRuStore/gen.py``html/` → headless Chrome.
## Заметка модератору
- `SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM` — будильник с радио (точное время срабатывания).
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — фоновое воспроизведение при выключенном
экране / в машине. Готовы убрать по требованию модерации.
- Приложение-агрегатор: воспроизводит публичные интернет-радиопотоки третьих лиц.
## Блокеры загрузки версии (из плана реализации)
1. HTTPS-домен `api.radiola.nexaweb.su` (DNS + Caddy).
2. Сервер: страница `/privacy`, https в `app-version.json`.
3. Gradle: flavors `store`/`sideload` + signingConfig release.
4. Манифест: `REQUEST_INSTALL_PACKAGES` → sideload.
5. Код: gate апдейтера, baseUrl → https, чистка настроек, SOVA (sideload).
6. Keystore (генерирует пользователь).
7. Сборка `:app:assembleStoreRelease`.

File diff suppressed because it is too large Load Diff