package com.radiola.service import android.content.Context import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager import android.net.Uri import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.ForwardingPlayer import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Metadata 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.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 ) { // Анализатор спектра реального звука — для «живого» эквалайзера. private val spectrumAnalyzer = AudioSpectrumAnalyzer() val spectrum: StateFlow = 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 = _isPlaying private val _currentStationPrefix = MutableStateFlow(null) val currentStationPrefix: StateFlow = _currentStationPrefix // Id играющей станции — для подсветки активной карточки в списке. private val _currentStationId = MutableStateFlow(null) val currentStationId: StateFlow = _currentStationId private val _icyTitle = MutableStateFlow(null) val icyTitle: StateFlow = _icyTitle.asStateFlow() // ── Таймер сна ── // Оставшееся время в мс (null = таймер выключен). В последние FADE_MS звук // плавно затухает (экспоненциальная кривая), затем пауза. private val _sleepRemainingMs = MutableStateFlow(null) val sleepRemainingMs: StateFlow = _sleepRemainingMs.asStateFlow() private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var sleepJob: Job? = null private companion object { const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера } private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager var onSkipToNext: (() -> Unit)? = null var onSkipToPrevious: (() -> Unit)? = null private val audioDeviceCallback = object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array?) { val addedPlayback = addedDevices?.any { it.isPlaybackDevice() } == true if (addedPlayback && _currentStationPrefix.value != null && !exoPlayer.isPlaying) { Log.d("PlayerController", "Playback device connected → resume") exoPlayer.play() } } override fun onAudioDevicesRemoved(removedDevices: Array?) { val removedPlayback = removedDevices?.any { it.isPlaybackDevice() } == true if (removedPlayback && exoPlayer.isPlaying) { Log.d("PlayerController", "Playback device removed → pause") exoPlayer.pause() } } } private fun AudioDeviceInfo.isPlaybackDevice(): Boolean { return isSink && ( type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || type == AudioDeviceInfo.TYPE_WIRED_HEADSET || type == AudioDeviceInfo.TYPE_USB_HEADSET || type == AudioDeviceInfo.TYPE_USB_DEVICE || type == AudioDeviceInfo.TYPE_BLE_HEADSET || type == AudioDeviceInfo.TYPE_BLE_SPEAKER ) } // 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) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build(), true ) .setHandleAudioBecomingNoisy(true) .build() .apply { addListener(object : Player.Listener { override fun onIsPlayingChanged(playing: Boolean) { _isPlaying.value = playing } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { val title = mediaMetadata.title?.toString() if (!title.isNullOrBlank()) { Log.d("PlayerController", "MediaMetadata title: $title") _icyTitle.value = title } } override fun onMetadata(metadata: Metadata) { Log.d("PlayerController", "onMetadata called, length=${metadata.length()}") for (i in 0 until metadata.length()) { val entry = metadata.get(i) Log.d("PlayerController", "Metadata entry[$i]: ${entry::class.java.simpleName}") when (entry) { is IcyInfo -> { Log.d("PlayerController", "IcyInfo title='${entry.title}', url='${entry.url}', raw='${entry.rawMetadata}'") entry.title?.let { if (it.isNotBlank()) { _icyTitle.value = it } } } } } } }) } val player: Player = object : ForwardingPlayer(exoPlayer) { override fun getAvailableCommands(): Player.Commands { return super.getAvailableCommands() .buildUpon() .add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) .build() } override fun seekToNextMediaItem() { onSkipToNext?.invoke() ?: super.seekToNextMediaItem() } override fun seekToPreviousMediaItem() { onSkipToPrevious?.invoke() ?: super.seekToPreviousMediaItem() } } init { audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) } fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) { Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix") _currentStationId.value = stationId _icyTitle.value = null val mediaItem = MediaItem.Builder() .setUri(url) .setMediaMetadata( MediaMetadata.Builder() .setTitle(stationName) .setArtist("") .build() ) .build() exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() exoPlayer.play() _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) } val updatedMediaItem = currentMediaItem.buildUpon() .setMediaMetadata( MediaMetadata.Builder() .setTitle(song) .setArtist("$artist • $stationName") .setAlbumTitle(stationName) .setArtworkUri(artworkUri) .build() ) .build() exoPlayer.replaceMediaItem(0, updatedMediaItem) } /** * Запустить таймер сна на [durationMs] мс. В последние FADE_MS звук плавно * затухает (экспоненциально, чтобы спад воспринимался естественно), затем пауза. */ fun startSleepTimer(durationMs: Long) { sleepJob?.cancel() exoPlayer.volume = 1f sleepJob = timerScope.launch { val end = SystemClock.elapsedRealtime() + durationMs while (true) { val remaining = end - SystemClock.elapsedRealtime() if (remaining <= 0L) break _sleepRemainingMs.value = remaining if (remaining <= FADE_MS) { // frac: 1 → 0; экспонента (frac^2) — громкость падает резче к концу. val frac = remaining.toFloat() / FADE_MS exoPlayer.volume = (frac * frac).coerceIn(0f, 1f) delay(150) } else { delay(1_000) } } exoPlayer.pause() exoPlayer.volume = 1f _sleepRemainingMs.value = null sleepJob = null } } /** Отменить таймер сна и вернуть полную громкость. */ fun cancelSleepTimer() { sleepJob?.cancel() sleepJob = null exoPlayer.volume = 1f _sleepRemainingMs.value = null } fun pause() { exoPlayer.pause() } fun play() { exoPlayer.play() } fun stop() { exoPlayer.stop() _currentStationPrefix.value = null _currentStationId.value = null } fun release() { audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) exoPlayer.release() } }