diff --git a/app/src/main/java/com/radiola/RadiolaApplication.kt b/app/src/main/java/com/radiola/RadiolaApplication.kt index 0fd2bdf..7f7c1bf 100644 --- a/app/src/main/java/com/radiola/RadiolaApplication.kt +++ b/app/src/main/java/com/radiola/RadiolaApplication.kt @@ -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() +} 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 b691230..dccfa8b 100644 --- a/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/StationRepositoryImpl.kt @@ -10,6 +10,8 @@ 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 @@ -35,9 +37,11 @@ class StationRepositoryImpl @Inject constructor( } } - override suspend fun refreshStations(): Result { + override suspend fun refreshStations(): Result = withContext(Dispatchers.IO) { android.util.Log.d("StationRepo", "refreshStations() called") - return try { + // Тяжёлый парс 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") diff --git a/app/src/main/java/com/radiola/service/AudioSpectrum.kt b/app/src/main/java/com/radiola/service/AudioSpectrum.kt index a20d072..35a5bf2 100644 --- a/app/src/main/java/com/radiola/service/AudioSpectrum.kt +++ b/app/src/main/java/com/radiola/service/AudioSpectrum.kt @@ -74,6 +74,12 @@ class AudioSpectrumAnalyzer( private val _spectrum = MutableStateFlow(FloatArray(bands)) val spectrum: StateFlow = _spectrum + // FFT считаем ТОЛЬКО когда есть наблюдатель (открыт плеер). Иначе ~86 FFT/с + // молотят впустую при фоновом проигрывании (экран выключен) — главный + // пожиратель батареи. Ставится из UI плеера (VisualizerHost). + @Volatile + var active: Boolean = false + // Меньше окно = меньше задержка реакции на удар (групповая задержка Hann ~окно/2) // и чаще обновления. Лайвность держит автогейн, а не размер окна. private val fftSize = 1024 @@ -100,6 +106,11 @@ class AudioSpectrumAnalyzer( 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 diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index 5f1ade1..90e1b44 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -28,6 +28,7 @@ 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 @@ -346,6 +347,11 @@ class PlayerController @Inject constructor( _sleepRemainingMs.value = null } + /** Включить/выключить расчёт спектра (FFT) — только пока открыт плеер. */ + fun setSpectrumActive(active: Boolean) { + spectrumAnalyzer.active = active + } + fun pause() { exoPlayer.pause() } @@ -361,6 +367,7 @@ class PlayerController @Inject constructor( } fun release() { + timerScope.cancel() audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) sleepSoundPlayer.stop() exoPlayer.release() diff --git a/app/src/main/java/com/radiola/service/PlayerService.kt b/app/src/main/java/com/radiola/service/PlayerService.kt index f8ac843..8466b19 100644 --- a/app/src/main/java/com/radiola/service/PlayerService.kt +++ b/app/src/main/java/com/radiola/service/PlayerService.kt @@ -19,6 +19,7 @@ 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 @@ -158,6 +159,10 @@ class PlayerService : MediaSessionService() { override fun onDestroy() { mediaSession?.release() mediaSession = null + // serviceScope — поле этого сервиса (пере-создаётся при рестарте), отменяем. + // playerController — @Singleton (переживает рестарт сервиса), его НЕ релизим: + // иначе новый PlayerService построит MediaSession на освобождённом плеере. + serviceScope.cancel() super.onDestroy() } } diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 155ee23..1ea3f5c 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -87,7 +87,6 @@ fun PlayerBottomSheet( var selectedSound by remember { mutableStateOf(null) } val currentQuality by viewModel.currentQuality.collectAsState() val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState() - val spectrum by viewModel.spectrum.collectAsState() val vizStyle by viewModel.visualizerStyle.collectAsState() val landscape = com.radiola.ui.util.isLandscape() @@ -188,10 +187,11 @@ fun PlayerBottomSheet( } val visualizerSection: @Composable () -> Unit = { - // Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать) - com.radiola.ui.components.Visualizer( - style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle), - levels = spectrum, + // Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost — + // чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf. + VisualizerHost( + viewModel = viewModel, + vizStyle = vizStyle, playing = isPlaying, color = colors.accent, modifier = Modifier @@ -614,6 +614,33 @@ private fun SleepRow( } } +/** + * 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) { diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index dbb6c7e..f70fb22 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -79,6 +79,9 @@ class PlayerViewModel @Inject constructor( playerController.startSleepTimer(minutes * 60_000L, sound) fun cancelSleepTimer() = playerController.cancelSleepTimer() + // Спектр (FFT) считаем только пока открыт плеер — экономия батареи в фоне. + fun setSpectrumActive(active: Boolean) = playerController.setSpectrumActive(active) + private var nowPlayingJob: Job? = null init { @@ -126,11 +129,16 @@ class PlayerViewModel @Inject constructor( viewModelScope.launch { pushHistoryUseCase(station.id) } nowPlayingJob?.cancel() nowPlayingJob = viewModelScope.launch { - // Polling loop for Record API now playing + // Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет + // внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть + // каждые 5с → батарея + лишняя нагрузка на бэкенд). launch { - while (true) { - nowPlayingRepository.refreshNowPlaying() - delay(5_000) // чаще — трек/обложка на плеере обновляются быстрее + playerController.isPlaying.collectLatest { playing -> + if (!playing) return@collectLatest + while (true) { + nowPlayingRepository.refreshNowPlaying() + delay(5_000) + } } } // Collect now playing for this station (API has priority: covers + accurate metadata)