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
.kotlin/ .kotlin/
# Релизная подпись (секреты — никогда в git)
keystore.properties
*.jks
*.keystore
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо # Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
design/logos/gen/ design/logos/gen/
design/logos/ref_*.png design/logos/ref_*.png
# Скрэтч-папка (картинки, HTML-эксперименты, мокапы RuStore) — не версионируем
tempfiles/
# ...кроме дизайн-файла Pencil — он остаётся под версией
!tempfiles/radiOLA.pen

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -7,6 +9,12 @@ plugins {
alias(libs.plugins.hilt) 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 { android {
namespace = "com.radiola" namespace = "com.radiola"
compileSdk = 34 compileSdk = 34
@@ -15,8 +23,8 @@ android {
applicationId = "com.radiola" applicationId = "com.radiola"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 6 versionCode = 9
versionName = "1.5" versionName = "1.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { 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 { buildTypes {
release { release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = 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.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" /> <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране <!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). --> (иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />

View File

@@ -109,6 +109,19 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState() val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false) 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 pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) }
var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) } 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) } var downloadedApk by remember { mutableStateOf<java.io.File?>(null) }
val updateScope = rememberCoroutineScope() val updateScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Авто-обновление только в sideload-сборке (в store обновляет RuStore).
if (!BuildConfig.ENABLE_SELF_UPDATE) return@LaunchedEffect
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
} }
@@ -355,18 +370,64 @@ class MainActivity : ComponentActivity() {
private fun maybeRequestBatteryExemption() { private fun maybeRequestBatteryExemption() {
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
// Спрашиваем один раз на установку, чтобы не надоедать.
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("battery_opt_asked", false)) return // Спрашиваем один раз на установку, чтобы не надоедать.
prefs.edit().putBoolean("battery_opt_asked", true).apply() if (!pm.isIgnoringBatteryOptimizations(packageName) &&
runCatching { !prefs.getBoolean("battery_opt_asked", false)
startActivity( ) {
Intent( prefs.edit().putBoolean("battery_opt_asked", true).apply()
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, runCatching {
android.net.Uri.parse("package:$packageName") 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 SLEEP_TIMER = intPreferencesKey("sleep_timer_minutes")
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services") private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset") private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate") private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
private val COUNTRY_CODE = stringPreferencesKey("country_code") private val COUNTRY_CODE = stringPreferencesKey("country_code")
private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style") 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 fun getEqualizerPreset(): Flow<String> = dataStore.data.map { it[EQUALIZER_PRESET] ?: "Flat" }
override suspend fun setEqualizerPreset(preset: String) { dataStore.edit { it[EQUALIZER_PRESET] = preset } } 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 fun getPreferredBitrate(): Flow<Int> = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 }
override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } } 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) val url = service.buildSearchUrl(track.artist, track.song)
Log.d("DeeplinkNavigator", "url=$url") Log.d("DeeplinkNavigator", "url=$url")
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(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 { try {
context.startActivity(Intent.createChooser(intent, "Открыть в...")) context.startActivity(Intent.createChooser(intent, "Открыть в..."))
} catch (e: Exception) { } catch (e: Exception) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,12 +108,30 @@ class PlayerViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepository.getEnabledDeeplinkServices().collect { ids -> 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 { viewModelScope.launch {
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it } 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 { viewModelScope.launch {
_currentTrack _currentTrack
.filterNotNull() .filterNotNull()
@@ -146,8 +164,20 @@ class PlayerViewModel @Inject constructor(
playerController.play(url, station.prefix, station.name, station.id) playerController.play(url, station.prefix, station.name, station.id)
} }
viewModelScope.launch { pushHistoryUseCase(station.id) } viewModelScope.launch { pushHistoryUseCase(station.id) }
startNowPlaying(station)
}
/**
* Запускает опрос now-playing для станции: мгновенный рефреш + цикл раз в 5с
* (пока играем) + сбор трека из API (приоритет) и ICY (фолбэк). Вынесено из
* play(), чтобы переиспользовать при восстановлении сессии (возврат из фона /
* пересоздание ViewModel) — иначе эфир «застывает» на последнем значении.
*/
private fun startNowPlaying(station: Station) {
nowPlayingJob?.cancel() nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch { nowPlayingJob = viewModelScope.launch {
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
launch { nowPlayingRepository.refreshNowPlaying() }
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет // Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть // внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
// каждые 5с → батарея + лишняя нагрузка на бэкенд). // каждые 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? { private fun pickInitialQuality(station: Station): StreamQuality? {
val list = station.qualities val list = station.qualities

View File

@@ -50,7 +50,6 @@ fun SettingsScreen(
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState() val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val themePalette by viewModel.themePalette.collectAsState() val themePalette by viewModel.themePalette.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.collectAsState() val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState() val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState() val testProgress by viewModel.testProgress.collectAsState()
@@ -408,7 +407,11 @@ fun SettingsScreen(
.background(colors.surface) .background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp)) .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 checked = service.serviceId in enabledServices
val trackColor by animateColorAsState( val trackColor by animateColorAsState(
targetValue = if (checked) colors.accent else colors.surface2, 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( HorizontalDivider(
color = colors.border, color = colors.border,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
@@ -449,51 +452,8 @@ fun SettingsScreen(
} }
} }
// --- Запись эфира --- // --- Тестирование станций (dev-инструмент, только в sideload) ---
item { if (com.radiola.BuildConfig.SHOW_DEV_TOOLS) 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 {
SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ") SectionLabel("ТЕСТИРОВАНИЕ СТАНЦИЙ")
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Column( Column(

View File

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

View File

@@ -32,7 +32,7 @@ data class VersionInfo(
*/ */
object UpdateManager { object UpdateManager {
private const val TAG = "radiOLA/Update" 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() private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .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