diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2aa5148..4c63578 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ android { } buildFeatures { compose = true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.14" diff --git a/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt index dccfa8b..9bc3f01 100644 --- a/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt @@ -38,13 +38,11 @@ class StationRepositoryImpl @Inject constructor( } override suspend fun refreshStations(): Result = withContext(Dispatchers.IO) { - android.util.Log.d("StationRepo", "refreshStations() called") // Тяжёлый парс 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) @@ -56,14 +54,16 @@ class StationRepositoryImpl @Inject constructor( // Локальные 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 -> // Обложки/потоки из Record API — только для станций сети Radio Record. // Иначе чужим сетям (DFM, HitFM и т.д.) цеплялись бы обложки Record. val apiStation = if (local.source == "record") { - apiStations.find { it.id == local.id } - ?: apiStations.find { - it.name.trim().equals(local.name.trim(), ignoreCase = true) - } + apiById[local.id] ?: apiByName[local.name.trim().lowercase()] } else null if (apiStation != null) { val domain = apiStation.toDomain() @@ -83,7 +83,6 @@ class StationRepositoryImpl @Inject constructor( // 4. Save to DB. Сохраняем текущие отметки «избранное», иначе REPLACE // в insertAll затрёт их при каждом пересоздании каталога (на старте). val favoriteIds = db.stationDao().getFavoriteIdsOnce().toSet() - android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB (избранных сохранено: ${favoriteIds.size})") val entities = merged.mapIndexed { index, station -> StationEntity( id = station.id, @@ -100,7 +99,6 @@ class StationRepositoryImpl @Inject constructor( ) } db.stationDao().insertAll(entities) - android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB") // 4b. Скрываем станции, которые бэкенд пометил оффлайн (мёртвые потоки). // Если бэкенд недоступен — оставляем как есть (фолбэк на статичный enabled). @@ -108,7 +106,6 @@ class StationRepositoryImpl @Inject constructor( val offlineIds = radiolaApi.getOfflineStationIds() if (offlineIds.isNotEmpty()) { db.stationDao().deleteByIds(offlineIds) - android.util.Log.d("StationRepo", "Скрыто оффлайн-станций: ${offlineIds.size}") } } catch (e: Exception) { android.util.Log.w("StationRepo", "Не удалось получить offline-id: ${e.message}") diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index afed78e..bf1b4b6 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -68,9 +68,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 { - level = HttpLoggingInterceptor.Level.BASIC - }) + .apply { + // Логирование каждого HTTP-запроса — только в debug. В релизе это лишний + // оверхед на каждый вызов и утечка URL/деталей в logcat. + if (com.radiola.BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + }) + } + } .build() @Provides diff --git a/app/src/main/java/com/radiola/domain/model/Station.kt b/app/src/main/java/com/radiola/domain/model/Station.kt index ccd820b..ad41b54 100644 --- a/app/src/main/java/com/radiola/domain/model/Station.kt +++ b/app/src/main/java/com/radiola/domain/model/Station.kt @@ -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, @@ -15,6 +21,7 @@ data class Station( ) /** Один вариант качества потока станции. */ +@Immutable data class StreamQuality( val bitrate: Int, // kbps val url: String, diff --git a/app/src/main/java/com/radiola/domain/model/Track.kt b/app/src/main/java/com/radiola/domain/model/Track.kt index f11b91a..bd79a49 100644 --- a/app/src/main/java/com/radiola/domain/model/Track.kt +++ b/app/src/main/java/com/radiola/domain/model/Track.kt @@ -1,5 +1,8 @@ package com.radiola.domain.model +import androidx.compose.runtime.Immutable + +@Immutable data class Track( val artist: String, val song: String, diff --git a/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt b/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt index 176f597..946b8c4 100644 --- a/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt +++ b/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt @@ -50,48 +50,63 @@ class RecordingPlaybackController @Inject constructor( .setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true) - private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context) - .setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory)) - .build().apply { - addListener(object : Player.Listener { - override fun onIsPlayingChanged(playing: Boolean) { - _isPlaying.value = playing - } + // Плеер создаём ЛЕНИВО — запись играют редко, а раньше второй ExoPlayer висел + // в памяти всю сессию у каждого. Освобождаем в stop(): обычно после остановки + // пользователь уходит с экрана записей, держать декодер/буферы незачем. + private var exoPlayer: ExoPlayer? = null - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - Player.STATE_READY -> { - val dur = duration - _durationMs.value = if (dur == C.TIME_UNSET) 0L else dur - } - Player.STATE_ENDED -> { - _isPlaying.value = false - _positionMs.value = _durationMs.value - } - else -> Unit + 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 - init { - // Цикл обновления позиции каждые 500мс + 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) { - if (exoPlayer.isPlaying) { - _positionMs.value = exoPlayer.currentPosition - val dur = exoPlayer.duration - if (dur != C.TIME_UNSET) { - _durationMs.value = dur - } + 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 + } + /** * Начать воспроизведение записи. Сначала останавливает радио. */ @@ -104,25 +119,24 @@ class RecordingPlaybackController @Inject constructor( _positionMs.value = 0L _durationMs.value = recording.duration ?: 0L + val player = ensurePlayer() val mediaItem = MediaItem.fromUri(android.net.Uri.fromFile(File(recording.filePath))) - exoPlayer.setMediaItem(mediaItem) - exoPlayer.prepare() - exoPlayer.play() + player.setMediaItem(mediaItem) + player.prepare() + player.play() } /** Переключить паузу/воспроизведение. */ fun togglePlayPause() { - if (exoPlayer.isPlaying) { - exoPlayer.pause() - } else { - exoPlayer.play() - } + 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)) - exoPlayer.seekTo(target) + player.seekTo(target) _positionMs.value = target } @@ -131,9 +145,11 @@ class RecordingPlaybackController @Inject constructor( seekTo(_positionMs.value + deltaMs) } - /** Остановить воспроизведение и сбросить текущую запись. */ + /** Остановить воспроизведение, освободить плеер и сбросить текущую запись. */ fun stop() { - exoPlayer.stop() + stopPositionPolling() + exoPlayer?.release() + exoPlayer = null _current.value = null _isPlaying.value = false _positionMs.value = 0L