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>
305 lines
12 KiB
Kotlin
305 lines
12 KiB
Kotlin
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<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 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<out AudioDeviceInfo>?) {
|
||
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<out AudioDeviceInfo>?) {
|
||
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()
|
||
}
|
||
}
|