mirror of
https://github.com/Michatec/Radio.git
synced 2026-05-31 04:12:40 +02:00
647 lines
26 KiB
Kotlin
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_EQ_BAND_8,
|
|
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)
|
|
}
|
|
}
|
|
}
|