Compare commits
12 Commits
6a21a84b86
...
feat/boots
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a380d34b | ||
|
|
75d256eda5 | ||
| 1771a5b975 | |||
|
|
91777fc459 | ||
|
|
7a00f53b20 | ||
|
|
3c7ae1eb4c | ||
|
|
d31e5d1119 | ||
|
|
ab09d92b0d | ||
|
|
c75ff8cb9a | ||
|
|
9e729512e9 | ||
|
|
92a7c614c1 | ||
|
|
cbd6451ee0 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -357,10 +370,11 @@ 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
|
// Спрашиваем один раз на установку, чтобы не надоедать.
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(packageName) &&
|
||||||
|
!prefs.getBoolean("battery_opt_asked", false)
|
||||||
|
) {
|
||||||
prefs.edit().putBoolean("battery_opt_asked", true).apply()
|
prefs.edit().putBoolean("battery_opt_asked", true).apply()
|
||||||
runCatching {
|
runCatching {
|
||||||
startActivity(
|
startActivity(
|
||||||
@@ -371,4 +385,49 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,4 +3,11 @@
|
|||||||
<!-- Установка скачанного APK — только в sideload-сборке (авто-апдейтер).
|
<!-- Установка скачанного APK — только в sideload-сборке (авто-апдейтер).
|
||||||
В store-сборке этого разрешения нет (требование RuStore). -->
|
В store-сборке этого разрешения нет (требование RuStore). -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<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>
|
</manifest>
|
||||||
|
|||||||
27
design/logos/icon_01.html
Normal file
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
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
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
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
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
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
83
docs/rustore-listing.md
Normal 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
Reference in New Issue
Block a user