Files
Radio/app/src/main/java/com/michatec/radio/PlayerService.kt
Michatec 669fd4683c - Release of 14.1
- Added new features
- Some bug fixes
2026-02-27 09:40:59 +01:00

575 lines
22 KiB
Kotlin

/*
* PlayerService.kt
* Implements the PlayerService class
* PlayerService is Radio's foreground service that plays radio station audio
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-22 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/
package com.michatec.radio
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.*
import android.media.audiofx.AudioEffect
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultAllocator
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.session.*
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.michatec.radio.core.Collection
import com.michatec.radio.helpers.AudioHelper
import com.michatec.radio.helpers.CollectionHelper
import com.michatec.radio.helpers.FileHelper
import com.michatec.radio.helpers.PreferencesHelper
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main
import java.util.*
/*
* PlayerService class
*/
@UnstableApi
class PlayerService : MediaLibraryService() {
/* Define log tag */
private val TAG: String = PlayerService::class.java.simpleName
/* Main class variables */
private lateinit var player: Player
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var sleepTimer: CountDownTimer
var sleepTimerTimeRemaining: Long = 0L
private var sleepTimerEndTime: Long = 0L
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
private var collection: Collection = Collection()
private lateinit var metadataHistory: MutableList<String>
private var bufferSizeMultiplier: Int = PreferencesHelper.loadBufferSizeMultiplier()
private var playbackRestartCounter: Int = 0
private var playLastStation: Boolean = false
private var manuallyCancelledSleepTimer = false
/* Overrides onCreate from Service */
override fun onCreate() {
super.onCreate()
// load collection
collection = FileHelper.readCollection(this)
// create and register collection changed receiver
LocalBroadcastManager.getInstance(application).registerReceiver(
collectionChangedReceiver,
IntentFilter(Keys.ACTION_COLLECTION_CHANGED)
)
// initialize player and session
initializePlayer()
initializeSession()
val notificationProvider: DefaultMediaNotificationProvider = CustomNotificationProvider()
notificationProvider.setSmallIcon(R.drawable.ic_notification_app_icon_white_24dp)
setMediaNotificationProvider(notificationProvider)
// fetch the metadata history
metadataHistory = PreferencesHelper.loadMetadataHistory()
}
/* Overrides onDestroy from Service */
override fun onDestroy() {
// player.removeAnalyticsListener(analyticsListener)
player.removeListener(playerListener)
player.release()
mediaLibrarySession.release()
super.onDestroy()
}
/* Overrides onTaskRemoved from Service */
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady) {
stopSelf()
}
}
/* Overrides onGetSession from MediaSessionService */
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
/* Initializes the ExoPlayer */
private fun initializePlayer() {
val exoPlayer: ExoPlayer = ExoPlayer.Builder(this).apply {
setAudioAttributes(AudioAttributes.DEFAULT, true)
setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
setMediaSourceFactory(
DefaultMediaSourceFactory(this@PlayerService).setLoadErrorHandlingPolicy(
loadErrorHandlingPolicy
)
)
}.build()
exoPlayer.addAnalyticsListener(analyticsListener)
exoPlayer.addListener(playerListener)
// manually add seek to next and seek to previous since headphones issue them and they are translated to next and previous station
player = object : ForwardingPlayer(exoPlayer) {
override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT)
.add(COMMAND_SEEK_TO_PREVIOUS).build()
}
override fun isCommandAvailable(command: Int): Boolean {
return availableCommands.contains(command)
}
override fun getDuration(): Long {
return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification
}
}
}
/* Initializes the MediaSession */
private fun initializeSession() {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntent(intent)
getPendingIntent(0, if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_CANCEL_CURRENT)
}
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback).apply {
setSessionActivity(pendingIntent)
}.build()
}
/* Creates a LoadControl - increase buffer size by given factor */
private fun createDefaultLoadControl(factor: Int): DefaultLoadControl {
val builder = DefaultLoadControl.Builder()
builder.setAllocator(DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE))
builder.setBufferDurationsMs(
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * factor,
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * factor,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS * factor,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS * factor
)
return builder.build()
}
/* Starts sleep timer / adds default duration to running sleeptimer */
private fun startSleepTimer(selectedTimeMillis: Long) {
// stop running timer
if (sleepTimerTimeRemaining > 0L && this::sleepTimer.isInitialized) {
sleepTimer.cancel()
}
// set the end time of the sleep timer
sleepTimerEndTime = System.currentTimeMillis() + selectedTimeMillis
// initialize timer
sleepTimer = object : CountDownTimer(selectedTimeMillis, 1000) {
override fun onFinish() {
Log.v(TAG, "Sleep timer finished. Sweet dreams.")
sleepTimerTimeRemaining = 0L
player.stop()
}
override fun onTick(millisUntilFinished: Long) {
sleepTimerTimeRemaining = millisUntilFinished
}
}
// start timer
sleepTimer.start()
// store timer state
PreferencesHelper.saveSleepTimerRunning(isRunning = true)
}
/* Cancels sleep timer */
private fun cancelSleepTimer() {
if (this::sleepTimer.isInitialized) {
if (manuallyCancelledSleepTimer) {
sleepTimerTimeRemaining = 0L
sleepTimer.cancel()
}
manuallyCancelledSleepTimer = false
}
// store timer state
PreferencesHelper.saveSleepTimerRunning(isRunning = false)
}
/* Function to cancel the timer manually */
fun manuallyCancelSleepTimer() {
manuallyCancelledSleepTimer = true
cancelSleepTimer()
}
/* Updates metadata */
private fun updateMetadata(metadata: String = String()) {
// get metadata string
val metadataString: String = metadata.ifEmpty {
player.currentMediaItem?.mediaMetadata?.artist.toString()
}
// remove duplicates
if (metadataHistory.contains(metadataString)) {
metadataHistory.removeAll { it == metadataString }
}
// append metadata to metadata history
metadataHistory.add(metadataString)
// trim metadata list
if (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) {
metadataHistory.removeAt(0)
}
// save history
PreferencesHelper.saveMetadataHistory(metadataHistory)
}
/* Reads collection of stations from storage using GSON */
private fun loadCollection(context: Context) {
Log.v(TAG, "Loading collection of stations from storage")
CoroutineScope(Main).launch {
// load collection on background thread
val deferred: Deferred<Collection> =
async(Dispatchers.Default) { FileHelper.readCollectionSuspended(context) }
// wait for result and update collection
collection = deferred.await()
// // special case: trigger metadata view update for stations that have no metadata
// if (player.isPlaying && station.name == getCurrentMetadata()) {
// station = CollectionHelper.getStation(collection, station.uuid)
// updateMetadata(null)
// }
}
}
/*
* Custom MediaSession Callback that handles player commands
*/
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>
): ListenableFuture<List<MediaItem>> {
val updatedMediaItems: List<MediaItem> =
mediaItems.map { mediaItem ->
CollectionHelper.getItem(this@PlayerService, collection, mediaItem.mediaId)
}
return Futures.immediateFuture(updatedMediaItems)
}
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
// add custom commands
val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller)
val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon()
builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY))
return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands)
}
override fun onSubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
params: LibraryParams?
): ListenableFuture<LibraryResult<Void>> {
val children: List<MediaItem> = CollectionHelper.getChildren(this@PlayerService, collection)
session.notifyChildrenChanged(browser, parentId, children.size, params)
return Futures.immediateFuture(LibraryResult.ofVoid())
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val children: List<MediaItem> = CollectionHelper.getChildren(this@PlayerService, collection)
return Futures.immediateFuture(LibraryResult.ofItemList(children, params))
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> {
return if (params?.extras?.containsKey(EXTRA_RECENT) == true) {
// special case: system requested media resumption via EXTRA_RECENT
playLastStation = true
Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRecent(this@PlayerService, collection), params))
} else {
Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRootItem(), params))
}
}
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
val item: MediaItem = CollectionHelper.getItem(this@PlayerService, collection, mediaId)
return Futures.immediateFuture(LibraryResult.ofItem(item, /* params = */ null))
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
Keys.CMD_START_SLEEP_TIMER -> {
val selectedTimeMillis = args.getLong(Keys.SLEEP_TIMER_DURATION)
startSleepTimer(selectedTimeMillis)
}
Keys.CMD_CANCEL_SLEEP_TIMER -> {
manuallyCancelSleepTimer()
}
Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING -> {
val resultBundle = Bundle()
resultBundle.putLong(Keys.EXTRA_SLEEP_TIMER_REMAINING, sleepTimerTimeRemaining)
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
resultBundle
)
)
}
Keys.CMD_REQUEST_METADATA_HISTORY -> {
val resultBundle = Bundle()
resultBundle.putStringArrayList(
Keys.EXTRA_METADATA_HISTORY,
ArrayList(metadataHistory)
)
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
resultBundle
)
)
}
}
return super.onCustomCommand(session, controller, customCommand, args)
}
}
/*
* NotificationProvider to customize Notification actions
*/
private inner class CustomNotificationProvider :
DefaultMediaNotificationProvider(this@PlayerService) {
override fun getMediaButtons(
session: MediaSession,
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
showPauseButton: Boolean
): ImmutableList<CommandButton> {
val seekToPreviousCommandButton = CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
.setIconResId(R.drawable.ic_notification_skip_to_previous_36dp)
.setEnabled(true)
.build()
val playCommandButton = CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setIconResId(if (player.isPlaying) R.drawable.ic_notification_stop_36dp else R.drawable.ic_notification_play_36dp)
.setEnabled(true)
.build()
val seekToNextCommandButton = CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
.setIconResId(R.drawable.ic_notification_skip_to_next_36dp)
.setEnabled(true)
.build()
val commandButtons: MutableList<CommandButton> = mutableListOf(
seekToPreviousCommandButton,
playCommandButton,
seekToNextCommandButton
)
return ImmutableList.copyOf(commandButtons)
}
}
/*
* Player.Listener: Called when one or more player states changed.
*/
private var playerListener: Player.Listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
// store state of playback
val currentMediaId: String = player.currentMediaItem?.mediaId ?: String()
PreferencesHelper.saveIsPlaying(isPlaying)
PreferencesHelper.saveCurrentStationId(currentMediaId)
// reset restart counter
playbackRestartCounter = 0
// save collection and player state
collection = CollectionHelper.savePlaybackState(
this@PlayerService,
collection,
currentMediaId,
isPlaying
)
//updatePlayerState(station, playbackState)
if (isPlaying) {
// playback is active
} else {
// cancel sleep timer
cancelSleepTimer()
// reset metadata
updateMetadata()
// playback is not active
// Not playing because playback is paused, ended, suppressed, or the player
// is buffering, stopped or failed. Check player.getPlayWhenReady,
// player.getPlaybackState, player.getPlaybackSuppressionReason and
// player.getPlaybackError for details.
when (player.playbackState) {
// player is able to immediately play from its current position
Player.STATE_READY -> {
stopSelf()
}
// buffering - data needs to be loaded
Player.STATE_BUFFERING -> {
stopSelf()
}
// player finished playing all media
Player.STATE_ENDED -> {
stopSelf()
}
// initial state or player is stopped or playback failed
Player.STATE_IDLE -> {
stopSelf()
}
}
}
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (!playWhenReady) {
when (reason) {
Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM -> {
stopSelf()
}
else -> {
stopSelf()
}
}
}
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
Log.d(TAG, "PlayerError occurred: ${error.errorCodeName}")
if (error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) {
player.prepare()
player.play()
}
}
override fun onMetadata(metadata: Metadata) {
super.onMetadata(metadata)
updateMetadata(AudioHelper.getMetadataString(metadata))
}
}
/*
* Custom LoadErrorHandlingPolicy that network drop outs
*/
private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
// try to reconnect every 5 seconds - up to 20 times
if (loadErrorInfo.errorCount <= Keys.DEFAULT_MAX_RECONNECTION_COUNT && loadErrorInfo.exception is HttpDataSource.HttpDataSourceException) {
return Keys.RECONNECTION_WAIT_INTERVAL
// } else {
// CoroutineScope(Main).launch {
// player.stop()
// }
}
return C.TIME_UNSET
}
override fun getMinimumLoadableRetryCount(dataType: Int): Int {
return Int.MAX_VALUE
}
}
/*
* Custom receiver that handles Keys.ACTION_COLLECTION_CHANGED
*/
private val collectionChangedReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.hasExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE)) {
val date = Date(intent.getLongExtra(Keys.EXTRA_COLLECTION_MODIFICATION_DATE, 0L))
if (date.after(collection.modificationDate)) {
Log.v(TAG, "PlayerService - reload collection after broadcast received.")
loadCollection(context)
}
}
}
}
/*
* Custom AnalyticsListener that enables AudioFX equalizer integration
*/
private val analyticsListener = object : AnalyticsListener {
override fun onAudioSessionIdChanged(
eventTime: AnalyticsListener.EventTime,
audioSessionId: Int
) {
super.onAudioSessionIdChanged(eventTime, audioSessionId)
// integrate with system equalizer (AudioFX)
val intent = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
intent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
sendBroadcast(intent)
}
}
}