Compare commits

..

3 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
11 changed files with 5128 additions and 286 deletions

5
.gitignore vendored
View File

@@ -34,3 +34,8 @@ keystore.properties
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо # Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
design/logos/gen/ design/logos/gen/
design/logos/ref_*.png design/logos/ref_*.png
# Скрэтч-папка (картинки, HTML-эксперименты, мокапы RuStore) — не версионируем
tempfiles/
# ...кроме дизайн-файла Pencil — он остаётся под версией
!tempfiles/radiOLA.pen

View File

@@ -23,8 +23,8 @@ android {
applicationId = "com.radiola" applicationId = "com.radiola"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 9
versionName = "1.7" versionName = "1.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

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()
} }

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