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 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 = 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 ): ListenableFuture> { val updatedMediaItems: List = 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> { val children: List = 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>> { val children: List = CollectionHelper.getChildren(this@PlayerService, collection) return Futures.immediateFuture(LibraryResult.ofItemList(children, params)) } override fun onGetLibraryRoot( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, params: LibraryParams? ): ListenableFuture> { 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> { 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 { 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, showPauseButton: Boolean ): ImmutableList { 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 = 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) } } }