887 lines
36 KiB
Kotlin
887 lines
36 KiB
Kotlin
package com.yaros.RadioUrl
|
|
|
|
import android.app.ActivityManager
|
|
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.content.IntentFilter
|
|
import android.graphics.Bitmap
|
|
import android.media.AudioManager
|
|
import android.net.Uri
|
|
import android.os.BatteryManager
|
|
import android.os.Binder
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.widget.Toast
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.media3.*
|
|
import androidx.media3.common.AudioAttributes
|
|
import androidx.media3.common.C
|
|
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 androidx.media3.common.util.UnstableApi
|
|
import androidx.media3.datasource.DataSource
|
|
import androidx.media3.datasource.DefaultDataSource
|
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
import androidx.media3.exoplayer.DefaultLoadControl
|
|
import androidx.media3.exoplayer.ExoPlayer
|
|
import androidx.media3.exoplayer.SeekParameters
|
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
import androidx.media3.session.MediaSession
|
|
import androidx.media3.session.MediaSessionService
|
|
import androidx.media3.session.SessionCommand
|
|
import androidx.media3.session.SessionResult
|
|
import androidx.media3.ui.PlayerNotificationManager
|
|
import com.bumptech.glide.Glide
|
|
import com.bumptech.glide.request.RequestOptions
|
|
import com.google.common.util.concurrent.Futures
|
|
import com.google.common.util.concurrent.ListenableFuture
|
|
import com.yaros.RadioUrl.helpers.AudioHelper
|
|
import com.yaros.RadioUrl.helpers.NetworkHelper
|
|
import com.yaros.RadioUrl.helpers.NetworkMonitor
|
|
import com.yaros.RadioUrl.helpers.PreferencesHelper
|
|
import com.yaros.RadioUrl.helpers.VolumeSettingsHelper
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import timber.log.Timber
|
|
|
|
@UnstableApi
|
|
class PlayerService : MediaSessionService(), Player.Listener {
|
|
private var exoPlayer: ExoPlayer? = null
|
|
private lateinit var mediaSession: MediaSession
|
|
private lateinit var notificationManager: NotificationManager
|
|
private lateinit var audioManager: AudioManager
|
|
private lateinit var networkMonitor: NetworkMonitor
|
|
private var isForegroundService = false
|
|
private lateinit var playerNotificationManager: PlayerNotificationManager
|
|
|
|
private var hasAudioFocus = false
|
|
private var playbackDelayed = false
|
|
private var resumeOnFocusGain = false
|
|
|
|
private val audioFocusRequest = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
|
Timber.tag("PlayerService").i("Audio focus changed: $focusChange")
|
|
when (focusChange) {
|
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
Timber.tag("PlayerService").i("Audio focus gained")
|
|
hasAudioFocus = true
|
|
|
|
exoPlayer?.let { player ->
|
|
// Восстанавливаем громкость если была уменьшена (ducking)
|
|
if (player.volume < 1.0f) {
|
|
player.volume = 1.0f
|
|
Timber.tag("PlayerService").i("Volume restored to 100%")
|
|
}
|
|
|
|
// Если воспроизведение было отложено - запускаем
|
|
if (playbackDelayed) {
|
|
Timber.tag("PlayerService").i("Starting delayed playback")
|
|
player.play()
|
|
playbackDelayed = false
|
|
}
|
|
// Если нужно возобновить после временной паузы
|
|
else if (resumeOnFocusGain) {
|
|
Timber.tag("PlayerService").i("Resuming playback after transient loss")
|
|
player.play()
|
|
resumeOnFocusGain = false
|
|
}
|
|
}
|
|
}
|
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
Timber.tag("PlayerService").i("Audio focus lost permanently")
|
|
hasAudioFocus = false
|
|
resumeOnFocusGain = false
|
|
playbackDelayed = false
|
|
exoPlayer?.pause()
|
|
// Останавливаем сервис при постоянной потере фокуса
|
|
stopServiceGracefully()
|
|
}
|
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
Timber.tag("PlayerService").i("Audio focus lost temporarily")
|
|
hasAudioFocus = false
|
|
resumeOnFocusGain = exoPlayer?.isPlaying == true
|
|
exoPlayer?.pause()
|
|
}
|
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
|
Timber.tag("PlayerService").i("Audio focus lost, can duck")
|
|
// Уменьшаем громкость вместо паузы
|
|
exoPlayer?.volume = 0.2f
|
|
}
|
|
}
|
|
}
|
|
|
|
inner class LocalBinder : Binder() {
|
|
val service: PlayerService
|
|
get() = this@PlayerService
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
initComponents()
|
|
|
|
// Немедленно запускаем foreground service с временным уведомлением
|
|
// чтобы избежать RemoteServiceException
|
|
startForeground(NOTIFICATION_ID, createInitialNotification())
|
|
isForegroundService = true
|
|
|
|
setupPlayer() // Синхронная инициализация
|
|
setupMediaSession() // Должна быть после setupPlayer()
|
|
setupNotificationManager() // Должна быть после setupMediaSession()
|
|
setupNetworkMonitor()
|
|
}
|
|
|
|
private fun initComponents() {
|
|
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
networkMonitor = NetworkMonitor(this)
|
|
createNotificationChannel()
|
|
}
|
|
|
|
private fun createDataSourceFactory(): DataSource.Factory {
|
|
return try {
|
|
OkHttpDataSource.Factory(NetworkHelper.client)
|
|
.setUserAgent("SoundWaveRadio/1.0")
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error creating data source factory: ${e.message}")
|
|
// Fallback to default
|
|
OkHttpDataSource.Factory(NetworkHelper.client)
|
|
.setUserAgent("SoundWaveRadio/1.0")
|
|
}
|
|
}
|
|
|
|
private fun setupPlayer() {
|
|
try {
|
|
val dataSourceFactory = DefaultDataSource.Factory(
|
|
this,
|
|
createDataSourceFactory()
|
|
)
|
|
exoPlayer = ExoPlayer.Builder(this)
|
|
.setHandleAudioBecomingNoisy(true) // Автоматически останавливает при отключении наушников
|
|
.setWakeMode(C.WAKE_MODE_NETWORK)
|
|
.setLoadControl(
|
|
DefaultLoadControl.Builder()
|
|
.setBufferDurationsMs(
|
|
MIN_BUFFER_MS,
|
|
MAX_BUFFER_MS,
|
|
PLAYBACK_BUFFER_MS,
|
|
REBUFFER_MS
|
|
)
|
|
.setPrioritizeTimeOverSizeThresholds(true)
|
|
.build()
|
|
)
|
|
.setMediaSourceFactory(
|
|
DefaultMediaSourceFactory(dataSourceFactory)
|
|
)
|
|
.build()
|
|
.apply {
|
|
addListener(this@PlayerService)
|
|
setAudioAttributes(
|
|
AudioAttributes.Builder()
|
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
.setUsage(C.USAGE_MEDIA)
|
|
.build(),
|
|
false // НЕ обрабатываем Audio Focus автоматически - делаем это вручную
|
|
)
|
|
playWhenReady = true
|
|
setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
|
trackSelectionParameters = trackSelectionParameters
|
|
.buildUpon()
|
|
.setForceHighestSupportedBitrate(true)
|
|
.build()
|
|
}
|
|
Timber.tag("PlayerService").i("ExoPlayer initialized successfully")
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error setting up player: ${e.message}")
|
|
throw e
|
|
}
|
|
}
|
|
|
|
private fun setupMediaSession() {
|
|
mediaSession = MediaSession.Builder(this, exoPlayer!!)
|
|
.setSessionActivity(getSessionActivity())
|
|
.setCallback(MediaSessionCallback())
|
|
.build()
|
|
}
|
|
|
|
private fun setupNotificationManager() {
|
|
playerNotificationManager = PlayerNotificationManager.Builder(
|
|
this,
|
|
NOTIFICATION_ID,
|
|
NOTIFICATION_CHANNEL_ID
|
|
)
|
|
.setMediaDescriptionAdapter(NotificationMediaAdapter())
|
|
.setChannelNameResourceId(R.string.notification_info)
|
|
.setChannelDescriptionResourceId(R.string.default_notification_channel_name)
|
|
.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
|
|
override fun onNotificationPosted(
|
|
notificationId: Int,
|
|
notification: Notification,
|
|
ongoing: Boolean
|
|
) {
|
|
// Обновляем уведомление, если сервис уже в foreground режиме
|
|
if (ongoing && isForegroundService) {
|
|
startForeground(notificationId, notification)
|
|
} else if (!ongoing) {
|
|
stopForeground(false)
|
|
isForegroundService = false
|
|
}
|
|
}
|
|
|
|
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
|
|
stopSelf()
|
|
}
|
|
})
|
|
.build()
|
|
.apply {
|
|
setPlayer(exoPlayer)
|
|
setColorized(true)
|
|
setPriority(NotificationCompat.PRIORITY_LOW)
|
|
}
|
|
}
|
|
|
|
|
|
private val notificationListener = object : PlayerNotificationManager.NotificationListener {
|
|
override fun onNotificationPosted(
|
|
notificationId: Int,
|
|
notification: Notification,
|
|
ongoing: Boolean
|
|
) {
|
|
if (ongoing && !isForegroundService) {
|
|
startForeground(notificationId, notification)
|
|
isForegroundService = true
|
|
}
|
|
}
|
|
|
|
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
|
|
stopSelf()
|
|
}
|
|
}
|
|
|
|
private fun setupNetworkMonitor() {
|
|
networkMonitor.addListener(object : NetworkMonitor.Listener {
|
|
override fun onNetworkAvailable() {
|
|
// Post to the main thread
|
|
Handler(Looper.getMainLooper()).post {
|
|
exoPlayer?.prepare()
|
|
exoPlayer?.play()
|
|
}
|
|
}
|
|
|
|
override fun onNetworkLost() {
|
|
// Post to the main thread
|
|
Handler(Looper.getMainLooper()).post {
|
|
exoPlayer?.pause()
|
|
}
|
|
}
|
|
})
|
|
networkMonitor.start()
|
|
}
|
|
|
|
|
|
private fun getSessionActivity(): PendingIntent {
|
|
val intent = Intent(this, MainActivity::class.java).apply {
|
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
}
|
|
|
|
val pendingIntentFlags = when {
|
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
|
}
|
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
}
|
|
else -> {
|
|
PendingIntent.FLAG_UPDATE_CURRENT
|
|
}
|
|
}
|
|
|
|
return PendingIntent.getActivity(
|
|
this,
|
|
0,
|
|
intent,
|
|
pendingIntentFlags
|
|
)
|
|
}
|
|
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
|
return if (::mediaSession.isInitialized) mediaSession else null
|
|
}
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
super.onStartCommand(intent, flags, startId)
|
|
|
|
// Убеждаемся, что сервис в foreground режиме при каждом вызове startForegroundService()
|
|
if (!isForegroundService) {
|
|
startForeground(NOTIFICATION_ID, createInitialNotification())
|
|
isForegroundService = true
|
|
}
|
|
|
|
intent?.let {
|
|
when (it.action) {
|
|
Keys.ACTION_PLAY_STREAM -> {
|
|
val streamUri = it.getStringExtra(Keys.EXTRA_STREAM_URI)
|
|
val stationName = it.getStringExtra(Keys.EXTRA_STATION_NAME)
|
|
val stationImage = it.getStringExtra(Keys.EXTRA_STATION_IMAGE)
|
|
val stationUuid = it.getStringExtra(Keys.EXTRA_STATION_UUID)
|
|
|
|
Timber.tag("PlayerService").i("Received ACTION_PLAY_STREAM: name=$stationName, uri=$streamUri, uuid=$stationUuid")
|
|
|
|
if (!streamUri.isNullOrBlank()) {
|
|
val artUri = if (!stationImage.isNullOrBlank()) {
|
|
android.net.Uri.parse(stationImage)
|
|
} else {
|
|
null
|
|
}
|
|
|
|
playRadio(
|
|
url = streamUri,
|
|
title = stationName ?: "Radio Station",
|
|
artist = getString(R.string.app_name),
|
|
artUri = artUri,
|
|
stationUuid = stationUuid
|
|
)
|
|
} else {
|
|
Timber.tag("PlayerService").e("Stream URI is null or blank")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return START_STICKY
|
|
}
|
|
|
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
super.onTaskRemoved(rootIntent)
|
|
Timber.tag("PlayerService").i("Task removed, isPlaying: ${exoPlayer?.isPlaying}")
|
|
|
|
// Останавливаем сервис только если не воспроизводится
|
|
if (exoPlayer?.isPlaying != true) {
|
|
Timber.tag("PlayerService").i("Stopping service - not playing")
|
|
stopServiceGracefully()
|
|
} else {
|
|
Timber.tag("PlayerService").i("Keeping service alive - still playing")
|
|
}
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
releaseResources()
|
|
super.onDestroy()
|
|
}
|
|
|
|
private fun releaseResources() {
|
|
try {
|
|
retryHandler.removeCallbacksAndMessages(null)
|
|
retryAttempts = 0
|
|
networkMonitor.stop()
|
|
|
|
// Освобождаем Audio Focus
|
|
if (hasAudioFocus) {
|
|
audioManager.abandonAudioFocus(audioFocusRequest)
|
|
hasAudioFocus = false
|
|
Timber.tag("PlayerService").i("Audio focus abandoned")
|
|
}
|
|
|
|
if (::mediaSession.isInitialized) {
|
|
mediaSession.release()
|
|
}
|
|
exoPlayer?.release()
|
|
exoPlayer = null
|
|
|
|
if (::playerNotificationManager.isInitialized) {
|
|
playerNotificationManager.setPlayer(null)
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error releasing resources: ${e.message}")
|
|
}
|
|
}
|
|
|
|
private inner class MediaSessionCallback : MediaSession.Callback {
|
|
override fun onConnect(
|
|
session: MediaSession,
|
|
controller: MediaSession.ControllerInfo
|
|
): MediaSession.ConnectionResult {
|
|
val baseResult = super.onConnect(session, controller)
|
|
val customCommands = listOf(
|
|
SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY),
|
|
SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY),
|
|
SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY),
|
|
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY)
|
|
)
|
|
val newSessionCommands = baseResult.availableSessionCommands
|
|
.buildUpon()
|
|
.apply { customCommands.forEach { add(it) } }
|
|
.build()
|
|
return MediaSession.ConnectionResult.accept(
|
|
newSessionCommands,
|
|
baseResult.availablePlayerCommands
|
|
)
|
|
}
|
|
|
|
override fun onCustomCommand(
|
|
session: MediaSession,
|
|
controller: MediaSession.ControllerInfo,
|
|
customCommand: SessionCommand,
|
|
args: Bundle
|
|
): ListenableFuture<SessionResult> {
|
|
when (customCommand.customAction) {
|
|
"play" -> {
|
|
if (isAppInForeground()) {
|
|
// Запрашиваем Audio Focus перед воспроизведением
|
|
val result = audioManager.requestAudioFocus(
|
|
audioFocusRequest,
|
|
AudioManager.STREAM_MUSIC,
|
|
AudioManager.AUDIOFOCUS_GAIN
|
|
)
|
|
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
|
hasAudioFocus = true
|
|
exoPlayer?.play()
|
|
Timber.tag("PlayerService").i("Play command: Audio focus granted, playing")
|
|
} else {
|
|
Timber.tag("PlayerService").w("Play command: Audio focus denied")
|
|
}
|
|
} else {
|
|
Timber.tag("PlayerService").w("Невозможно воспроизвести из фонового режима")
|
|
}
|
|
}
|
|
"pause" -> handlePauseCommand()
|
|
"stop" -> stopServiceGracefully()
|
|
Keys.CMD_REQUEST_METADATA_HISTORY -> {
|
|
val metadataHistory = PreferencesHelper.loadMetadataHistory()
|
|
val resultBundle = Bundle().apply {
|
|
putStringArrayList(Keys.EXTRA_METADATA_HISTORY, ArrayList(metadataHistory))
|
|
}
|
|
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS, resultBundle))
|
|
}
|
|
}
|
|
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
}
|
|
|
|
private fun handlePauseCommand() {
|
|
exoPlayer?.pause()
|
|
|
|
// Освобождаем Audio Focus при паузе
|
|
if (hasAudioFocus) {
|
|
audioManager.abandonAudioFocus(audioFocusRequest)
|
|
hasAudioFocus = false
|
|
Timber.tag("PlayerService").i("Audio focus abandoned on pause")
|
|
}
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
stopForeground(STOP_FOREGROUND_DETACH)
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
stopForeground(false)
|
|
}
|
|
isForegroundService = false
|
|
}
|
|
}
|
|
|
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
super.onIsPlayingChanged(isPlaying)
|
|
Timber.tag("PlayerService").i("onIsPlayingChanged: $isPlaying")
|
|
|
|
if (!isPlaying) {
|
|
// Когда воспроизведение останавливается, освобождаем Audio Focus
|
|
if (hasAudioFocus && exoPlayer?.playWhenReady == false) {
|
|
audioManager.abandonAudioFocus(audioFocusRequest)
|
|
hasAudioFocus = false
|
|
Timber.tag("PlayerService").i("Audio focus abandoned - playback stopped")
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
try {
|
|
when (playbackState) {
|
|
Player.STATE_READY -> {
|
|
Timber.tag("PlayerService").i("Playback state: READY")
|
|
retryAttempts = 0 // Reset retry counter on successful playback
|
|
handlePlaybackReady()
|
|
}
|
|
Player.STATE_ENDED -> {
|
|
Timber.tag("PlayerService").i("Playback state: ENDED")
|
|
handlePlaybackEnd()
|
|
}
|
|
Player.STATE_IDLE -> {
|
|
Timber.tag("PlayerService").i("Playback state: IDLE")
|
|
// Останавливаем сервис если плеер не должен воспроизводить
|
|
if (exoPlayer?.playWhenReady == false) {
|
|
// Освобождаем Audio Focus
|
|
if (hasAudioFocus) {
|
|
audioManager.abandonAudioFocus(audioFocusRequest)
|
|
hasAudioFocus = false
|
|
Timber.tag("PlayerService").i("Audio focus abandoned on idle")
|
|
}
|
|
stopServiceGracefully()
|
|
}
|
|
}
|
|
Player.STATE_BUFFERING -> {
|
|
Timber.tag("PlayerService").i("Playback state: BUFFERING")
|
|
handleBufferingState()
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error in onPlaybackStateChanged: ${e.message}")
|
|
}
|
|
}
|
|
|
|
override fun onPlayerError(error: PlaybackException) {
|
|
Timber.tag("PlayerService").e("Playback error: ${error.errorCodeName} - ${error.message}")
|
|
when (error.errorCode) {
|
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> {
|
|
Timber.tag("PlayerService").e("Network connection failed")
|
|
handlePlaybackError(error)
|
|
}
|
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> {
|
|
Timber.tag("PlayerService").e("Bad HTTP status: ${error.message}")
|
|
handlePlaybackError(error)
|
|
}
|
|
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> {
|
|
Timber.tag("PlayerService").e("Invalid HTTP content type")
|
|
handlePlaybackError(error)
|
|
}
|
|
PlaybackException.ERROR_CODE_TIMEOUT -> {
|
|
Timber.tag("PlayerService").e("Playback timeout")
|
|
handlePlaybackError(error)
|
|
}
|
|
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
|
|
Timber.tag("PlayerService").e("Unspecified IO error")
|
|
handlePlaybackError(error)
|
|
}
|
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> {
|
|
Timber.tag("PlayerService").e("Malformed stream format")
|
|
stopServiceGracefully()
|
|
}
|
|
else -> {
|
|
Timber.tag("PlayerService").e("Unknown playback error: ${error.errorCode}")
|
|
handlePlaybackError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onMetadata(metadata: Metadata) {
|
|
super.onMetadata(metadata)
|
|
try {
|
|
if (metadata.length() > 0) {
|
|
val metadataString = AudioHelper.getMetadataString(metadata)
|
|
if (metadataString.isNotEmpty()) {
|
|
Timber.tag("PlayerService").i("Metadata received: $metadataString")
|
|
|
|
// Сохраняем метаданные в историю
|
|
val metadataHistory = PreferencesHelper.loadMetadataHistory()
|
|
|
|
// Добавляем только если это новые метаданные
|
|
if (metadataHistory.isEmpty() || metadataHistory.last() != metadataString) {
|
|
metadataHistory.add(metadataString)
|
|
|
|
// Ограничиваем размер истории
|
|
while (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) {
|
|
metadataHistory.removeAt(0)
|
|
}
|
|
|
|
PreferencesHelper.saveMetadataHistory(metadataHistory)
|
|
Timber.tag("PlayerService").i("Metadata saved to history")
|
|
}
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error processing metadata: ${e.message}")
|
|
}
|
|
}
|
|
|
|
private fun handlePlaybackReady() {
|
|
optimizePowerUsage()
|
|
}
|
|
|
|
private fun handleBufferingState() {
|
|
exoPlayer?.playWhenReady = true
|
|
}
|
|
|
|
private fun createNotificationChannel() {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
NOTIFICATION_CHANNEL_ID,
|
|
getString(R.string.notification_info),
|
|
NotificationManager.IMPORTANCE_LOW
|
|
).apply {
|
|
description = getString(R.string.default_notification_channel_name)
|
|
setShowBadge(false)
|
|
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
enableLights(false)
|
|
enableVibration(false)
|
|
setSound(null, null)
|
|
}
|
|
notificationManager.createNotificationChannel(channel)
|
|
}
|
|
}
|
|
|
|
private fun createInitialNotification(): Notification {
|
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
.setContentTitle(getString(R.string.app_name))
|
|
.setContentText(getString(R.string.notification_info))
|
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
|
.setContentIntent(getSessionActivity())
|
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
.setOngoing(true)
|
|
.build()
|
|
}
|
|
|
|
fun playRadio(url: String, title: String, artist: String, artUri: Uri?, stationUuid: String? = null) {
|
|
try {
|
|
Timber.tag("PlayerService").i("=== playRadio called ===")
|
|
Timber.tag("PlayerService").i("Title: $title, URL: $url, UUID: $stationUuid")
|
|
|
|
if (url.isBlank()) {
|
|
Timber.tag("PlayerService").e("Invalid URL: URL is blank")
|
|
return
|
|
}
|
|
|
|
exoPlayer?.apply {
|
|
// Применяем индивидуальную громкость ДО начала воспроизведения
|
|
val targetVolume = if (!stationUuid.isNullOrBlank()) {
|
|
val stationVolume = VolumeSettingsHelper.getVolume(this@PlayerService, stationUuid)
|
|
Timber.tag("PlayerService").i("Station has custom volume: $stationVolume (${(stationVolume * 100).toInt()}%)")
|
|
stationVolume
|
|
} else {
|
|
Timber.tag("PlayerService").i("No UUID provided, using default volume: 1.0")
|
|
1.0f
|
|
}
|
|
|
|
// Устанавливаем громкость перед подготовкой
|
|
volume = targetVolume
|
|
Timber.tag("PlayerService").i("Volume set to: $targetVolume before playback")
|
|
|
|
// Сбрасываем флаги Audio Focus для нового воспроизведения
|
|
resumeOnFocusGain = false
|
|
playbackDelayed = false
|
|
|
|
// Останавливаем текущее воспроизведение
|
|
stop()
|
|
clearMediaItems()
|
|
|
|
val mediaItem = MediaItem.Builder()
|
|
.setUri(url)
|
|
.setMediaMetadata(
|
|
MediaMetadata.Builder()
|
|
.setTitle(title)
|
|
.setArtist(artist)
|
|
.setArtworkUri(artUri)
|
|
.build()
|
|
)
|
|
.build()
|
|
|
|
setMediaItem(mediaItem)
|
|
|
|
// Подтверждаем, что громкость установлена
|
|
Timber.tag("PlayerService").i("Current player volume before prepare: $volume")
|
|
|
|
// Запрашиваем Audio Focus ПЕРЕД prepare и play
|
|
requestAudioFocusAndPlay()
|
|
} ?: run {
|
|
Timber.tag("PlayerService").e("ExoPlayer is null, cannot play radio")
|
|
}
|
|
} catch (e: IllegalArgumentException) {
|
|
Timber.tag("PlayerService").e("Invalid URL format: ${e.message}")
|
|
} catch (e: IllegalStateException) {
|
|
Timber.tag("PlayerService").e("Player in invalid state: ${e.message}")
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error playing radio: ${e.message}")
|
|
handlePlaybackError(PlaybackException("Error playing radio", e, PlaybackException.ERROR_CODE_UNSPECIFIED))
|
|
}
|
|
}
|
|
|
|
private fun requestAudioFocusAndPlay() {
|
|
Timber.tag("PlayerService").i("Requesting audio focus...")
|
|
|
|
val result = audioManager.requestAudioFocus(
|
|
audioFocusRequest,
|
|
AudioManager.STREAM_MUSIC,
|
|
AudioManager.AUDIOFOCUS_GAIN
|
|
)
|
|
|
|
Timber.tag("PlayerService").i("Audio focus request result: $result")
|
|
|
|
when (result) {
|
|
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
|
Timber.tag("PlayerService").i("Audio focus GRANTED - starting playback")
|
|
hasAudioFocus = true
|
|
playbackDelayed = false
|
|
|
|
// Теперь можем безопасно начать воспроизведение
|
|
exoPlayer?.let { player ->
|
|
player.prepare()
|
|
player.play()
|
|
Timber.tag("PlayerService").i("Playback started with audio focus")
|
|
}
|
|
}
|
|
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
|
|
Timber.tag("PlayerService").w("Audio focus FAILED - cannot play")
|
|
hasAudioFocus = false
|
|
playbackDelayed = false
|
|
Toast.makeText(this, getString(R.string.notification_play), Toast.LENGTH_SHORT).show()
|
|
}
|
|
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
|
|
Timber.tag("PlayerService").i("Audio focus DELAYED - will play when granted")
|
|
hasAudioFocus = false
|
|
playbackDelayed = true
|
|
|
|
// prepare() вызовем сейчас, play() будет вызван в onAudioFocusChange
|
|
exoPlayer?.prepare()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun stopServiceGracefully() {
|
|
Timber.tag("PlayerService").i("Stopping service gracefully")
|
|
|
|
// Освобождаем Audio Focus перед остановкой
|
|
if (hasAudioFocus) {
|
|
audioManager.abandonAudioFocus(audioFocusRequest)
|
|
hasAudioFocus = false
|
|
Timber.tag("PlayerService").i("Audio focus abandoned on stop")
|
|
}
|
|
|
|
exoPlayer?.stop()
|
|
stopForeground(true)
|
|
stopSelf()
|
|
}
|
|
|
|
private fun optimizePowerUsage() {
|
|
exoPlayer?.setWakeMode(
|
|
if (isCharging(this)) C.WAKE_MODE_NETWORK
|
|
else C.WAKE_MODE_LOCAL
|
|
)
|
|
}
|
|
|
|
private fun handlePlaybackEnd() {
|
|
stopServiceGracefully()
|
|
}
|
|
|
|
private fun isAppInForeground(): Boolean {
|
|
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
|
val appProcesses = activityManager.runningAppProcesses ?: return false
|
|
val packageName = packageName
|
|
return appProcesses.any { process ->
|
|
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
|
|
process.processName == packageName
|
|
}
|
|
}
|
|
|
|
fun isCharging(context: Context): Boolean {
|
|
val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
|
|
val batteryStatus: Intent? = context.registerReceiver(null, intentFilter)
|
|
|
|
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
|
return status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
|
}
|
|
|
|
private var retryAttempts = 0
|
|
private val maxRetryAttempts = 3
|
|
private val retryHandler = Handler(Looper.getMainLooper())
|
|
|
|
private fun handlePlaybackError(error: PlaybackException) {
|
|
Timber.tag("PlayerService").e("Playback error: ${error.errorCodeName} - ${error.message}")
|
|
when (error.errorCode) {
|
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
|
PlaybackException.ERROR_CODE_TIMEOUT,
|
|
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
|
|
if (retryAttempts < maxRetryAttempts) {
|
|
retryPlayback()
|
|
} else {
|
|
Timber.tag("PlayerService").e("Max retry attempts reached, stopping playback")
|
|
retryAttempts = 0
|
|
stopServiceGracefully()
|
|
}
|
|
}
|
|
else -> {
|
|
retryAttempts = 0
|
|
stopServiceGracefully()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun retryPlayback() {
|
|
retryAttempts++
|
|
val delayMillis = (retryAttempts * 2000L) // Exponential backoff: 2s, 4s, 6s
|
|
Timber.tag("PlayerService").i("Retrying playback (attempt $retryAttempts/$maxRetryAttempts) in ${delayMillis}ms")
|
|
|
|
retryHandler.postDelayed({
|
|
try {
|
|
exoPlayer?.let { player ->
|
|
if (player.playbackState == Player.STATE_IDLE ||
|
|
player.playbackState == Player.STATE_ENDED) {
|
|
player.prepare()
|
|
player.play()
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.tag("PlayerService").e("Error during retry: ${e.message}")
|
|
retryAttempts = 0
|
|
stopServiceGracefully()
|
|
}
|
|
}, delayMillis)
|
|
}
|
|
|
|
private inner class NotificationMediaAdapter : PlayerNotificationManager.MediaDescriptionAdapter {
|
|
override fun getCurrentContentTitle(player: Player): CharSequence {
|
|
return player.mediaMetadata.title ?: getString(R.string.app_name)
|
|
}
|
|
|
|
override fun createCurrentContentIntent(player: Player): PendingIntent? {
|
|
return getSessionActivity()
|
|
}
|
|
|
|
override fun getCurrentContentText(player: Player): CharSequence? {
|
|
return player.mediaMetadata.artist ?: getString(R.string.sample_text_station_metadata)
|
|
}
|
|
|
|
override fun getCurrentLargeIcon(
|
|
player: Player,
|
|
callback: PlayerNotificationManager.BitmapCallback
|
|
): Bitmap? {
|
|
val imageUri = player.mediaMetadata.artworkUri
|
|
return if (imageUri != null) {
|
|
loadBitmapAsync(imageUri.toString(), callback)
|
|
null
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun loadBitmapAsync(url: String, callback: PlayerNotificationManager.BitmapCallback) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val bitmap = Glide.with(this@PlayerService)
|
|
.asBitmap()
|
|
.load(url)
|
|
.apply(RequestOptions().timeout(10000))
|
|
.submit()
|
|
.get()
|
|
callback.onBitmap(bitmap)
|
|
} catch (e: Exception) {
|
|
Timber.tag("Player").v("Playback error: notification")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
const val NOTIFICATION_ID = 2031
|
|
const val NOTIFICATION_CHANNEL_ID = "Проигрование"
|
|
private const val MIN_BUFFER_MS = 5000
|
|
private const val MAX_BUFFER_MS = 10000
|
|
private const val PLAYBACK_BUFFER_MS = 2000
|
|
private const val REBUFFER_MS = 5000
|
|
}
|
|
}
|