Files
Radio/app/src/main/java/com/michatec/radio/PlayerService.kt
T

647 lines
26 KiB
Kotlin

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.cast.CastPlayer
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
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.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main
import java.util.*
/*
* PlayerService class
*/
@UnstableApi
class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenceChangeListener {
/* Define log tag */
private val TAG: String = PlayerService::class.java.simpleName
/* Main class variables */
private lateinit var player: Player
private lateinit var exoPlayer: ExoPlayer
private lateinit var castPlayer: CastPlayer
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
// Native Audio Processor instance
private val nativeAudioProcessor = NativeAudioProcessor()
/* 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()
// register preference change listener
PreferencesHelper.registerPreferenceChangeListener(this)
// apply initial audio effects
applyAudioEffects()
}
/* Overrides onDestroy from Service */
override fun onDestroy() {
// Reset playing state in preferences
PreferencesHelper.saveIsPlaying(false)
player.removeListener(playerListener)
player.release()
exoPlayer.release()
castPlayer.release()
mediaLibrarySession.release()
// unregister preference change listener
PreferencesHelper.unregisterPreferenceChangeListener(this)
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 audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
// Create a RenderersFactory that injects the NativeAudioProcessor
val renderersFactory = object : DefaultRenderersFactory(this) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean
): AudioSink? {
return DefaultAudioSink.Builder(context)
.setAudioProcessors(arrayOf(nativeAudioProcessor))
.build()
}
}
exoPlayer = ExoPlayer.Builder(this, renderersFactory).apply {
setAudioAttributes(audioAttributes, true)
setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
setMediaSourceFactory(
DefaultMediaSourceFactory(this@PlayerService).setLoadErrorHandlingPolicy(
loadErrorHandlingPolicy
)
)
}.build()
exoPlayer.addAnalyticsListener(analyticsListener)
exoPlayer.addListener(playerListener)
// Initialize CastPlayer
castPlayer = CastPlayer.Builder(this).setLocalPlayer(exoPlayer).build()
// manually add seek to next and seek to previous since headphones issue them, and they are translated to next and previous station
// IMPORTANT: Use castPlayer here instead of exoPlayer so the session controls both local and remote playback
player = object : ForwardingPlayer(castPlayer) {
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
}
}
player.addListener(playerListener)
}
/* 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) {
sleepTimer.cancel()
}
sleepTimerTimeRemaining = 0L
// 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()
}
}
/* Applies audio effects based on preferences */
private fun applyAudioEffects() {
val selectedPreset = PreferencesHelper.loadSelectedPreset()
if (selectedPreset.isNotEmpty()) {
applyPreset(selectedPreset)
} else {
// Apply manual settings
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadBassBoost())
nativeAudioProcessor.setReverb(PreferencesHelper.loadReverb())
nativeAudioProcessor.enableDrc(PreferencesHelper.loadDrcEnabled())
nativeAudioProcessor.setWidth(1f)
// Apply all 10 EQ bands
val eqGains = FloatArray(10)
for (i in 0 until 10) {
eqGains[i] = PreferencesHelper.loadEqBand(i).toFloat()
}
nativeAudioProcessor.setEqAll(eqGains)
}
}
/* Applies a saved preset */
private fun applyPreset(presetName: String) {
when (presetName) {
getString(R.string.pref_preset_rock) -> nativeAudioProcessor.setPresetRock()
getString(R.string.pref_preset_pop) -> nativeAudioProcessor.setPresetPop()
getString(R.string.pref_preset_jazz) -> nativeAudioProcessor.setPresetJazz()
getString(R.string.pref_preset_flat) -> nativeAudioProcessor.setPresetFlat()
else -> {
// Custom preset - load from preferences
nativeAudioProcessor.enableDrc(PreferencesHelper.loadPresetDrc())
nativeAudioProcessor.setReverb(PreferencesHelper.loadPresetReverb())
nativeAudioProcessor.setWidth(PreferencesHelper.loadPresetStereoWidth())
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadPresetBassBoost())
val eqGains = FloatArray(10)
for (i in 0 until 10) {
eqGains[i] = PreferencesHelper.loadPresetEqBand(i).toFloat()
}
nativeAudioProcessor.setEqAll(eqGains)
}
}
}
/* Overrides onSharedPreferenceChanged from SharedPreferences.OnSharedPreferenceChangeListener */
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
Keys.PREF_BASS_BOOST, Keys.PREF_REVERB, Keys.PREF_DRC,
Keys.PREF_EQ_LOW, Keys.PREF_EQ_MID, Keys.PREF_EQ_HIGH,
Keys.PREF_EQ_BAND_1, Keys.PREF_EQ_BAND_2, Keys.PREF_EQ_BAND_3,
Keys.PREF_EQ_BAND_4, Keys.PREF_EQ_BAND_5, Keys.PREF_EQ_BAND_6,
Keys.PREF_EQ_BAND_7,
Keys.PREF_PRESET_SELECTED,
Keys.PREF_PRESET_EQ_BAND_0, Keys.PREF_PRESET_EQ_BAND_1, Keys.PREF_PRESET_EQ_BAND_2,
Keys.PREF_PRESET_EQ_BAND_3, Keys.PREF_PRESET_EQ_BAND_4, Keys.PREF_PRESET_EQ_BAND_5,
Keys.PREF_PRESET_EQ_BAND_6, Keys.PREF_PRESET_EQ_BAND_7, Keys.PREF_PRESET_EQ_BAND_8,
Keys.PREF_PRESET_EQ_BAND_9,
Keys.PREF_PRESET_BASS_BOOST, Keys.PREF_PRESET_REVERB,
Keys.PREF_PRESET_DRC, Keys.PREF_PRESET_STEREO_WIDTH -> {
applyAudioEffects()
}
}
}
/*
* 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 {
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))
builder.add(SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, 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
)
)
}
Keys.CMD_GET_VISUALIZER_DATA -> {
val resultBundle = Bundle()
resultBundle.putFloatArray(
Keys.EXTRA_VISUALIZER_DATA,
nativeAudioProcessor.getVisualizer()
)
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(CommandButton.ICON_UNDEFINED)
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
.setCustomIconResId(R.drawable.ic_notification_skip_to_previous_36dp)
.setEnabled(true)
.build()
val playCommandButton = CommandButton.Builder(CommandButton.ICON_UNDEFINED)
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setCustomIconResId(if (player.isPlaying) R.drawable.ic_notification_stop_36dp else R.drawable.ic_notification_play_36dp)
.setEnabled(true)
.build()
val seekToNextCommandButton = CommandButton.Builder(CommandButton.ICON_UNDEFINED)
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
.setCustomIconResId(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
)
if (!isPlaying) {
// cancel sleep timer
cancelSleepTimer()
// reset metadata
updateMetadata()
// Check playback state to decide whether to stop the service
when (player.playbackState) {
Player.STATE_ENDED, Player.STATE_IDLE -> {
stopSelf()
}
Player.STATE_READY -> {
// Playback is paused. For radio, we can stop the service to remove the notification.
stopSelf()
}
Player.STATE_BUFFERING -> {
// DO NOT stop the service while buffering (especially important for Cast)
}
}
}
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (!playWhenReady) {
// Only stop if not buffering and not ready to play (i.e. truly stopped/paused)
if (player.playbackState != Player.STATE_BUFFERING) {
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
}
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)
}
}
}