101 Commits

Author SHA1 Message Date
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
nk
86b39f9fea chore: bump backend submodule (privacy) 2026-06-08 13:58:13 +03:00
nk
dbc99bcb10 docs: план реализации подготовки к RuStore 2026-06-08 13:45:21 +03:00
nk
6159cc13cc docs(rustore-spec): добавить кнопку SOVA (дип-линк, только sideload) 2026-06-08 13:38:23 +03:00
nk
56d96382fa docs(rustore-spec): убрать тумблер записи (мёртвый) + тестер станций под флаг store 2026-06-08 13:33:05 +03:00
nk
4391f3ec33 docs: дизайн-спек подготовки к публикации в RuStore 2026-06-08 09:47:10 +03:00
nk
bb40d26621 chore: bump backend submodule (compose: конфиг авто-обновления) 2026-06-07 19:27:36 +03:00
nk
9828bdf8d1 chore(app): bump версии до 6 / 1.5 (релиз: распознавание Shazam + история) 2026-06-07 19:22:06 +03:00
nk
bdeb57c2ad fix(app): кнопка распознавания видна и при пустом исполнителе трека
track == null почти не выполнялось: «безымянные» станции шлют ICY-строку без
разделителя → parseIcyTitle делает трек с пустым artist. Показываем кнопку, когда
нет РЕАЛЬНОГО трека (track null ИЛИ пустой artist/song ИЛИ song == имя станции).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:59:46 +03:00
nk
38ddc96fab feat(app): сообщение «слишком много запросов» (429) при распознавании
Bump backend submodule (глобальный лимит распознаваний).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:47:38 +03:00
nk
c0ee47b699 fix(app): таймаут 30с для запроса распознавания Shazam
Бэкенд-распознавание асинхронное (чанк аудио + поллинг ~до 18с) — точечно
поднимаем readTimeout до 30с только для пути shazam/recognize (базовый 10с мал).
Bump backend submodule (двухстадийный флоу shazam-api.com).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:45:08 +03:00
nk
69682268f3 feat(app): кнопка «Распознать трек» (Shazam) + история распознанных
- кнопка распознавания в плеере: видна только на музыкальных станциях без
  метаданных эфира (track == null), показывает спиннер и результат через Toast
- распознанный трек отображается в плеере и пишется в ОТДЕЛЬНУЮ историю
  распознанных (не дублируется в историю эфирных треков — гейт по ключу)
- экран Истории: переключатель «Треки эфира | Распознанные», два списка
- Room: таблица recognized_track (миграция 7→8), DAO/репозиторий
- ShazamRepository → POST /shazam/recognize/{stationId}, маппинг 503/400 в текст
- MusicGenres.isMusicStation — клиентский гейт (синхронизирован с бэкендом)
- bump backend submodule (модуль shazam)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:38:17 +03:00
nk
251809df33 fix(filters): уменьшение чипа только у кнопки (offset=0 в покое = полный)
Баг: offset первого чипа в покое = 0 (считается от начала контента, не от вьюпорта),
а формула ждала offset≥235px → «Все» и соседние были невидимы/крошечные через пол-экрана.
Фикс: recedeFactor = info.offset / shrinkPx + 1 — полный размер в покое и правее (offset≥0),
уменьшение/затухание ТОЛЬКО когда чип уезжает влево под кнопку (offset<0, зона ~32dp).
Отступ-зазор уменьшен 96→60dp (Радио) / 100→64dp (Чарты) — «Все» вплотную к кнопке.
2026-06-07 17:55:11 +03:00
nk
8b1c65fa43 feat(filters): чип уменьшается и тает у кнопки (эффект отдаления в глубину)
Вместо простого затухания края — per-chip трансформация по позиции: чип масштабируется
0.42..1 + alpha по recedeFactor (offset элемента в LazyRow: правее 92dp — норма, к 46dp —
исчез). transformOrigin центр-лево → будто уходит в глубину под кнопку-категорию.
Отступ слева увеличен (взлётка под кнопкой 96/100dp). Радио и Чарты.
2026-06-07 17:43:10 +03:00
nk
87dca7a6df feat(filters): чипы растворяются под кнопкой-категорией (fade left edge)
Кнопка-категории теперь ПОВЕРХ чипов (Box-оверлей), чипы идут во всю ширину с
отступом слева под кнопку. У левого края — затухание прозрачности (Modifier.
fadingStartEdge: graphicsLayer Offscreen + horizontalGradient BlendMode.DstIn), так
чипы при прокрутке влево красиво уплывают под кнопку и растворяются, а не обрезаются.
FilterChips/GenreSelector получили параметр contentPadding. Экраны Радио и Чарты.
2026-06-07 17:33:35 +03:00
nk
78282e97ca feat(filters): быстрый выбор категории + очистка поиска
- Кнопка-«категории» (круглая, акцентная рамка, иконка SlidersHorizontal) СЛЕВА от
  чипа «Все» — на экранах Радио и Чарты. Открывает шторку со списком всех категорий
  (Радио — жанры, Чарты — стили) + поиск, чтобы не листать чипы. CategoryPicker —
  переиспользуемый компонент с поиском и отметкой выбранного.
- SearchBar: анимированная кнопка очистки (X, scale+fade появление, haptic) при
  непустом запросе.
2026-06-07 17:25:12 +03:00
nk
645c2f14db fix(nav): анимация иконки заметнее — медленнее и с отскоком (spring 0.36/240) 2026-06-07 17:08:55 +03:00
nk
a5d9a06c3f feat(nav+fav): порядок меню, анимация иконки при выборе, индикатор на избранном
- Порядок нижнего меню: Радио · Избранное · История · Чарты · Запись · Настройки.
- Иконка вкладки при выборе делает упругий scale-«поп» (spring MediumBouncy) —
  в нижнем баре и боковом рейле.
- На экране «Избранное» играющая станция теперь подсвечивается так же, как на
  главной: вращающееся свечение под обложкой + индикатор-эквалайзер в углу
  (FavoritesViewModel отдаёт playingStationId/isPlaying из PlayerController,
  FavoritesScreen передаёт isCurrent/isPlaying в StationCard).
2026-06-07 17:06:28 +03:00
nk
d63c1d4187 feat(splash+icon): фон иконки-градиент под тему + темо-зависимый сплэш
- Подложка adaptive-иконки: градиент под акцент темы + радиальное свечение + мягкая
  тень от логотипа (ic_bg_<тема>, было плоским цветом). Иконку-лого не трогал.
- Сплэш под выбранную тему: системный сплэш Android 12+ нельзя перекрасить под выбор
  пользователя (alias-тема на ColorOS игнорится), поэтому системный = просто тёмный
  (splash_transparent), а красивый сплэш рисуем сами на Compose (SplashOverlay):
  3D-лого + акцентное свечение + тень + анимация, цвет берём из текущей темы.
- Тему на старте читаем синхронно из SharedPreferences (мгновенно, без блокировки кадра).
- Ускорен холодный старт до первого кадра 1.48с→1.11с: сплэш рисуется на первом
  дешёвом кадре, тяжёлый контент (ViewModels/плеер) композится под ним; старт
  PlayerService уведён с критического пути. Остаток — оверхед debug-сборки.
2026-06-07 16:57:01 +03:00
nk
01729e0a52 fix(brand): отступы лого в иконке + новый логотип на сплэше
- ic_fg_<тема> уменьшены (196/432) — у логотипа появились поля от краёв подложки.
- splash: windowSplashScreenAnimatedIcon → @drawable/splash_logo (новая монограмма,
  forest-цвет, т.к. сплэш показывается до загрузки темы); было @mipmap/ic_launcher.
- дефолтная ic_launcher/round переведена на новый forest-логотип (недавние/настройки).
2026-06-07 16:22:47 +03:00
nk
2fcc065a18 feat(brand): новый 3D-логотип (монограмма R) + лого/иконка под цветовую тему
Логотип: монограмма-R пользователя отрендерена в матовый 3D через routerai
(gpt-5.4-image), один мастер перекрашен под 8 тем (recolor по яркости, форма
идентична).
- Внутри приложения: AppMark показывает перекрашенный 3D-логотип текущей палитры
  (LocalThemePalette + ThemePalette.logoRes, drawable logo_<тема>).
- Иконка лаунчера следует теме: 8 adaptive-иконок (ic_fg_<тема> + ic_bg_<тема>) и
  8 activity-alias в манифесте; LauncherIconManager включает alias выбранной темы,
  гасит остальные (ровно один активен, guard против лишних миганий). Переключение —
  в MainActivity по LaunchedEffect(paletteId). На ColorOS иконка может обновляться
  с задержкой — особенность системы.
Скрипты генерации в design/logos (ключ routerai — вне репо, ~/.routerai_key).
2026-06-07 16:17:39 +03:00
nk
07f56acf27 fix(player): фон не глохнет — запрос уведомлений + исключение из оптимизации батареи
На OnePlus/ColorOS радио глохло в фоне даже с wake mode. Причины: POST_NOTIFICATIONS
не выдан (медиа-уведомление не показывалось → foreground-сервис хрупкий) и
приложение не в вайтлисте Doze. MainActivity на старте запрашивает POST_NOTIFICATIONS
(13+), затем системный диалог REQUEST_IGNORE_BATTERY_OPTIMIZATIONS (один раз).
v1.4 / versionCode 5 (clean-сборка).
2026-06-07 14:42:00 +03:00
nk
69f48d235e fix(release): v1.3 (versionCode 4) clean-сборка — лечит зацикливание обновления
Причина петли: при bump 2→3 инкрементальная сборка НЕ пересобрала BuildConfig.dex —
в v3 APK manifest versionCode=3, но BuildConfig.VERSION_CODE остался 2. Приложение
сравнивало manifest(3) > BuildConfig(2) → всегда предлагало обновиться, ставило
тот же v3, снова предлагало. Фикс: ./gradlew clean перед сборкой релиза —
BuildConfig теперь совпадает с versionCode. Проверено на телефоне: v4, диалога нет.
2026-06-07 14:29:26 +03:00
nk
44807c9dba chore(release): v1.2 (versionCode 3) — фоновое воспроизведение, эквалайзер, темы 2026-06-07 12:37:01 +03:00
nk
6eb614a729 fix(player): не глохнуть в фоне (wake mode) + авто-переподключение потока
Симптом: по Bluetooth в машине с выключенным экраном радио через время замолкало.
Причины и фиксы:
- setWakeMode(C.WAKE_MODE_NETWORK) + право WAKE_LOCK — ExoPlayer держит partial
  wakelock + wifilock во время игры. Без этого система усыпляла CPU/Wi-Fi при
  выключенном экране → буфер пустел → поток глох (главная причина).
- onPlayerError → scheduleReconnect(): при обрыве сети (туннели, край соты) поток
  пере-готавливается с нарастающей задержкой (2с→15с, до 10 попыток), а не
  замолкает навсегда. Счётчик сбрасывается при успешном старте; переподключение
  отменяется при ручной паузе/стопе/смене станции.
2026-06-07 12:35:07 +03:00
nk
e736c2393f feat(eq): настоящий эквалайзер + улучшайзеры звука (audiofx)
Раньше пресет эквалайзера в настройках был косметикой (лежал в DataStore, к звуку
не подключён). Теперь — реальные системные эффекты на фикс. аудиосессии плеера:
- AudioEffectsController: графический Equalizer (полосы устройства), BassBoost,
  Virtualizer (объём), LoudnessEnhancer (громкость тихих, до +12 дБ). Привязка к
  generateAudioSessionId() в PlayerController, переживает смену станций. Применение
  в реальном времени, сохранение в DataStore (commit на отпускании слайдера).
- Отдельный экран EqualizerScreen (Настройки → ЗВУК → Эквалайзер): тумблер,
  системные пресеты + «Свой», слайдеры полос (±дБ), bass/virtualizer/loudness.
- Эффекты best-effort: при отсутствии поддержки блок недоступен (null), UI скрывает.
- Убран фейковый чип-пресет Flat/Rock/Pop/Jazz/Bass.
2026-06-06 21:14:38 +03:00
nk
0c01eaab2d feat(update): авто-обновление APK (как в nkVPN)
UpdateManager: на старте дёргает /app-version, при version_code > BuildConfig.
VERSION_CODE показывает UpdateDialog. Скачивает APK во внутр. Download, сверяет
SHA-256 (защита от подмены по HTTP/битой загрузки), ставит через системный
установщик (FileProvider). force_update делает диалог необкрываемым. versionCode
1→2, versionName 1.0→1.1. Добавлено право REQUEST_INSTALL_PACKAGES, путь в file_paths.
2026-06-06 20:21:55 +03:00
nk
ed926e0a9d feat(theme): выбор цветовой темы (8 палитр) в настройках
8 тёмных палитр (Лес/Океан/Закат/Аметист/Неон/Янтарь/Лёд/Роза) в Palettes.kt.
RadiolaColors теперь несёт все токены + градиент; RadiolaTheme(palette) строит из
неё и RadiolaColors, и Material ColorScheme — всё приложение берёт цвета через
RadiolaTheme.colors, поэтому смена палитры перекрашивает мгновенно. Бренд-марка
(AppMark/Wordmark) тоже следует теме. Выбор в DataStore (theme_palette, дефолт
forest), читается в MainActivity и подаётся в тему. Секция «ТЕМА ОФОРМЛЕНИЯ» в
настройках — горизонтальный ряд свотчей с превью (фон+акцент+градиент).
2026-06-06 19:11:33 +03:00
nk
d9acc0efb4 fix(sleep): звук сна вплывает в конце таймера, а не в первые 90 секунд
Было: при таймере со звуком радио кроссфейдилось в шум в НАЧАЛЕ (CROSSFADE_MS=90с),
и для 15-мин таймера уже через ~1.5 мин играл полный белый шум всё оставшееся время.

Стало: радио играет почти весь таймер; в последние SOUND_OUTRO_MS (3 мин, но не
больше половины таймера) включается звук сна — радио кроссфейдится в шум, шум держится,
в самом конце затухает в тишину. Генератор шума стартует лениво (только в аутро, не
молотит весь таймер). Засыпаешь под радио, а не под резкий шум сразу.
2026-06-06 18:21:04 +03:00
nk
84c2b33473 perf(android): ленивый плеер записей, O(n) merge каталога, @Immutable, лог только в debug
- RecordingPlaybackController: ExoPlayer создаётся лениво (на первый play) и
  освобождается в stop() — раньше второй плеер висел в памяти всю сессию у каждого.
  Поллер позиции 500мс крутится только во время игры (был вечный 2 Гц main-loop).
- StationRepositoryImpl.refreshStations: merge каталога O(n) через apiById/apiByName
  индексы вместо .find на каждую станцию (было O(n²) ~700×700 на холодном старте).
  Убраны verbose Log.d-трейсы (оставлен Log.e/.w на ошибки).
- Track/Station/StreamQuality помечены @Immutable — read-only модели, иначе
  списки tags/qualities делали класс нестабильным → лишние рекомпозиции списков.
- HttpLoggingInterceptor только при BuildConfig.DEBUG (включён buildConfig feature):
  в релизе нет оверхеда на каждый запрос и утечки URL в logcat.
2026-06-06 17:13:48 +03:00
nk
f423344d13 perf(android): батарея и плавность — gate FFT, изоляция рекомпозиции, поллинг на паузе
- AudioSpectrumAnalyzer: FFT считается ТОЛЬКО когда открыт плеер (флаг active);
  раньше ~86 FFT/с молотили всегда при проигрывании (даже экран выкл) — главный
  пожиратель батареи. Включается из VisualizerHost через DisposableEffect.
- Спектр (45/с) собирается в leaf VisualizerHost, а не на верху PlayerBottomSheet —
  весь плеер больше не рекомпозится 45 раз/сек.
- now-playing поллинг (5с) останавливается на паузе (isPlaying.collectLatest) —
  раньше на паузе зря дёргали сеть каждые 5с.
- PlayerService.onDestroy отменяет serviceScope (singleton-плеер НЕ релизим).
- refreshStations (парс ~700 станций + сеть + Room) уведён на Dispatchers.IO с
  главного потока (jank/ANR на старте).
- Coil ImageLoader: память 25% + диск 100МБ (обложки не перекачиваются каждую сессию).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:33:00 +03:00
nk
861b0e2b8f feat: будильник с радиостанцией + выбор битрейта по умолчанию
Будильник (Settings → Будильник): несколько будильников, время, станция, дни недели,
fade-in пробуждения. AlarmManager.setAlarmClock (вне doze) + фолбэк, BootReceiver
перепланирует после перезагрузки, AlarmReceiver→PlayerService (foreground) →
PlayerController.startAlarmPlayback (нарастание громкости). Room: AlarmEntity/Dao, БД v7.
Выбор битрейта по умолчанию в Settings (Авто/Эконом/Стандарт/Высокое) → preferredBitrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:25:42 +03:00
nk
4411d53a6c feat(player): звуки для сна (белый/розовый/коричневый шум) + Smart Sleep Fade
SleepSoundPlayer — процедурная генерация цветного шума через AudioTrack (розовый —
фильтр Келлета, коричневый — random walk). В таймере сна выбор звука: радио плавно
перетекает в выбранный шум (кроссфейд ≤90с), шум играет, к концу затухает — как в
спеке («Smart Sleep Fade»). В шторке таймера — чипы выбора звука.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:08:32 +03:00
nk
bda2c5b30f feat(player): таймер сна с плавным затуханием (fade-out)
P0-фича из спеки. PlayerController: startSleepTimer/cancelSleepTimer — в последние
20с экспоненциальный fade-out громкости (frac^2), затем пауза + возврат громкости.
В плеере — пилюля «Таймер сна» (иконка Moon): при активном показывает остаток
M:SS акцентом. Шторка с интервалами 15/30/45/60/90/120 мин + «Выключить».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:08:54 +03:00
nk
29cbe8997f feat(stations): убран чип «Новый год» (межсезонный тег Record)
Christmas Chill и др. остаются в разделе Radio Record (genre=Radio Record),
просто скрыт сам тег-чип через hiddenTags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:36:41 +03:00
nk
5da077b698 fix(stations): Новое Радио BY — маунт Wake Up (wakeupshow→wakeup, был 404)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:21:52 +03:00
nk
2e970317f6 fix(stations): Record — звук на станциях с мёртвым stream_128 (Лето и др.)
У части станций Record поле stream_128 ведёт на мёртвый маунт {prefix}64.aacp
(404) — обложка/трек есть, а поток молчит (Summer Lounge, Beach Party, Reggae,
Mashup, Afro House, Nu Dance, Workout, Gop FM…). Поле stream_320 ({prefix}96.aacp)
живо у всех. Сменён приоритет выбора потока на stream_320.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:10:48 +03:00
nk
be6e1acfd8 fix(stations): жанровые чипы Record (Лето и др.) больше не пустые
Record API кладёт категории станции в поле "genre" (массив {id,name}), а
StationDto/ApiMapper читали только "tags" (у станции отсутствует) → у всех
станций Рекорда жанровые теги были пустыми, и чипы вроде «Лето» при клике
показывали пустоту. Добавлено поле genres (@SerialName genre), маплю genre+tags
в Station.tags. Раздел «Лето» теперь наполняется летними станциями Record
(Chill House, Beach Party, Tropical, Summer Lounge, Mashup и др.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:52:21 +03:00
nk
5408bbd6c5 fix(stations): ГУСЬ — потоки на канонический https://radiogoose.ru/listen/{slug}/play
Многострочные stream_url (url1\nurl2) рвали воспроизведение и health-check.
Почищены на одиночный HTTPS-поток AzuraCast (16 каналов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:24:23 +03:00
nk
d504218d33 chore(stations): убраны мёртвые каналы Зайцева New year/Hvilya из каталога
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:07:28 +03:00
nk
06cb6c16f1 feat(orientation): полноценная поддержка альбомной ориентации
- боковой nav-rail слева вместо нижнего бара в альбоме (SideNavRail)
- мини-плеер уезжает под контент в альбомной раскладке
- плеер эфира: двухпанельный (обложка слева, инфо/эквалайзер/контролы справа)
- плеер записи: слева управление, справа прокручиваемый список треков
- сетки станций и избранного: 4 колонки в альбоме вместо 2
- хелпер isLandscape() через LocalConfiguration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:19:47 +03:00
nk
fabf780450 fix(now-playing): трек/обложка не обновлялись — залипшее socket-значение
Корень: NowPlayingSocketClient копит трек по станции и не чистит; combine
предпочитал socket (socketMap[id] ?: restMap[id]). Если сокет один раз
прислал трек и отвалился, залипшее значение НАВСЕГДА затеняло свежий REST —
на открытом плеере трек/обложка не менялись (Radio Record и др.). Теперь
приоритет REST (он регулярно поллится), socket — фолбэк. Поллинг плеера
ускорен 10с→5с.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:15:40 +03:00
nk
53cd1601dc fix(recordings): не зависать плееру записи; меньше задержка обложки
Bug1: плеер записи (singleton ExoPlayer) не глушился при закрытии шторки и
уходе с экрана → аудио-сирота без управления, запуск радио конфликтовал.
Теперь воспроизведение записи останавливается на onDismiss и onDispose
экрана записей, а старт радио глушит плеер записи (взаимоисключение).

Bug2: обложка/трек на открытом плеере обновлялись с задержкой при записи.
Эмиссия спектра ограничена ~45/с (было ~86/с) — меньше перегруз перерисовки;
поллинг now-playing в захвате маркеров ускорен 15с→8с (точнее тайм-коды).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:03:44 +03:00
nk
d9c83a83e9 fix(player): меньше задержка эквалайзера — окно FFT 2048→1024
Визуал отставал от бита. Главная остаточная задержка — окно FFT 2048 (Hann
даёт групповую задержку ~окно/2 ≈ 20мс) + редкие обновления. Окно 1024:
задержка реакции вдвое меньше, обновлений вдвое больше. Лайвность держит
автогейн, низов хватает (binHz ~43 покрывает бочку).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:37:25 +03:00
nk
1dfee941a0 fix(player): эквалайзер «в ритм» — перекрытие окон + быстрый спад
Визуализатор отставал/висел: медленный спад (бар держался ~300мс после
удара) и редкие обновления. Теперь FFT с перекрытием 50% (~43 обновл/с),
мгновенный рост на удар и быстрый спад (0.78→0.55), убран искусственный
троттл 33мс. Реакция плотнее попадает в бит.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:25:26 +03:00
nk
1e00287486 feat(player): 4 стиля визуализатора + выбор в настройках
Добавлены стили анимации воспроизведения: столбики от центра, столбики
снизу (спектр), плавная волна, радиальный — все от реального спектра звука
(Visualizer.kt). Пользователь выбирает стиль в Настройках → «Анимация
воспроизведения» (живые превью каждого стиля, тап выбирает). Сохраняется
пер-юзер (DataStore visualizer_style). Плеер рисует выбранный стиль
(радиальный — повыше). Превью и пауза — мягкая «дышащая» анимация.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:18:55 +03:00
nk
900a4ad813 fix(player): живой эквалайзер — автогейн + частотный маппинг
Эквалайзер почти не двигался: лог-маппинг схлопывал низы в 1-2 бина,
нормализация была слабой (двигались лишь правые полосы). Переделано:
FFT 1024→2048 (разрешение низов), полосы по частотам 40Гц-16кГц со
средним по бинам, автогейн по бегущему пику (всегда полная высота),
перцептивный лифт (sqrt) + лёгкий подъём верхов. Теперь реагируют все
полосы и заметно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:09:40 +03:00
nk
05e5538945 feat(player): живой эквалайзер по реальному звуку (FFT-спектр)
Эквалайзер на плеере больше не декоративная синус-волна — реагирует на
реальный звук. Через TeeAudioProcessor подключаемся к декодированному PCM в
аудио-конвейере ExoPlayer (без разрешений/микрофона), считаем FFT → лог-полосы
(AudioSpectrumAnalyzer), PlayerController отдаёт спектр StateFlow'ом, LiveEqualizer
рисует столбики по уровням (с быстрым ростом/плавным спадом). Когда звука нет
(пауза/float-выход) — фолбэк на прежнюю синус-волну.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:39:09 +03:00
nk
a46e437351 feat(player): 3D-переворот обложки при смене трека
Вместо простой смены — эффект переворота (как страница альбома/пластинка):
старая обложка улетает передней гранью (0–90°), новая прилетает задней
(90–180°, контр-вращение чтобы не зеркалилась). Компонент FlipCover,
подключён к обложке в плеере; срабатывает при смене coverUrl трека.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:19:10 +03:00
nk
147b3ac81d feat(covers): приоритет играющего трека + троттл 0.8с
Обложки наливались общей очередью (1.5с) — играющий трек ждал свою очередь.
Добавлена приоритетная дорожка: трек, который слушают сейчас, обогащается
первым (PlayerViewModel → NowPlayingRepository.enrichCoverNow). Троттл общей
очереди ускорен 1.5с→0.8с. Дедуп разнесён на enqueued/processed, чтобы
дорожки не дублировали работу.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:11:44 +03:00
nk
4a33aa6fb5 feat(covers): клиентское обогащение обложек через iTunes (обход бана сервера)
Серверный IP забанен Apple (iTunes search 429), а Deezer из РФ пуст — обложки
перестали наливаться. Теперь iTunes-поиск делает КЛИЕНТ (его IP не забанен):
для now-playing-треков без обложки ищет арт в iTunes и шлёт ССЫЛКУ на наш
бэкенд (POST /covers/submit), сервер качает её (CDN из РФ доступен) и кладёт
WebP — дальше обложка приходит всем через /now-playing. Дедуп по треку +
троттлинг 1.5с (CoverEnrichmentManager). Сервер: host-whitelist (SSRF),
идемпотентность (first-write-wins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:59:32 +03:00
nk
4612a8f33c fix(player): следовать кросс-протокольным редиректам (http→https)
Многие станции отдают по http 301 на https; ExoPlayer по умолчанию не идёт
по кросс-протокольному редиректу и не играет их. Включён общий HTTP-источник
с setAllowCrossProtocolRedirects(true) — такие станции заиграют без правки URL.
ICY-метаданные сохраняются (обрабатываются на уровне ProgressiveMediaSource).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:42:43 +03:00
nk
bd62016026 fix(stations): Royal Radio — https вместо http (ничего не играло)
royalradio.space по http отдаёт 301 на https, а ExoPlayer по умолчанию НЕ
следует кросс-протокольным редиректам (http→https) — поэтому все 10 каналов
Royal Radio не воспроизводились. Потоки переведены на прямой https (200
audio/mpeg). Прод-БД тоже обновлена (для ICY now-playing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:31:09 +03:00
nk
b2aff51c62 feat(stations): оживить главный «Радио Романтика» в разделе Romantika
В разделе Romantika были только саб-каналы (Piano Covers, Love Songs,
Акустика, Прикосновение, Easy Listening), а главный «Romantika» (711) был
отключён — мёртвый поток srv21.gpmradio (и в offline-ids бэкенда). Включил
главный на рабочем HLS (hls-01-gpm.hostingradio.ru/romantika495) + фирменный
логотип Романтики (применяется ко всем каналам сети). Прод-БД: 711 → online.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:18:21 +03:00
nk
4a9622ca92 feat(stations): добавить Like FM (главный канал) в раздел Like FM
Раздел Like FM был пустой — все тематические каналы «Хиты по годам» (101.ru)
закрыты (404). Like FM теперь вещает как единый канал (на сайте — только
региональные FM-частоты того же эфира). Включил главный Like FM на рабочем
HLS (hls-01-gpm.hostingradio.ru/likefm495, Москва 87.9), группа «Like FM»,
+ фирменный логотип. Бэкенд: 718 помечена online (была в offline-ids).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:05:04 +03:00
nk
8d2c53c441 feat(stations): скрывать украинские станции (ROKS, Kiss FM) для РФ
Radio ROKS и Kiss FM (TavR Media, хосты radioroks.ua / kissfm.ua) недоступны
с российских IP без VPN. Теперь для пользователей из РФ они полностью скрыты
— и сами станции (везде, где используется список), и их чипы-категории.

Страна определяется по IP (api.country.is → ipapi.co; при VPN вернёт страну
выходного узла, тогда станции доступны и НЕ скрываются), с фолбэком на страну
SIM/сети/локали устройства, если IP-сервис недоступен (в РФ часто заблокирован).
Код страны кэшируется (DataStore). Фильтр в GetStationsUseCase (combine со
страной) + чипы в StationsViewModel. id 741 «Радио РОКС» (stream.roks.com) —
российская, под правило не попадает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:46:42 +03:00
nk
4697e27eb4 fix(stations): свечение верхнего ряда не обрезается, уходит под чипы
Грид растянут под чипы (верхний contentPadding = высота чипов), а чипы
вынесены отдельным слоем поверх грида с фоном-градиентом (вверху
непрозрачный — маскирует прокрутку, книзу прозрачный). Свечение играющей
станции из верхнего ряда больше не режется границей и мягко проступает
из-под чипов категорий.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:27:41 +03:00
nk
b6c0e92758 fix(stations): бесшовная петля свечения играющей станции
Центр свечения по cy двигался с sin(t*1.3) — некратная гармоника давала
скачок на стыке цикла. Заменено на sin(2t): значения и скорость совпадают
на t=0 и t=2π, петля повторяется ровно и плавно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:14:46 +03:00
nk
9268e14cc6 feat(stations): свайп по списку листает чипы + свечение играющей станции
1) Горизонтальный свайп по области списка переключает фильтры-чипы в их
   порядке ([Все]+жанры), выбранный чип автоскроллится в зону видимости.
   Вертикальная прокрутка грида сохраняется.

2) У играющей станции в списке — мягкое радиальное свечение позади обложки,
   которое «гуляет» (двигается центр) и вылезает из-под краёв, + эквалайзер-
   бейдж в углу. Источник активной станции — PlayerController.currentStationId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:12:01 +03:00
nk
603e232dff fix(recordings): шторка записи на весь экран, список треков над навигацией
navigationBarsPadding не применялся в partial-режиме ModalBottomSheet —
список треков налезал на системную навигацию. Включён skipPartiallyExpanded
(как у радио-плеера) + navigationBarsPadding на контенте.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:49:08 +03:00
nk
b3912a9dca fix(recordings): список треков не уходит под системную навигацию
У RecordingPlayerSheet не было navigationBarsPadding — нижние строки
списка треков накладывались на кнопки навигации Android. Добавлен отступ
под навбар (вне скролла, чтобы низ всегда был над кнопками).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:44:15 +03:00
nk
7df9b62403 feat(recordings): запись HLS-станций (EMG: Европа Плюс и др.)
Раньше запись просто писала тело URL в файл — у HLS это m3u8-плейлист
(текст), а не аудио, поэтому EMG-станции не записывались. Добавлен
HLS-рекордер: резолвит мастер→медиа-плейлист, опрашивает его и докачивает
новые .ts-сегменты, склеивая в файл (валидный MPEG-TS, ExoPlayer играет
и перематывает). На первом проходе пишется только хвост окна — запись
начинается примерно с момента нажатия. Сплошные потоки (ICY) — прежним
путём (recordRaw). Тайм-коды треков работают и для EMG (now-playing с бэка).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:39:27 +03:00
nk
fc63814f97 feat(recordings): перемотка записей + тайм-коды треков
1) Перемотка: записи эфира — сырой ADTS-AAC/MP3 без индексов, ExoPlayer
   считал их неперематываемыми (старт всегда с нуля). Включён CBR-seeking
   (DefaultExtractorsFactory.setConstantBitrateSeekingEnabled) — seek работает.

2) Тайм-коды треков: при записи фиксируются смены now-playing с offset от
   начала (модель TrackMarker, колонка markers в recordings, миграция v6,
   захват через NowPlayingRepository — свой поллинг, не зависит от экрана).
   В плеере записи — список «Треки в записи»: тайм-код + название, тап
   переходит к моменту, текущий трек подсвечен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:18:23 +03:00
nk
777f5d5082 fix(favorites): не терять избранное после перезапуска приложения
refreshStations пересоздавал каталог с isFavorite=false, а insertAll
(OnConflictStrategy.REPLACE) затирал строки — отметки «избранное»
пропадали при каждом старте. Перед вставкой считываем текущие id
избранного (getFavoriteIdsOnce) и проставляем их новым записям.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:53:55 +03:00
nk
6aa2588641 fix(player): не обрезать низ плеера на телефонах (скролл + компактнее)
Плеер живёт в ModalBottomSheet без скролла. На телефонах с высоким dpi
высоты в dp меньше (480dpi → ~800dp против ~914dp у эмулятора 420dpi),
из-за чего низ — кнопка «Текст песни» — обрезался шторкой и был виден
лишь полоской. Добавлен verticalScroll (низ доступен на любом экране) и
ужата высота (обложка 220→190, крупные отступы), чтобы влезало без скролла.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:51:15 +03:00
nk
c77c131a09 fix(player): крупная видимая кнопка «Текст песни» (пилюля с фоном)
На реальном телефоне мелкий TextButton (13sp + иконка 16dp в приглушённом
акценте) почти не виден. Заменён на пилюлю с фоном surface2: иконка 20dp,
текст 15sp medium — читается на физическом экране.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:44:44 +03:00
nk
5ffaf9a924 feat(player): переключатель качества звука на экране воспроизведения
Перепроверены все 594 рабочие станции на наличие битрейт-вариантов
потока (скрипт-пробер). У 71 станции найдено по 2–4 качества
(Record-флагманы 96/64/32, zaycev 256/128/48, ВГТРК 192/128/64,
НАШЕ/Орфей/Шансон HQ и др.) — записаны в поле qualities в stations.json.
HLS (EMG) и Love (UID-привязка) корректно пропущены.

Клиент: модель StreamQuality, хранение в Room (миграция v5),
предпочтение битрейта в настройках. На экране плеера — чип текущего
качества (виден только если вариантов ≥2) и шторка «Качество звука»
со ступенями; переключение на лету без сброса now-playing, выбор
запоминается между станциями.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:36:47 +03:00
nk
5b256a3421 feat(love): логотипы каналов Love Radio (меньше, с отступами) как обложки
Сгенерены из их SVG на фирменном цвете канала, захостены у нас (/covers/love_*_s.webp),
заданы через StationLogos.byName. Вместо унылых буквенных плиток — фирменные логотипы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:33:39 +03:00
nk
320cac546b feat(love): воспроизведение Love Radio через сессионный UID + now-playing главного
Потоки Love защищены: клиент берёт UID из их player/config (со своего IP) и
подставляет в n340-поток — играет музыка. LoveStreamResolver + LoveApi. Каталог
переведён на n340. Now-playing главного Love Radio по ICY; саб-каналы трек не
отдают нигде — показываем без трека.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:14:52 +03:00
nk
615e3435e3 feat(stations): клиент скрывает оффлайн-станции с бэкенда (системно)
При обновлении каталога тянем GET /stations/offline-ids и удаляем эти станции
из локальной БД. Мёртвые плитки теперь пропадают сами (бэк их метит health-check'ом),
без пересборки приложения. Фолбэк на статичный enabled, если бэк недоступен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:04:35 +03:00
nk
4c4c6e05d8 chore(stations): отключить 67 мёртвых станций (потоки не отвечают)
Свип по всем потокам (корректная проверка: живой = пришли заголовки 200,
мёртвый = ошибка/4xx/5xx/нет ответа). Помечены enabled=false. Активных 595/697.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:48:30 +03:00
nk
4a45cb575e chore(stations): отключить мёртвые каналы Европы Плюс (Acoustic, ResiDance)
Потоки не отвечают (000/404), meta now-playing пуст — каналы не вещают.
enabled=false → скрыты в приложении.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:05:00 +03:00
nk
1ef60b6053 fix(now-playing): матч текущего трека по id станции, а не по имени
Станции с одинаковым именем в разных сетях (напр. «Deep» у Record и DFM)
показывали один и тот же трек — матч был по lowercase-имени. Каталожный id
(== station.id) уникален и совпадает со stationId в /now-playing, поэтому
матчим по id. Убран весь by-name путь (репозиторий, плеер, карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:54:24 +03:00
nk
eeceb754ea feat(stations): актуальные emgsound HLS-потоки линейки Европы Плюс
8 каналов EP (Europa Plus/Top 40/New/Party/Urban/Acoustic/ResiDance/Fresh)
переведены на рабочие emgsound HLS — играют + получают now-playing/обложки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:47:45 +03:00
nk
b93bec028e feat(player): показывать название станции под «В ЭФИРЕ» над обложкой
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:55:48 +03:00
nk
72ecbae866 fix(player): матч now-playing по имени станции (обложки DFM в плеере)
Плеер искал now-playing по числовому id станции, а у локальных станций (DFM)
id не совпадает с каталожным → API-путь с обложкой не срабатывал, плеер падал
на ICY из потока (без обложки). Теперь getNowPlaying матчит по id, затем по
имени станции (как карточки). DFM-обложки появляются и в плеере.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:36:39 +03:00
nk
77772789bb fix(ui): подпись играющего трека на карточке станции даже без обложки трека
Раньше now-track (трек/исполнитель + обложка) показывался ТОЛЬКО при наличии
обложки трека — поэтому DFM-станции без обогащённой обложки оставались пустой
плиткой. Теперь: если трек известен — всегда показываем подпись, а фоном берём
обложку трека → лого станции → плитку. DFM работает как Record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:16:41 +03:00
nk
99503fc77a feat(charts): фильтр по жанру + жанр/стиль/лейбл/год на детальной трека
Подтягиваем обогащённые данные с бэкенда (Discogs): genre/styles/label/year
в чартах и детальной странице.

- ChartEntry/TrackStats + DTO: добавлены genre/styles/label/year
- RadiolaApi: getCharts(?genre=), новый getGenres()
- ChartsViewModel: состояние выбранного жанра + список жанров, перезагрузка
- ChartsScreen: ряд чипов-фильтров по жанру (Все + жанры),
  жанр/стили чипами и «Лейбл · Год» на детальной
- убран демо-fallback (SAMPLE_CHARTS) — бэкенд живой

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:55:35 +03:00
nk
b0c3dae20a feat(stations): локальные обложки Comedy FM/Spa/StandUp/Женский StandUp
вшиты в ресурсы (res/drawable), привязаны по имени станции (приоритет над
логотипом по домену). Загрузка через android.resource:// в Coil.
2026-06-03 12:35:03 +03:00
nk
5e22db5571 feat(stations): кураторские логотипы по домену сайта (Comedy Radio)
StationLogos: карта домен -> URL логотипа для сетей без своего API обложек.
Comedy Radio (Comedy FM/Club/Spa, comedy-radio.ru) -> официальный apple-touch
логотип. Расширяемо по сетям.
2026-06-03 12:26:11 +03:00
nk
ee689ce380 feat(stations): обложка текущего трека на карточке станции + подпись
Для станций без своей обложки (и для Radio Record — единый стиль) карточка
показывает обложку играющего трека с тёмным градиентом и подписью трек/исполнитель.
Источник — /now-playing (теперь с name станции), матч по имени, обновление 20с.
Приоритет: трек -> логотип станции -> фирменная плитка.
2026-06-03 12:18:19 +03:00
nk
9d115b148e fix(lyrics): краш при открытии текста — Unspecified lineHeight
bodyLarge.lineHeight не задан (Unspecified), умножение на 1.4f бросало
IllegalArgumentException (Cannot perform operation for Unspecified type).
Задан конкретный lineHeight = 22.sp.
2026-06-03 11:55:31 +03:00
nk
ba32973beb feat(lyrics): тексты песен внутри приложения через LRCLIB
- LrcLibApi (api/get + api/search, User-Agent), DI @Named(lrclib) Retrofit
- LyricsRepository.fetchLyrics -> LyricsResult (plain/synced/instrumental)
- LyricsViewModel + LyricsSheet (загрузка/инструментал/найдено/не найдено),
  прокрутка + атрибуция LRCLIB
- кнопка «Текст песни» открывает встроенный экран (плеер + деталь трека чартов),
  вместо ссылки в браузере
2026-06-03 11:47:00 +03:00
nk
5fd97d27fd fix(stations): обложки Record только для Record-станций + своя плитка остальным
- сети, отличные от Radio Record (DFM, HitFM и др.), больше не получают
  обложки Radio Record (обогащение Record API гейтится по source=record)
- станции без обложки рисуют свою фирменную плитку: цвет по названию + инициалы
  (вместо общего значка/чужой обложки)
2026-06-03 11:36:24 +03:00
nk
32e5108d98 fix(stations): подтягивать обложки/потоки Record по названию станции
Локальные станции (assets/stations.json, id 1,2,3...) обогащались данными
Record только по id, но id Record-каталога другие (15016...) и prefix в
ассетах нет — поэтому совпадений почти не было и обложки не грузились.
Добавлен фолбэк-матч по названию станции (стабильный общий ключ).
2026-06-03 11:22:57 +03:00
nk
a50a108f63 fix(ui): иконочный таб-бар, заголовок станций, ровные кнопки плеера, рабочая ссылка на текст
- таб-бар только иконки (6 разделов не помещались с подписями)
- «Откройте радио» -> «Выберите радиостанцию»
- кнопки плеера (лайк/prev/next/запись) единого размера 24/48, ряд SpaceBetween
  (кнопка записи больше не обрезается и не выбивается размером)
- текст песни: Musixmatch резал соединение -> веб-поиск трека (открывается)
2026-06-03 11:15:29 +03:00
nk
fc9b23f62c fix(player): now-playing с нашего бэкенда вместо сырого Record-эндпоинта
Record /stations/now использует id now-слотов, не совпадающие с id каталога,
поэтому клиент не находил трек по station.id (трек/обложка не показывались).
Теперь берём GET /now-playing с нашего бэка (корректный маппинг recordSync,
ключ = id станции) -> плеер показывает название трека и обложку.
2026-06-03 10:59:59 +03:00
nk
eca0c49ad4 fix(ui): тёмный сплэш с фирменной иконкой вместо белого экрана
- Theme.Radiola -> тёмная (windowBackground #0C1410), прозрачные системные бары
- Theme.Radiola.Splash (core-splashscreen): фон #0C1410 + иконка приложения
- installSplashScreen() в MainActivity; тема сплэша на launcher-активити
2026-06-03 00:27:29 +03:00
nk
e190444577 feat: фирменная иконка приложения + внутренний плеер записей
- адаптивная иконка лаунчера: градиентный фон (C2F25B->6FA53C) + монограмма R
  (foreground + monochrome для тем Android 13), манифест -> @mipmap
- воспроизведение своих записей ВНУТРИ приложения вместо внешнего плеера:
  RecordingPlaybackController (отдельный ExoPlayer, останавливает радио),
  RecordingPlayerSheet с перемоткой (Slider), play/pause, +/-15с, таймеры
2026-06-03 00:13:12 +03:00
nk
d0e5f4e8c5 feat(charts): раздел «Чарты» (клиент) + детальная страница трека с графиком
- вкладка «Чарты» в навигации; экран: периоды (День/Неделя/Месяц/Всё),
  ранжированный список треков (ранг, обложка, проигрывания, тренд)
- детальная карточка трека: метрики, график популярности (Canvas), лайк,
  кнопки музыкальных сервисов, кнопка «Текст песни» (ссылка на лицензированный
  Musixmatch — полный текст не встраиваем, авторское право)
- ChartsRepository/LyricsRepository + эндпоинты charts/* в RadiolaApi (DTO)
- превью-данные пока бэкенд не отдаёт charts (помечено TODO)
2026-06-02 23:24:42 +03:00
183 changed files with 11507 additions and 584 deletions

9
.gitignore vendored
View File

@@ -25,3 +25,12 @@ app/build/
# Kotlin
.kotlin/
# Релизная подпись (секреты — никогда в git)
keystore.properties
*.jks
*.keystore
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
design/logos/gen/
design/logos/ref_*.png

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 = 1
versionName = "1.0"
versionCode = 8
versionName = "1.7"
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
@@ -42,6 +78,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
@@ -61,6 +98,7 @@ android {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)

View File

@@ -8,15 +8,23 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<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" />
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Просить исключение из оптимизации батареи (Doze/ColorOS душат фоновое аудио). -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".RadiolaApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Radiola"
android:usesCleartextTraffic="true"
@@ -25,13 +33,59 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Radiola"
android:configChanges="orientation|screenSize|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
android:theme="@style/Theme.Radiola.Splash"
android:configChanges="orientation|screenSize|smallestScreenSize" />
<!-- Иконка лаунчера под цветовую тему: всегда включён ровно ОДИН alias.
Переключается в рантайме (LauncherIconManager) при смене темы. -->
<activity-alias android:name=".MainAliasForest" android:enabled="true" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_forest" android:roundIcon="@mipmap/ic_launcher_forest_round"
android:theme="@style/Theme.Radiola.Splash.Forest">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasOcean" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_ocean" android:roundIcon="@mipmap/ic_launcher_ocean_round"
android:theme="@style/Theme.Radiola.Splash.Ocean">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasSunset" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_sunset" android:roundIcon="@mipmap/ic_launcher_sunset_round"
android:theme="@style/Theme.Radiola.Splash.Sunset">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasAmethyst" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_amethyst" android:roundIcon="@mipmap/ic_launcher_amethyst_round"
android:theme="@style/Theme.Radiola.Splash.Amethyst">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasNeon" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_neon" android:roundIcon="@mipmap/ic_launcher_neon_round"
android:theme="@style/Theme.Radiola.Splash.Neon">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasAmber" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_amber" android:roundIcon="@mipmap/ic_launcher_amber_round"
android:theme="@style/Theme.Radiola.Splash.Amber">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasIce" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_ice" android:roundIcon="@mipmap/ic_launcher_ice_round"
android:theme="@style/Theme.Radiola.Splash.Ice">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<activity-alias android:name=".MainAliasRose" android:enabled="false" android:exported="true"
android:targetActivity=".MainActivity" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_rose" android:roundIcon="@mipmap/ic_launcher_rose_round"
android:theme="@style/Theme.Radiola.Splash.Rose">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias>
<service
android:name=".service.PlayerService"
@@ -57,6 +111,18 @@
android:resource="@xml/file_paths" />
</provider>
<receiver
android:name=".service.AlarmReceiver"
android:exported="false" />
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".widget.PlayerWidgetProvider"
android:exported="false">

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
package com.radiola
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -18,6 +20,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.lifecycleScope
import com.radiola.data.local.TokenDataStore
import com.radiola.ui.auth.AuthScreen
import com.radiola.ui.charts.ChartsScreen
import com.radiola.ui.components.MiniPlayer
import com.radiola.ui.favorites.FavoritesScreen
import com.radiola.ui.favorites.FavoritesViewModel
@@ -29,6 +32,7 @@ import com.radiola.ui.player.PlayerViewModel
import com.radiola.ui.recordings.RecordingsScreen
import com.radiola.ui.settings.SettingsScreen
import com.radiola.ui.stations.StationsScreen
import com.radiola.ui.alarms.AlarmsScreen
import com.radiola.ui.stations.StationsViewModel
import com.radiola.service.PlayerService
import com.radiola.ui.theme.RadiolaTheme
@@ -43,15 +47,53 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var tokenDataStore: TokenDataStore
@Inject
lateinit var settingsRepository: com.radiola.domain.repository.SettingsRepository
@Inject
lateinit var launcherIconManager: com.radiola.util.LauncherIconManager
// После ответа на запрос уведомлений — просим исключение из оптимизации батареи.
private val notifPermLauncher = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { maybeRequestBatteryExemption() }
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
startService(Intent(this, PlayerService::class.java))
lifecycleScope.launch {
tokenDataStore.preload()
// Старт плеер-сервиса уводим с критического пути запуска — ускоряет
// появление первого кадра (сплэша).
startService(Intent(this@MainActivity, PlayerService::class.java))
}
ensureBackgroundPlaybackAllowed()
enableEdgeToEdge()
// Тему берём из быстрого SharedPreferences (его пишет LauncherIconManager при
// смене темы) — синхронно и МГНОВЕННО, без блокировки первого кадра. Так сплэш
// и приложение сразу нужного цвета, и тёмный системный сплэш не висит лишнее.
val initialPaletteId = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
.getString("icon_alias", "forest") ?: "forest"
setContent {
RadiolaTheme {
// Выбранная цветовая тема (мгновенно перекрашивает всё приложение).
val paletteId by settingsRepository.getThemePalette().collectAsState(initial = initialPaletteId)
// Иконка лаунчера следует теме (срабатывает на старте и при смене темы).
LaunchedEffect(paletteId) {
launcherIconManager.applyIfNeeded(com.radiola.ui.theme.ThemePalette.fromId(paletteId))
}
RadiolaTheme(palette = com.radiola.ui.theme.ThemePalette.fromId(paletteId)) {
// Сплэш рисуем на ПЕРВОМ (дешёвом) кадре; тяжёлый контент (ViewModels,
// плеер) композим следующим кадром ПОД сплэшем — так логотип появляется
// почти сразу, без долгого тёмного ожидания холодного старта.
var showSplash by remember { mutableStateOf(true) }
var contentReady by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { contentReady = true }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(1600)
showSplash = false
}
if (contentReady) {
val navController = rememberNavController()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showPlayer by remember { mutableStateOf(false) }
@@ -67,6 +109,33 @@ 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) }
var updateProgress by remember { mutableIntStateOf(0) }
var updateError by remember { mutableStateOf<String?>(null) }
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
}
// Авторизация необязательна — всегда стартуем со станций.
// Вход доступен из Настроек.
val startDestination = NavDestinations.Stations.route
@@ -75,9 +144,13 @@ class MainActivity : ComponentActivity() {
.currentBackStackEntryAsState().value?.destination?.route
val showChrome = currentRoute != NavDestinations.Auth.route
// Альбомная ориентация: вместо нижнего бара — боковой рейл слева,
// мини-плеер уезжает под контент. Портрет — прежняя раскладка.
val landscape = com.radiola.ui.util.isLandscape()
Scaffold(
bottomBar = {
if (showChrome) {
if (showChrome && !landscape) {
Column(Modifier.navigationBarsPadding()) {
if (currentStation != null) {
MiniPlayer(
@@ -96,10 +169,20 @@ class MainActivity : ComponentActivity() {
}
}
) { paddingValues ->
Row(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.then(if (landscape) Modifier.displayCutoutPadding() else Modifier)
) {
if (showChrome && landscape) {
com.radiola.ui.navigation.SideNavRail(navController)
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(paddingValues),
modifier = Modifier.weight(1f).fillMaxWidth(),
enterTransition = {
androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) +
androidx.compose.animation.slideInVertically(
@@ -118,6 +201,9 @@ class MainActivity : ComponentActivity() {
}
)
}
composable(NavDestinations.Charts.route) {
ChartsScreen()
}
composable(NavDestinations.Favorites.route) {
FavoritesScreen(
onStationClick = { station ->
@@ -136,9 +222,25 @@ class MainActivity : ComponentActivity() {
SettingsScreen(
onNavigateToAuth = {
navController.navigate(NavDestinations.Auth.route)
},
onNavigateToAlarms = {
navController.navigate(NavDestinations.Alarms.route)
},
onNavigateToEqualizer = {
navController.navigate(NavDestinations.Equalizer.route)
}
)
}
composable(NavDestinations.Alarms.route) {
AlarmsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(NavDestinations.Equalizer.route) {
com.radiola.ui.equalizer.EqualizerScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(NavDestinations.Auth.route) {
AuthScreen(
onAuthSuccess = {
@@ -154,6 +256,18 @@ class MainActivity : ComponentActivity() {
)
}
}
if (showChrome && landscape && currentStation != null) {
MiniPlayer(
stationName = currentStation!!.name,
track = currentTrack,
isPlaying = isPlaying,
onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() }
)
Spacer(Modifier.height(12.dp))
}
} // конец контентной колонки
} // конец Row (рейл + контент)
}
if (showPlayer) {
@@ -176,7 +290,144 @@ class MainActivity : ComponentActivity() {
)
}
}
pendingUpdate?.let { update ->
com.radiola.ui.update.UpdateDialog(
versionName = update.versionName,
notes = update.notes,
isForce = update.forceUpdate,
state = updateState,
progress = updateProgress,
errorMsg = updateError,
onPrimary = {
when (updateState) {
com.radiola.ui.update.UpdateState.DOWNLOADED ->
downloadedApk?.let {
com.radiola.update.UpdateManager.installApk(this@MainActivity, it)
}
com.radiola.ui.update.UpdateState.DOWNLOADING -> {}
else -> {
updateState = com.radiola.ui.update.UpdateState.DOWNLOADING
updateProgress = 0
updateError = null
updateScope.launch {
val f = com.radiola.update.UpdateManager.downloadApk(
this@MainActivity, update.downloadUrl
) { updateProgress = it }
if (f != null &&
com.radiola.update.UpdateManager.verifySha256(f, update.sha256)
) {
downloadedApk = f
updateState = com.radiola.ui.update.UpdateState.DOWNLOADED
com.radiola.update.UpdateManager.installApk(this@MainActivity, f)
} else {
updateError = if (f == null) "Не удалось загрузить обновление"
else "Контрольная сумма не совпала"
updateState = com.radiola.ui.update.UpdateState.ERROR
}
}
}
}
},
onDismiss = { if (!update.forceUpdate) pendingUpdate = null }
)
}
} // конец if (contentReady): тяжёлый контент композится под сплэшем
// Тематический экран загрузки поверх всего (рисуем сами — системный
// сплэш Android 12+ нельзя перекрасить под выбранную тему).
androidx.compose.animation.AnimatedVisibility(
visible = showSplash,
enter = androidx.compose.animation.EnterTransition.None,
exit = androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(450))
) {
com.radiola.ui.components.SplashOverlay(
com.radiola.ui.theme.ThemePalette.fromId(paletteId)
)
}
}
}
}
/**
* Условия для стабильного фонового воспроизведения:
* 1) POST_NOTIFICATIONS (Android 13+) — без него не видно медиа-уведомление и
* foreground-сервис легко убивается системой;
* 2) исключение из оптимизации батареи (Doze/ColorOS глушат фон).
*/
private fun ensureBackgroundPlaybackAllowed() {
if (android.os.Build.VERSION.SDK_INT >= 33 &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
// После ответа (в колбэке) попросим про батарею — не два диалога разом.
notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
maybeRequestBatteryExemption()
}
}
private fun maybeRequestBatteryExemption() {
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
// Спрашиваем один раз на установку, чтобы не надоедать.
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

@@ -1,7 +1,30 @@
package com.radiola
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class RadiolaApplication : Application()
class RadiolaApplication : Application(), ImageLoaderFactory {
// Явная настройка кэша Coil: иначе обложки (iTunes/Record/own-CDN) перекачиваются
// каждую сессию, и нет контроля над памятью. Память 25% + диск 100МБ.
override fun newImageLoader(): ImageLoader =
ImageLoader.Builder(this)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(100L * 1024 * 1024)
.build()
}
.crossfade(true)
.build()
}

View File

@@ -4,10 +4,14 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.dao.RecognizedTrackDao
import com.radiola.data.local.dao.RecordingDao
import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TagDao
import com.radiola.data.local.dao.TrackHistoryDao
import com.radiola.data.local.entity.AlarmEntity
import com.radiola.data.local.entity.RecognizedTrackEntity
import com.radiola.data.local.entity.RecordingEntity
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity
@@ -44,13 +48,65 @@ val MIGRATION_3_4 = object : Migration(3, 4) {
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE stations ADD COLUMN qualities TEXT NOT NULL DEFAULT ''")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE recordings ADD COLUMN markers TEXT NOT NULL DEFAULT ''")
}
}
// Добавляем таблицу будильников
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
hour INTEGER NOT NULL,
minute INTEGER NOT NULL,
daysMask INTEGER NOT NULL,
stationId INTEGER NOT NULL,
stationName TEXT NOT NULL,
enabled INTEGER NOT NULL,
fadeInSec INTEGER NOT NULL
)
""".trimIndent()
)
}
}
// Добавляем таблицу истории распознанных треков (Shazam)
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS recognized_track (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
artist TEXT NOT NULL,
song TEXT NOT NULL,
stationName TEXT NOT NULL,
coverUrl TEXT,
timestamp INTEGER NOT NULL
)
""".trimIndent()
)
}
}
@Database(
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
version = 4
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class, RecognizedTrackEntity::class],
version = 8
)
abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
abstract fun trackHistoryDao(): TrackHistoryDao
abstract fun tagDao(): TagDao
abstract fun recordingDao(): RecordingDao
abstract fun alarmDao(): AlarmDao
abstract fun recognizedTrackDao(): RecognizedTrackDao
}

View File

@@ -29,16 +29,22 @@ class LocalStationDataSource @Inject constructor(
.map { dto ->
val group = groupMap[dto.groupId]
val prefix = generatePrefix(dto.name)
// Определяем сеть: только станции Radio Record можно обогащать
// обложками из Record API. Остальные сети — свой источник.
val isRecord = dto.site?.contains("radiorecord", ignoreCase = true) == true
Station(
id = dto.id,
name = dto.name,
prefix = prefix,
streamUrl = dto.stream!!,
coverUrl = group?.let { generateCoverUrl(it.name, dto.name) } ?: "",
coverUrl = StationLogos.forName(dto.name) ?: StationLogos.forSite(dto.site) ?: "",
genre = group?.name ?: "",
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
sortOrder = dto.id,
source = "local"
source = if (isRecord) "record" else "local",
qualities = dto.qualities.orEmpty().map {
com.radiola.domain.model.StreamQuality(it.bitrate, it.url, it.type)
}
)
}
}

View File

@@ -0,0 +1,58 @@
package com.radiola.data.local
/**
* Кураторские логотипы станций по домену сайта — для сетей, у которых нет
* своего API обложек. Пополняется по мере проработки сетей.
* Ключ — хост сайта (без www), значение — прямой URL квадратного логотипа.
*/
object StationLogos {
// Локальные обложки в ресурсах (res/drawable) — ключ: имя станции в нижнем регистре.
private fun res(name: String) = "android.resource://com.radiola/drawable/$name"
// Логотипы каналов Love Radio (сгенерены из их SVG на фирменном цвете, захостены у нас)
private fun love(id: Int) = "http://121.127.37.212:3000/covers/love_${id}_s.webp"
private val byName: Map<String, String> = mapOf(
"comedy fm" to res("cover_comedy_fm"),
"comedy spa" to res("cover_comedy_spa"),
"standup" to res("cover_standup"),
"женский standup" to res("cover_standup_women"),
"love radio" to love(28),
"love rnb" to love(2),
"love top40" to love(3),
"love dance" to love(4),
"love chill" to love(5),
"love gold" to love(6),
"love russian" to love(7),
"love kpop" to love(10),
"love power" to love(11),
"love summer" to love(1),
)
private val byDomain: Map<String, String> = mapOf(
// Comedy Radio (Comedy Club и пр.) — платформа 101.ru
"comedy-radio.ru" to "https://comedy-radio.ru/design/images/logo/apple-touch-icon-180.png?v=2",
// Like FM (GPM Radio)
"likefm.ru" to "https://www.likefm.ru/apple-touch-icon.png",
// Радио Романтика (GPM Radio)
"radioromantika.ru" to "https://radioromantika.ru/design/images/new_romantika_images/img_for_design/icons/touch-icon-iphone-retina.png",
)
/** Обложка по точному имени станции, либо null. */
fun forName(name: String?): String? =
name?.trim()?.lowercase()?.let { byName[it] }
/** Логотип станции по её сайту, либо null. */
fun forSite(site: String?): String? {
val host = site
?.substringAfter("://", site)
?.removePrefix("www.")
?.substringBefore("/")
?.trim()
?.lowercase()
?.takeIf { it.isNotEmpty() }
?: return null
return byDomain[host]
}
}

View File

@@ -0,0 +1,30 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.radiola.data.local.entity.AlarmEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AlarmDao {
@Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
fun getAll(): Flow<List<AlarmEntity>>
@Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
suspend fun getAllOnce(): List<AlarmEntity>
@Query("SELECT * FROM alarms WHERE id = :id")
suspend fun getById(id: Int): AlarmEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(alarm: AlarmEntity): Long
@Query("DELETE FROM alarms WHERE id = :id")
suspend fun delete(id: Int)
@Query("UPDATE alarms SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: Int, enabled: Boolean)
}

View File

@@ -0,0 +1,20 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.radiola.data.local.entity.RecognizedTrackEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RecognizedTrackDao {
@Query("SELECT * FROM recognized_track ORDER BY timestamp DESC LIMIT 200")
fun getAll(): Flow<List<RecognizedTrackEntity>>
@Insert
suspend fun insert(track: RecognizedTrackEntity)
@Query("DELETE FROM recognized_track WHERE id NOT IN (SELECT id FROM recognized_track ORDER BY timestamp DESC LIMIT 200)")
suspend fun cleanupOld()
}

View File

@@ -21,6 +21,9 @@ interface RecordingDao {
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
suspend fun updateEndTime(id: Long, endTime: Long, duration: Long)
@Query("UPDATE recordings SET markers = :markers WHERE id = :id")
suspend fun updateMarkers(id: Long, markers: String)
@Query("SELECT * FROM recordings WHERE id = :id")
suspend fun getById(id: Long): RecordingEntity?
}

View File

@@ -23,6 +23,9 @@ interface StationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(stations: List<StationEntity>)
@Query("DELETE FROM stations WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<Int>)
@Update
suspend fun update(station: StationEntity)
@@ -34,4 +37,13 @@ interface StationDao {
@Query("SELECT id FROM stations WHERE isFavorite = 1")
fun getFavoriteIds(): Flow<List<Int>>
// Разовое чтение id избранного — чтобы при пересоздании каталога (refreshStations)
// не потерять отметки «избранное» (insertAll = REPLACE затирает строки).
@Query("SELECT id FROM stations WHERE isFavorite = 1")
suspend fun getFavoriteIdsOnce(): List<Int>
// Разовое чтение станции по id — используется в сервисе будильника.
@Query("SELECT * FROM stations WHERE id = :id")
suspend fun getByIdOnce(id: Int): StationEntity?
}

View File

@@ -17,7 +17,15 @@ data class LocalStationDto(
@SerialName("bgColor") val bgColor: String? = null,
@SerialName("enabled") val enabled: Boolean = true,
@SerialName("notWorked") val notWorked: Boolean = false,
@SerialName("isNew") val isNew: Boolean = false
@SerialName("isNew") val isNew: Boolean = false,
@SerialName("qualities") val qualities: List<LocalQualityDto>? = null
)
@Serializable
data class LocalQualityDto(
@SerialName("bitrate") val bitrate: Int,
@SerialName("url") val url: String,
@SerialName("type") val type: String = "aac"
)
@Serializable

View File

@@ -0,0 +1,20 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Будильник: время, дни недели, станция, fade-in.
* daysMask — битовая маска Пн..Вс (биты 0..6); 0 = разовый (следующее совпадение).
*/
@Entity(tableName = "alarms")
data class AlarmEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val hour: Int,
val minute: Int,
val daysMask: Int, // 0 = разовый; бит 0=Пн, ..., бит 6=Вс
val stationId: Int,
val stationName: String,
val enabled: Boolean = true,
val fadeInSec: Int = 60
)

View File

@@ -0,0 +1,14 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "recognized_track")
data class RecognizedTrackEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val artist: String,
val song: String,
val stationName: String,
val coverUrl: String?,
val timestamp: Long
)

View File

@@ -12,5 +12,7 @@ data class RecordingEntity(
val startTime: Long,
val endTime: Long?,
val trackName: String?,
val duration: Long?
val duration: Long?,
// Тайм-коды треков: строки "offsetMs\tartist\tsong", разделённые \n.
val markers: String = ""
)

View File

@@ -14,5 +14,7 @@ data class StationEntity(
val tags: String,
val sortOrder: Int,
val source: String = "record",
val isFavorite: Boolean = false
val isFavorite: Boolean = false,
// Качества потока, закодированы строкой: строки "bitrate\ttype\turl", разделённые \n.
val qualities: String = ""
)

View File

@@ -9,7 +9,10 @@ object ApiMapper {
fun StationDto.toDomain(): Station {
val cover = iconFillColored ?: bgImageMobile ?: bgImage ?: ""
val stream = stream128 ?: stream320 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
// ВНИМАНИЕ: поле stream_128 у части станций Record указывает на мёртвый
// маунт {prefix}64.aacp (404) — звука нет, хотя обложка/трек есть. Поле
// stream_320 (= {prefix}96.aacp) живо у ВСЕХ станций. Поэтому 320 первым.
val stream = stream320 ?: stream128 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
return Station(
id = id,
name = name,
@@ -17,7 +20,9 @@ object ApiMapper {
streamUrl = stream,
coverUrl = cover,
genre = tooltip ?: "",
tags = tags.map { it.name },
// Жанры-категории станции лежат в "genre"; "tags" обычно пуст. Берём
// оба, чтобы работали жанровые чипы Record (Лето и пр.).
tags = (genres + tags).map { it.name }.distinct(),
sortOrder = sort
)
}

View File

@@ -0,0 +1,100 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.SubmitCoverDto
import com.radiola.domain.model.Track
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
/**
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
* Дальше обложка приходит ВСЕМ через /now-playing.
*
* Две дорожки: приоритетная (трек, который слушают прямо сейчас — обрабатывается
* первой) и общая (остальные now-playing). Дедуп + троттлинг, чтобы не
* злоупотреблять iTunes с устройства.
*/
@Singleton
class CoverEnrichmentManager @Inject constructor(
private val itunesApi: ItunesApi,
private val radiolaApi: RadiolaApi,
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Что уже поставлено в общую очередь (чтобы не дублировать пачку now-playing).
private val enqueued = Collections.synchronizedSet(HashSet<String>())
// Что уже обработали (чтобы приоритет и общая дорожка не делали двойную работу).
private val processed = Collections.synchronizedSet(HashSet<String>())
private val priority = Channel<Track>(Channel.UNLIMITED)
private val normal = Channel<Track>(Channel.UNLIMITED)
init {
scope.launch {
while (true) {
val track = priority.tryReceive().getOrNull() ?: select {
priority.onReceive { it }
normal.onReceive { it }
}
processOne(track)
delay(THROTTLE_MS)
}
}
}
/** Поставить пачку now-playing-треков без обложки в общую очередь. */
fun enqueue(tracks: Collection<Track>) {
for (t in tracks) {
if (!isEnrichable(t)) continue
if (enqueued.add(normKey(t))) normal.trySend(t)
}
}
/** Трек, который слушают прямо сейчас — вперёд очереди (вызывать при смене трека). */
fun enqueuePriority(track: Track?) {
if (track == null || !isEnrichable(track)) return
priority.trySend(track)
}
private fun isEnrichable(t: Track): Boolean =
t.coverUrl.isNullOrBlank() && t.artist.isNotBlank() && t.song.isNotBlank()
private suspend fun processOne(track: Track) {
val key = normKey(track)
if (!processed.add(key)) return // уже обрабатывали (другая дорожка)
try {
val term = clean("${track.artist} ${track.song}")
if (term.isBlank()) return
val art = itunesApi.search(term).results.firstOrNull()?.artworkUrl100 ?: return
// 100x100 → 600x600 (источник покрупнее, сервер всё равно ресайзит)
val big = art.replace(Regex("/\\d+x\\d+bb\\."), "/600x600bb.")
val resp = radiolaApi.submitCover(SubmitCoverDto(track.artist, track.song, big))
android.util.Log.d("CoverEnrich", "submit '${track.artist} - ${track.song}' -> ${resp.coverUrl}")
} catch (_: Exception) {
// сеть/429/таймаут — не критично; снимаем метку, чтобы могли попробовать позже
processed.remove(key)
}
}
private fun normKey(t: Track): String =
"${t.artist.trim().lowercase()}|${t.song.trim().lowercase()}"
/** Убираем суффиксы «(Original Mix)», «[... Dub]» и пунктуацию — лучше матчит. */
private fun clean(s: String): String = s
.replace(Regex("\\([^)]*\\)|\\[[^\\]]*\\]"), " ")
.replace(Regex("[^\\p{L}\\p{N}]+"), " ")
.replace(Regex("\\s+"), " ")
.trim()
companion object {
private const val THROTTLE_MS = 800L
}
}

View File

@@ -0,0 +1,18 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.ItunesSearchResponse
import retrofit2.http.GET
import retrofit2.http.Query
/**
* iTunes Search API — дёргаем С УСТРОЙСТВА пользователя (его IP не забанен
* Apple, в отличие от нашего серверного). Нужна только обложка трека.
*/
interface ItunesApi {
@GET("search")
suspend fun search(
@Query("term") term: String,
@Query("entity") entity: String = "song",
@Query("limit") limit: Int = 1,
): ItunesSearchResponse
}

View File

@@ -0,0 +1,17 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.LoveConfigDto
import retrofit2.http.GET
import retrofit2.http.Headers
interface LoveApi {
// Сессионный UID для доступа к потокам Love Radio (привязан к IP клиента,
// поэтому запрашиваем именно с устройства).
@GET("player/config")
@Headers(
"User-Agent: Mozilla/5.0",
"Referer: https://www.loveradio.ru/",
"Origin: https://www.loveradio.ru"
)
suspend fun getConfig(): LoveConfigDto
}

View File

@@ -0,0 +1,31 @@
package com.radiola.data.remote
import javax.inject.Inject
import javax.inject.Singleton
/**
* Потоки Love Radio (n340.com) отдают музыку только с валидным сессионным UID,
* привязанным к IP клиента. Берём UID с устройства из их player/config и
* подставляем в URL потока. UID кэшируем (он стабилен в рамках сессии).
*/
@Singleton
class LoveStreamResolver @Inject constructor(
private val loveApi: LoveApi
) {
@Volatile
private var cachedUid: String? = null
private fun isLove(url: String): Boolean =
url.contains("n340.com") || url.contains("loveradio")
suspend fun resolve(url: String): String {
if (!isLove(url)) return url
val uid = cachedUid ?: runCatching { loveApi.getConfig().data.uid }
.getOrNull()
?.takeIf { it.isNotBlank() }
?.also { cachedUid = it }
if (uid.isNullOrBlank()) return url // фолбэк: пусть играет что есть
val base = url.substringBefore("?")
return "$base?type=aac&UID=$uid"
}
}

View File

@@ -0,0 +1,24 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.LrcLibLyricsDto
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface LrcLibApi {
@GET("api/get")
suspend fun get(
@Header("User-Agent") userAgent: String = "radiOLA Android (https://radiorecord.ru)",
@Query("artist_name") artistName: String,
@Query("track_name") trackName: String,
@Query("duration") durationSec: Int? = null
): LrcLibLyricsDto
@GET("api/search")
suspend fun search(
@Header("User-Agent") userAgent: String = "radiOLA Android (https://radiorecord.ru)",
@Query("artist_name") artistName: String,
@Query("track_name") trackName: String
): List<LrcLibLyricsDto>
}

View File

@@ -1,10 +1,15 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.AuthResponseDto
import com.radiola.data.remote.dto.BackendNowPlayingDto
import com.radiola.data.remote.dto.BackendStationDto
import com.radiola.data.remote.dto.ChartsResponseDto
import com.radiola.data.remote.dto.GenresResponseDto
import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto
import com.radiola.data.remote.dto.RecognizeResponseDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.data.remote.dto.UserSettingsDto
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
@@ -13,6 +18,7 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PATCH
import retrofit2.http.Path
import retrofit2.http.Query
interface RadiolaApi {
@@ -22,6 +28,21 @@ interface RadiolaApi {
@POST("auth/verify")
suspend fun verifyMagicLink(@Body dto: MagicLinkVerifyDto): AuthResponseDto
@GET("now-playing")
suspend fun getNowPlaying(): List<BackendNowPlayingDto>
// Распознавание играющего трека через Shazam (бэкенд сам тянет аудио из потока).
@POST("shazam/recognize/{stationId}")
suspend fun recognizeTrack(@Path("stationId") stationId: Int): RecognizeResponseDto
// Сабмит обложки, найденной клиентом в iTunes (см. CoverEnrichmentManager).
@POST("covers/submit")
suspend fun submitCover(@Body dto: com.radiola.data.remote.dto.SubmitCoverDto): com.radiola.data.remote.dto.SubmitCoverResponse
// station_id оффлайн-станций — скрываем их в каталоге (мёртвые потоки)
@GET("stations/offline-ids")
suspend fun getOfflineStationIds(): List<Int>
@GET("users/me")
suspend fun getMe(): JsonObject
@@ -45,4 +66,25 @@ interface RadiolaApi {
@POST("users/me/history/{stationId}")
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
// --- Чарты ---
@GET("charts/tracks")
suspend fun getCharts(
@Query("period") period: String,
@Query("limit") limit: Int = 100,
@Query("genre") genre: String? = null
): ChartsResponseDto
@GET("charts/genres")
suspend fun getGenres(): GenresResponseDto
@GET("charts/tracks/{trackId}")
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto
@POST("charts/tracks/{trackId}/like")
suspend fun likeTrack(@Path("trackId") trackId: String): JsonObject
@DELETE("charts/tracks/{trackId}/like")
suspend fun unlikeTrack(@Path("trackId") trackId: String): JsonObject
}

View File

@@ -0,0 +1,14 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Текущий трек станции с нашего бэкенда (ключ — числовой id станции каталога).
@Serializable
data class BackendNowPlayingDto(
@SerialName("stationId") val stationId: Int,
@SerialName("name") val name: String = "",
@SerialName("song") val song: String,
@SerialName("artist") val artist: String,
@SerialName("coverUrl") val coverUrl: String? = null
)

View File

@@ -0,0 +1,74 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
/** DTO ответа чартов — список позиций. */
@Serializable
data class ChartsResponseDto(
val items: List<ChartEntryDto> = emptyList()
)
/** DTO списка доступных жанров для фильтра. */
@Serializable
data class GenresResponseDto(
val genres: List<String> = emptyList()
)
/** Одна позиция в чарте. */
@Serializable
data class ChartEntryDto(
val rank: Int,
val trackId: String,
val artist: String,
val song: String,
val coverUrl: String? = null,
val genre: String? = null,
val styles: List<String> = emptyList(),
val label: String? = null,
val year: Int? = null,
val plays: Int = 0,
val stationsCount: Int = 0,
val likes: Int = 0,
val prevRank: Int? = null,
/** Направление: up | down | new | same */
val trend: String = "same"
)
/** Подробная статистика трека. */
@Serializable
data class TrackStatsDto(
val trackId: String,
val artist: String,
val song: String,
val album: String? = null,
val coverUrl: String? = null,
val genre: String? = null,
val styles: List<String> = emptyList(),
val label: String? = null,
val year: Int? = null,
val releaseDate: String? = null,
val firstSeen: String? = null,
val totalPlays: Int = 0,
val totalLikes: Int = 0,
val isLiked: Boolean = false,
val currentRank: Int? = null,
val peakRank: Int? = null,
val stations: List<StationPlaysDto> = emptyList(),
val playsTimeline: List<PointDto> = emptyList(),
val likesTimeline: List<PointDto> = emptyList()
)
/** Проигрывания на конкретной станции. */
@Serializable
data class StationPlaysDto(
val stationId: Int,
val name: String,
val plays: Int
)
/** Одна точка тайм-лайна. */
@Serializable
data class PointDto(
val date: String,
val value: Int
)

View File

@@ -0,0 +1,28 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Тело сабмита найденной клиентом обложки на наш бэкенд. */
@Serializable
data class SubmitCoverDto(
val artist: String,
val song: String,
val artworkUrl: String,
)
@Serializable
data class SubmitCoverResponse(
val coverUrl: String? = null,
)
/** Ответ iTunes Search API (берём только обложку). */
@Serializable
data class ItunesSearchResponse(
val results: List<ItunesResult> = emptyList(),
)
@Serializable
data class ItunesResult(
@SerialName("artworkUrl100") val artworkUrl100: String? = null,
)

View File

@@ -0,0 +1,14 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
// Ответ player/config Love Radio — нужен только uid сессии (для доступа к потоку)
@Serializable
data class LoveConfigDto(
val data: LoveConfigData = LoveConfigData()
)
@Serializable
data class LoveConfigData(
val uid: String = ""
)

View File

@@ -0,0 +1,16 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LrcLibLyricsDto(
@SerialName("id") val id: Int? = null,
@SerialName("trackName") val trackName: String? = null,
@SerialName("artistName") val artistName: String? = null,
@SerialName("albumName") val albumName: String? = null,
@SerialName("duration") val duration: Double? = null,
@SerialName("instrumental") val instrumental: Boolean = false,
@SerialName("plainLyrics") val plainLyrics: String? = null,
@SerialName("syncedLyrics") val syncedLyrics: String? = null
)

View File

@@ -0,0 +1,12 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class RecognizeResponseDto(
val matched: Boolean,
val artist: String? = null,
val song: String? = null,
val coverUrl: String? = null,
val album: String? = null
)

View File

@@ -16,6 +16,10 @@ data class StationDto(
@SerialName("stream_128") val stream128: String? = null,
@SerialName("stream_320") val stream320: String? = null,
@SerialName("stream_hls") val streamHls: String? = null,
// Record API кладёт жанры-категории станции в поле "genre" (массив {id,name}),
// а не в "tags". Раньше читали только "tags" (его у станции нет) — поэтому
// жанровые чипы Record (Лето, House, …) были пустыми. Берём оба.
@SerialName("genre") val genres: List<TagDto> = emptyList(),
@SerialName("tags") val tags: List<TagDto> = emptyList()
)

View File

@@ -0,0 +1,123 @@
package com.radiola.data.repository
import android.util.Log
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.remote.dto.ChartEntryDto
import com.radiola.data.remote.dto.PointDto
import com.radiola.data.remote.dto.StationPlaysDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.ChartTrend
import com.radiola.domain.model.StatPoint
import com.radiola.domain.model.StationPlays
import com.radiola.domain.model.TrackStats
import com.radiola.domain.repository.ChartsRepository
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChartsRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ChartsRepository {
override suspend fun getCharts(period: ChartPeriod, genre: String?): List<ChartEntry> {
return try {
val response = api.getCharts(period.apiValue, genre = genre)
response.items.map { it.toDomain() }
} catch (e: Exception) {
Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}")
emptyList()
}
}
override suspend fun getGenres(): List<String> {
return try {
api.getGenres().genres
} catch (e: Exception) {
Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}")
emptyList()
}
}
override suspend fun getTrackStats(trackId: String): TrackStats {
return api.getTrackStats(trackId).toDomain()
}
override suspend fun setLiked(trackId: String, liked: Boolean) {
try {
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
} catch (e: Exception) {
Log.w("ChartsRepository", "Ошибка лайка трека $trackId: ${e.message}")
}
}
// ---- Маппинг DTO → Domain ----
private fun ChartEntryDto.toDomain() = ChartEntry(
rank = rank,
trackId = trackId,
artist = artist,
song = song,
coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
plays = plays,
stationsCount = stationsCount,
likes = likes,
prevRank = prevRank,
trend = when (trend) {
"up" -> ChartTrend.UP
"down" -> ChartTrend.DOWN
"new" -> ChartTrend.NEW
else -> ChartTrend.SAME
}
)
private fun TrackStatsDto.toDomain() = TrackStats(
trackId = trackId,
artist = artist,
song = song,
album = album,
coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
releaseDate = releaseDate,
firstSeen = firstSeen,
totalPlays = totalPlays,
totalLikes = totalLikes,
isLiked = isLiked,
currentRank = currentRank,
peakRank = peakRank,
stations = stations.map { it.toDomain() },
playsTimeline = playsTimeline.map { it.toDomain() },
likesTimeline = likesTimeline.map { it.toDomain() }
)
private fun StationPlaysDto.toDomain() = StationPlays(stationId, name, plays)
private fun PointDto.toDomain(): StatPoint {
val epochMs = try {
Instant.parse(date).toEpochMilli()
} catch (e: Exception) {
// Попробуем как yyyy-MM-dd
try {
LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
} catch (e2: Exception) {
System.currentTimeMillis()
}
}
return StatPoint(epochMs, value)
}
}

View File

@@ -0,0 +1,66 @@
package com.radiola.data.repository
import com.radiola.data.remote.LrcLibApi
import com.radiola.domain.repository.LyricsRepository
import com.radiola.domain.repository.LyricsResult
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
private const val USER_AGENT = "radiOLA Android (https://radiorecord.ru)"
@Singleton
class LyricsRepositoryImpl @Inject constructor(
private val api: LrcLibApi
) : LyricsRepository {
override fun providerUrl(artist: String, song: String): String {
val query = URLEncoder.encode("$artist $song текст песни", "UTF-8")
return "https://yandex.ru/search/?text=$query"
}
override suspend fun fetchLyrics(
artist: String,
song: String,
durationSec: Int?
): LyricsResult? {
val cleanArtist = artist.trim()
val cleanSong = song.trim()
if (cleanArtist.isEmpty() || cleanSong.isEmpty()) return null
return try {
// Сначала точный запрос
val dto = api.get(
userAgent = USER_AGENT,
artistName = cleanArtist,
trackName = cleanSong,
durationSec = durationSec
)
LyricsResult(
plain = dto.plainLyrics?.takeIf { it.isNotBlank() },
synced = dto.syncedLyrics?.takeIf { it.isNotBlank() },
instrumental = dto.instrumental
)
} catch (_: Exception) {
// Фолбэк на поиск — берём первый результат с непустым текстом
try {
val results = api.search(
userAgent = USER_AGENT,
artistName = cleanArtist,
trackName = cleanSong
)
val found = results.firstOrNull { !it.plainLyrics.isNullOrBlank() }
?: results.firstOrNull { it.instrumental }
found?.let {
LyricsResult(
plain = it.plainLyrics?.takeIf { p -> p.isNotBlank() },
synced = it.syncedLyrics?.takeIf { s -> s.isNotBlank() },
instrumental = it.instrumental
)
}
} catch (_: Exception) {
null
}
}
}
}

View File

@@ -1,8 +1,8 @@
package com.radiola.data.repository
import com.radiola.data.remote.CoverEnrichmentManager
import com.radiola.data.remote.NowPlayingSocketClient
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.ApiMapper.toDomain
import com.radiola.data.remote.RadiolaApi
import com.radiola.domain.model.Track
import com.radiola.domain.repository.NowPlayingRepository
import kotlinx.coroutines.flow.Flow
@@ -11,8 +11,9 @@ import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class NowPlayingRepositoryImpl @Inject constructor(
private val api: RecordApi,
private val socketClient: NowPlayingSocketClient
private val radiolaApi: RadiolaApi,
private val socketClient: NowPlayingSocketClient,
private val coverEnrichment: CoverEnrichmentManager
) : NowPlayingRepository {
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
@@ -21,28 +22,42 @@ class NowPlayingRepositoryImpl @Inject constructor(
socketClient.connect()
}
// Объединяем два источника: сокет (реалтайм, приоритет) и REST-поллинг
// (refreshNowPlaying). Раньше REST-данные писались в _nowPlaying, но никем
// не читались — из-за этого трек и обложка не отображались.
// Сокет (реалтайм, приоритет) + REST-поллинг с нашего бэкенда.
// Оба источника ключуются по числовому id станции каталога (== station.id),
// поэтому матчатся однозначно — без коллизий по одинаковым названиям станций.
// REST поллится регулярно и всегда свежий; socket-значения НАКАПЛИВАЮТСЯ и
// НЕ обновляются, если сокет отвалился — поэтому REST в приоритете, иначе
// залипшее socket-значение навсегда затеняет свежий трек (обложка/название
// переставали обновляться).
override fun getNowPlaying(stationId: Int): Flow<Track?> {
return combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
socketMap[stationId] ?: restMap[stationId]
restMap[stationId] ?: socketMap[stationId]
}
}
override fun getAllNowPlaying(): Flow<Map<Int, Track>> =
combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
restMap + socketMap
socketMap + restMap
}
override suspend fun refreshNowPlaying(): Result<Unit> {
return try {
val response = api.getNowPlaying()
val map = response.result.associate { it.id to it.toDomain() }
_nowPlaying.value = map
val list = radiolaApi.getNowPlaying()
_nowPlaying.value = list.associate { dto ->
dto.stationId to Track(
artist = dto.artist,
song = dto.song,
coverUrl = dto.coverUrl,
stationName = dto.name
)
}
// Треки без обложки — обогащаем через iTunes с устройства (наш IP забанен).
coverEnrichment.enqueue(_nowPlaying.value.values)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override fun enrichCoverNow(track: Track) = coverEnrichment.enqueuePriority(track)
}

View File

@@ -0,0 +1,34 @@
package com.radiola.data.repository
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.RecognizedTrackEntity
import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecognizedTrackRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class RecognizedTrackRepositoryImpl @Inject constructor(
private val db: AppDatabase
) : RecognizedTrackRepository {
override fun getHistory(): Flow<List<Track>> =
db.recognizedTrackDao().getAll().map { list -> list.map { it.toDomain() } }
override suspend fun addTrack(track: Track) {
db.recognizedTrackDao().insert(
RecognizedTrackEntity(
artist = track.artist,
song = track.song,
stationName = track.stationName,
coverUrl = track.coverUrl,
timestamp = System.currentTimeMillis()
)
)
db.recognizedTrackDao().cleanupOld()
}
private fun RecognizedTrackEntity.toDomain(): Track = Track(
artist = artist, song = song, coverUrl = coverUrl, stationName = stationName
)
}

View File

@@ -10,6 +10,8 @@ import com.radiola.data.local.entity.RecordingEntity
import com.radiola.domain.model.Recording
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.model.TrackMarker
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RecordingRepository
import com.radiola.service.RecordingService
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -17,6 +19,7 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -24,10 +27,12 @@ import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
class RecordingRepositoryImpl @Inject constructor(
private val db: AppDatabase,
private val okHttpClient: OkHttpClient,
private val nowPlayingRepository: NowPlayingRepository,
@ApplicationContext private val context: Context
) : RecordingRepository {
@@ -35,6 +40,7 @@ class RecordingRepositoryImpl @Inject constructor(
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
private var recordingJob: Job? = null
private var markerJob: Job? = null
private var currentCall: okhttp3.Call? = null
private var currentRecordingId: Long? = null
@@ -53,7 +59,9 @@ class RecordingRepositoryImpl @Inject constructor(
val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings")
dir.mkdirs()
val isHls = station.streamUrl.contains(".m3u8", ignoreCase = true)
val ext = when {
isHls -> "ts"
station.streamUrl.contains(".aac", ignoreCase = true) -> "aac"
station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3"
else -> "audio"
@@ -74,6 +82,36 @@ class RecordingRepositoryImpl @Inject constructor(
db.recordingDao().insert(entity)
_isRecording.value = true
// Захват тайм-кодов треков: первый трек на 0, далее по смене now-playing.
val markers = mutableListOf<TrackMarker>()
track?.let {
if (it.artist.isNotBlank() || it.song.isNotBlank()) {
markers.add(TrackMarker(0L, it.artist, it.song))
}
}
markerJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
if (markers.isNotEmpty()) {
try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {}
}
// Свой поллинг now-playing — чтобы метки писались независимо от экрана радио
launch {
while (isActive) {
try { nowPlayingRepository.refreshNowPlaying() } catch (_: Exception) {}
delay(8_000) // чаще — точнее тайм-коды треков в записи
}
}
nowPlayingRepository.getNowPlaying(station.id)
.distinctUntilChangedBy { "${it?.artist}|${it?.song}" }
.collect { t ->
if (t == null || (t.artist.isBlank() && t.song.isBlank())) return@collect
val last = markers.lastOrNull()
if (last != null && last.artist == t.artist && last.song == t.song) return@collect
val offset = (System.currentTimeMillis() - id).coerceAtLeast(0L)
markers.add(TrackMarker(offset, t.artist, t.song))
try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {}
}
}
// Start foreground service to keep process alive during recording
val serviceIntent = Intent(context, RecordingService::class.java).apply {
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
@@ -83,31 +121,12 @@ class RecordingRepositoryImpl @Inject constructor(
recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
var output: FileOutputStream? = null
try {
val request = Request.Builder().url(station.streamUrl).build()
val call = okHttpClient.newCall(request)
currentCall = call
val response = call.execute()
if (!response.isSuccessful) {
Log.e("RecordingRepo", "HTTP error: ${response.code}")
return@launch
}
output = FileOutputStream(file)
val input = response.body?.byteStream()
if (input == null) {
Log.e("RecordingRepo", "Empty response body")
return@launch
if (isHls) {
recordHls(station.streamUrl, output)
} else {
recordRaw(station.streamUrl, output)
}
val buffer = ByteArray(8192)
var bytesRead: Int
while (isActive) {
bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
}
input.close()
} catch (e: IOException) {
if (e.message?.contains("Canceled") == true) {
Log.d("RecordingRepo", "Recording cancelled normally")
@@ -120,11 +139,101 @@ class RecordingRepositoryImpl @Inject constructor(
}
}
/** Запись сплошного потока (ICY/Icecast): просто пишем тело ответа в файл. */
private suspend fun recordRaw(streamUrl: String, output: FileOutputStream) {
val request = Request.Builder().url(streamUrl).build()
val call = okHttpClient.newCall(request)
currentCall = call
val response = call.execute()
if (!response.isSuccessful) {
Log.e("RecordingRepo", "HTTP error: ${response.code}")
return
}
val input = response.body?.byteStream() ?: run {
Log.e("RecordingRepo", "Empty response body")
return
}
val buffer = ByteArray(8192)
while (coroutineContext.isActive) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
}
input.close()
}
/**
* Запись HLS-станций (EMG: Европа Плюс, Ретро FM и др.). Поток — это m3u8-плейлист,
* а не сплошной поток, поэтому скачиваем .ts-сегменты и склеиваем в файл
* (валидный MPEG-TS, ExoPlayer его проигрывает и перематывает).
*/
private suspend fun recordHls(streamUrl: String, output: FileOutputStream) {
// 1. Резолвим мастер-плейлист в медиа-плейлист (берём первый вариант — у EMG
// он наибольшего битрейта).
var mediaUrl = streamUrl
val firstText = httpGetText(mediaUrl) ?: return
if (firstText.contains("#EXT-X-STREAM-INF")) {
val variant = firstText.lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
if (variant != null) mediaUrl = resolveUrl(mediaUrl, variant)
}
// 2. Опрашиваем медиа-плейлист, дописываем новые сегменты.
val downloaded = LinkedHashSet<String>()
var firstPass = true
while (coroutineContext.isActive) {
val text = httpGetText(mediaUrl)
if (text == null) { delay(2000); continue }
var targetDur = 6
val segments = mutableListOf<String>()
for (raw in text.lineSequence()) {
val line = raw.trim()
when {
line.startsWith("#EXT-X-TARGETDURATION:") ->
targetDur = line.substringAfter(":").toIntOrNull() ?: targetDur
line.isNotEmpty() && !line.startsWith("#") ->
segments.add(resolveUrl(mediaUrl, line))
}
}
segments.forEachIndexed { i, segUrl ->
if (!coroutineContext.isActive || downloaded.contains(segUrl)) return@forEachIndexed
downloaded.add(segUrl)
// На первом проходе пропускаем «прошлое» окно (пишем только хвост),
// чтобы запись начиналась примерно с момента нажатия.
if (firstPass && i < segments.size - 2) return@forEachIndexed
httpGetBytes(segUrl)?.let { output.write(it); output.flush() }
}
firstPass = false
if (downloaded.size > 500) {
val keep = downloaded.toList().takeLast(200)
downloaded.clear(); downloaded.addAll(keep)
}
delay((targetDur * 500L).coerceIn(2000L, 6000L))
}
}
private fun httpGetText(url: String): String? = try {
okHttpClient.newCall(Request.Builder().url(url).header("User-Agent", "radiOLA").build())
.execute().use { if (it.isSuccessful) it.body?.string() else null }
} catch (e: Exception) { Log.w("RecordingRepo", "playlist fetch fail: ${e.message}"); null }
private fun httpGetBytes(url: String): ByteArray? = try {
okHttpClient.newCall(Request.Builder().url(url).header("User-Agent", "radiOLA").build())
.execute().use { if (it.isSuccessful) it.body?.bytes() else null }
} catch (e: Exception) { Log.w("RecordingRepo", "segment fetch fail: ${e.message}"); null }
/** Разрешает относительный URL (вариант/сегмент) относительно базового плейлиста. */
private fun resolveUrl(base: String, ref: String): String =
try { java.net.URI(base).resolve(ref).toString() } catch (e: Exception) { ref }
override suspend fun stopRecording() {
currentCall?.cancel()
currentCall = null
recordingJob?.cancelAndJoin()
recordingJob = null
markerJob?.cancel()
markerJob = null
_isRecording.value = false
// Stop foreground service
@@ -158,6 +267,22 @@ class RecordingRepositoryImpl @Inject constructor(
startTime = startTime,
endTime = endTime,
trackName = trackName,
duration = duration
duration = duration,
markers = decodeMarkers(markers)
)
// Метки кодируем строкой "offsetMs\tartist\tsong" по строкам \n
// (названия треков не содержат \t/\n).
private fun encodeMarkers(list: List<TrackMarker>): String =
list.joinToString("\n") { "${it.offsetMs}\t${it.artist}\t${it.song}" }
private fun decodeMarkers(raw: String): List<TrackMarker> {
if (raw.isBlank()) return emptyList()
return raw.split("\n").mapNotNull { line ->
val p = line.split("\t")
if (p.size != 3) return@mapNotNull null
val off = p[0].toLongOrNull() ?: return@mapNotNull null
TrackMarker(off, p[1], p[2])
}
}
}

View File

@@ -0,0 +1,79 @@
package com.radiola.data.repository
import android.content.Context
import android.telephony.TelephonyManager
import android.util.Log
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* Определяет страну пользователя по IP (для гео-фильтрации). Код кэшируется в
* настройках, так что после первого успешного запроса работает мгновенно и
* оффлайн. Источник — публичные гео-IP сервисы (без ключа).
*/
@Singleton
class RegionRepositoryImpl @Inject constructor(
private val okHttpClient: OkHttpClient,
private val settingsRepository: SettingsRepository,
@ApplicationContext private val context: Context
) : RegionRepository {
override fun countryCode(): Flow<String?> = settingsRepository.getCountryCode()
override suspend fun refresh() {
withContext(Dispatchers.IO) {
val code = fetchCountry()
Log.d("RegionRepo", "country=$code")
if (code != null) settingsRepository.setCountryCode(code)
}
}
private fun fetchCountry(): String? {
// IP — приоритет (учитывает VPN: при VPN страна = выходного узла, и тогда
// украинские потоки доступны → не скрываем).
ipCountry()?.let { return it }
// Фолбэк, если IP-сервис недоступен (напр. заблокирован): страна SIM/сети/локали.
return deviceCountry()
}
private fun ipCountry(): String? {
// 1) api.country.is → {"ip":"..","country":"RU"}
request("https://api.country.is/")?.let { body ->
Regex("\"country\"\\s*:\\s*\"([A-Za-z]{2})\"").find(body)?.groupValues?.get(1)?.let {
return it.uppercase()
}
}
// 2) ipapi.co/country/ → "RU"
request("https://ipapi.co/country/")?.trim()?.let { body ->
if (body.matches(Regex("[A-Za-z]{2}"))) return body.uppercase()
}
return null
}
private fun deviceCountry(): String? = try {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
val net = tm?.networkCountryIso?.takeIf { it.isNotBlank() }
val sim = tm?.simCountryIso?.takeIf { it.isNotBlank() }
(net ?: sim ?: Locale.getDefault().country.takeIf { it.isNotBlank() })?.uppercase()
} catch (e: Exception) {
null
}
private fun request(url: String): String? = try {
okHttpClient.newCall(
Request.Builder().url(url).header("User-Agent", "radiOLA").build()
).execute().use { if (it.isSuccessful) it.body?.string() else null }
} catch (e: Exception) {
Log.w("RegionRepo", "geo fetch fail ($url): ${e.message}")
null
}
}

View File

@@ -28,7 +28,16 @@ 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")
private val THEME_PALETTE = stringPreferencesKey("theme_palette")
private val EQ_ENABLED = booleanPreferencesKey("eq_enabled")
private val EQ_PRESET = intPreferencesKey("eq_preset")
private val EQ_BANDS = stringPreferencesKey("eq_bands")
private val EQ_BASS = intPreferencesKey("eq_bass")
private val EQ_VIRTUALIZER = intPreferencesKey("eq_virtualizer")
private val EQ_LOUDNESS = intPreferencesKey("eq_loudness")
}
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
@@ -47,6 +56,33 @@ 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 } }
override fun getCountryCode(): Flow<String?> = dataStore.data.map { it[COUNTRY_CODE] }
override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = code } }
override fun getVisualizerStyle(): Flow<String> = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" }
override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } }
override fun getThemePalette(): Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "forest" }
override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } }
override fun getEqEnabled(): Flow<Boolean> = dataStore.data.map { it[EQ_ENABLED] ?: false }
override suspend fun setEqEnabled(enabled: Boolean) { dataStore.edit { it[EQ_ENABLED] = enabled } }
override fun getEqPreset(): Flow<Int> = dataStore.data.map { it[EQ_PRESET] ?: -1 }
override suspend fun setEqPreset(index: Int) { dataStore.edit { it[EQ_PRESET] = index } }
override fun getEqBands(): Flow<String> = dataStore.data.map { it[EQ_BANDS] ?: "" }
override suspend fun setEqBands(csv: String) { dataStore.edit { it[EQ_BANDS] = csv } }
override fun getEqBass(): Flow<Int> = dataStore.data.map { it[EQ_BASS] ?: 0 }
override suspend fun setEqBass(value: Int) { dataStore.edit { it[EQ_BASS] = value } }
override fun getEqVirtualizer(): Flow<Int> = dataStore.data.map { it[EQ_VIRTUALIZER] ?: 0 }
override suspend fun setEqVirtualizer(value: Int) { dataStore.edit { it[EQ_VIRTUALIZER] = value } }
override fun getEqLoudness(): Flow<Int> = dataStore.data.map { it[EQ_LOUDNESS] ?: 0 }
override suspend fun setEqLoudness(value: Int) { dataStore.edit { it[EQ_LOUDNESS] = value } }
}

View File

@@ -0,0 +1,41 @@
package com.radiola.data.repository
import com.radiola.data.remote.RadiolaApi
import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecognizeResult
import com.radiola.domain.repository.ShazamRepository
import retrofit2.HttpException
import javax.inject.Inject
class ShazamRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ShazamRepository {
override suspend fun recognize(stationId: Int, stationName: String): RecognizeResult {
return try {
val res = api.recognizeTrack(stationId)
if (res.matched && !res.artist.isNullOrBlank() && !res.song.isNullOrBlank()) {
RecognizeResult.Found(
Track(
artist = res.artist,
song = res.song,
coverUrl = res.coverUrl,
stationName = stationName
)
)
} else {
RecognizeResult.NotFound
}
} catch (e: HttpException) {
val msg = when (e.code()) {
503 -> "Распознавание временно недоступно"
400 -> "На этой станции нет музыки"
429 -> "Слишком много запросов, попробуйте позже"
else -> "Не удалось распознать трек"
}
RecognizeResult.Error(msg)
} catch (e: Exception) {
RecognizeResult.Error("Нет связи с сервером")
}
}
}

View File

@@ -5,9 +5,13 @@ import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.remote.ApiMapper.toDomain
import com.radiola.domain.model.Station
import com.radiola.domain.model.StreamQuality
import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -16,24 +20,29 @@ import javax.inject.Inject
class StationRepositoryImpl @Inject constructor(
private val api: RecordApi,
private val radiolaApi: RadiolaApi,
private val db: AppDatabase,
private val localDataSource: LocalStationDataSource
) : StationRepository {
private val _tags = MutableStateFlow<List<String>>(emptyList())
// Теги-чипы, которые не показываем (сезонные/мусорные). «Новый год» —
// межсезонный тег Record (Christmas Chill и т.п. остаются в разделе Radio Record).
private val hiddenTags = setOf("Новый год")
override fun getStations(): Flow<List<Station>> {
return db.stationDao().getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun refreshStations(): Result<Unit> {
android.util.Log.d("StationRepo", "refreshStations() called")
return try {
override suspend fun refreshStations(): Result<Unit> = withContext(Dispatchers.IO) {
// Тяжёлый парс stations.json (~700) + сетевые вызовы + запись в Room —
// на IO, а не на главном потоке (был риск jank/ANR при холодном старте).
try {
// 1. Load local stations from assets
val localStations = localDataSource.loadStations()
android.util.Log.d("StationRepo", "Loaded ${localStations.size} local stations")
val localGroups = localDataSource.loadGroups()
// 2. Try to enrich with Record API data (covers, streams, tags)
@@ -41,9 +50,21 @@ class StationRepositoryImpl @Inject constructor(
val apiStations = apiResponse?.result?.stations ?: emptyList()
val apiTags = apiResponse?.result?.tags?.map { it.name } ?: emptyList()
// 3. Merge: local stations enriched with API data where IDs match
// 3. Merge: local stations enriched with API data.
// Локальные id (1,2,3…) не совпадают с id Record-каталога, и в ассетах
// нет prefix — поэтому сопоставляем сначала по id, затем по названию
// (стабильный общий ключ), иначе обложки/потоки не подтягиваются.
// Индексы строим один раз (было O(n²): .find по ~700 на каждую из ~700).
// asReversed+associateBy сохраняет ПЕРВЫЙ по имени (как делал .find).
val apiById = apiStations.associateBy { it.id }
val apiByName = apiStations.asReversed()
.associateBy { it.name.trim().lowercase() }
val merged = localStations.map { local ->
val apiStation = apiStations.find { it.id == local.id }
// Обложки/потоки из Record API — только для станций сети Radio Record.
// Иначе чужим сетям (DFM, HitFM и т.д.) цеплялись бы обложки Record.
val apiStation = if (local.source == "record") {
apiById[local.id] ?: apiByName[local.name.trim().lowercase()]
} else null
if (apiStation != null) {
val domain = apiStation.toDomain()
local.copy(
@@ -59,8 +80,9 @@ class StationRepositoryImpl @Inject constructor(
}
}
// 4. Save to DB
android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB")
// 4. Save to DB. Сохраняем текущие отметки «избранное», иначе REPLACE
// в insertAll затрёт их при каждом пересоздании каталога (на старте).
val favoriteIds = db.stationDao().getFavoriteIdsOnce().toSet()
val entities = merged.mapIndexed { index, station ->
StationEntity(
id = station.id,
@@ -72,15 +94,28 @@ class StationRepositoryImpl @Inject constructor(
tags = station.tags.joinToString(","),
sortOrder = index,
source = station.source,
isFavorite = false
isFavorite = station.id in favoriteIds,
qualities = encodeQualities(station.qualities)
)
}
db.stationDao().insertAll(entities)
android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB")
// 4b. Скрываем станции, которые бэкенд пометил оффлайн (мёртвые потоки).
// Если бэкенд недоступен — оставляем как есть (фолбэк на статичный enabled).
try {
val offlineIds = radiolaApi.getOfflineStationIds()
if (offlineIds.isNotEmpty()) {
db.stationDao().deleteByIds(offlineIds)
}
} catch (e: Exception) {
android.util.Log.w("StationRepo", "Не удалось получить offline-id: ${e.message}")
}
// 5. Update tags: group names + API tags
val groupNames = localGroups.map { it.name }.filter { it.isNotBlank() }
val allTags = (groupNames + apiTags).distinct().sorted()
val allTags = (groupNames + apiTags).distinct()
.filterNot { it in hiddenTags }
.sorted()
db.tagDao().clearAll()
db.tagDao().insertAll(allTags.map { TagEntity(it) })
_tags.value = allTags
@@ -109,6 +144,22 @@ class StationRepositoryImpl @Inject constructor(
genre = genre,
tags = tags.split(",").filter { it.isNotBlank() },
sortOrder = sortOrder,
source = source
source = source,
qualities = decodeQualities(qualities)
)
// Качества кодируем строкой "bitrate\ttype\turl" по строкам (URL может содержать ; и |,
// но не \t/\n — поэтому такие разделители безопасны).
private fun encodeQualities(list: List<StreamQuality>): String =
list.joinToString("\n") { "${it.bitrate}\t${it.type}\t${it.url}" }
private fun decodeQualities(raw: String): List<StreamQuality> {
if (raw.isBlank()) return emptyList()
return raw.split("\n").mapNotNull { line ->
val parts = line.split("\t")
if (parts.size != 3) return@mapNotNull null
val br = parts[0].toIntOrNull() ?: return@mapNotNull null
StreamQuality(br, parts[2], parts[1])
}
}
}

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

@@ -7,22 +7,39 @@ import com.radiola.data.local.LocalStationDataSource
import com.radiola.data.local.MIGRATION_1_2
import com.radiola.data.local.MIGRATION_2_3
import com.radiola.data.local.MIGRATION_3_4
import com.radiola.data.local.MIGRATION_4_5
import com.radiola.data.local.MIGRATION_5_6
import com.radiola.data.local.MIGRATION_6_7
import com.radiola.data.local.MIGRATION_7_8
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.LrcLibApi
import com.radiola.data.remote.LoveApi
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.RadiolaApi
import com.radiola.data.repository.AuthRepositoryImpl
import com.radiola.data.repository.ChartsRepositoryImpl
import com.radiola.data.repository.FavoritesRepositoryImpl
import com.radiola.data.repository.LyricsRepositoryImpl
import com.radiola.data.repository.NowPlayingRepositoryImpl
import com.radiola.data.repository.RecordingRepositoryImpl
import com.radiola.data.repository.RegionRepositoryImpl
import com.radiola.data.repository.SettingsRepositoryImpl
import com.radiola.data.repository.StationRepositoryImpl
import com.radiola.data.repository.SyncRepositoryImpl
import com.radiola.data.repository.RecognizedTrackRepositoryImpl
import com.radiola.data.repository.ShazamRepositoryImpl
import com.radiola.data.repository.TrackHistoryRepositoryImpl
import com.radiola.domain.repository.AuthRepository
import com.radiola.domain.repository.RecognizedTrackRepository
import com.radiola.domain.repository.ShazamRepository
import com.radiola.domain.repository.ChartsRepository
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.LyricsRepository
import com.radiola.domain.repository.SyncRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RecordingRepository
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.repository.TrackHistoryRepository
@@ -56,9 +73,15 @@ object AppModule {
fun provideBaseOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
.apply {
// Логирование каждого HTTP-запроса — только в debug. В релизе это лишний
// оверхед на каждый вызов и утечка URL/деталей в logcat.
if (com.radiola.BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
}
}
.build()
@Provides
@@ -69,6 +92,19 @@ object AppModule {
authInterceptor: AuthInterceptor
): OkHttpClient = baseClient.newBuilder()
.addInterceptor(authInterceptor)
// Распознавание Shazam на бэкенде асинхронное (тянет аудио из потока +
// поллит результат ~до 18с) — базового readTimeout 10с не хватает.
// Поднимаем таймаут точечно только для этого пути.
.addInterceptor { chain ->
val req = chain.request()
if (req.url.encodedPath.contains("shazam/recognize")) {
chain.withReadTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.withConnectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.proceed(req)
} else {
chain.proceed(req)
}
}
.build()
@Provides
@@ -87,11 +123,51 @@ 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()
@Provides
@Singleton
@Named("itunes")
fun provideItunesRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
.baseUrl("https://itunes.apple.com/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideItunesApi(@Named("itunes") retrofit: Retrofit): com.radiola.data.remote.ItunesApi =
retrofit.create(com.radiola.data.remote.ItunesApi::class.java)
@Provides
@Singleton
@Named("lrclib")
fun provideLrcLibRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
.baseUrl("https://lrclib.net/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
@Named("love")
fun provideLoveRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
.baseUrl("https://api.loveradio.ru/api/v1/love-radio/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideLoveApi(@Named("love") retrofit: Retrofit): LoveApi = retrofit.create(LoveApi::class.java)
@Provides
@Singleton
fun provideLrcLibApi(@Named("lrclib") retrofit: Retrofit): LrcLibApi = retrofit.create(LrcLibApi::class.java)
@Provides
@Singleton
fun provideRecordApi(@Named("record") retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)
@@ -104,9 +180,17 @@ object AppModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
.build()
@Provides
@Singleton
fun provideAlarmDao(db: AppDatabase): AlarmDao = db.alarmDao()
@Provides
@Singleton
fun provideStationDao(db: AppDatabase): com.radiola.data.local.dao.StationDao = db.stationDao()
@Provides
@Singleton
fun provideLocalStationDataSource(
@@ -134,6 +218,10 @@ object AppModule {
@Singleton
fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl
@Provides
@Singleton
fun provideRegionRepository(impl: RegionRepositoryImpl): RegionRepository = impl
@Provides
@Singleton
fun provideRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository = impl
@@ -145,4 +233,20 @@ object AppModule {
@Provides
@Singleton
fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl
@Provides
@Singleton
fun provideChartsRepository(impl: ChartsRepositoryImpl): ChartsRepository = impl
@Provides
@Singleton
fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl
@Provides
@Singleton
fun provideRecognizedTrackRepository(impl: RecognizedTrackRepositoryImpl): RecognizedTrackRepository = impl
@Provides
@Singleton
fun provideShazamRepository(impl: ShazamRepositoryImpl): ShazamRepository = impl
}

View File

@@ -0,0 +1,26 @@
package com.radiola.domain.geo
import com.radiola.domain.model.Station
/**
* Гео-ограничения станций. Украинские потоки (TavR Media: Radio ROKS, Kiss FM)
* не отдаются на российские IP (geoblock) — для пользователей из РФ их полностью
* скрываем (и сами станции, и их чипы-категории). При VPN (не-RU IP) — показываем,
* т.к. потоки тогда доступны.
*/
object GeoBlock {
// Хосты украинских станций. id 741 «Радио РОКС» (stream.roks.com) — российская,
// под это правило НЕ попадает.
private val UA_HOSTS = listOf("radioroks.ua", "kissfm.ua")
private val BLOCKED_COUNTRIES = setOf("RU")
fun isUaStation(station: Station): Boolean =
UA_HOSTS.any { station.streamUrl.contains(it, ignoreCase = true) }
/** Скрыта ли станция для пользователя из данной страны. */
fun isHidden(station: Station, countryCode: String?): Boolean =
countryCode in BLOCKED_COUNTRIES && isUaStation(station)
/** Нужно ли вообще скрывать украинские станции в этой стране. */
fun shouldHideUa(countryCode: String?): Boolean = countryCode in BLOCKED_COUNTRIES
}

View File

@@ -0,0 +1,30 @@
package com.radiola.domain.model
/** Период чарта, выбираемый пользователем. */
enum class ChartPeriod(val apiValue: String, val label: String) {
DAY("day", "День"),
WEEK("week", "Неделя"),
MONTH("month", "Месяц"),
ALL("all", "Всё время")
}
/** Направление движения позиции в чарте. */
enum class ChartTrend { UP, DOWN, NEW, SAME }
/** Одна позиция в чарте. */
data class ChartEntry(
val rank: Int,
val trackId: String,
val artist: String,
val song: String,
val coverUrl: String?,
val genre: String?,
val styles: List<String>,
val label: String?,
val year: Int?,
val plays: Int,
val stationsCount: Int,
val likes: Int,
val prevRank: Int?,
val trend: ChartTrend
)

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

@@ -0,0 +1,18 @@
package com.radiola.domain.model
/**
* Клиентский признак «музыкальная ли станция». На разговорных/юмористических/
* новостных станциях распознавать нечего — кнопку Shazam там не показываем.
* Список синхронизирован с backend (common/station-classification.ts).
*/
object MusicGenres {
private val NON_MUSIC = setOf(
"Станция Кассиопея", "Юмор ФМ", "Рассказы", "Радио Вера",
"Comedy Radio", "ВГТРК", "Старое радио",
)
fun isMusicStation(genre: String?): Boolean {
if (genre.isNullOrBlank()) return true
return genre.trim() !in NON_MUSIC
}
}

View File

@@ -8,5 +8,17 @@ data class Recording(
val startTime: Long,
val endTime: Long?,
val trackName: String?,
val duration: Long?
val duration: Long?,
// Тайм-коды треков, звучавших во время записи (для навигации при прослушивании).
val markers: List<TrackMarker> = emptyList()
)
/** Отметка трека в записи: смещение от начала записи + что играло. */
data class TrackMarker(
val offsetMs: Long,
val artist: String,
val song: String
) {
val title: String
get() = listOf(artist, song).filter { it.isNotBlank() }.joinToString("")
}

View File

@@ -1,5 +1,11 @@
package com.radiola.domain.model
import androidx.compose.runtime.Immutable
// @Immutable: модели read-only (заполняются один раз из БД/маппера и не мутируются).
// Список tags/qualities иначе делает класс «нестабильным» для Compose → лишние
// рекомпозиции списков станций. Помечаем явно — компилятор сможет пропускать.
@Immutable
data class Station(
val id: Int,
val name: String,
@@ -9,5 +15,25 @@ data class Station(
val genre: String,
val tags: List<String>,
val sortOrder: Int,
val source: String = "record"
val source: String = "record",
// Доступные качества потока (битрейты). Пусто или один элемент — переключателя нет.
val qualities: List<StreamQuality> = emptyList()
)
/** Один вариант качества потока станции. */
@Immutable
data class StreamQuality(
val bitrate: Int, // kbps
val url: String,
val type: String // "aac" | "mp3"
) {
/** Человекочитаемая ступень качества по битрейту. */
val tierLabel: String
get() = when {
bitrate >= 256 -> "Максимальное"
bitrate >= 128 -> "Высокое"
bitrate >= 96 -> "Среднее"
bitrate >= 64 -> "Экономно"
else -> "Минимальное"
}
}

View File

@@ -1,5 +1,8 @@
package com.radiola.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Track(
val artist: String,
val song: String,

View File

@@ -0,0 +1,38 @@
package com.radiola.domain.model
/** Одна точка на графике популярности. */
data class StatPoint(
/** Метка времени в epoch-миллисекундах. */
val date: Long,
val value: Int
)
/** Проигрывания на конкретной станции. */
data class StationPlays(
val stationId: Int,
val name: String,
val plays: Int
)
/** Полная статистика трека (детальная карточка). */
data class TrackStats(
val trackId: String,
val artist: String,
val song: String,
val album: String?,
val coverUrl: String?,
val genre: String?,
val styles: List<String>,
val label: String?,
val year: Int?,
val releaseDate: String?,
val firstSeen: String?,
val totalPlays: Int,
val totalLikes: Int,
val isLiked: Boolean,
val currentRank: Int?,
val peakRank: Int?,
val stations: List<StationPlays>,
val playsTimeline: List<StatPoint>,
val likesTimeline: List<StatPoint>
)

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.repository
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.TrackStats
interface ChartsRepository {
suspend fun getCharts(period: ChartPeriod, genre: String? = null): List<ChartEntry>
suspend fun getGenres(): List<String>
suspend fun getTrackStats(trackId: String): TrackStats
suspend fun setLiked(trackId: String, liked: Boolean)
}

View File

@@ -0,0 +1,24 @@
package com.radiola.domain.repository
/**
* Тексты песен предоставляются через публичный API LRCLIB (https://lrclib.net).
* LRCLIB — открытая база текстов без авторских ограничений (CC0 / community-maintained).
*/
interface LyricsRepository {
/** URL поиска-фолбэк (Яндекс). */
fun providerUrl(artist: String, song: String): String
/** Загрузить текст трека через LRCLIB. null — трек не найден. */
suspend fun fetchLyrics(
artist: String,
song: String,
durationSec: Int? = null
): LyricsResult?
}
data class LyricsResult(
val plain: String?,
val synced: String?,
val instrumental: Boolean
)

View File

@@ -7,4 +7,6 @@ interface NowPlayingRepository {
fun getNowPlaying(stationId: Int): Flow<Track?>
fun getAllNowPlaying(): Flow<Map<Int, Track>>
suspend fun refreshNowPlaying(): Result<Unit>
/** Обогатить обложку трека приоритетно (тот, что слушают прямо сейчас). */
fun enrichCoverNow(track: Track)
}

View File

@@ -0,0 +1,9 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Track
import kotlinx.coroutines.flow.Flow
interface RecognizedTrackRepository {
fun getHistory(): Flow<List<Track>>
suspend fun addTrack(track: Track)
}

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.repository
import kotlinx.coroutines.flow.Flow
/** Регион пользователя (по IP) — для гео-фильтрации станций. */
interface RegionRepository {
/** Код страны пользователя (напр. "RU"), null пока не определён. */
fun countryCode(): Flow<String?>
/** Обновить код страны по IP (кэшируется). */
suspend fun refresh()
}

View File

@@ -12,6 +12,32 @@ 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)
// Код страны пользователя (по IP), напр. "RU". null — не определён.
fun getCountryCode(): Flow<String?>
suspend fun setCountryCode(code: String)
// Стиль визуализатора звука в плеере (ключ VisualizerStyle).
fun getVisualizerStyle(): Flow<String>
suspend fun setVisualizerStyle(style: String)
// Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest".
fun getThemePalette(): Flow<String>
suspend fun setThemePalette(id: String)
// ── Эквалайзер и улучшайзеры звука (android.media.audiofx) ──
fun getEqEnabled(): Flow<Boolean>
suspend fun setEqEnabled(enabled: Boolean)
// Индекс системного пресета эквалайзера; -1 = свой (ручные полосы).
fun getEqPreset(): Flow<Int>
suspend fun setEqPreset(index: Int)
// Уровни полос в миллибелах через запятую (под текущее число полос устройства).
fun getEqBands(): Flow<String>
suspend fun setEqBands(csv: String)
fun getEqBass(): Flow<Int> // 0..100 %
suspend fun setEqBass(value: Int)
fun getEqVirtualizer(): Flow<Int> // 0..100 %
suspend fun setEqVirtualizer(value: Int)
fun getEqLoudness(): Flow<Int> // 0..100 % → 0..+12 дБ
suspend fun setEqLoudness(value: Int)
}

View File

@@ -0,0 +1,13 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Track
sealed interface RecognizeResult {
data class Found(val track: Track) : RecognizeResult
data object NotFound : RecognizeResult
data class Error(val message: String) : RecognizeResult
}
interface ShazamRepository {
suspend fun recognize(stationId: Int, stationName: String): RecognizeResult
}

View File

@@ -1,12 +1,23 @@
package com.radiola.domain.usecase
import com.radiola.domain.geo.GeoBlock
import com.radiola.domain.model.Station
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class GetStationsUseCase @Inject constructor(
private val stationRepository: StationRepository
private val stationRepository: StationRepository,
private val regionRepository: RegionRepository
) {
operator fun invoke(): Flow<List<Station>> = stationRepository.getStations()
// Гео-фильтр: для пользователей из РФ убираем недоступные украинские станции
// (Radio ROKS, Kiss FM) из всех мест, где используется список станций.
operator fun invoke(): Flow<List<Station>> = combine(
stationRepository.getStations(),
regionRepository.countryCode()
) { stations, country ->
stations.filterNot { GeoBlock.isHidden(it, country) }
}
}

View File

@@ -0,0 +1,23 @@
package com.radiola.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
/**
* BroadcastReceiver-триггер будильника.
* Не Hilt-инжектируемый — намеренно простой: только передаёт id в PlayerService.
*/
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val alarmId = intent.getIntExtra("alarm_id", -1)
if (alarmId < 0) return
val serviceIntent = Intent(context, PlayerService::class.java).apply {
action = PlayerService.ACTION_ALARM
putExtra("alarm_id", alarmId)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
}

View File

@@ -0,0 +1,147 @@
package com.radiola.service
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import com.radiola.MainActivity
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.entity.AlarmEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Calendar
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AlarmScheduler @Inject constructor(
@ApplicationContext private val context: Context
) {
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
/**
* Запланировать следующее срабатывание будильника.
* Если daysMask == 0 — разовый: берём ближайшее сегодня/завтра совпадение по времени.
* Если daysMask != 0 — повторяющийся: ищем ближайший день недели из маски.
*/
fun schedule(alarm: AlarmEntity) {
val triggerMs = nextTriggerMillis(alarm)
val operation = buildPendingIntent(alarm.id)
val showIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setAlarmClock(
AlarmManager.AlarmClockInfo(triggerMs, showIntent),
operation
)
} else {
// Нет разрешения на точные будильники — используем менее точный метод
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
}
} else {
alarmManager.setAlarmClock(
AlarmManager.AlarmClockInfo(triggerMs, showIntent),
operation
)
}
Log.d("AlarmScheduler", "Будильник #${alarm.id} запланирован на $triggerMs")
} catch (e: SecurityException) {
Log.w("AlarmScheduler", "SecurityException при setAlarmClock — фолбэк на setAndAllowWhileIdle", e)
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
}
}
/** Отменить будильник по id. */
fun cancel(alarmId: Int) {
alarmManager.cancel(buildPendingIntent(alarmId))
Log.d("AlarmScheduler", "Будильник #$alarmId отменён")
}
/** Пересчитать расписание всех будильников из базы. */
suspend fun rescheduleAll(alarmDao: AlarmDao) {
val alarms = alarmDao.getAllOnce()
alarms.forEach { alarm ->
cancel(alarm.id)
if (alarm.enabled) schedule(alarm)
}
Log.d("AlarmScheduler", "Перепланировано ${alarms.size} будильников")
}
// ──────────────────────────────────────────────
private fun buildPendingIntent(alarmId: Int): PendingIntent {
val intent = Intent(context, AlarmReceiver::class.java).apply {
putExtra("alarm_id", alarmId)
}
return PendingIntent.getBroadcast(
context,
alarmId,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
/**
* Вычислить эпоху (мс) следующего срабатывания будильника.
* Calendar.MONDAY=2 .. Calendar.SUNDAY=1 — маппим в биты 0..6 (Пн..Вс).
*/
private fun nextTriggerMillis(alarm: AlarmEntity): Long {
val cal = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, alarm.hour)
set(Calendar.MINUTE, alarm.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return if (alarm.daysMask == 0) {
// Разовый: если время уже прошло сегодня — сдвигаем на завтра
if (cal.timeInMillis <= System.currentTimeMillis()) {
cal.add(Calendar.DAY_OF_YEAR, 1)
}
cal.timeInMillis
} else {
// Повторяющийся: ищем ближайший день из маски
var found = false
repeat(7) { offset ->
if (!found) {
val checkCal = cal.clone() as Calendar
if (offset > 0) checkCal.add(Calendar.DAY_OF_YEAR, offset)
val bit = calDayToBit(checkCal.get(Calendar.DAY_OF_WEEK))
if (alarm.daysMask and (1 shl bit) != 0) {
// Тот же день, но время уже прошло → пропускаем
if (offset == 0 && checkCal.timeInMillis <= System.currentTimeMillis()) return@repeat
cal.timeInMillis = checkCal.timeInMillis
found = true
}
}
}
if (!found) {
// Крайний случай: все биты проверены — ждём ещё 7 дней (не должно случиться)
cal.add(Calendar.DAY_OF_YEAR, 7)
}
cal.timeInMillis
}
}
/**
* Перевод Calendar.DAY_OF_WEEK → бит в daysMask (0=Пн, 6=Вс).
* Calendar: SUNDAY=1, MONDAY=2, ..., SATURDAY=7
*/
private fun calDayToBit(calDay: Int): Int = when (calDay) {
Calendar.MONDAY -> 0
Calendar.TUESDAY -> 1
Calendar.WEDNESDAY -> 2
Calendar.THURSDAY -> 3
Calendar.FRIDAY -> 4
Calendar.SATURDAY -> 5
Calendar.SUNDAY -> 6
else -> 0
}
}

View File

@@ -0,0 +1,259 @@
package com.radiola.service
import android.content.Context
import android.media.audiofx.BassBoost
import android.media.audiofx.Equalizer
import android.media.audiofx.LoudnessEnhancer
import android.media.audiofx.Virtualizer
import android.util.Log
import com.radiola.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/** Одна полоса эквалайзера: центральная частота и текущий/предельный уровень в мБ. */
data class EqBand(
val index: Int,
val centerHz: Int,
val minMb: Int,
val maxMb: Int,
val levelMb: Int
)
/** Снимок состояния эквалайзера и улучшайзеров для UI. */
data class EqState(
val available: Boolean = false,
val enabled: Boolean = false,
val bands: List<EqBand> = emptyList(),
val presets: List<String> = emptyList(),
val currentPreset: Int = -1, // -1 = свой
val hasBass: Boolean = false,
val hasVirtualizer: Boolean = false,
val hasLoudness: Boolean = false,
val bass: Int = 0, // 0..100 %
val virtualizer: Int = 0, // 0..100 %
val loudness: Int = 0 // 0..100 % → 0..+12 дБ
)
/**
* Управляет системными аудиоэффектами (android.media.audiofx), привязанными к
* аудиосессии плеера: графический эквалайзер + Bass Boost + Virtualizer (объём) +
* LoudnessEnhancer (громкость тихих). Применяет в реальном времени, переживает смену
* станции (сессия фиксированная), сохраняет настройки в DataStore. Эффекты — best-effort:
* на устройствах без поддержки соответствующий блок просто недоступен (null).
*/
@Singleton
class AudioEffectsController @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsRepository
) {
private val tag = "AudioEffects"
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var equalizer: Equalizer? = null
private var bassBoost: BassBoost? = null
private var virtualizer: Virtualizer? = null
private var loudness: LoudnessEnhancer? = null
private var masterEnabled = false
private val _state = MutableStateFlow(EqState())
val state: StateFlow<EqState> = _state.asStateFlow()
/** Привязывает эффекты к аудиосессии и применяет сохранённые настройки. */
fun attach(sessionId: Int) {
release()
equalizer = try {
Equalizer(0, sessionId)
} catch (e: Exception) {
Log.w(tag, "Equalizer недоступен: ${e.message}"); null
}
bassBoost = try {
BassBoost(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
} catch (e: Exception) {
null
}
virtualizer = try {
Virtualizer(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
} catch (e: Exception) {
null
}
loudness = try {
LoudnessEnhancer(sessionId)
} catch (e: Exception) {
null
}
scope.launch { loadAndApply() }
}
private suspend fun loadAndApply() {
val enabled = settings.getEqEnabled().first()
val preset = settings.getEqPreset().first()
val bandsCsv = settings.getEqBands().first()
val bass = settings.getEqBass().first()
val virt = settings.getEqVirtualizer().first()
val loud = settings.getEqLoudness().first()
masterEnabled = enabled
val eq = equalizer
if (eq != null) {
runCatching { eq.enabled = enabled }
val saved = bandsCsv.split(",").mapNotNull { it.trim().toShortOrNull() }
if (preset in 0 until eq.numberOfPresets) {
runCatching { eq.usePreset(preset.toShort()) }
} else if (saved.size == eq.numberOfBands.toInt()) {
saved.forEachIndexed { i, lvl -> runCatching { eq.setBandLevel(i.toShort(), lvl) } }
}
}
applyBassInternal(bass)
applyVirtualizerInternal(virt)
applyLoudnessInternal(loud)
emitState(enabled, preset, bass, virt, loud)
}
// ── Публичные действия (UI) ──
fun setEnabled(on: Boolean) {
masterEnabled = on
runCatching { equalizer?.enabled = on }
val s = _state.value
applyBassInternal(s.bass)
applyVirtualizerInternal(s.virtualizer)
applyLoudnessInternal(s.loudness)
persist { settings.setEqEnabled(on) }
_state.value = s.copy(enabled = on)
}
fun selectPreset(index: Int) {
val eq = equalizer ?: return
if (index !in 0 until eq.numberOfPresets) return
runCatching { eq.usePreset(index.toShort()) }
persist { settings.setEqPreset(index); settings.setEqBands(currentBandsCsv()) }
emitState(_state.value.enabled, index, _state.value.bass, _state.value.virtualizer, _state.value.loudness)
}
// setBand/setBass/... применяют к железу + правят in-memory состояние БЕЗ записи в
// DataStore (вызываются на каждое движение слайдера). Запись — один раз в commit()
// на onValueChangeFinished, чтобы не спамить хранилище десятками правок за драг.
fun setBand(index: Int, levelMb: Int) {
val eq = equalizer ?: return
runCatching { eq.setBandLevel(index.toShort(), levelMb.toShort()) }
val s = _state.value
_state.value = s.copy(
currentPreset = -1, // ручная правка → «свой»
bands = s.bands.map { if (it.index == index) it.copy(levelMb = levelMb) else it }
)
}
fun setBass(value: Int) {
applyBassInternal(value)
_state.value = _state.value.copy(bass = value)
}
fun setVirtualizer(value: Int) {
applyVirtualizerInternal(value)
_state.value = _state.value.copy(virtualizer = value)
}
fun setLoudness(value: Int) {
applyLoudnessInternal(value)
_state.value = _state.value.copy(loudness = value)
}
/** Сохраняет текущее состояние полос/улучшайзеров (вызывать при отпускании слайдера). */
fun commit() {
val s = _state.value
persist {
settings.setEqPreset(s.currentPreset)
settings.setEqBands(currentBandsCsv())
settings.setEqBass(s.bass)
settings.setEqVirtualizer(s.virtualizer)
settings.setEqLoudness(s.loudness)
}
}
// ── Внутреннее применение к железу ──
private fun applyBassInternal(value: Int) {
val bb = bassBoost ?: return
runCatching {
bb.enabled = masterEnabled && value > 0
if (masterEnabled && value > 0) bb.setStrength((value.coerceIn(0, 100) * 10).toShort())
}
}
private fun applyVirtualizerInternal(value: Int) {
val vz = virtualizer ?: return
runCatching {
vz.enabled = masterEnabled && value > 0
if (masterEnabled && value > 0) vz.setStrength((value.coerceIn(0, 100) * 10).toShort())
}
}
private fun applyLoudnessInternal(value: Int) {
val le = loudness ?: return
runCatching {
le.enabled = masterEnabled && value > 0
// 0..100 % → 0..1200 мБ (= 0..+12 дБ)
if (masterEnabled && value > 0) le.setTargetGain(value.coerceIn(0, 100) * 12)
}
}
private fun currentBandsCsv(): String {
val eq = equalizer ?: return ""
return (0 until eq.numberOfBands).joinToString(",") { eq.getBandLevel(it.toShort()).toString() }
}
private fun emitState(enabled: Boolean, preset: Int, bass: Int, virt: Int, loud: Int) {
val eq = equalizer
val bands = if (eq != null) {
val range = eq.bandLevelRange // [min, max] в мБ
(0 until eq.numberOfBands).map { i ->
val b = i.toShort()
EqBand(
index = i,
centerHz = eq.getCenterFreq(b) / 1000, // мГц → Гц
minMb = range[0].toInt(),
maxMb = range[1].toInt(),
levelMb = eq.getBandLevel(b).toInt()
)
}
} else emptyList()
val presets = if (eq != null) {
(0 until eq.numberOfPresets).map { eq.getPresetName(it.toShort()) }
} else emptyList()
_state.value = EqState(
available = eq != null,
enabled = enabled,
bands = bands,
presets = presets,
currentPreset = preset,
hasBass = bassBoost != null,
hasVirtualizer = virtualizer != null,
hasLoudness = loudness != null,
bass = bass,
virtualizer = virt,
loudness = loud
)
}
private fun persist(block: suspend () -> Unit) {
scope.launch { runCatching { block() } }
}
fun release() {
runCatching { equalizer?.release() }
runCatching { bassBoost?.release() }
runCatching { virtualizer?.release() }
runCatching { loudness?.release() }
equalizer = null; bassBoost = null; virtualizer = null; loudness = null
}
}

View File

@@ -0,0 +1,182 @@
package com.radiola.service
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.cos
import kotlin.math.ln
import kotlin.math.sqrt
/** Итеративный radix-2 FFT (in-place). Размер — степень двойки. */
internal object Fft {
fun transform(re: FloatArray, im: FloatArray) {
val n = re.size
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
var t = re[i]; re[i] = re[j]; re[j] = t
t = im[i]; im[i] = im[j]; im[j] = t
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * Math.PI / len
val wr = cos(ang).toFloat()
val wi = kotlin.math.sin(ang).toFloat()
var i = 0
while (i < n) {
var curR = 1f
var curI = 0f
val half = len / 2
for (k in 0 until half) {
val reK = re[i + k + half]
val imK = im[i + k + half]
val bR = reK * curR - imK * curI
val bI = reK * curI + imK * curR
val aR = re[i + k]
val aI = im[i + k]
re[i + k] = aR + bR
im[i + k] = aI + bI
re[i + k + half] = aR - bR
im[i + k + half] = aI - bI
val nCurR = curR * wr - curI * wi
curI = curR * wi + curI * wr
curR = nCurR
}
i += len
}
len = len shl 1
}
}
}
/**
* Подключается к декодированному PCM в аудио-конвейере ExoPlayer (через
* TeeAudioProcessor — без изменения звука и без разрешений) и считает спектр
* (FFT → лог-полосы) для «живого» эквалайзера, реагирующего на реальный звук.
*/
@OptIn(UnstableApi::class)
class AudioSpectrumAnalyzer(
private val bands: Int = 32,
) : TeeAudioProcessor.AudioBufferSink {
private val _spectrum = MutableStateFlow(FloatArray(bands))
val spectrum: StateFlow<FloatArray> = _spectrum
// FFT считаем ТОЛЬКО когда есть наблюдатель (открыт плеер). Иначе ~86 FFT/с
// молотят впустую при фоновом проигрывании (экран выключен) — главный
// пожиратель батареи. Ставится из UI плеера (VisualizerHost).
@Volatile
var active: Boolean = false
// Меньше окно = меньше задержка реакции на удар (групповая задержка Hann ~окно/2)
// и чаще обновления. Лайвность держит автогейн, а не размер окна.
private val fftSize = 1024
private val sample = FloatArray(fftSize)
private val re = FloatArray(fftSize)
private val im = FloatArray(fftSize)
private val window = FloatArray(fftSize) { 0.5f * (1f - cos(2.0 * Math.PI * it / (fftSize - 1)).toFloat()) }
private val smoothed = FloatArray(bands)
private var filled = 0
private var channelCount = 2
private var pcm16 = true
private var sampleRate = 44100
private var lastEmit = 0L
// Автогейн: бегущий пик амплитуды — чтобы столбики всегда использовали всю
// высоту независимо от громкости трека.
private var agcPeak = 1e-4f
override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) {
this.sampleRate = if (sampleRateHz > 0) sampleRateHz else 44100
this.channelCount = channelCount.coerceAtLeast(1)
this.pcm16 = encoding == C.ENCODING_PCM_16BIT
filled = 0
}
override fun handleBuffer(buffer: ByteBuffer) {
if (!pcm16) return
// Нет наблюдателя — не тратим CPU на FFT (батарея при фоновом проигрывании).
if (!active) {
filled = 0
return
}
val b = buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN)
val ch = channelCount
val hop = fftSize / 2
while (b.remaining() >= 2 * ch) {
var sum = 0f
for (c in 0 until ch) sum += b.short.toFloat()
sample[filled++] = (sum / ch) / 32768f
if (filled >= fftSize) {
compute()
// Перекрытие 50%: оставляем вторую половину — чаще обновляем (~43к/с),
// спектр идёт «впритык» к биту, без рывков.
System.arraycopy(sample, hop, sample, 0, fftSize - hop)
filled = fftSize - hop
}
}
}
private fun compute() {
for (i in 0 until fftSize) {
re[i] = sample[i] * window[i]
im[i] = 0f
}
Fft.transform(re, im)
val half = fftSize / 2
val binHz = sampleRate.toFloat() / fftSize
val fMin = 40f
val fMax = 16000f
val ratio = fMax / fMin
val raw = FloatArray(bands)
var frameMax = 0f
for (band in 0 until bands) {
// Лог-частотные полосы 40Гц..16кГц, среднее по бинам полосы.
val fLo = fMin * Math.pow(ratio.toDouble(), band.toDouble() / bands).toFloat()
val fHi = fMin * Math.pow(ratio.toDouble(), (band + 1.0) / bands).toFloat()
val binLo = (fLo / binHz).toInt().coerceIn(1, half - 1)
val binHi = (fHi / binHz).toInt().coerceIn(binLo + 1, half)
var sum = 0f
for (bin in binLo until binHi) {
sum += sqrt(re[bin] * re[bin] + im[bin] * im[bin])
}
// Лёгкий подъём верхов (у них меньше энергии) — чтобы спектр был ровнее.
val tilt = 1f + 1.5f * (band.toFloat() / bands)
val mag = sum / (binHi - binLo) * tilt
raw[band] = mag
if (mag > frameMax) frameMax = mag
}
// Автогейн: мгновенный рост пика, плавный спад → всегда полная высота.
agcPeak = maxOf(agcPeak * 0.94f, frameMax, 1e-4f)
val out = FloatArray(bands)
for (band in 0 until bands) {
// Нормируем по пику + перцептивный лифт (sqrt), чтобы тихое было видно.
val v = sqrt((raw[band] / agcPeak).coerceIn(0f, 1f))
val prev = smoothed[band]
// Мгновенный рост на удар, быстрый спад — чтобы попадать в ритм, не «висеть».
smoothed[band] = if (v > prev) v else prev * 0.55f + v * 0.45f
out[band] = smoothed[band]
}
// Считаем сглаживание каждый хоп (плавность), но эмитим в UI ~45/с, чтобы
// не перегружать перерисовку плеера.
val now = System.nanoTime()
if (now - lastEmit >= 22_000_000L) {
lastEmit = now
_spectrum.value = out
}
}
}

View File

@@ -0,0 +1,36 @@
package com.radiola.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.radiola.data.local.dao.AlarmDao
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Перепланирует будильники после перезагрузки устройства.
*/
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var alarmDao: AlarmDao
@Inject
lateinit var alarmScheduler: AlarmScheduler
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
alarmScheduler.rescheduleAll(alarmDao)
} finally {
pending.finish()
}
}
}
}

View File

@@ -11,30 +11,92 @@ import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.extractor.metadata.icy.IcyInfo
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@UnstableApi
@Singleton
class PlayerController @Inject constructor(
@ApplicationContext context: Context
@ApplicationContext context: Context,
private val sleepSoundPlayer: SleepSoundPlayer,
private val audioEffects: AudioEffectsController
) {
// Анализатор спектра реального звука — для «живого» эквалайзера.
private val spectrumAnalyzer = AudioSpectrumAnalyzer()
val spectrum: StateFlow<FloatArray> = spectrumAnalyzer.spectrum
// RenderersFactory, который вставляет наш tee-процессор в аудио-конвейер
// (читает декодированный PCM, не меняя звук).
private val renderersFactory = object : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean,
): AudioSink {
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setAudioProcessors(arrayOf(TeeAudioProcessor(spectrumAnalyzer)))
.build()
}
}
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying
private val _currentStationPrefix = MutableStateFlow<String?>(null)
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
// Id играющей станции — для подсветки активной карточки в списке.
private val _currentStationId = MutableStateFlow<Int?>(null)
val currentStationId: StateFlow<Int?> = _currentStationId
private val _icyTitle = MutableStateFlow<String?>(null)
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
// ── Таймер сна ──
// Оставшееся время в мс (null = таймер выключен). В последние FADE_MS звук
// плавно затухает (экспоненциальная кривая), затем пауза.
private val _sleepRemainingMs = MutableStateFlow<Long?>(null)
val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow()
private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var sleepJob: Job? = null
// Переподключение при обрыве потока (дорога/туннели).
private var retryCount = 0
private var reconnectJob: Job? = null
private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
const val SOUND_VOL = 0.6f // комфортная громкость шума
const val SOUND_OUTRO_MS = 180_000L // финальное окно со звуком сна (последние ~3 мин)
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
var onSkipToNext: (() -> Unit)? = null
@@ -70,7 +132,19 @@ class PlayerController @Inject constructor(
)
}
// HTTP-источник с разрешёнными кросс-протокольными редиректами (http→https):
// многие станции отдают 301 c http на https, без этого ExoPlayer их не играет.
private val mediaSourceFactory = DefaultMediaSourceFactory(
DefaultDataSource.Factory(
context,
DefaultHttpDataSource.Factory()
.setAllowCrossProtocolRedirects(true)
)
)
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
@@ -79,11 +153,24 @@ class PlayerController @Inject constructor(
true
)
.setHandleAudioBecomingNoisy(true)
// Держим CPU + Wi-Fi активными, пока играем (partial wakelock + wifilock).
// Без этого при выключенном экране система усыпляет сеть → буфер пустеет →
// радио глохнет (главная причина «обрыва» в машине по Bluetooth).
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
.apply {
addListener(object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
_isPlaying.value = playing
// Успешно играем — сбрасываем счётчик попыток переподключения.
if (playing) retryCount = 0
}
override fun onPlayerError(error: PlaybackException) {
// В дороге сигнал рвётся (туннели, край соты). Не глушим радио
// навсегда — пере-готовим поток с нарастающей задержкой.
Log.w("PlayerController", "Ошибка плеера: ${error.errorCodeName}, переподключение")
scheduleReconnect()
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
@@ -112,6 +199,29 @@ class PlayerController @Inject constructor(
}
}
})
// Фиксированная аудиосессия → эффекты (эквалайзер и т.д.) держатся на ней
// и переживают смену станций. Привязываем их сразу после создания плеера.
val sessionId = audioManager.generateAudioSessionId()
runCatching { setAudioSessionId(sessionId) }
audioEffects.attach(sessionId)
}
/**
* Переподключение после ошибки потока с нарастающей задержкой (2с→15с, до 10
* попыток ≈ пережить туннель). Счётчик сбрасывается, как только снова заиграло.
*/
private fun scheduleReconnect() {
reconnectJob?.cancel()
if (retryCount >= 10) return
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
retryCount++
reconnectJob = timerScope.launch {
delay(delayMs)
runCatching {
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
}
val player: Player = object : ForwardingPlayer(exoPlayer) {
@@ -136,8 +246,12 @@ class PlayerController @Inject constructor(
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
}
fun play(url: String, stationPrefix: String, stationName: String) {
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
// Новая станция — сбрасываем переподключение предыдущего потока.
reconnectJob?.cancel()
retryCount = 0
_currentStationId.value = stationId
_icyTitle.value = null
val mediaItem = MediaItem.Builder()
.setUri(url)
@@ -154,6 +268,18 @@ class PlayerController @Inject constructor(
_currentStationPrefix.value = stationPrefix
}
/** Сменить URL потока (переключение качества) без потери текущих метаданных/обложки. */
fun changeStream(url: String) {
Log.d("PlayerController", "changeStream() url=$url")
val keepMetadata = exoPlayer.currentMediaItem?.mediaMetadata
_icyTitle.value = null
val builder = MediaItem.Builder().setUri(url)
if (keepMetadata != null) builder.setMediaMetadata(keepMetadata)
exoPlayer.setMediaItem(builder.build())
exoPlayer.prepare()
exoPlayer.play()
}
fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) {
val currentMediaItem = exoPlayer.currentMediaItem ?: return
val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }
@@ -170,7 +296,125 @@ class PlayerController @Inject constructor(
exoPlayer.replaceMediaItem(0, updatedMediaItem)
}
/**
* Запустить таймер сна на [durationMs] мс.
* Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза.
* Со [sound]: радио играет почти весь таймер; в последние SOUND_OUTRO_MS (не больше
* половины таймера) включается звук для сна — радио кроссфейдится в шум (радио ↓,
* шум ↑), шум держится, в самом конце затухает в тишину. Засыпаешь под радио, а не
* под резкий белый шум в первые же полторы минуты.
*/
fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) {
sleepJob?.cancel()
exoPlayer.volume = 1f
sleepSoundPlayer.stop()
sleepJob = timerScope.launch {
val start = SystemClock.elapsedRealtime()
val end = start + durationMs
// Финальное окно со звуком — не длиннее половины таймера (для коротких).
val outro = if (sound != null) SOUND_OUTRO_MS.coerceAtMost(durationMs / 2) else 0L
// Кроссфейд радио→шум занимает первую половину аутро.
val crossfade = CROSSFADE_MS.coerceAtMost(outro / 2).coerceAtLeast(1L)
var soundStarted = false
while (true) {
val now = SystemClock.elapsedRealtime()
val remaining = end - now
if (remaining <= 0L) break
_sleepRemainingMs.value = remaining
when {
sound != null && remaining <= outro -> {
// Генератор шума стартуем лениво — только в аутро, не весь таймер.
if (!soundStarted) {
sleepSoundPlayer.start(sound)
soundStarted = true
}
val outroElapsed = outro - remaining
when {
outroElapsed < crossfade -> {
// Кроссфейд: радио вниз, шум вверх.
val f = outroElapsed.toFloat() / crossfade
exoPlayer.volume = (1f - f).coerceIn(0f, 1f)
sleepSoundPlayer.setVolume(f * SOUND_VOL)
}
remaining <= FADE_MS -> {
// Финальное затухание шума в тишину.
val frac = remaining.toFloat() / FADE_MS
sleepSoundPlayer.setVolume((frac * frac) * SOUND_VOL)
}
else -> {
// Радио отыграло — пауза, шум на комфортной громкости.
if (exoPlayer.isPlaying) exoPlayer.pause()
sleepSoundPlayer.setVolume(SOUND_VOL)
}
}
delay(150)
}
sound == null && remaining <= FADE_MS -> {
// Без звука: экспоненциальное затухание радио в конце.
val frac = remaining.toFloat() / FADE_MS
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f)
delay(150)
}
else -> {
// Основная фаза: радио играет как обычно.
delay(1_000)
}
}
}
if (exoPlayer.isPlaying) exoPlayer.pause()
exoPlayer.volume = 1f
sleepSoundPlayer.stop()
_sleepRemainingMs.value = null
sleepJob = null
}
}
/**
* Запуск воспроизведения станции по будильнику: играет [url] и плавно нарастает
* громкость 0 → 1 за [fadeInMs] (мягкое пробуждение).
*/
fun startAlarmPlayback(
url: String,
prefix: String,
name: String,
id: Int?,
fadeInMs: Long = 60_000L,
) {
cancelSleepTimer()
play(url, prefix, name, id)
exoPlayer.volume = 0f
sleepJob?.cancel()
sleepJob = timerScope.launch {
val start = SystemClock.elapsedRealtime()
while (true) {
val elapsed = SystemClock.elapsedRealtime() - start
if (elapsed >= fadeInMs) break
exoPlayer.volume = (elapsed.toFloat() / fadeInMs).coerceIn(0f, 1f)
delay(200)
}
exoPlayer.volume = 1f
sleepJob = null
}
}
/** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
fun cancelSleepTimer() {
sleepJob?.cancel()
sleepJob = null
exoPlayer.volume = 1f
sleepSoundPlayer.stop()
_sleepRemainingMs.value = null
}
/** Включить/выключить расчёт спектра (FFT) — только пока открыт плеер. */
fun setSpectrumActive(active: Boolean) {
spectrumAnalyzer.active = active
}
fun pause() {
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
// позже само возобновит воспроизведение.
reconnectJob?.cancel()
exoPlayer.pause()
}
@@ -179,12 +423,16 @@ class PlayerController @Inject constructor(
}
fun stop() {
reconnectJob?.cancel()
exoPlayer.stop()
_currentStationPrefix.value = null
_currentStationId.value = null
}
fun release() {
timerScope.cancel()
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
sleepSoundPlayer.stop()
exoPlayer.release()
}
}

View File

@@ -1,21 +1,55 @@
package com.radiola.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.radiola.MainActivity
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.dao.StationDao
import com.radiola.data.remote.LoveStreamResolver
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@UnstableApi
class PlayerService : MediaSessionService() {
companion object {
const val ACTION_ALARM = "com.radiola.ALARM"
private const val CHANNEL_ALARM = "radiola_alarm"
private const val NOTIF_ID_ALARM = 9001
}
@Inject
lateinit var playerController: PlayerController
@Inject
lateinit var alarmDao: AlarmDao
@Inject
lateinit var stationDao: StationDao
@Inject
lateinit var loveStreamResolver: LoveStreamResolver
@Inject
lateinit var alarmScheduler: AlarmScheduler
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var mediaSession: MediaSession? = null
override fun onCreate() {
@@ -30,6 +64,88 @@ class PlayerService : MediaSessionService() {
)
)
.build()
ensureAlarmChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_ALARM) {
val alarmId = intent.getIntExtra("alarm_id", -1)
if (alarmId >= 0) handleAlarm(alarmId)
}
return START_NOT_STICKY
}
private fun handleAlarm(alarmId: Int) {
// Немедленно поднимаем foreground-уведомление (Android требует ≤5 с после startForegroundService)
startForeground(NOTIF_ID_ALARM, buildAlarmNotification())
serviceScope.launch {
try {
val alarm = alarmDao.getById(alarmId)
if (alarm == null) {
Log.w("PlayerService", "Будильник #$alarmId не найден в БД")
stopForeground(STOP_FOREGROUND_REMOVE)
return@launch
}
val station = stationDao.getByIdOnce(alarm.stationId)
if (station == null) {
Log.w("PlayerService", "Станция #${alarm.stationId} не найдена, будильник #$alarmId")
stopForeground(STOP_FOREGROUND_REMOVE)
return@launch
}
val url = loveStreamResolver.resolve(station.streamUrl)
playerController.startAlarmPlayback(
url = url,
prefix = station.prefix,
name = station.name,
id = station.id,
fadeInMs = alarm.fadeInSec * 1000L
)
// Перепланируем или деактивируем будильник
if (alarm.daysMask != 0) {
// Повторяющийся — планируем следующее срабатывание
alarmScheduler.schedule(alarm)
} else {
// Разовый — отключаем
alarmDao.setEnabled(alarmId, false)
}
// MediaSession-уведомление возьмёт на себя отображение воспроизведения
stopForeground(STOP_FOREGROUND_DETACH)
} catch (e: Exception) {
Log.e("PlayerService", "Ошибка воспроизведения будильника #$alarmId", e)
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
}
private fun ensureAlarmChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ALARM) == null) {
val channel = NotificationChannel(
CHANNEL_ALARM,
"Будильник",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Уведомления о срабатывании будильника"
}
nm.createNotificationChannel(channel)
}
}
private fun buildAlarmNotification(): Notification {
val mainIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ALARM)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Будильник")
.setContentText("Запуск радио…")
.setContentIntent(mainIntent)
.setOngoing(true)
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
@@ -43,6 +159,10 @@ class PlayerService : MediaSessionService() {
override fun onDestroy() {
mediaSession?.release()
mediaSession = null
// serviceScope — поле этого сервиса (пере-создаётся при рестарте), отменяем.
// playerController — @Singleton (переживает рестарт сервиса), его НЕ релизим:
// иначе новый PlayerService построит MediaSession на освобождённом плеере.
serviceScope.cancel()
super.onDestroy()
}
}

View File

@@ -0,0 +1,158 @@
package com.radiola.service
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.extractor.DefaultExtractorsFactory
import com.radiola.domain.model.Recording
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Контроллер воспроизведения записей эфира (отдельный ExoPlayer, независимый от радио).
*/
@Singleton
class RecordingPlaybackController @Inject constructor(
@ApplicationContext private val context: Context,
private val playerController: PlayerController
) {
private val scope = CoroutineScope(Dispatchers.Main)
private val _current = MutableStateFlow<Recording?>(null)
val current: StateFlow<Recording?> = _current
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying
private val _positionMs = MutableStateFlow(0L)
val positionMs: StateFlow<Long> = _positionMs
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _durationMs
// Записи эфира — сырые ADTS-AAC/MP3 без контейнера и индексов перемотки.
// Включаем CBR-seeking, иначе ExoPlayer считает поток неперематываемым
// (seekTo не работал, запись всегда стартовала с начала).
private val extractorsFactory = DefaultExtractorsFactory()
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
// Плеер создаём ЛЕНИВО — запись играют редко, а раньше второй ExoPlayer висел
// в памяти всю сессию у каждого. Освобождаем в stop(): обычно после остановки
// пользователь уходит с экрана записей, держать декодер/буферы незачем.
private var exoPlayer: ExoPlayer? = null
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
_isPlaying.value = playing
// Поллер позиции крутится ТОЛЬКО во время игры — раньше цикл 2 Гц
// работал всю сессию вхолостую (буст main-loop / батарея).
if (playing) startPositionPolling() else stopPositionPolling()
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
val dur = exoPlayer?.duration ?: C.TIME_UNSET
_durationMs.value = if (dur == C.TIME_UNSET) 0L else dur
}
Player.STATE_ENDED -> {
_isPlaying.value = false
_positionMs.value = _durationMs.value
stopPositionPolling()
}
else -> Unit
}
}
}
private var positionPollingJob: Job? = null
private fun ensurePlayer(): ExoPlayer =
exoPlayer ?: ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory))
.build()
.also { it.addListener(playerListener); exoPlayer = it }
private fun startPositionPolling() {
if (positionPollingJob?.isActive == true) return
positionPollingJob = scope.launch {
while (isActive) {
val p = exoPlayer ?: break
if (p.isPlaying) {
_positionMs.value = p.currentPosition
val dur = p.duration
if (dur != C.TIME_UNSET) _durationMs.value = dur
}
delay(500)
}
}
}
private fun stopPositionPolling() {
positionPollingJob?.cancel()
positionPollingJob = null
}
/**
* Начать воспроизведение записи. Сначала останавливает радио.
*/
fun play(recording: Recording) {
// Останавливаем радиоплеер
playerController.pause()
playerController.stop()
_current.value = recording
_positionMs.value = 0L
_durationMs.value = recording.duration ?: 0L
val player = ensurePlayer()
val mediaItem = MediaItem.fromUri(android.net.Uri.fromFile(File(recording.filePath)))
player.setMediaItem(mediaItem)
player.prepare()
player.play()
}
/** Переключить паузу/воспроизведение. */
fun togglePlayPause() {
val player = exoPlayer ?: return
if (player.isPlaying) player.pause() else player.play()
}
/** Перейти к позиции в мс. */
fun seekTo(ms: Long) {
val player = exoPlayer ?: return
val target = ms.coerceIn(0L, _durationMs.value.coerceAtLeast(1L))
player.seekTo(target)
_positionMs.value = target
}
/** Перемотать на deltaMs (может быть отрицательным). */
fun seekBy(deltaMs: Long) {
seekTo(_positionMs.value + deltaMs)
}
/** Остановить воспроизведение, освободить плеер и сбросить текущую запись. */
fun stop() {
stopPositionPolling()
exoPlayer?.release()
exoPlayer = null
_current.value = null
_isPlaying.value = false
_positionMs.value = 0L
_durationMs.value = 0L
}
}

View File

@@ -0,0 +1,120 @@
package com.radiola.service
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.random.Random
/** Тип звука для засыпания (процедурная генерация цветного шума). */
enum class SleepSound(val key: String, val title: String) {
WHITE("white", "Белый шум"),
PINK("pink", "Розовый шум"),
BROWN("brown", "Коричневый шум");
companion object {
fun fromKey(key: String?): SleepSound? = entries.firstOrNull { it.key == key }
}
}
/**
* Проигрыватель цветного шума для засыпания. Генерирует PCM на отдельном потоке и
* пишет в [AudioTrack] (streaming). Громкость регулируется на лету (для fade/кроссфейда).
* Розовый — фильтр Пола Келлета, коричневый — интегрированный белый (random walk).
*/
@Singleton
class SleepSoundPlayer @Inject constructor() {
private val sampleRate = 44100
private val bufSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(4096)
@Volatile private var track: AudioTrack? = null
@Volatile private var thread: Thread? = null
@Volatile private var running = false
/** Запустить генерацию шума [sound]. Стартовая громкость 0 — нарастает кроссфейдом. */
@Synchronized
fun start(sound: SleepSound) {
stop()
val at = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
)
.setBufferSizeInBytes(bufSize * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
at.setVolume(0f)
at.play()
track = at
running = true
thread = Thread { generate(at, sound) }.apply { priority = Thread.MIN_PRIORITY; start() }
}
/** Громкость 0..1 (для плавного появления/затухания). */
fun setVolume(v: Float) {
track?.setVolume(v.coerceIn(0f, 1f))
}
@Synchronized
fun stop() {
running = false
thread?.let { runCatching { it.join(300) } }
thread = null
track?.let { t ->
runCatching { t.stop() }
runCatching { t.release() }
}
track = null
}
private fun generate(at: AudioTrack, sound: SleepSound) {
val n = 2048
val buf = ShortArray(n)
// Состояние фильтров розового шума (Пол Келлет)
var b0 = 0f; var b1 = 0f; var b2 = 0f; var b3 = 0f; var b4 = 0f; var b5 = 0f; var b6 = 0f
var lastBrown = 0f
while (running) {
for (i in 0 until n) {
val white = Random.nextFloat() * 2f - 1f
val sample = when (sound) {
SleepSound.WHITE -> white * 0.35f
SleepSound.PINK -> {
b0 = 0.99886f * b0 + white * 0.0555179f
b1 = 0.99332f * b1 + white * 0.0750759f
b2 = 0.96900f * b2 + white * 0.1538520f
b3 = 0.86650f * b3 + white * 0.3104856f
b4 = 0.55000f * b4 + white * 0.5329522f
b5 = -0.7616f * b5 - white * 0.0168980f
val pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362f
b6 = white * 0.115926f
pink * 0.11f
}
SleepSound.BROWN -> {
val brown = (lastBrown + 0.02f * white) / 1.02f
lastBrown = brown
brown * 3.5f
}
}
buf[i] = (sample.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
}
val written = at.write(buf, 0, n)
if (written < 0) break
}
}
}

View File

@@ -0,0 +1,386 @@
package com.radiola.ui.alarms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Trash2
import com.radiola.data.local.entity.AlarmEntity
import com.radiola.domain.model.Station
import com.radiola.ui.theme.RadiolaTheme
/** Экран управления будильниками. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlarmsScreen(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AlarmsViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val alarms by viewModel.alarms.collectAsState()
val stations by viewModel.stations.collectAsState()
// Состояние диалога добавления/редактирования
var editingAlarm by remember { mutableStateOf<AlarmEntity?>(null) }
var showEditor by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxSize()
.background(colors.bgBase)
) {
// Шапка с кнопкой «Назад»
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onNavigateBack) {
Icon(Lucide.ArrowLeft, contentDescription = "Назад", tint = colors.textPrimary)
}
Text(
text = "Будильник",
style = MaterialTheme.typography.headlineMedium,
color = colors.textPrimary,
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
IconButton(onClick = {
editingAlarm = null
showEditor = true
}) {
Icon(Lucide.Plus, contentDescription = "Добавить будильник", tint = colors.accent)
}
}
if (alarms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Lucide.AlarmClock, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(48.dp))
Text("Нет будильников", color = colors.textMuted, style = MaterialTheme.typography.bodyLarge)
Text(
"Нажмите «+» чтобы добавить",
color = colors.textMuted,
style = MaterialTheme.typography.labelMedium
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(alarms, key = { it.id }) { alarm ->
AlarmCard(
alarm = alarm,
onToggle = { viewModel.toggle(alarm) },
onEdit = {
editingAlarm = alarm
showEditor = true
},
onDelete = { viewModel.delete(alarm) }
)
}
}
}
}
// Диалог добавления / редактирования
if (showEditor) {
AlarmEditorSheet(
initial = editingAlarm,
stations = stations,
onSave = { alarm ->
viewModel.addOrUpdate(alarm)
showEditor = false
},
onDismiss = { showEditor = false }
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
@Composable
private fun AlarmCard(
alarm: AlarmEntity,
onToggle: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Время
Text(
text = "%02d:%02d".format(alarm.hour, alarm.minute),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = if (alarm.enabled) colors.textPrimary else colors.textMuted
)
Spacer(Modifier.width(16.dp))
// Станция + дни
Column(modifier = Modifier.weight(1f)) {
Text(
text = alarm.stationName,
style = MaterialTheme.typography.titleMedium,
color = if (alarm.enabled) colors.textPrimary else colors.textMuted,
maxLines = 1
)
Text(
text = daysSummary(alarm.daysMask),
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
// Удалить
IconButton(onClick = onDelete) {
Icon(Lucide.Trash2, contentDescription = "Удалить", tint = colors.textMuted, modifier = Modifier.size(18.dp))
}
// Вкл/выкл
Switch(
checked = alarm.enabled,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
private val DAY_LABELS = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
private fun daysSummary(mask: Int): String {
if (mask == 0) return "Один раз"
val all = (1 shl 7) - 1
if (mask == all) return "Каждый день"
val weekdays = 0b0011111 // Пн-Пт
if (mask == weekdays) return "По будням"
val weekend = 0b1100000 // Сб-Вс
if (mask == weekend) return "По выходным"
return DAY_LABELS.filterIndexed { i, _ -> mask and (1 shl i) != 0 }.joinToString(" ")
}
// ─────────────────────────────────────────────────────────────────────────────
/**
* Нижний лист редактирования / создания будильника.
* Использует Material3 TimePicker + выбор станции + чипы дней.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AlarmEditorSheet(
initial: AlarmEntity?,
stations: List<Station>,
onSave: (AlarmEntity) -> Unit,
onDismiss: () -> Unit
) {
val colors = RadiolaTheme.colors
// Начальные значения
val initHour = initial?.hour ?: 7
val initMinute = initial?.minute ?: 0
var selectedHour by remember { mutableStateOf(initHour) }
var selectedMinute by remember { mutableStateOf(initMinute) }
var daysMask by remember { mutableStateOf(initial?.daysMask ?: 0) }
var selectedStation by remember {
mutableStateOf(stations.firstOrNull { it.id == initial?.stationId } ?: stations.firstOrNull())
}
var fadeInSec by remember { mutableStateOf(initial?.fadeInSec ?: 60) }
var stationDropdownExpanded by remember { mutableStateOf(false) }
// Обновим станцию если список подгрузился после открытия
LaunchedEffect(stations) {
if (selectedStation == null) selectedStation = stations.firstOrNull()
}
val timePickerState = rememberTimePickerState(
initialHour = initHour,
initialMinute = initMinute,
is24Hour = true
)
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = colors.elevated
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(
text = if (initial == null) "Новый будильник" else "Изменить будильник",
style = MaterialTheme.typography.titleLarge,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold
)
// Выбор времени
TimePicker(
state = timePickerState,
colors = TimePickerDefaults.colors(
clockDialColor = colors.surface2,
selectorColor = colors.accent,
timeSelectorSelectedContainerColor = colors.accent,
timeSelectorUnselectedContainerColor = colors.surface,
timeSelectorSelectedContentColor = colors.bgBase,
timeSelectorUnselectedContentColor = colors.textPrimary,
periodSelectorBorderColor = colors.border,
clockDialSelectedContentColor = colors.bgBase,
clockDialUnselectedContentColor = colors.textPrimary
),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
// Выбор станции
Column {
Text("Станция", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
Spacer(Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(12.dp))
.clickable { stationDropdownExpanded = true }
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = selectedStation?.name ?: "Выберите станцию",
color = if (selectedStation != null) colors.textPrimary else colors.textMuted,
style = MaterialTheme.typography.bodyLarge
)
}
DropdownMenu(
expanded = stationDropdownExpanded,
onDismissRequest = { stationDropdownExpanded = false },
modifier = Modifier.background(colors.elevated).heightIn(max = 300.dp)
) {
stations.forEach { station ->
DropdownMenuItem(
text = { Text(station.name, color = colors.textPrimary) },
onClick = {
selectedStation = station
stationDropdownExpanded = false
}
)
}
}
}
// Дни недели
Column {
Text("Повтор", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
DAY_LABELS.forEachIndexed { i, label ->
val selected = daysMask and (1 shl i) != 0
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) colors.accent else colors.surface)
.border(1.dp, if (selected) colors.accent else colors.border, RoundedCornerShape(8.dp))
.clickable {
daysMask = daysMask xor (1 shl i)
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
Spacer(Modifier.height(4.dp))
Text(
text = if (daysMask == 0) "Один раз (ближайшее совпадение)" else daysSummary(daysMask),
style = MaterialTheme.typography.labelSmall,
color = colors.textSecondary
)
}
// Кнопки
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
) {
Text("Отмена")
}
Button(
onClick = {
val station = selectedStation ?: return@Button
onSave(
AlarmEntity(
id = initial?.id ?: 0,
hour = timePickerState.hour,
minute = timePickerState.minute,
daysMask = daysMask,
stationId = station.id,
stationName = station.name,
enabled = initial?.enabled ?: true,
fadeInSec = fadeInSec
)
)
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
),
shape = RoundedCornerShape(10.dp)
) {
Text("Сохранить", fontWeight = FontWeight.SemiBold)
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.radiola.ui.alarms
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.entity.AlarmEntity
import com.radiola.domain.model.Station
import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.service.AlarmScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AlarmsViewModel @Inject constructor(
private val alarmDao: AlarmDao,
private val alarmScheduler: AlarmScheduler,
getStationsUseCase: GetStationsUseCase
) : ViewModel() {
val alarms: StateFlow<List<AlarmEntity>> = alarmDao.getAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val stations: StateFlow<List<Station>> = getStationsUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Добавить или обновить будильник; если включён — запланировать. */
fun addOrUpdate(alarm: AlarmEntity) {
viewModelScope.launch {
val id = alarmDao.upsert(alarm).toInt()
val saved = alarm.copy(id = if (alarm.id == 0) id else alarm.id)
if (saved.enabled) alarmScheduler.schedule(saved)
else alarmScheduler.cancel(saved.id)
}
}
/** Переключить включён/выключен. */
fun toggle(alarm: AlarmEntity) {
viewModelScope.launch {
val newEnabled = !alarm.enabled
alarmDao.setEnabled(alarm.id, newEnabled)
if (newEnabled) alarmScheduler.schedule(alarm.copy(enabled = true))
else alarmScheduler.cancel(alarm.id)
}
}
/** Удалить будильник и отменить планировщик. */
fun delete(alarm: AlarmEntity) {
viewModelScope.launch {
alarmScheduler.cancel(alarm.id)
alarmDao.delete(alarm.id)
}
}
}

View File

@@ -0,0 +1,779 @@
package com.radiola.ui.charts
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.composables.icons.lucide.*
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.ChartTrend
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StatPoint
import com.radiola.domain.model.TrackStats
import com.radiola.ui.components.CategoryPicker
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.recede
import com.radiola.ui.components.recedeFactor
import com.radiola.ui.components.PopularityChart
import com.radiola.ui.components.crossfadeModel
import com.radiola.ui.components.serviceLogoRes
import com.radiola.ui.lyrics.LyricsSheet
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ChartsScreen(
modifier: Modifier = Modifier,
viewModel: ChartsViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val period by viewModel.period.collectAsState()
val charts by viewModel.charts.collectAsState()
val genres by viewModel.genres.collectAsState()
val selectedGenre by viewModel.selectedGenre.collectAsState()
val isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
val selectedStats by viewModel.selectedTrackStats.collectAsState()
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
Column(
modifier = modifier
.fillMaxSize()
.background(colors.bgBase)
) {
// Двухцветный заголовок
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = colors.textPrimary)) { append("Ча") }
withStyle(SpanStyle(color = colors.accent)) { append("рты") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(top = 20.dp, bottom = 2.dp)
)
Text(
text = "Популярное на всех станциях",
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Сегменты периода (стиль FilterChips)
PeriodSelector(
selected = period,
onSelect = viewModel::selectPeriod
)
// Фильтр по жанру (если бэкенд уже накопил жанры)
if (genres.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Box(modifier = Modifier.fillMaxWidth().height(44.dp)) {
GenreSelector(
genres = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre,
contentPadding = PaddingValues(start = 64.dp, end = 20.dp),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
)
CategoryPicker(
title = "Стиль музыки",
items = genres,
selected = selectedGenre,
onSelect = viewModel::selectGenre,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 20.dp)
)
}
}
Spacer(Modifier.height(12.dp))
// Список чартов
if (isLoadingCharts) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = colors.accent)
}
} else if (charts.isEmpty()) {
EmptyState(
message = "Чарты пока недоступны",
icon = Lucide.TrendingUp
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 88.dp)
) {
itemsIndexed(
items = charts,
key = { _, entry -> entry.trackId }
) { _, entry ->
ChartRow(
entry = entry,
modifier = Modifier.animateItemPlacement(),
onClick = { viewModel.selectTrack(entry.trackId) }
)
HorizontalDivider(
color = colors.border.copy(alpha = 0.4f),
thickness = 0.5.dp,
modifier = Modifier.padding(horizontal = 20.dp)
)
}
}
}
}
// Детальная карточка — ModalBottomSheet
if (selectedStats != null || isLoadingStats) {
TrackDetailSheet(
stats = selectedStats,
isLoading = isLoadingStats,
onDismiss = viewModel::clearSelection,
onToggleLike = { viewModel.toggleLike(it) }
)
}
}
// ---- Селектор периода ----
@Composable
private fun PeriodSelector(
selected: ChartPeriod,
onSelect: (ChartPeriod) -> Unit
) {
val colors = RadiolaTheme.colors
LazyRow(
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = PaddingValues(horizontal = 20.dp)
) {
items(ChartPeriod.entries) { p ->
PeriodChip(
label = p.label,
selected = selected == p,
onClick = { onSelect(p) }
)
}
}
}
@Composable
private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val bg by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "periodChipBg"
)
val fg by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "periodChipFg"
)
Text(
text = label,
color = fg,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.background(bg)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp, vertical = 9.dp)
)
}
// ---- Селектор жанра ----
@Composable
private fun GenreSelector(
genres: List<String>,
selected: String?,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp)
) {
val listState = rememberLazyListState()
val all = remember(genres) { listOf<String?>(null) + genres }
LazyRow(
modifier = modifier,
state = listState,
horizontalArrangement = Arrangement.spacedBy(9.dp),
contentPadding = contentPadding
) {
itemsIndexed(all, key = { _, g -> g ?: " all" }) { index, g ->
Box(modifier = Modifier.recede(recedeFactor(listState, index))) {
PeriodChip(
label = g ?: "Все",
selected = selected == g,
onClick = { onSelect(g) }
)
}
}
}
}
// ---- Строка чарта ----
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChartRow(
entry: ChartEntry,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = modifier
.fillMaxWidth()
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Номер ранга
val rankColor = when (entry.rank) {
1, 2, 3 -> colors.accent
else -> colors.textMuted
}
Text(
text = entry.rank.toString(),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontSize = 18.sp
),
color = rankColor,
modifier = Modifier.width(30.dp)
)
// Обложка трека
Box(
modifier = Modifier
.size(50.dp)
.clip(RoundedCornerShape(8.dp))
.background(colors.surface2)
) {
if (entry.coverUrl != null) {
AsyncImage(
model = crossfadeModel(entry.coverUrl),
contentDescription = "${entry.artist}${entry.song}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier
.size(22.dp)
.align(Alignment.Center)
)
}
}
// Название + исполнитель
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = entry.song,
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = entry.artist,
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Правая часть: иконка тренда + число проигрываний
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TrendIcon(trend = entry.trend)
Text(
text = formatPlays(entry.plays),
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted
)
}
}
}
@Composable
private fun TrendIcon(trend: ChartTrend) {
val colors = RadiolaTheme.colors
when (trend) {
ChartTrend.UP -> Icon(
imageVector = Lucide.TrendingUp,
contentDescription = "Рост",
tint = colors.accent,
modifier = Modifier.size(16.dp)
)
ChartTrend.DOWN -> Icon(
imageVector = Lucide.TrendingDown,
contentDescription = "Падение",
tint = colors.live,
modifier = Modifier.size(16.dp)
)
ChartTrend.NEW -> Icon(
imageVector = Lucide.Sparkles,
contentDescription = "Новинка",
tint = colors.accent,
modifier = Modifier.size(16.dp)
)
ChartTrend.SAME -> Icon(
imageVector = Lucide.Minus,
contentDescription = "Без изменений",
tint = colors.textMuted,
modifier = Modifier.size(16.dp)
)
}
}
// ---- Детальная карточка трека ----
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun TrackDetailSheet(
stats: TrackStats?,
isLoading: Boolean,
onDismiss: () -> Unit,
onToggleLike: (String) -> Unit
) {
val colors = RadiolaTheme.colors
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
var showLyrics by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = colors.elevated,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
if (isLoading || stats == null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(280.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = colors.accent)
}
} else {
LazyColumn(
contentPadding = PaddingValues(bottom = 32.dp)
) {
item {
// Большая обложка
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(colors.surface2)
) {
if (stats.coverUrl != null) {
AsyncImage(
model = crossfadeModel(stats.coverUrl),
contentDescription = "${stats.artist}${stats.song}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
)
}
}
Spacer(Modifier.height(16.dp))
// Название + исполнитель
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = stats.song,
style = MaterialTheme.typography.headlineLarge,
color = colors.textPrimary,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
Text(
text = stats.artist,
style = MaterialTheme.typography.titleLarge,
color = colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (stats.album != null) {
Text(
text = stats.album,
style = MaterialTheme.typography.bodyMedium,
color = colors.textMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
// Лейбл · Год (либо дата релиза как запасной вариант)
val metaLine = buildList {
stats.label?.let { add(it) }
stats.year?.let { add(it.toString()) }
}.joinToString(" · ").ifEmpty {
stats.releaseDate?.let { "Вышел: $it" } ?: ""
}
if (metaLine.isNotEmpty()) {
Text(
text = metaLine,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
// Жанр + стили
val genreTags = buildList {
stats.genre?.let { add(it) }
addAll(stats.styles)
}.distinct()
if (genreTags.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
GenreTags(tags = genreTags)
}
Spacer(Modifier.height(16.dp))
// Ряд метрик
MetricsRow(stats)
Spacer(Modifier.height(20.dp))
// График популярности
if (stats.playsTimeline.size >= 2) {
Text(
text = "Популярность за 30 дней",
style = MaterialTheme.typography.labelLarge,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 8.dp)
)
PopularityChart(
points = stats.playsTimeline,
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.clip(RoundedCornerShape(8.dp))
.background(colors.surface2)
.padding(horizontal = 8.dp, vertical = 12.dp)
)
Spacer(Modifier.height(20.dp))
}
// Кнопка лайка
LikeButton(
isLiked = stats.isLiked,
likesCount = stats.totalLikes,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleLike(stats.trackId)
}
)
Spacer(Modifier.height(20.dp))
// Кнопка «Текст песни» — открывает встроенный экран
OutlinedButton(
onClick = { showLyrics = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = colors.textSecondary
),
border = androidx.compose.foundation.BorderStroke(
1.dp, colors.border
)
) {
Icon(
imageVector = Lucide.FileText,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Text("Текст песни")
}
Spacer(Modifier.height(20.dp))
}
// Кнопки музыкальных сервисов
Text(
text = "Слушать в сервисе",
style = MaterialTheme.typography.labelLarge,
color = colors.textSecondary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp)
)
Spacer(Modifier.height(12.dp))
}
// Сетка сервисов
items(DeeplinkService.entries) { service ->
ServiceRow(
service = service,
onClick = {
val url = service.buildSearchUrl(stats.artist, stats.song)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
}
)
}
}
}
}
// Шторка текста песни поверх детальной карточки
if (showLyrics && stats != null) {
ModalBottomSheet(
onDismissRequest = { showLyrics = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
LyricsSheet(
artist = stats.artist,
song = stats.song
)
}
}
}
// ---- Чипы жанра/стилей на детальной ----
@Composable
private fun GenreTags(tags: List<String>) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
Text(
text = tag,
style = MaterialTheme.typography.labelMedium,
color = colors.accent,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(colors.accent.copy(alpha = 0.12f))
.padding(horizontal = 10.dp, vertical = 5.dp)
)
}
}
}
// ---- Метрики трека ----
@Composable
private fun MetricsRow(stats: TrackStats) {
val colors = RadiolaTheme.colors
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
MetricChip(
label = formatPlays(stats.totalPlays),
description = "проигрываний",
modifier = Modifier.weight(1f)
)
MetricChip(
label = formatPlays(stats.totalLikes),
description = "лайков",
modifier = Modifier.weight(1f)
)
if (stats.peakRank != null) {
MetricChip(
label = "#${stats.peakRank}",
description = "пик",
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
private fun MetricChip(label: String, description: String, modifier: Modifier = Modifier) {
val colors = RadiolaTheme.colors
Column(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(colors.surface2)
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = colors.accent,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
maxLines = 1
)
}
}
// ---- Кнопка лайка ----
@Composable
private fun LikeButton(isLiked: Boolean, likesCount: Int, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val heartColor by animateColorAsState(
targetValue = if (isLiked) colors.live else colors.textMuted,
animationSpec = tween(Motion.Fast),
label = "heartColor"
)
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colors.surface2)
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = if (isLiked) Lucide.Heart else Lucide.Heart,
contentDescription = if (isLiked) "Убрать лайк" else "Поставить лайк",
tint = heartColor,
modifier = Modifier.size(22.dp)
)
Text(
text = if (isLiked) "Нравится · ${formatPlays(likesCount)}" else "Нравится · ${formatPlays(likesCount)}",
style = MaterialTheme.typography.bodyMedium,
color = heartColor
)
}
}
// ---- Строка сервиса ----
@Composable
private fun ServiceRow(service: DeeplinkService, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
val logoRes = serviceLogoRes(service)
if (logoRes != null) {
Icon(
painter = androidx.compose.ui.res.painterResource(logoRes),
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(20.dp)
)
} else {
Icon(
imageVector = Lucide.Music,
contentDescription = service.displayName,
tint = colors.textSecondary,
modifier = Modifier.size(18.dp)
)
}
}
Text(
text = service.displayName,
style = MaterialTheme.typography.bodyMedium,
color = colors.textPrimary
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = Lucide.ChevronRight,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(16.dp)
)
}
}
// ---- Утилиты ----
/** Форматирует число проигрываний: 1234 → «1.2k», 1_000_000 → «1.0M». */
private fun formatPlays(value: Int): String {
val df = DecimalFormat("#.#")
return when {
value >= 1_000_000 -> "${df.format(value / 1_000_000.0)}M"
value >= 1_000 -> "${df.format(value / 1_000.0)}k"
else -> value.toString()
}
}

View File

@@ -0,0 +1,119 @@
package com.radiola.ui.charts
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.ChartEntry
import com.radiola.domain.model.ChartPeriod
import com.radiola.domain.model.TrackStats
import com.radiola.domain.repository.ChartsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ChartsViewModel @Inject constructor(
private val chartsRepository: ChartsRepository
) : ViewModel() {
private val _period = MutableStateFlow(ChartPeriod.WEEK)
val period: StateFlow<ChartPeriod> = _period.asStateFlow()
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
/** Доступные жанры для фильтра (с бэкенда). */
private val _genres = MutableStateFlow<List<String>>(emptyList())
val genres: StateFlow<List<String>> = _genres.asStateFlow()
/** Выбранный жанр (null — «Все»). */
private val _selectedGenre = MutableStateFlow<String?>(null)
val selectedGenre: StateFlow<String?> = _selectedGenre.asStateFlow()
private val _isLoadingCharts = MutableStateFlow(false)
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
private val _selectedTrackStats = MutableStateFlow<TrackStats?>(null)
val selectedTrackStats: StateFlow<TrackStats?> = _selectedTrackStats.asStateFlow()
private val _isLoadingStats = MutableStateFlow(false)
val isLoadingStats: StateFlow<Boolean> = _isLoadingStats.asStateFlow()
init {
loadCharts()
loadGenres()
}
fun selectPeriod(newPeriod: ChartPeriod) {
if (_period.value == newPeriod) return
_period.value = newPeriod
loadCharts()
}
fun selectGenre(genre: String?) {
if (_selectedGenre.value == genre) return
_selectedGenre.value = genre
loadCharts()
}
fun selectTrack(trackId: String) {
viewModelScope.launch {
_isLoadingStats.value = true
_selectedTrackStats.value = null
try {
val stats = chartsRepository.getTrackStats(trackId)
_selectedTrackStats.value = stats
} catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка загрузки статистики трека $trackId", e)
} finally {
_isLoadingStats.value = false
}
}
}
fun clearSelection() {
_selectedTrackStats.value = null
}
fun toggleLike(trackId: String) {
val stats = _selectedTrackStats.value ?: return
val newLiked = !stats.isLiked
// Оптимистично обновляем UI
_selectedTrackStats.value = stats.copy(
isLiked = newLiked,
totalLikes = if (newLiked) stats.totalLikes + 1 else stats.totalLikes - 1
)
viewModelScope.launch {
try {
chartsRepository.setLiked(trackId, newLiked)
} catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка переключения лайка трека $trackId", e)
// Откатываем при ошибке
_selectedTrackStats.value = stats
}
}
}
private fun loadCharts() {
viewModelScope.launch {
_isLoadingCharts.value = true
try {
_charts.value = chartsRepository.getCharts(_period.value, _selectedGenre.value)
} catch (e: Exception) {
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
_charts.value = emptyList()
} finally {
_isLoadingCharts.value = false
}
}
}
private fun loadGenres() {
viewModelScope.launch {
_genres.value = chartsRepository.getGenres()
}
}
}

View File

@@ -0,0 +1,183 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.SlidersHorizontal
import com.radiola.ui.theme.RadiolaTheme
/**
* Кнопка-«быстрый выбор категории» рядом с чипами: круглая (визуально отличается от
* чипов-пилюль), по нажатию открывает шторку со ПОЛНЫМ списком категорий + поиском,
* чтобы не листать чипы. Drop-in: сам держит состояние шторки.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryPicker(
title: String,
items: List<String>,
selected: String?,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
allLabel: String = "Все"
) {
val colors = RadiolaTheme.colors
var show by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(38.dp)
.clip(CircleShape)
.background(colors.surface2)
.border(1.5.dp, colors.accent, CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { show = true }
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Lucide.SlidersHorizontal,
contentDescription = title,
tint = colors.accent,
modifier = Modifier.size(18.dp)
)
}
if (show) {
CategorySheet(
title = title,
items = items,
selected = selected,
allLabel = allLabel,
onSelect = onSelect,
onDismiss = { show = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategorySheet(
title: String,
items: List<String>,
selected: String?,
allLabel: String,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit
) {
val colors = RadiolaTheme.colors
var query by remember { mutableStateOf("") }
val filtered = remember(items, query) {
val q = query.trim()
if (q.isBlank()) items else items.filter { it.contains(q, ignoreCase = true) }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = colors.elevated,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = colors.textPrimary,
modifier = Modifier.padding(bottom = 14.dp)
)
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Поиск…",
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
LazyColumn(modifier = Modifier.heightIn(max = 460.dp)) {
if (query.isBlank()) {
item {
CategoryRow(allLabel, selected == null) {
onSelect(null); onDismiss()
}
}
}
items(filtered) { item ->
CategoryRow(item, selected == item) {
onSelect(item); onDismiss()
}
}
}
}
}
}
@Composable
private fun CategoryRow(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (selected) colors.accent else colors.textPrimary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
if (selected) {
Icon(
imageVector = Lucide.Check,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(18.dp)
)
}
}
}

View File

@@ -0,0 +1,78 @@
package com.radiola.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
/**
* Обложка с эффектом 3D-переворота при смене изображения — как будто
* перелистывается страница альбома / пластинка. Старая обложка «улетает»
* передней стороной (090°), новая «прилетает» задней (90180°).
*/
@Composable
fun FlipCover(
model: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
fallback: @Composable () -> Unit,
) {
var current by remember { mutableStateOf(model) }
var previous by remember { mutableStateOf(model) }
val rotation = remember { Animatable(0f) }
LaunchedEffect(model) {
if (model != current) {
previous = current
current = model
rotation.snapTo(0f)
rotation.animateTo(180f, animationSpec = tween(620, easing = FastOutSlowInEasing))
// Оседаем: новая обложка становится «лицом», угол 0 — без рывка.
previous = current
rotation.snapTo(0f)
}
}
val angle = rotation.value
val showFront = angle <= 90f
val faceModel = if (showFront) previous else current
Box(
modifier = modifier.graphicsLayer {
rotationY = angle
cameraDistance = 16f * density
},
contentAlignment = Alignment.Center,
) {
// Заднюю грань контр-вращаем, чтобы изображение не было зеркальным.
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { rotationY = if (showFront) 0f else 180f },
contentAlignment = Alignment.Center,
) {
if (!faceModel.isNullOrBlank()) {
AsyncImage(
model = faceModel,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
fallback()
}
}
}
}

View File

@@ -1,17 +1,32 @@
package com.radiola.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
import com.composables.icons.lucide.X
import com.radiola.ui.theme.RadiolaTheme
@Composable
@@ -22,6 +37,7 @@ fun SearchBar(
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
TextField(
value = query,
onValueChange = onQueryChange,
@@ -29,6 +45,31 @@ fun SearchBar(
shape = RoundedCornerShape(14.dp),
placeholder = { Text(placeholder, color = colors.textMuted) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = colors.textMuted) },
// Кнопка «очистить» — появляется/исчезает с анимацией (scale + fade).
trailingIcon = {
AnimatedVisibility(
visible = query.isNotEmpty(),
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Icon(
imageVector = Lucide.X,
contentDescription = "Очистить",
tint = colors.textSecondary,
modifier = Modifier
.size(34.dp)
.padding(7.dp)
.clip(CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onQueryChange("")
}
)
}
},
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = colors.surface,

View File

@@ -0,0 +1,133 @@
package com.radiola.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import com.radiola.domain.model.StatPoint
import com.radiola.ui.theme.RadiolaTheme
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Компонент-спарклайн: сглаженный линейный график с градиентной заливкой.
* Используется для отображения популярности трека (проигрывания / лайки).
* Не показывает оси — только форму данных.
*/
@Composable
fun PopularityChart(
points: List<StatPoint>,
modifier: Modifier = Modifier,
lineColor: Color = RadiolaTheme.colors.accent
) {
val colors = RadiolaTheme.colors
val dateFmt = remember { SimpleDateFormat("d MMM", Locale("ru")) }
Box(modifier = modifier) {
if (points.size >= 2) {
val minVal = points.minOf { it.value }.toFloat()
val maxVal = points.maxOf { it.value }.toFloat()
val range = (maxVal - minVal).coerceAtLeast(1f)
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width
val h = size.height
val topPad = 4.dp.toPx()
val botPad = 4.dp.toPx()
val drawH = h - topPad - botPad
// Вычисляем координаты точек
fun xAt(i: Int) = i * w / (points.size - 1)
fun yAt(v: Float) = topPad + drawH * (1f - (v - minVal) / range)
// Сглаженный path через cubic bezier
val linePath = Path()
linePath.moveTo(xAt(0), yAt(points[0].value.toFloat()))
for (i in 1 until points.size) {
val x0 = xAt(i - 1)
val y0 = yAt(points[i - 1].value.toFloat())
val x1 = xAt(i)
val y1 = yAt(points[i].value.toFloat())
val cx = (x0 + x1) / 2f
linePath.cubicTo(cx, y0, cx, y1, x1, y1)
}
// Заливка под графиком
val fillPath = Path().apply {
addPath(linePath)
lineTo(xAt(points.size - 1), h)
lineTo(xAt(0), h)
close()
}
drawPath(
path = fillPath,
brush = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.28f), Color.Transparent),
startY = topPad,
endY = h
)
)
// Линия графика
drawPath(
path = linePath,
color = lineColor,
style = Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
// Точка первого и последнего значения
drawCircle(
color = lineColor,
radius = 3.dp.toPx(),
center = Offset(xAt(0), yAt(points.first().value.toFloat()))
)
drawCircle(
color = lineColor,
radius = 3.dp.toPx(),
center = Offset(xAt(points.size - 1), yAt(points.last().value.toFloat()))
)
}
// Подписи дат по краям
val firstDate = dateFmt.format(Date(points.first().date))
val lastDate = dateFmt.format(Date(points.last().date))
Text(
text = firstDate,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 4.dp, bottom = 2.dp)
)
Text(
text = lastDate,
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 4.dp, bottom = 2.dp)
)
}
// При 0 или 1 точке — ничего не рисуем (корректная пустая обработка)
}
}

View File

@@ -0,0 +1,96 @@
package com.radiola.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.radiola.ui.theme.RadiolaWordmark
import com.radiola.ui.theme.ThemePalette
import kotlinx.coroutines.launch
/**
* Свой экран загрузки: тематический 3D-логотип с акцентным свечением под цвет темы,
* мягкой тенью и плавным появлением. Рисуем сами (а не системный сплэш), потому что
* Android 12+ не даёт менять иконку системного сплэша под выбранную пользователем тему.
*/
@Composable
fun SplashOverlay(palette: ThemePalette, modifier: Modifier = Modifier) {
val colors = palette.colors
// Появление снапное: лого видно почти сразу (короткий fade), затем мягкий «вдох».
val scale = remember { Animatable(0.92f) }
val fade = remember { Animatable(0f) }
LaunchedEffect(Unit) {
launch { fade.animateTo(1f, tween(200)) }
launch { scale.animateTo(1f, tween(420, easing = FastOutSlowInEasing)) }
}
Box(
modifier = modifier.fillMaxSize().background(colors.bgBase),
contentAlignment = Alignment.Center
) {
// Акцентное свечение под цвет темы
Box(
Modifier
.size(380.dp)
.graphicsLayer { alpha = fade.value }
.background(
Brush.radialGradient(
listOf(
colors.accent.copy(alpha = 0.32f),
colors.accent.copy(alpha = 0.10f),
Color.Transparent
)
)
)
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(contentAlignment = Alignment.Center) {
// Мягкая тень логотипа
Image(
painter = painterResource(palette.logoRes),
contentDescription = null,
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f)),
modifier = Modifier
.size(176.dp)
.scale(scale.value)
.offset(y = 14.dp)
.blur(18.dp)
.graphicsLayer { alpha = fade.value }
)
// Логотип
Image(
painter = painterResource(palette.logoRes),
contentDescription = "radiOLA",
modifier = Modifier
.size(176.dp)
.scale(scale.value)
.graphicsLayer { alpha = fade.value }
)
}
Spacer(Modifier.height(20.dp))
Box(Modifier.graphicsLayer { alpha = fade.value }) {
RadiolaWordmark(fontSize = 30)
}
}
}
}

View File

@@ -1,6 +1,12 @@
package com.radiola.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -14,7 +20,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -23,8 +37,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@@ -35,7 +49,10 @@ fun StationCard(
isFavorite: Boolean,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
nowTrack: Track? = null,
isCurrent: Boolean = false,
isPlaying: Boolean = false
) {
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
@@ -54,32 +71,120 @@ fun StationCard(
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
// Свечение активной станции — позади обложки, мягко вылезает из-под краёв.
if (isCurrent) {
PlayingGlow(
modifier = Modifier.matchParentSize(),
color = colors.accent,
playing = isPlaying
)
}
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface2)
) {
if (!station.coverUrl.isNullOrBlank()) {
val trackCover = nowTrack?.coverUrl?.takeIf { it.isNotBlank() }
// Фон карточки: обложка трека → логотип станции → фирменная плитка.
when {
trackCover != null -> {
AsyncImage(
model = crossfadeModel(trackCover),
contentDescription = nowTrack.song,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
!station.coverUrl.isNullOrBlank() -> {
AsyncImage(
model = crossfadeModel(station.coverUrl),
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
Lucide.Radio,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.align(Alignment.Center).size(34.dp)
}
else -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(stationTileBrush(station.name)),
contentAlignment = Alignment.Center
) {
Text(
text = stationInitials(station.name),
color = Color.White,
fontWeight = FontWeight.Black,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium
)
}
}
}
// Подпись играющего трека — поверх любого фона, если трек известен.
if (nowTrack != null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.5f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.8f)
)
)
)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp)
) {
Text(
text = nowTrack.song,
color = Color.White,
fontWeight = FontWeight.Bold,
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
Text(
text = nowTrack.artist,
color = Color.White.copy(alpha = 0.8f),
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
}
}
// Бейдж активной станции: эквалайзер в углу обложки.
if (isCurrent) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(10.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 7.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
com.radiola.ui.theme.LiveEqualizer(
modifier = Modifier.size(width = 16.dp, height = 12.dp),
barCount = 4,
color = colors.accent,
playing = isPlaying
)
}
}
// Кнопка сердечка — поверх всего, top-end.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp)
.size(32.dp)
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
.background(Color.Black.copy(alpha = 0.4f))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onFavoriteClick()
@@ -94,6 +199,7 @@ fun StationCard(
)
}
}
}
Spacer(Modifier.height(10.dp))
Text(
text = station.name,
@@ -114,3 +220,76 @@ fun StationCard(
}
}
}
/**
* Мягкое радиальное свечение играющей станции — рисуется ПОЗАДИ обложки и
* вылезает из-под её краёв. Центр градиента «гуляет», размер дышит.
*/
@Composable
private fun PlayingGlow(
modifier: Modifier = Modifier,
color: Color,
playing: Boolean
) {
val transition = rememberInfiniteTransition(label = "glow")
val t by transition.animateFloat(
initialValue = 0f,
targetValue = (2f * Math.PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(4200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "glowT"
)
val pulse by transition.animateFloat(
initialValue = if (playing) 1.05f else 1.0f,
targetValue = if (playing) 1.20f else 1.07f,
animationSpec = infiniteRepeatable(
animation = tween(2200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "glowPulse"
)
// Целые гармоники (1 и 2) → значения совпадают на t=0 и t=2π, петля бесшовна.
val cx = 0.5f + 0.22f * kotlin.math.cos(t)
val cy = 0.5f + 0.20f * kotlin.math.sin(2f * t)
Box(
modifier = modifier
.scale(pulse)
.blur(28.dp, BlurredEdgeTreatment.Unbounded)
.drawBehind {
val brush = Brush.radialGradient(
colors = listOf(
color.copy(alpha = 0.85f),
color.copy(alpha = 0.35f),
Color.Transparent
),
center = Offset(size.width * cx, size.height * cy),
radius = size.minDimension * 0.72f
)
drawRoundRect(
brush = brush,
cornerRadius = CornerRadius(22.dp.toPx(), 22.dp.toPx())
)
}
)
}
/** Инициалы станции для плитки-плейсхолдера (12 символа). */
private fun stationInitials(name: String): String {
val words = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
return when {
words.isEmpty() -> "?"
words.size == 1 -> words[0].take(2).uppercase()
else -> (words[0].take(1) + words[1].take(1)).uppercase()
}
}
/** Детерминированный фирменный градиент плитки по названию станции. */
private fun stationTileBrush(name: String): Brush {
val h = (name.hashCode().toLong() and 0xFFFFFFFFL)
val hue = (h % 360L).toFloat()
val c1 = Color.hsv(hue, 0.55f, 0.45f)
val c2 = Color.hsv((hue + 28f) % 360f, 0.6f, 0.30f)
return Brush.linearGradient(listOf(c1, c2))
}

View File

@@ -0,0 +1,150 @@
package com.radiola.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** Стиль анимации воспроизведения (выбирается пользователем в настройках). */
enum class VisualizerStyle(val key: String, val label: String) {
BARS_CENTER("bars_center", "Центр"),
BARS_BOTTOM("bars_bottom", "Снизу"),
WAVE("wave", "Волна"),
RADIAL("radial", "Круг");
companion object {
fun fromKey(k: String?): VisualizerStyle = entries.firstOrNull { it.key == k } ?: BARS_CENTER
}
}
/**
* Визуализатор звука выбранного стиля. levels — реальный спектр (0..1 по полосам);
* если его нет (пауза/превью) — мягкая «дышащая» анимация.
*/
@Composable
fun Visualizer(
style: VisualizerStyle,
levels: FloatArray?,
playing: Boolean,
color: Color,
modifier: Modifier = Modifier,
) {
val phase by rememberInfiniteTransition(label = "viz").animateFloat(
initialValue = 0f,
targetValue = (Math.PI * 2).toFloat(),
animationSpec = infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart),
label = "vizPhase",
)
Canvas(modifier = modifier) {
when (style) {
VisualizerStyle.BARS_CENTER -> drawBars(levels, phase, playing, color, centered = true)
VisualizerStyle.BARS_BOTTOM -> drawBars(levels, phase, playing, color, centered = false)
VisualizerStyle.WAVE -> drawWave(levels, phase, playing, color)
VisualizerStyle.RADIAL -> drawRadial(levels, phase, playing, color)
}
}
}
/** Уровень полосы i из n: реальный спектр → иначе «дышащая» синус-волна. */
private fun levelAt(i: Int, n: Int, levels: FloatArray?, phase: Float, playing: Boolean): Float {
if (!playing) return 0.16f
if (levels != null && levels.isNotEmpty()) {
val idx = (i * levels.size / n).coerceIn(0, levels.size - 1)
return (0.06f + 0.94f * levels[idx]).coerceIn(0f, 1f)
}
return 0.42f + 0.5f * abs(sin(phase + i * 0.7f))
}
private fun DrawScope.drawBars(
levels: FloatArray?,
phase: Float,
playing: Boolean,
color: Color,
centered: Boolean,
) {
val barCount = 36
val gap = 3.dp.toPx()
val barWidth = (size.width - gap * (barCount - 1)) / barCount
val maxH = size.height
for (i in 0 until barCount) {
val h = maxH * levelAt(i, barCount, levels, phase, playing)
val x = i * (barWidth + gap)
val y = if (centered) (maxH - h) / 2f else (maxH - h)
drawRoundRect(
color = color,
topLeft = Offset(x, y),
size = Size(barWidth, h),
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
)
}
}
private fun DrawScope.drawWave(levels: FloatArray?, phase: Float, playing: Boolean, color: Color) {
val points = 48
val maxH = size.height
val stepX = size.width / (points - 1)
val ys = FloatArray(points) { i -> maxH * (1f - levelAt(i, points, levels, phase, playing)) }
val line = Path()
val fill = Path()
line.moveTo(0f, ys[0])
fill.moveTo(0f, maxH)
fill.lineTo(0f, ys[0])
for (i in 1 until points) {
val x = i * stepX
val px = (i - 1) * stepX
val midX = (px + x) / 2f
// Сглаживание кубическими кривыми между точками.
line.cubicTo(midX, ys[i - 1], midX, ys[i], x, ys[i])
fill.cubicTo(midX, ys[i - 1], midX, ys[i], x, ys[i])
}
fill.lineTo(size.width, maxH)
fill.close()
drawPath(fill, color = color.copy(alpha = 0.18f))
drawPath(line, color = color, style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round))
}
private fun DrawScope.drawRadial(levels: FloatArray?, phase: Float, playing: Boolean, color: Color) {
val n = 40
val cx = size.width / 2f
val cy = size.height / 2f
val rInner = minOf(cx, cy) * 0.42f
val rMax = minOf(cx, cy) * 0.98f
val stroke = (2 * Math.PI * rInner / n / 2).toFloat().coerceIn(2f, 6f)
// тонкое кольцо-основа
drawCircle(color = color.copy(alpha = 0.25f), radius = rInner * 0.9f, center = Offset(cx, cy), style = Stroke(1.5.dp.toPx()))
for (i in 0 until n) {
val lv = levelAt(i, n, levels, phase, playing)
val ang = (i.toFloat() / n) * (2 * Math.PI).toFloat() - (Math.PI / 2).toFloat()
val len = rInner + (rMax - rInner) * lv
val sx = cx + cos(ang) * rInner
val sy = cy + sin(ang) * rInner
val ex = cx + cos(ang) * len
val ey = cy + sin(ang) * len
drawLine(
color = color,
start = Offset(sx, sy),
end = Offset(ex, ey),
strokeWidth = stroke,
cap = StrokeCap.Round,
)
}
}

View File

@@ -0,0 +1,311 @@
package com.radiola.ui.equalizer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.Lucide
import com.radiola.service.EqBand
import com.radiola.ui.theme.RadiolaTheme
@Composable
fun EqualizerScreen(
onNavigateBack: () -> Unit,
viewModel: EqualizerViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val state by viewModel.state.collectAsState()
val on = state.enabled
Column(modifier = Modifier.fillMaxSize()) {
// Шапка с кнопкой «назад»
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Lucide.ChevronLeft,
contentDescription = "Назад",
tint = colors.textPrimary,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable { onNavigateBack() }
.padding(8.dp)
.size(24.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Эквалайзер",
style = MaterialTheme.typography.headlineSmall,
color = colors.textPrimary,
fontWeight = FontWeight.Bold
)
}
if (!state.available) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Эквалайзер недоступен на этом устройстве",
color = colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(32.dp)
)
}
return
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Главный тумблер
Card(colors) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(
"Эквалайзер",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
"Тонкая настройка звука и улучшайзеры",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Switch(
checked = on,
onCheckedChange = { viewModel.setEnabled(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
}
// Пресеты
if (state.presets.isNotEmpty()) {
Label("ПРЕСЕТЫ")
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
state.presets.forEachIndexed { index, name ->
val selected = state.currentPreset == index
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (selected) colors.accent else colors.surface2)
.clickable(enabled = on) { viewModel.selectPreset(index) }
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
text = name,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
// «Свой» — активен, когда полосы правились вручную
val custom = state.currentPreset == -1
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (custom) colors.accent else colors.surface2)
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
"Свой",
style = MaterialTheme.typography.labelLarge,
color = if (custom) colors.bgBase else colors.textSecondary,
fontWeight = if (custom) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
// Полосы эквалайзера
Label("ЧАСТОТЫ")
Card(colors) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
state.bands.forEach { band ->
BandRow(
band = band,
enabled = on,
colors = colors,
onChange = { viewModel.setBand(band.index, it) },
onCommit = { viewModel.commit() }
)
}
}
}
// Улучшайзеры
Label("УЛУЧШАЙЗЕРЫ")
Card(colors) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (state.hasBass) {
EnhancerSlider("Bass Boost", "Усиление баса", state.bass, on, colors,
{ viewModel.setBass(it) }, { viewModel.commit() })
}
if (state.hasVirtualizer) {
EnhancerSlider("Virtualizer", "Объём / ширина стерео", state.virtualizer, on, colors,
{ viewModel.setVirtualizer(it) }, { viewModel.commit() })
}
if (state.hasLoudness) {
EnhancerSlider("Громкость", "Подъём тихих станций (до +12 дБ)", state.loudness, on, colors,
{ viewModel.setLoudness(it) }, { viewModel.commit() })
}
}
}
}
}
}
@Composable
private fun Card(colors: com.radiola.ui.theme.RadiolaColors, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
) { content() }
}
@Composable
private fun Label(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = RadiolaTheme.colors.textMuted,
letterSpacing = 1.sp
)
}
@Composable
private fun BandRow(
band: EqBand,
enabled: Boolean,
colors: com.radiola.ui.theme.RadiolaColors,
onChange: (Int) -> Unit,
onCommit: () -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = freqLabel(band.centerHz),
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary,
modifier = Modifier.width(60.dp)
)
Slider(
value = band.levelMb.toFloat(),
onValueChange = { onChange(it.toInt()) },
onValueChangeFinished = onCommit,
valueRange = band.minMb.toFloat()..band.maxMb.toFloat(),
enabled = enabled,
colors = SliderDefaults.colors(
thumbColor = colors.accent,
activeTrackColor = colors.accent,
inactiveTrackColor = colors.surface2
),
modifier = Modifier.weight(1f)
)
Text(
text = "%+d dB".format(band.levelMb / 100),
style = MaterialTheme.typography.labelMedium,
color = colors.accent,
modifier = Modifier.width(52.dp),
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun EnhancerSlider(
title: String,
subtitle: String,
value: Int,
enabled: Boolean,
colors: com.radiola.ui.theme.RadiolaColors,
onChange: (Int) -> Unit,
onCommit: () -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = colors.textPrimary)
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = colors.textSecondary)
}
Text(
"$value%",
style = MaterialTheme.typography.titleMedium,
color = colors.accent,
fontWeight = FontWeight.SemiBold
)
}
Slider(
value = value.toFloat(),
onValueChange = { onChange(it.toInt()) },
onValueChangeFinished = onCommit,
valueRange = 0f..100f,
enabled = enabled,
colors = SliderDefaults.colors(
thumbColor = colors.accent,
activeTrackColor = colors.accent,
inactiveTrackColor = colors.surface2
)
)
}
}
private fun freqLabel(hz: Int): String =
if (hz >= 1000) "%.1f kHz".format(hz / 1000f) else "$hz Hz"

View File

@@ -0,0 +1,24 @@
package com.radiola.ui.equalizer
import androidx.lifecycle.ViewModel
import com.radiola.service.AudioEffectsController
import com.radiola.service.EqState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class EqualizerViewModel @Inject constructor(
private val audioEffects: AudioEffectsController
) : ViewModel() {
val state: StateFlow<EqState> = audioEffects.state
fun setEnabled(on: Boolean) = audioEffects.setEnabled(on)
fun selectPreset(index: Int) = audioEffects.selectPreset(index)
fun setBand(index: Int, levelMb: Int) = audioEffects.setBand(index, levelMb)
fun setBass(value: Int) = audioEffects.setBass(value)
fun setVirtualizer(value: Int) = audioEffects.setVirtualizer(value)
fun setLoudness(value: Int) = audioEffects.setLoudness(value)
fun commit() = audioEffects.commit()
}

View File

@@ -34,6 +34,9 @@ fun FavoritesScreen(
) {
val favorites by viewModel.favorites.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState()
val playingStationId by viewModel.playingStationId.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val colors = RadiolaTheme.colors
Column(
@@ -81,7 +84,7 @@ fun FavoritesScreen(
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
columns = GridCells.Fixed(if (com.radiola.ui.util.isLandscape()) 4 else 2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -93,6 +96,9 @@ fun FavoritesScreen(
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.id],
isCurrent = station.id == playingStationId,
isPlaying = isPlaying,
modifier = Modifier.animateItemPlacement()
)
}

View File

@@ -3,11 +3,15 @@ package com.radiola.ui.favorites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushFavoriteUseCase
import com.radiola.domain.usecase.auth.SyncFavoritesUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -20,9 +24,15 @@ class FavoritesViewModel @Inject constructor(
private val favoritesRepository: FavoritesRepository,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val pushFavoriteUseCase: PushFavoriteUseCase,
private val syncFavoritesUseCase: SyncFavoritesUseCase
private val syncFavoritesUseCase: SyncFavoritesUseCase,
private val nowPlayingRepository: NowPlayingRepository,
private val playerController: PlayerController
) : ViewModel() {
// Активная (играющая) станция — для подсветки карточки, как на экране всех станций.
val playingStationId: StateFlow<Int?> = playerController.currentStationId
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -30,10 +40,21 @@ class FavoritesViewModel @Inject constructor(
.map { list -> list.map { it.id }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
// Текущие треки по id станции (каталожный == station.id) — без коллизий по имени.
val nowPlaying: StateFlow<Map<Int, Track>> = nowPlayingRepository.getAllNowPlaying()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init {
viewModelScope.launch {
syncFavoritesUseCase()
}
// Периодическое обновление now-playing каждые 20 секунд.
viewModelScope.launch {
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(20_000)
}
}
}
fun toggleFavorite(station: Station) {

View File

@@ -5,19 +5,26 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
import com.radiola.domain.model.Track
import com.radiola.ui.components.DeeplinkBottomSheet
import com.radiola.ui.components.EmptyState
@@ -32,10 +39,14 @@ fun HistoryScreen(
viewModel: HistoryViewModel = hiltViewModel()
) {
val history by viewModel.history.collectAsState()
val recognized by viewModel.recognized.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
var selectedTrack by remember { mutableStateOf<Track?>(null) }
var tab by remember { mutableStateOf(0) }
val colors = RadiolaTheme.colors
val items = if (tab == 0) history else recognized
Column(
modifier = modifier
.fillMaxSize()
@@ -51,6 +62,30 @@ fun HistoryScreen(
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)
)
// Переключатель вкладок: Треки эфира / Распознанные
Row(
modifier = Modifier.padding(bottom = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf("Треки эфира", "Распознанные").forEachIndexed { index, label ->
val selected = tab == index
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(if (selected) colors.accent else colors.surface2)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { tab = index }
.padding(horizontal = 16.dp, vertical = 9.dp)
)
}
}
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
@@ -59,7 +94,7 @@ fun HistoryScreen(
)
Crossfade(
targetState = history.isEmpty(),
targetState = items.isEmpty(),
label = "historyState"
) { isEmpty ->
if (isEmpty) {
@@ -67,18 +102,26 @@ fun HistoryScreen(
visible = true,
enter = fadeIn() + slideInVertically()
) {
if (tab == 1) {
EmptyState(
message = "Пока ничего не распознано",
icon = Lucide.Mic,
modifier = Modifier.fillMaxSize()
)
} else {
EmptyState(
message = "История пуста",
icon = Lucide.History,
modifier = Modifier.fillMaxSize()
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(history) { track ->
items(items) { track ->
TrackListItem(
track = track,
onClick = { selectedTrack = track }

View File

@@ -3,37 +3,36 @@ package com.radiola.ui.history
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecognizedTrackRepository
import com.radiola.domain.repository.TrackHistoryRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HistoryViewModel @Inject constructor(
private val trackHistoryRepository: TrackHistoryRepository
private val trackHistoryRepository: TrackHistoryRepository,
private val recognizedTrackRepository: RecognizedTrackRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val history: StateFlow<List<Track>> = combine(
trackHistoryRepository.getHistory(),
_searchQuery
) { tracks, query ->
private fun filtered(source: Flow<List<Track>>): StateFlow<List<Track>> =
combine(source, _searchQuery) { tracks, query ->
if (query.isBlank()) tracks else tracks.filter {
it.artist.contains(query, ignoreCase = true) ||
it.song.contains(query, ignoreCase = true)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Треки эфира (как игрались на станциях)
val history: StateFlow<List<Track>> = filtered(trackHistoryRepository.getHistory())
// Распознанные через Shazam
val recognized: StateFlow<List<Track>> = filtered(recognizedTrackRepository.getHistory())
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
}
fun removeTrack(track: Track) {
viewModelScope.launch {
trackHistoryRepository.removeTrack(track)
}
}
}

View File

@@ -0,0 +1,126 @@
package com.radiola.ui.lyrics
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.ui.theme.RadiolaTheme
/**
* Содержимое шторки текста песни. Данные загружаются через LyricsViewModel → LRCLIB API.
* Встраивается в ModalBottomSheet на стороне вызывающего экрана.
*/
@Composable
fun LyricsSheet(
artist: String,
song: String,
durationSec: Int? = null,
viewModel: LyricsViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val state by viewModel.state.collectAsState()
LaunchedEffect(artist, song) {
viewModel.load(artist, song, durationSec)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
// Заголовок
Text(
text = song,
style = MaterialTheme.typography.titleLarge,
color = colors.textPrimary,
maxLines = 2
)
Spacer(Modifier.height(4.dp))
Text(
text = artist,
style = MaterialTheme.typography.bodyMedium,
color = colors.textSecondary,
maxLines = 1
)
Spacer(Modifier.height(20.dp))
// Состояния
when (val s = state) {
is LyricsState.Loading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = colors.accent)
}
}
is LyricsState.Instrumental -> {
Text(
text = "Инструментальная композиция",
style = MaterialTheme.typography.bodyLarge,
color = colors.textMuted,
modifier = Modifier.padding(vertical = 40.dp)
)
}
is LyricsState.Found -> {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 480.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
s.plain.lines().forEach { line ->
Text(
text = line.ifEmpty { " " },
style = MaterialTheme.typography.bodyLarge,
color = colors.textPrimary,
lineHeight = 22.sp
)
}
}
}
is LyricsState.NotFound -> {
Text(
text = "Текст не найден",
style = MaterialTheme.typography.bodyLarge,
color = colors.textMuted,
modifier = Modifier.padding(vertical = 40.dp)
)
}
is LyricsState.Error -> {
Text(
text = "Не удалось загрузить текст",
style = MaterialTheme.typography.bodyLarge,
color = colors.textMuted,
modifier = Modifier.padding(vertical = 40.dp)
)
}
}
Spacer(Modifier.height(12.dp))
// Атрибуция LRCLIB
Text(
text = "Тексты: LRCLIB",
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
)
}
}

View File

@@ -0,0 +1,44 @@
package com.radiola.ui.lyrics
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.repository.LyricsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface LyricsState {
data object Loading : LyricsState
data object Instrumental : LyricsState
data class Found(val plain: String) : LyricsState
data object NotFound : LyricsState
data object Error : LyricsState
}
@HiltViewModel
class LyricsViewModel @Inject constructor(
private val repository: LyricsRepository
) : ViewModel() {
private val _state = MutableStateFlow<LyricsState>(LyricsState.Loading)
val state: StateFlow<LyricsState> = _state
fun load(artist: String, song: String, durationSec: Int? = null) {
viewModelScope.launch {
_state.value = LyricsState.Loading
try {
val result = repository.fetchLyrics(artist, song, durationSec)
_state.value = when {
result == null -> LyricsState.NotFound
result.instrumental -> LyricsState.Instrumental
!result.plain.isNullOrBlank() -> LyricsState.Found(result.plain)
else -> LyricsState.NotFound
}
} catch (_: Exception) {
_state.value = LyricsState.Error
}
}
}
}

View File

@@ -2,6 +2,8 @@ package com.radiola.ui.navigation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
@@ -12,40 +14,62 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
// при холодном старте может содержать null (порядок инициализации Kotlin).
private val navItems = listOf(
NavDestinations.Stations,
NavDestinations.Favorites,
NavDestinations.History,
NavDestinations.Charts,
NavDestinations.Recordings,
NavDestinations.Settings
)
/** Переход на раздел с сохранением состояния (общий для нижнего бара и бокового рейла). */
private fun NavController.navigateToTab(route: String, currentRoute: String?) {
if (currentRoute != route) {
navigate(route) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
}
@Composable
fun BottomNavBar(navController: NavController) {
val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
// при холодном старте может содержать null (порядок инициализации Kotlin).
val items = listOf(
NavDestinations.Stations,
NavDestinations.Favorites,
NavDestinations.History,
NavDestinations.Recordings,
NavDestinations.Settings
)
val items = navItems
Row(
modifier = Modifier
@@ -65,21 +89,94 @@ fun BottomNavBar(navController: NavController) {
label = destination.labelRes,
icon = destination.icon,
selected = selected,
modifier = Modifier.weight(if (selected) 1.9f else 1f),
onClick = {
if (currentRoute != destination.route) {
navController.navigate(destination.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
}
modifier = Modifier.weight(1f),
onClick = { navController.navigateToTab(destination.route, currentRoute) }
)
}
}
}
/**
* Боковой навигационный рейл для альбомной ориентации.
* Вертикальная капсула с теми же иконками-вкладками, что и нижний бар.
*/
@Composable
fun SideNavRail(navController: NavController) {
val colors = RadiolaTheme.colors
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
Column(
modifier = Modifier
.fillMaxHeight()
.statusBarsPadding()
.padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp)
.width(64.dp)
.clip(RoundedCornerShape(32.dp))
.background(colors.surface2)
.border(1.dp, colors.border, RoundedCornerShape(32.dp))
.verticalScroll(rememberScrollState())
.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
navItems.forEach { destination ->
VerticalPillTab(
label = destination.labelRes,
icon = destination.icon,
selected = currentRoute == destination.route,
onClick = { navController.navigateToTab(destination.route, currentRoute) }
)
}
}
}
@Composable
private fun VerticalPillTab(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
selected: Boolean,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
val bg by animateColorAsState(
targetValue = if (selected) colors.accent else androidx.compose.ui.graphics.Color.Transparent,
animationSpec = tween(Motion.Medium),
label = "railTabBg"
)
val content by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "railTabFg"
)
val pop = remember { Animatable(1f) }
LaunchedEffect(selected) {
if (selected) {
pop.snapTo(0.45f)
pop.animateTo(1f, spring(dampingRatio = 0.36f, stiffness = 240f))
}
}
Row(
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(26.dp))
.background(bg)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = content,
modifier = Modifier.size(22.dp).scale(pop.value)
)
}
}
@Composable
private fun PillTab(
label: String,
@@ -99,6 +196,14 @@ private fun PillTab(
animationSpec = tween(Motion.Medium),
label = "tabFg"
)
// Упругий «поп» иконки при выборе вкладки — маленькая приятная деталь.
val pop = remember { Animatable(1f) }
LaunchedEffect(selected) {
if (selected) {
pop.snapTo(0.45f)
pop.animateTo(1f, spring(dampingRatio = 0.36f, stiffness = 240f))
}
}
Row(
modifier = modifier
.fillMaxWidth()
@@ -113,26 +218,12 @@ private fun PillTab(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// Только иконки — подписи не помещались для 6 разделов.
Icon(
imageVector = icon,
contentDescription = label,
tint = content,
modifier = Modifier.height(18.dp).width(18.dp)
)
AnimatedVisibility(
visible = selected,
enter = fadeIn(tween(Motion.Medium)) + expandHorizontally(tween(Motion.Medium)),
exit = fadeOut(tween(Motion.Fast)) + shrinkHorizontally(tween(Motion.Fast))
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(8.dp))
Text(
text = label.uppercase(),
color = content,
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
maxLines = 1
modifier = Modifier.height(22.dp).width(22.dp).scale(pop.value)
)
}
}
}
}

View File

@@ -1,12 +1,14 @@
package com.radiola.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.Settings
import com.composables.icons.lucide.TrendingUp
sealed class NavDestinations(
val route: String,
@@ -15,13 +17,16 @@ sealed class NavDestinations(
val showInBottomBar: Boolean = true
) {
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
data object Charts : NavDestinations("charts", "Чарты", Lucide.TrendingUp)
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
data object History : NavDestinations("history", "История", Lucide.History)
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
data object Equalizer : NavDestinations("equalizer", "Эквалайзер", Lucide.Settings, showInBottomBar = false)
companion object {
val items = listOf(Stations, Favorites, History, Recordings, Settings)
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
}
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -32,26 +34,34 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.FileText
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.MicOff
import com.composables.icons.lucide.Moon
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.composables.icons.lucide.SlidersHorizontal
import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.ui.lyrics.LyricsSheet
import com.radiola.ui.theme.LiveEqualizer
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PlayerBottomSheet(
station: Station?,
@@ -69,18 +79,31 @@ fun PlayerBottomSheet(
) {
val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
val recognizing by viewModel.recognizing.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.fillMaxWidth()
.background(colors.bgBase)
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Метка «В ЭФИРЕ»
LaunchedEffect(Unit) {
viewModel.recognizeEvent.collect { msg ->
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
}
}
var showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) }
var showSleep by remember { mutableStateOf(false) }
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
val currentQuality by viewModel.currentQuality.collectAsState()
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
val vizStyle by viewModel.visualizerStyle.collectAsState()
val landscape = com.radiola.ui.util.isLandscape()
// ── Секции плеера как лямбды: переиспользуются в портретной (колонка)
// и альбомной (две панели) раскладках. ──
val labelSection: @Composable () -> Unit = {
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
text = "В ЭФИРЕ",
style = MaterialTheme.typography.labelSmall,
@@ -88,25 +111,49 @@ fun PlayerBottomSheet(
letterSpacing = 2.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(16.dp))
val qualities = station?.qualities.orEmpty()
if (qualities.size >= 2) {
QualityChip(
label = "${(currentQuality?.bitrate ?: qualities.first().bitrate)}k",
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
showQuality = true
},
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
val nameSection: @Composable () -> Unit = {
// Название радиостанции — под меткой, над обложкой
Text(
text = station?.name ?: "",
style = MaterialTheme.typography.titleLarge,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.basicMarquee()
)
}
val coverSection: @Composable (Dp) -> Unit = { coverSize ->
// Обложка станции/трека
Box(
modifier = Modifier
.size(220.dp)
.size(coverSize)
.clip(RoundedCornerShape(24.dp))
.background(colors.surface2),
contentAlignment = Alignment.Center
) {
val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) {
AsyncImage(
model = com.radiola.ui.components.crossfadeModel(coverModel),
com.radiola.ui.components.FlipCover(
model = coverModel,
contentDescription = station?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = Lucide.Radio,
contentDescription = null,
@@ -115,8 +162,9 @@ fun PlayerBottomSheet(
)
}
}
Spacer(Modifier.height(22.dp))
}
val trackInfoSection: @Composable () -> Unit = {
// Название трека и исполнитель с Crossfade при смене
Crossfade(
targetState = track?.song to track?.artist,
@@ -143,21 +191,83 @@ fun PlayerBottomSheet(
)
}
}
Spacer(Modifier.height(20.dp))
}
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
LiveEqualizer(
// Кнопка распознавания (Shazam) — только для музыкальных станций без РЕАЛЬНЫХ
// метаданных эфира. «Безымянные» станции часто шлют ICY-строку (слоган/название)
// без разделителя → parseIcyTitle делает трек с ПУСТЫМ исполнителем; такой трек
// и есть «нет названия» → кнопку показываем. Настоящий «Исполнитель — Трек»
// (artist и song заполнены) → кнопка скрыта.
val recognizeSection: @Composable () -> Unit = {
val noRealTrack = track == null ||
track.artist.isBlank() ||
track.song.isBlank() ||
track.song == station?.name
val show = station != null &&
noRealTrack &&
com.radiola.domain.model.MusicGenres.isMusicStation(station.genre)
if (show) {
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(colors.accent.copy(alpha = 0.15f))
.pressScale(interactionSource = interaction)
.clickable(
interactionSource = interaction,
indication = null,
enabled = !recognizing
) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.recognizeCurrentTrack()
}
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (recognizing) {
CircularProgressIndicator(
color = colors.accent,
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp)
)
} else {
Icon(
imageVector = Lucide.Mic,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(20.dp)
)
}
Text(
text = if (recognizing) "Распознаём…" else "Распознать трек",
color = colors.accent,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
}
val visualizerSection: @Composable () -> Unit = {
// Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost —
// чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf.
VisualizerHost(
viewModel = viewModel,
vizStyle = vizStyle,
playing = isPlaying,
color = colors.accent,
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
playing = isPlaying,
color = colors.accent
.height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp)
)
Spacer(Modifier.height(24.dp))
}
val controlsSection: @Composable () -> Unit = {
// Управление воспроизведением
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка избранного
@@ -166,15 +276,15 @@ fun PlayerBottomSheet(
animationSpec = tween(Motion.Medium),
label = "heartTint"
)
PlayerIconBtn(size = 44.dp) {
PlayerIconBtn(size = 48.dp) {
IconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleFavorite()
},
modifier = Modifier.size(44.dp)
modifier = Modifier.size(48.dp)
) {
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(24.dp))
}
}
@@ -226,8 +336,8 @@ fun PlayerBottomSheet(
animationSpec = tween(Motion.Medium),
label = "recordTint"
)
PlayerIconBtn(size = 44.dp) {
IconButton(onClick = onToggleRecording, modifier = Modifier.size(44.dp)) {
PlayerIconBtn(size = 48.dp) {
IconButton(onClick = onToggleRecording, modifier = Modifier.size(48.dp)) {
Crossfade(
targetState = isRecording,
animationSpec = tween(Motion.Fast),
@@ -237,14 +347,15 @@ fun PlayerBottomSheet(
imageVector = if (recording) Lucide.MicOff else Lucide.Mic,
contentDescription = if (recording) "Остановить запись" else "Запись",
tint = recordTint,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(24.dp)
)
}
}
}
}
Spacer(Modifier.height(20.dp))
}
val servicesSection: @Composable () -> Unit = {
// Ряд кнопок музыкальных сервисов
if (enabledServices.isNotEmpty()) {
LazyRow(
@@ -266,6 +377,434 @@ fun PlayerBottomSheet(
}
}
}
val lyricsSection: @Composable () -> Unit = {
// Кнопка «Текст песни» — активна только когда играет трек.
// Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден.
if (track != null) {
val lyricsInteraction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(colors.surface2)
.pressScale(interactionSource = lyricsInteraction)
.clickable(interactionSource = lyricsInteraction, indication = null) {
showLyrics = true
}
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Lucide.FileText,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(20.dp)
)
Text(
text = "Текст песни",
color = colors.accent,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
}
// Кнопка таймера сна. Активен → подсветка акцентом + оставшееся время MM:SS.
val sleepSection: @Composable () -> Unit = {
val active = sleepRemainingMs != null
val sleepInteraction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(if (active) colors.accent.copy(alpha = 0.15f) else colors.surface2)
.pressScale(interactionSource = sleepInteraction)
.clickable(interactionSource = sleepInteraction, indication = null) {
showSleep = true
}
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Lucide.Moon,
contentDescription = null,
tint = if (active) colors.accent else colors.textSecondary,
modifier = Modifier.size(20.dp)
)
Text(
text = sleepRemainingMs?.let { "Сон · ${formatSleep(it)}" } ?: "Таймер сна",
color = if (active) colors.accent else colors.textSecondary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
if (landscape) {
// Альбом: слева обложка с названием станции, справа — трек, эквалайзер,
// управление и сервисы (правая панель скроллится на низких экранах).
Row(
modifier = modifier
.fillMaxWidth()
.background(colors.bgBase)
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(0.42f),
horizontalAlignment = Alignment.CenterHorizontally
) {
labelSection()
Spacer(Modifier.height(6.dp))
nameSection()
Spacer(Modifier.height(14.dp))
coverSection(170.dp)
}
Spacer(Modifier.width(24.dp))
Column(
modifier = Modifier
.weight(0.58f)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
trackInfoSection()
recognizeSection()
Spacer(Modifier.height(16.dp))
visualizerSection()
Spacer(Modifier.height(16.dp))
controlsSection()
Spacer(Modifier.height(16.dp))
servicesSection()
Spacer(Modifier.height(12.dp))
sleepSection()
if (track != null) {
Spacer(Modifier.height(10.dp))
lyricsSection()
}
}
}
} else {
Column(
modifier = modifier
.fillMaxWidth()
.background(colors.bgBase)
.navigationBarsPadding()
// Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi)
// низ плеера (кнопка «Текст песни») не обрезался шторкой.
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
labelSection()
Spacer(Modifier.height(6.dp))
nameSection()
Spacer(Modifier.height(16.dp))
coverSection(190.dp)
Spacer(Modifier.height(14.dp))
trackInfoSection()
recognizeSection()
Spacer(Modifier.height(20.dp))
visualizerSection()
Spacer(Modifier.height(16.dp))
controlsSection()
Spacer(Modifier.height(20.dp))
servicesSection()
if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp))
sleepSection()
Spacer(Modifier.height(10.dp))
lyricsSection()
}
}
// Шторка выбора качества
if (showQuality && station != null) {
val qualities = station.qualities
ModalBottomSheet(
onDismissRequest = { showQuality = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp)
) {
Text(
text = "Качество звука",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 12.dp)
)
qualities.forEach { q ->
QualityRow(
quality = q,
selected = currentQuality?.bitrate == q.bitrate,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.selectQuality(q)
showQuality = false
}
)
}
}
}
}
// Шторка текста песни
if (showLyrics && track != null) {
ModalBottomSheet(
onDismissRequest = { showLyrics = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
LyricsSheet(
artist = track.artist,
song = track.song
)
}
}
// Шторка таймера сна
if (showSleep) {
ModalBottomSheet(
onDismissRequest = { showSleep = false },
containerColor = colors.bgBase,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp)
) {
Text(
text = "Таймер сна",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 12.dp)
)
Text(
text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.",
style = MaterialTheme.typography.bodySmall,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 12.dp)
)
// Выбор звука для сна: радио плавно перетечёт в выбранный шум.
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
) {
item {
SoundChip("Без звука", selectedSound == null) { selectedSound = null }
}
items(com.radiola.service.SleepSound.entries) { snd ->
SoundChip(snd.title, selectedSound == snd) { selectedSound = snd }
}
}
// Если активен — показываем остаток и кнопку отмены
if (sleepRemainingMs != null) {
SleepRow(
label = "Осталось ${formatSleep(sleepRemainingMs!!)}",
selected = true,
onClick = {
viewModel.cancelSleepTimer()
showSleep = false
},
trailing = "Выключить"
)
Spacer(Modifier.height(4.dp))
}
listOf(15, 30, 45, 60, 90, 120).forEach { min ->
SleepRow(
label = "$min минут",
selected = false,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.startSleepTimer(min, selectedSound)
showSleep = false
}
)
}
}
}
}
}
/** Строка выбора интервала таймера сна. */
@Composable
private fun SleepRow(
label: String,
selected: Boolean,
onClick: () -> Unit,
trailing: String? = null
) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Lucide.Moon,
contentDescription = null,
tint = if (selected) colors.accent else colors.textSecondary,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(12.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = if (selected) colors.accent else colors.textPrimary,
modifier = Modifier.weight(1f)
)
if (trailing != null) {
Text(
text = trailing,
style = MaterialTheme.typography.labelLarge,
color = colors.live,
fontWeight = FontWeight.SemiBold
)
}
}
}
/**
* Leaf-обёртка эквалайзера: сама собирает спектр (обновляется ~45/с) и включает
* расчёт FFT только пока скомпонована (открыт плеер) — это и изолирует частые
* рекомпозиции от остального плеера, и гасит FFT в фоне (батарея).
*/
@Composable
private fun VisualizerHost(
viewModel: PlayerViewModel,
vizStyle: String,
playing: Boolean,
color: Color,
modifier: Modifier
) {
val spectrum by viewModel.spectrum.collectAsState()
DisposableEffect(Unit) {
viewModel.setSpectrumActive(true)
onDispose { viewModel.setSpectrumActive(false) }
}
com.radiola.ui.components.Visualizer(
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
levels = spectrum,
playing = playing,
color = color,
modifier = modifier
)
}
/** Чип выбора звука для сна. */
@Composable
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(if (selected) colors.accent else colors.surface2)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 14.dp, vertical = 9.dp)
)
}
/** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */
private fun formatSleep(ms: Long): String {
val total = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(total / 60, total % 60)
}
/** Компактный чип текущего качества звука. */
@Composable
private fun QualityChip(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
val interaction = remember { MutableInteractionSource() }
Row(
modifier = modifier
.clip(RoundedCornerShape(50))
.background(colors.surface2)
.pressScale(interactionSource = interaction)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Lucide.SlidersHorizontal,
contentDescription = "Качество",
tint = colors.accent,
modifier = Modifier.size(13.dp)
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold
)
}
}
/** Строка выбора одного качества в шторке. */
@Composable
private fun QualityRow(
quality: com.radiola.domain.model.StreamQuality,
selected: Boolean,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = quality.tierLabel,
style = MaterialTheme.typography.bodyLarge,
color = if (selected) colors.accent else colors.textPrimary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
Text(
text = "${quality.bitrate} kbps · ${quality.type.uppercase()}",
style = MaterialTheme.typography.bodySmall,
color = colors.textSecondary
)
}
if (selected) {
Icon(
imageVector = Lucide.Check,
contentDescription = "Выбрано",
tint = colors.accent,
modifier = Modifier.size(20.dp)
)
}
}
}
/** Обёртка для иконок-кнопок управления — прозрачный фон. */

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.domain.model.StreamQuality
import com.radiola.domain.model.Track
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
@@ -12,6 +13,9 @@ import com.radiola.domain.repository.RecordingRepository
import com.radiola.domain.usecase.GetNowPlayingUseCase
import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.SearchTrackInServiceUseCase
import com.radiola.domain.repository.RecognizeResult
import com.radiola.domain.repository.RecognizedTrackRepository
import com.radiola.domain.repository.ShazamRepository
import com.radiola.domain.repository.TrackHistoryRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushHistoryUseCase
@@ -33,13 +37,21 @@ class PlayerViewModel @Inject constructor(
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val trackHistoryRepository: TrackHistoryRepository,
private val recognizedTrackRepository: RecognizedTrackRepository,
private val shazamRepository: ShazamRepository,
private val settingsRepository: SettingsRepository,
private val recordingRepository: RecordingRepository,
private val pushHistoryUseCase: PushHistoryUseCase
private val pushHistoryUseCase: PushHistoryUseCase,
private val loveStreamResolver: com.radiola.data.remote.LoveStreamResolver,
private val recordingPlaybackController: com.radiola.service.RecordingPlaybackController
) : ViewModel() {
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
val spectrum: StateFlow<FloatArray> = playerController.spectrum
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), "bars_center")
private val _currentStation = MutableStateFlow<Station?>(null)
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
@@ -47,6 +59,17 @@ class PlayerViewModel @Inject constructor(
private val _currentTrack = MutableStateFlow<Track?>(null)
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
// Распознавание трека (Shazam) — индикатор и одноразовые сообщения для UI.
private val _recognizing = MutableStateFlow(false)
val recognizing: StateFlow<Boolean> = _recognizing.asStateFlow()
private val _recognizeEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val recognizeEvent: SharedFlow<String> = _recognizeEvent.asSharedFlow()
// Ключ трека, добавленного через распознавание — его НЕ дублируем в историю
// «эфирных» треков (он идёт в отдельную историю распознанных).
private var recognizedKey: String? = null
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
@@ -56,8 +79,25 @@ class PlayerViewModel @Inject constructor(
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
// Выбранное качество текущей станции (битрейт). null — у станции нет вариантов.
private val _currentQuality = MutableStateFlow<StreamQuality?>(null)
val currentQuality: StateFlow<StreamQuality?> = _currentQuality.asStateFlow()
// Предпочитаемый битрейт пользователя (0 = авто/по умолчанию станции).
private var preferredBitrate: Int = 0
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
// Таймер сна: оставшееся время в мс (null = выключен).
val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs
fun startSleepTimer(minutes: Int, sound: com.radiola.service.SleepSound? = null) =
playerController.startSleepTimer(minutes * 60_000L, sound)
fun cancelSleepTimer() = playerController.cancelSleepTimer()
// Спектр (FFT) считаем только пока открыт плеер — экономия батареи в фоне.
fun setSpectrumActive(active: Boolean) = playerController.setSpectrumActive(active)
private var nowPlayingJob: Job? = null
init {
@@ -68,7 +108,28 @@ 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 {
@@ -76,24 +137,57 @@ class PlayerViewModel @Inject constructor(
.filterNotNull()
.distinctUntilChanged()
.collect { track ->
// Распознанный трек уже в истории распознанных — не дублируем в эфирную.
if (trackKey(track) == recognizedKey) return@collect
trackHistoryRepository.addTrack(track)
}
}
}
fun play(station: Station, playlist: List<Station>? = null) {
// Глушим плеер записи, если он играл — иначе два ExoPlayer'а конфликтуют
// (радио не стартует, запись зависает без управления).
recordingPlaybackController.stop()
_currentStation.value = station
_currentTrack.value = null
recognizedKey = null
_playlist.value = playlist ?: _stations.value
playerController.play(station.streamUrl, station.prefix, station.name)
// Выбираем стартовое качество: предпочтение пользователя → совпадение с
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
val quality = pickInitialQuality(station)
_currentQuality.value = quality
val streamUrl = quality?.url ?: station.streamUrl
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
// Для остальных resolve вернёт URL как есть.
viewModelScope.launch {
val url = loveStreamResolver.resolve(streamUrl)
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 {
// Polling loop for Record API now playing
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
launch { nowPlayingRepository.refreshNowPlaying() }
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
// каждые 5с → батарея + лишняя нагрузка на бэкенд).
launch {
playerController.isPlaying.collectLatest { playing ->
if (!playing) return@collectLatest
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(10_000)
delay(5_000)
}
}
}
// Collect now playing for this station (API has priority: covers + accurate metadata)
@@ -103,6 +197,10 @@ class PlayerViewModel @Inject constructor(
.collect { track ->
if (track != null) {
_currentTrack.value = track
// Нет обложки — обогащаем приоритетно (играет прямо сейчас).
if (track.coverUrl.isNullOrBlank()) {
nowPlayingRepository.enrichCoverNow(track)
}
playerController.updateMetadata(
track.song,
track.artist,
@@ -136,6 +234,39 @@ 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
if (list.size < 2) return null
return list.firstOrNull { it.bitrate == preferredBitrate }
?: list.firstOrNull { it.url == station.streamUrl }
?: list.first()
}
/** Переключить качество текущей станции на лету (без сброса now-playing). */
fun selectQuality(quality: StreamQuality) {
val station = _currentStation.value ?: return
if (_currentQuality.value?.bitrate == quality.bitrate) return
_currentQuality.value = quality
preferredBitrate = quality.bitrate
viewModelScope.launch { settingsRepository.setPreferredBitrate(quality.bitrate) }
viewModelScope.launch {
val url = loveStreamResolver.resolve(quality.url)
playerController.changeStream(url)
}
}
private fun parseIcyTitle(title: String?): Track? {
if (title.isNullOrBlank()) return null
val separators = listOf(" - ", "", " ")
@@ -193,6 +324,32 @@ class PlayerViewModel @Inject constructor(
return searchTrackInServiceUseCase(track, service)
}
/** Распознать играющий сейчас трек через Shazam (бэкенд тянет аудио из потока). */
fun recognizeCurrentTrack() {
val station = _currentStation.value ?: return
if (_recognizing.value) return
_recognizing.value = true
viewModelScope.launch {
when (val r = shazamRepository.recognize(station.id, station.name)) {
is RecognizeResult.Found -> {
recognizedKey = trackKey(r.track)
_currentTrack.value = r.track
recognizedTrackRepository.addTrack(r.track)
playerController.updateMetadata(
r.track.song, r.track.artist, r.track.coverUrl ?: "", station.name
)
_recognizeEvent.emit("Распознано: ${r.track.artist}${r.track.song}")
}
is RecognizeResult.NotFound -> _recognizeEvent.emit("Не удалось распознать трек")
is RecognizeResult.Error -> _recognizeEvent.emit(r.message)
}
_recognizing.value = false
}
}
private fun trackKey(t: Track): String =
(t.artist.trim() + "|" + t.song.trim()).lowercase()
fun toggleFavorite(station: Station) {
viewModelScope.launch {
toggleFavoriteUseCase(station)

View File

@@ -0,0 +1,336 @@
package com.radiola.ui.recordings
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RotateCcw
import com.composables.icons.lucide.RotateCw
import com.radiola.domain.model.Recording
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.pressScale
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Контент нижнего листа (bottom sheet) для воспроизведения записи эфира.
* Оборачивать в [ModalBottomSheet] на стороне вызывающего.
*/
@Composable
fun RecordingPlayerSheet(
recording: Recording,
onDismiss: () -> Unit,
viewModel: RecordingPlayerViewModel
) {
val isPlaying by viewModel.isPlaying.collectAsState()
val positionMs by viewModel.positionMs.collectAsState()
val durationMs by viewModel.durationMs.collectAsState()
val haptic = LocalHapticFeedback.current
val colors = RadiolaTheme.colors
val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) }
// Запуск воспроизведения при открытии листа
LaunchedEffect(recording) {
viewModel.play(recording)
}
val landscape = com.radiola.ui.util.isLandscape()
val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L)
val headerSection: @Composable () -> Unit = {
// Метка «ЗАПИСЬ ЭФИРА»
Text(
text = "ЗАПИСЬ ЭФИРА",
style = MaterialTheme.typography.labelSmall.copy(
letterSpacing = 2.sp,
fontWeight = FontWeight.SemiBold
),
color = colors.accent
)
Spacer(modifier = Modifier.height(12.dp))
// Название станции
Text(
text = recording.stationName,
style = MaterialTheme.typography.headlineSmall,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
// Трек (если есть) + дата
val meta = buildString {
if (!recording.trackName.isNullOrBlank()) {
append(recording.trackName)
append(" · ")
}
append(dateFormat.format(Date(recording.startTime)))
}
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val seekSection: @Composable () -> Unit = {
// Seekbar
Slider(
value = positionMs.toFloat(),
onValueChange = { viewModel.seekTo(it.toLong()) },
valueRange = 0f..effectiveDuration.toFloat(),
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = colors.accent,
activeTrackColor = colors.accent,
inactiveTrackColor = colors.surface2
)
)
// Время: текущее и общее
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatMs(positionMs),
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted
)
Text(
text = formatMs(effectiveDuration),
style = MaterialTheme.typography.labelSmall,
color = colors.textMuted
)
}
}
val controlsSection: @Composable () -> Unit = {
// Ряд управления: rewind15, play/pause, forward15
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
// Перемотка назад на 15 секунд
IconButton(
onClick = { viewModel.rewind15() },
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Lucide.RotateCcw,
contentDescription = "Назад 15 сек",
tint = colors.textSecondary,
modifier = Modifier.size(24.dp)
)
}
// Кнопка play/pause
val playInteraction = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.pressScale(interactionSource = playInteraction),
contentAlignment = Alignment.Center
) {
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.togglePlayPause()
},
modifier = Modifier.fillMaxSize(),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
),
contentPadding = PaddingValues(0.dp),
interactionSource = playInteraction
) {
Crossfade(
targetState = isPlaying,
animationSpec = tween(Motion.Fast),
label = "playPauseCrossfade"
) { playing ->
Icon(
imageVector = if (playing) Lucide.Pause else Lucide.Play,
contentDescription = if (playing) "Пауза" else "Воспроизвести",
modifier = Modifier.size(28.dp)
)
}
}
}
// Перемотка вперёд на 15 секунд
IconButton(
onClick = { viewModel.forward15() },
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Lucide.RotateCw,
contentDescription = "Вперёд 15 сек",
tint = colors.textSecondary,
modifier = Modifier.size(24.dp)
)
}
}
}
// Заголовок + строки списка треков. modifier — чтобы в альбоме правая
// панель скроллилась отдельно.
val markersSection: @Composable (Modifier) -> Unit = { listModifier ->
if (recording.markers.isNotEmpty()) {
Column(modifier = listModifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Треки в записи",
style = MaterialTheme.typography.titleSmall,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${recording.markers.size}",
style = MaterialTheme.typography.labelMedium,
color = colors.textMuted
)
}
Spacer(modifier = Modifier.height(8.dp))
// Индекс текущего трека: последняя метка, до которой уже дошло время
val activeIndex = recording.markers.indexOfLast { positionMs >= it.offsetMs }
recording.markers.forEachIndexed { index, marker ->
MarkerRow(
timecode = formatMs(marker.offsetMs),
title = marker.title,
active = index == activeIndex,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.seekTo(marker.offsetMs)
}
)
}
}
}
}
if (landscape) {
// Альбом: слева управление, справа — прокручиваемый список треков.
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 16.dp)
) {
Column(
modifier = Modifier.weight(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
headerSection()
Spacer(modifier = Modifier.height(20.dp))
seekSection()
Spacer(modifier = Modifier.height(20.dp))
controlsSection()
}
if (recording.markers.isNotEmpty()) {
Spacer(modifier = Modifier.width(24.dp))
markersSection(
Modifier
.weight(0.5f)
.verticalScroll(rememberScrollState())
)
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
// Отступ под системную навигацию — иначе список треков уходит под кнопки
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(8.dp))
headerSection()
Spacer(modifier = Modifier.height(24.dp))
seekSection()
Spacer(modifier = Modifier.height(24.dp))
controlsSection()
if (recording.markers.isNotEmpty()) {
Spacer(modifier = Modifier.height(28.dp))
markersSection(Modifier.fillMaxWidth())
}
}
}
}
/** Строка трека в записи: тайм-код + название, тап → переход. */
@Composable
private fun MarkerRow(
timecode: String,
title: String,
active: Boolean,
onClick: () -> Unit
) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(if (active) colors.surface2 else androidx.compose.ui.graphics.Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = timecode,
style = MaterialTheme.typography.labelMedium,
color = if (active) colors.accent else colors.textMuted,
fontWeight = FontWeight.Medium
)
Text(
text = title.ifBlank { "" },
style = MaterialTheme.typography.bodyMedium,
color = if (active) colors.accent else colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
/** Форматирует миллисекунды в строку mm:ss. */
private fun formatMs(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "%02d:%02d".format(minutes, seconds)
}

View File

@@ -0,0 +1,38 @@
package com.radiola.ui.recordings
import androidx.lifecycle.ViewModel
import com.radiola.domain.model.Recording
import com.radiola.service.RecordingPlaybackController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* ViewModel экрана воспроизведения записи. Проксирует состояние и команды
* к [RecordingPlaybackController].
*/
@HiltViewModel
class RecordingPlayerViewModel @Inject constructor(
private val controller: RecordingPlaybackController
) : ViewModel() {
val current: StateFlow<Recording?> = controller.current
val isPlaying: StateFlow<Boolean> = controller.isPlaying
val positionMs: StateFlow<Long> = controller.positionMs
val durationMs: StateFlow<Long> = controller.durationMs
fun play(recording: Recording) = controller.play(recording)
fun togglePlayPause() = controller.togglePlayPause()
fun seekTo(ms: Long) = controller.seekTo(ms)
/** Перемотка назад на 15 секунд. */
fun rewind15() = controller.seekBy(-15_000L)
/** Перемотка вперёд на 15 секунд. */
fun forward15() = controller.seekBy(15_000L)
/** Закрыть плеер и остановить воспроизведение. */
fun close() = controller.stop()
}

View File

@@ -19,7 +19,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
@@ -40,15 +39,22 @@ import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RecordingsScreen(
viewModel: RecordingsViewModel = hiltViewModel()
) {
val recordings by viewModel.recordings.collectAsState()
val isRecording by viewModel.isRecording.collectAsState()
val context = LocalContext.current
val colors = RadiolaTheme.colors
var playing by remember { mutableStateOf<Recording?>(null) }
// Плеер записи — singleton-контроллер; держим его VM здесь, чтобы корректно
// ОСТАНОВИТЬ воспроизведение при закрытии шторки и уходе с экрана (иначе
// аудио продолжает играть без UI и конфликтует с радио).
val recPlayerVm: RecordingPlayerViewModel = hiltViewModel()
DisposableEffect(Unit) {
onDispose { recPlayerVm.close() }
}
Column(
modifier = Modifier
@@ -119,21 +125,7 @@ fun RecordingsScreen(
items(recordings, key = { it.id }) { recording ->
RecordingItem(
recording = recording,
onPlay = {
// TODO: воспроизвести запись через ExoPlayer или внешний плеер
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(
androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
java.io.File(recording.filePath)
),
"audio/*"
)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
},
onPlay = { playing = recording },
onDelete = { viewModel.deleteRecording(recording.id) },
modifier = Modifier.animateItemPlacement()
)
@@ -142,6 +134,23 @@ fun RecordingsScreen(
}
}
}
// Встроенный плеер в нижнем листе. skipPartiallyExpanded — как у радио-плеера,
// иначе в partial-режиме navigationBarsPadding не применяется и список треков
// налезает на системную навигацию.
playing?.let { rec ->
ModalBottomSheet(
onDismissRequest = { recPlayerVm.close(); playing = null },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = colors.bgBase
) {
RecordingPlayerSheet(
recording = rec,
onDismiss = { recPlayerVm.close(); playing = null },
viewModel = recPlayerVm
)
}
}
}
@Composable

View File

@@ -5,7 +5,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -24,31 +26,37 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.SlidersHorizontal
import com.composables.icons.lucide.User
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.StationTestStatus
import com.radiola.ui.theme.Motion
import com.radiola.ui.theme.RadiolaTheme
import com.radiola.ui.theme.ThemePalette
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateToAuth: () -> Unit,
onNavigateToAlarms: () -> Unit = {},
onNavigateToEqualizer: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel()
) {
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
val enabledServices by viewModel.enabledServices.collectAsState()
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val themePalette by viewModel.themePalette.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState()
val testTotal by viewModel.testTotal.collectAsState()
val testResults by viewModel.testResults.collectAsState()
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
val currentUser by viewModel.currentUser.collectAsState()
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
var showReport by remember { mutableStateOf(false) }
val colors = RadiolaTheme.colors
@@ -71,6 +79,26 @@ fun SettingsScreen(
)
}
// --- Тема оформления ---
item {
SectionLabel("ТЕМА ОФОРМЛЕНИЯ")
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ThemePalette.entries.forEach { palette ->
ThemeSwatch(
palette = palette,
selected = themePalette == palette.id,
onClick = { viewModel.setThemePalette(palette.id) }
)
}
}
}
// --- Профиль ---
item {
SectionLabel("ПРОФИЛЬ")
@@ -143,6 +171,48 @@ fun SettingsScreen(
}
}
// --- Будильник ---
item {
SectionLabel("БУДИЛЬНИК")
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.clickable { onNavigateToAlarms() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Icon(
Lucide.AlarmClock,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(22.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Будильники",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "Просыпайтесь под любимое радио",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Icon(
Lucide.ChevronRight,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(18.dp)
)
}
}
// --- Таймер сна ---
item {
SectionLabel("ТАЙМЕР СНА")
@@ -186,10 +256,11 @@ fun SettingsScreen(
}
}
// --- Эквалайзер ---
// --- Качество звука по умолчанию ---
item {
SectionLabel("ЭКВАЛАЙЗЕР")
SectionLabel("КАЧЕСТВО ЗВУКА")
Spacer(Modifier.height(8.dp))
val options = listOf(0 to "Авто", 64 to "Эконом", 128 to "Стандарт", 320 to "Высокое")
Row(
modifier = Modifier
.fillMaxWidth()
@@ -199,37 +270,131 @@ fun SettingsScreen(
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
presets.forEach { preset ->
val selected = equalizerPreset == preset
options.forEach { (bitrate, label) ->
val selected = preferredBitrate == bitrate
val bgColor by animateColorAsState(
targetValue = if (selected) colors.accent else colors.surface2,
animationSpec = tween(Motion.Medium),
label = "eqSegment"
label = "qSegment"
)
val textColor by animateColorAsState(
targetValue = if (selected) colors.bgBase else colors.textSecondary,
animationSpec = tween(Motion.Medium),
label = "eqText"
label = "qText"
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.clickable { viewModel.setEqualizerPreset(preset) }
.padding(vertical = 8.dp),
.clickable { viewModel.setPreferredBitrate(bitrate) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = preset,
text = label,
style = MaterialTheme.typography.labelLarge,
color = textColor,
fontWeight = FontWeight.Medium
)
}
}
}
Text(
text = "Применяется к станциям с несколькими потоками. «Авто» — выбор станции.",
style = MaterialTheme.typography.bodySmall,
color = colors.textMuted,
modifier = Modifier.padding(top = 8.dp)
)
}
// --- Эквалайзер (отдельный детальный экран) ---
item {
SectionLabel("ЗВУК")
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.clickable { onNavigateToEqualizer() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Icon(
Lucide.SlidersHorizontal,
contentDescription = null,
tint = colors.accent,
modifier = Modifier.size(22.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Эквалайзер",
style = MaterialTheme.typography.titleMedium,
color = colors.textPrimary
)
Text(
text = "Полосы, пресеты, бас, объём, громкость",
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
Icon(
Lucide.ChevronRight,
contentDescription = null,
tint = colors.textMuted,
modifier = Modifier.size(18.dp)
)
}
}
// --- Стиль визуализации воспроизведения ---
item {
SectionLabel("АНИМАЦИЯ ВОСПРОИЗВЕДЕНИЯ")
Spacer(Modifier.height(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
com.radiola.ui.components.VisualizerStyle.entries.chunked(2).forEach { rowStyles ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowStyles.forEach { style ->
val selected = visualizerStyle == style.key
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) colors.accent else colors.border,
shape = RoundedCornerShape(16.dp)
)
.clickable { viewModel.setVisualizerStyle(style.key) }
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
com.radiola.ui.components.Visualizer(
style = style,
levels = null,
playing = true,
color = colors.accent,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
)
Text(
text = style.label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.accent else colors.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
}
}
}
// --- Музыкальные сервисы ---
item {
@@ -242,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,
@@ -273,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)
@@ -283,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(
@@ -449,6 +575,61 @@ fun SettingsScreen(
}
}
/**
* Превью цветовой темы: квадрат с фоном палитры, акцентным кружком и брендовым
* градиентом снизу. Выбранная — с акцентной рамкой и жирной подписью.
*/
@Composable
private fun ThemeSwatch(
palette: ThemePalette,
selected: Boolean,
onClick: () -> Unit
) {
val p = palette.colors
val outer = RadiolaTheme.colors
Column(
modifier = Modifier
.width(72.dp)
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(18.dp))
.background(p.bgBase)
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) outer.accent else outer.border,
shape = RoundedCornerShape(18.dp)
)
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(26.dp)
.clip(CircleShape)
.background(p.accent)
)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(10.dp)
.background(p.brandGradient)
)
}
Text(
text = palette.title,
style = MaterialTheme.typography.labelMedium,
color = if (selected) outer.accent else outer.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
/** Подпись секции: заглавные буквы, textMuted, labelSmall. */
@Composable
private fun SectionLabel(text: String) {

View File

@@ -32,8 +32,15 @@ class SettingsViewModel @Inject constructor(
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat")
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center")
val themePalette: StateFlow<String> = settingsRepository.getThemePalette()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest")
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -57,6 +64,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
}
fun setPreferredBitrate(bitrate: Int) {
viewModelScope.launch { settingsRepository.setPreferredBitrate(bitrate) }
}
fun toggleService(serviceId: String, enabled: Boolean) {
viewModelScope.launch {
val current = enabledServices.value.toMutableSet()
@@ -69,8 +80,12 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) }
}
fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
fun setVisualizerStyle(style: String) {
viewModelScope.launch { settingsRepository.setVisualizerStyle(style) }
}
fun setThemePalette(id: String) {
viewModelScope.launch { settingsRepository.setThemePalette(id) }
}
fun startTesting() {

View File

@@ -1,6 +1,8 @@
package com.radiola.ui.stations
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -9,6 +11,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
@@ -34,14 +41,32 @@ fun StationsScreen(
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val favoriteIds by viewModel.favoriteIds.collectAsState()
val nowPlaying by viewModel.nowPlaying.collectAsState()
val playingStationId by viewModel.playingStationId.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current
// В альбоме шире окно — больше колонок, иначе карточки растягиваются.
val gridColumns = if (com.radiola.ui.util.isLandscape()) 4 else 2
// Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему.
val orderedTags = remember(tags) { listOf<String?>(null) + tags }
fun switchTag(forward: Boolean) {
if (orderedTags.size <= 1) return
val idx = orderedTags.indexOf(selectedTag).coerceAtLeast(0)
val newIdx = idx + if (forward) 1 else -1
if (newIdx in orderedTags.indices) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.onTagSelected(orderedTags[newIdx])
}
}
Column(modifier = modifier.fillMaxSize()) {
// Двухцветный заголовок экрана
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = colors.textPrimary)) { append("Откройте ") }
withStyle(SpanStyle(color = colors.accent)) { append("радио") }
withStyle(SpanStyle(color = colors.textPrimary)) { append("Выберите ") }
withStyle(SpanStyle(color = colors.accent)) { append("радиостанцию") }
},
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 16.dp)
@@ -55,18 +80,26 @@ fun StationsScreen(
)
Spacer(Modifier.height(12.dp))
// Жанры — всегда видны
if (tags.isNotEmpty()) {
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected
)
Spacer(Modifier.height(8.dp))
// Область результатов — единственная прокручиваемая зона.
// Горизонтальный свайп листает фильтры-чипы (вертикаль остаётся у грида).
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.pointerInput(orderedTags, selectedTag) {
var totalDx = 0f
detectHorizontalDragGestures(
onDragStart = { totalDx = 0f },
onDragEnd = {
val threshold = 56.dp.toPx()
when {
totalDx <= -threshold -> switchTag(forward = true)
totalDx >= threshold -> switchTag(forward = false)
}
// Область результатов — единственная прокручиваемая зона
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
}
) { _, dragAmount -> totalDx += dragAmount }
}
) {
when {
isLoading && stations.isEmpty() -> {
CircularProgressIndicator(
@@ -105,9 +138,11 @@ fun StationsScreen(
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
columns = GridCells.Fixed(gridColumns),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 20.dp),
// top = высота чипов: грид уходит ПОД них, свечение верхнего ряда
// не обрезается и проступает за чипами.
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 54.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
@@ -117,11 +152,55 @@ fun StationsScreen(
isFavorite = favoriteIds.contains(station.id),
onClick = { onStationClick(station) },
onFavoriteClick = { viewModel.toggleFavorite(station) },
nowTrack = nowPlaying[station.id],
isCurrent = station.id == playingStationId,
isPlaying = isPlaying,
modifier = Modifier.animateItemPlacement()
)
}
}
}
// Чипы-фильтры поверх грида. Фон-градиент: вверху непрозрачный
// (маскирует прокручиваемые карточки), книзу прозрачный — свечение
// верхнего ряда станций проступает ИЗ-ПОД чипов.
if (tags.isNotEmpty()) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to colors.bgBase,
0.55f to colors.bgBase,
1f to Color.Transparent
)
)
.padding(top = 2.dp, bottom = 12.dp)
) {
Box(modifier = Modifier.fillMaxWidth().height(44.dp)) {
// Чипы во всю ширину, но с отступом слева под кнопку; у левого
// края — затухание прозрачности (чипы «уплывают» под кнопку).
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
contentPadding = PaddingValues(start = 60.dp, end = 16.dp),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
)
// Кнопка-категории — поверх чипов, слева.
CategoryPicker(
title = "Категории",
items = tags,
selected = selectedTag,
onSelect = viewModel::onTagSelected,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 16.dp)
)
}
}
}
}
}
}

View File

@@ -2,14 +2,20 @@ package com.radiola.ui.stations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.geo.GeoBlock
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.RegionRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.PlayStationUseCase
import com.radiola.domain.usecase.RefreshStationsUseCase
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,9 +27,16 @@ class StationsViewModel @Inject constructor(
private val playStationUseCase: PlayStationUseCase,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val favoritesRepository: FavoritesRepository,
private val stationRepository: StationRepository
private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository,
private val regionRepository: RegionRepository,
private val playerController: PlayerController
) : ViewModel() {
// Активная (играющая) станция — для подсветки карточки в списке.
val playingStationId: StateFlow<Int?> = playerController.currentStationId
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
@@ -54,20 +67,45 @@ class StationsViewModel @Inject constructor(
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val tags: StateFlow<List<String>> = stationRepository.getTags()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Чипы-жанры. Для пользователей из РФ убираем жанры украинских станций
// (Radio ROKS, Kiss FM) — их чипы не показываем вовсе.
val tags: StateFlow<List<String>> = combine(
stationRepository.getTags(),
stationRepository.getStations(),
regionRepository.countryCode()
) { tags, allStations, country ->
if (GeoBlock.shouldHideUa(country)) {
val uaGenres = allStations.filter { GeoBlock.isUaStation(it) }.map { it.genre }.toSet()
tags.filterNot { it in uaGenres }
} else {
tags
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val favoriteIds: StateFlow<Set<Int>> = favoritesRepository.getFavorites()
.map { list -> list.map { it.id }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
// Текущие треки по id станции (каталожный == station.id) — без коллизий по имени.
val nowPlaying: StateFlow<Map<Int, Track>> = nowPlayingRepository.getAllNowPlaying()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init {
// Определяем страну пользователя по IP (для гео-фильтрации станций).
viewModelScope.launch { regionRepository.refresh() }
viewModelScope.launch {
_isLoading.value = true
refreshStationsUseCase()
.onFailure { _error.value = it.localizedMessage ?: "Ошибка загрузки" }
_isLoading.value = false
}
// Периодическое обновление now-playing каждые 20 секунд.
viewModelScope.launch {
while (true) {
nowPlayingRepository.refreshNowPlaying()
delay(20_000)
}
}
}
fun onSearchQueryChange(query: String) {

View File

@@ -23,29 +23,20 @@ import androidx.compose.material3.Text
fun brandGradient(): Brush = Brush.linearGradient(listOf(BrandGradientStart, BrandGradientEnd))
/**
* Иконка-марка приложения: градиентный squircle с монограммой «R».
* Марка приложения: объёмная 3D-монограмма «R», перекрашенная под текущую тему.
*/
@Composable
fun AppMark(
size: Dp = 76.dp,
modifier: Modifier = Modifier
) {
val radius = (size.value * 0.29f).dp
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(radius))
.background(brandGradient()),
contentAlignment = Alignment.Center
) {
Text(
text = "R",
color = BgBase,
fontWeight = FontWeight.Black,
fontSize = (size.value * 0.62f).sp
val palette = RadiolaTheme.palette
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(palette.logoRes),
contentDescription = "radiOLA",
modifier = modifier.size(size)
)
}
}
/**
* Текстовый логотип «radiOLA»: «radi» основным цветом, «OLA» акцентом.
@@ -55,16 +46,17 @@ fun RadiolaWordmark(
fontSize: Int = 26,
modifier: Modifier = Modifier
) {
val colors = RadiolaTheme.colors
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "radi",
color = TextPrimary,
color = colors.textPrimary,
fontWeight = FontWeight.Bold,
fontSize = fontSize.sp
)
Text(
text = "OLA",
color = Accent,
color = colors.accent,
fontWeight = FontWeight.Bold,
fontSize = fontSize.sp
)

Some files were not shown because too many files have changed in this diff Show More