package com.cappielloantonio.tempo.service import android.annotation.SuppressLint import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.TaskStackBuilder import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.os.Binder import android.os.Bundle import android.os.IBinder import android.os.Handler import android.os.Looper import android.util.Log import androidx.media3.common.* import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.repository.QueueRepository import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.* import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture @UnstableApi open class BaseMediaService : MediaLibraryService() { companion object { private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON" private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF" private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF" private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE" private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL" const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED" } protected lateinit var exoplayer: ExoPlayer protected lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var networkCallback: CustomNetworkCallback private lateinit var equalizerManager: EqualizerManager private val widgetUpdateHandler = Handler(Looper.getMainLooper()) private var widgetUpdateScheduled = false private val widgetUpdateRunnable = object : Runnable { override fun run() { val player = mediaLibrarySession.player if (!player.isPlaying) { widgetUpdateScheduled = false return } updateWidget(player) widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) } } private val binder = LocalBinder() open fun playerInitHook() { initializeExoPlayer() initializeMediaLibrarySession(exoplayer) initializePlayerListener(exoplayer) setPlayer(null, exoplayer) } open fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback { return CustomMediaLibrarySessionCallback(baseContext) } fun updateMediaItems(player: Player) { Log.d(javaClass.toString(), "update items") val n = player.mediaItemCount val k = player.currentMediaItemIndex val current = player.currentPosition val items = (0..n - 1).map { MappingUtil.mapMediaItem(player.getMediaItemAt(it)) } player.clearMediaItems() player.setMediaItems(items, k, current) } fun restorePlayerFromQueue(player: Player) { if (player.mediaItemCount > 0) return val queueRepository = QueueRepository() val storedQueue = queueRepository.media if (storedQueue.isNullOrEmpty()) return val mediaItems = MappingUtil.mapMediaItems(storedQueue) if (mediaItems.isEmpty()) return val lastIndex = try { queueRepository.lastPlayedMediaIndex } catch (_: Exception) { 0 }.coerceIn(0, mediaItems.size - 1) val lastPosition = try { queueRepository.lastPlayedMediaTimestamp } catch (_: Exception) { 0L }.let { if (it < 0L) 0L else it } player.setMediaItems(mediaItems, lastIndex, lastPosition) player.prepare() updateWidget(player) } fun initializePlayerListener(player: Player) { player.addListener(object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { Log.d(javaClass.toString(), "onMediaItemTransition" + player.currentMediaItemIndex) if (mediaItem == null) return if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { MediaManager.setLastPlayedTimestamp(mediaItem) } updateWidget(player) } override fun onTracksChanged(tracks: Tracks) { Log.d(javaClass.toString(), "onTracksChanged " + player.currentMediaItemIndex) ReplayGainUtil.setReplayGain(player, tracks) val currentMediaItem = player.currentMediaItem if (currentMediaItem != null) { val item = MappingUtil.mapMediaItem(currentMediaItem) if (item.mediaMetadata.extras != null) MediaManager.scrobble(item, false) if (player.nextMediaItemIndex == C.INDEX_UNSET) MediaManager.continuousPlay(player.currentMediaItem) } if (player is ExoPlayer) { // https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs if (MediaManager.justStarted.get()) { Log.d(javaClass.toString(), "update shuffle order") MediaManager.justStarted.set(false) val shuffledList = IntArray(player.mediaItemCount) { i -> i } shuffledList.shuffle() val index = shuffledList.indexOf(player.currentMediaItemIndex) // swap current media index to the first index if (index > -1 && shuffledList.isNotEmpty()) { val tmp = shuffledList[0] shuffledList[0] = shuffledList[index] shuffledList[index] = tmp } player.shuffleOrder = DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong()) } } } override fun onIsPlayingChanged(isPlaying: Boolean) { Log.d(javaClass.toString(), "onIsPlayingChanged " + player.currentMediaItemIndex) if (!isPlaying) { MediaManager.setPlayingPausedTimestamp( player.currentMediaItem, player.currentPosition ) } else { MediaManager.scrobble(player.currentMediaItem, false) } if (isPlaying) { scheduleWidgetUpdates() } else { stopWidgetUpdates() } updateWidget(player) } override fun onPlaybackStateChanged(playbackState: Int) { Log.d(javaClass.toString(), "onPlaybackStateChanged") super.onPlaybackStateChanged(playbackState) if (!player.hasNextMediaItem() && playbackState == Player.STATE_ENDED && player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC ) { MediaManager.scrobble(player.currentMediaItem, true) MediaManager.saveChronology(player.currentMediaItem) } updateWidget(player) } override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int ) { Log.d(javaClass.toString(), "onPositionDiscontinuity") super.onPositionDiscontinuity(oldPosition, newPosition, reason) if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { MediaManager.scrobble(oldPosition.mediaItem, true) MediaManager.saveChronology(oldPosition.mediaItem) } if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { MediaManager.setLastPlayedTimestamp(newPosition.mediaItem) } } } override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { Preferences.setShuffleModeEnabled(shuffleModeEnabled) } override fun onRepeatModeChanged(repeatMode: Int) { Preferences.setRepeatMode(repeatMode) } override fun onAudioSessionIdChanged(audioSessionId: Int) { Log.d(javaClass.toString(), "onAudioSessionIdChanged") attachEqualizerIfPossible(audioSessionId) } }) if (player.isPlaying) { scheduleWidgetUpdates() } } fun setPlayer(oldPlayer: Player?, newPlayer: Player) { if (oldPlayer === newPlayer) return if (oldPlayer != null) { val currentQueue = getQueueFromPlayer(oldPlayer) val currentIndex = oldPlayer.currentMediaItemIndex val currentPosition = oldPlayer.currentPosition val isPlaying = oldPlayer.playWhenReady oldPlayer.stop() newPlayer.setMediaItems(currentQueue, currentIndex, currentPosition) newPlayer.playWhenReady = isPlaying newPlayer.prepare() } mediaLibrarySession.player = newPlayer } open fun releasePlayers() { exoplayer.release() } fun getQueueFromPlayer(player: Player): List { return (0..player.mediaItemCount - 1).map(player::getMediaItemAt) } override fun onTaskRemoved(rootIntent: Intent?) { val player = mediaLibrarySession.player if (!player.playWhenReady || player.mediaItemCount == 0) { stopSelf() } } override fun onCreate() { super.onCreate() playerInitHook() initializeEqualizerManager() initializeNetworkListener() restorePlayerFromQueue(mediaLibrarySession.player) } override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession { return mediaLibrarySession } override fun onDestroy() { releaseNetworkCallback() equalizerManager.release() stopWidgetUpdates() releasePlayers() mediaLibrarySession.release() super.onDestroy() } override fun onBind(intent: Intent?): IBinder? { // Check if the intent is for our custom equalizer binder if (intent?.action == ACTION_BIND_EQUALIZER) { return binder } // Otherwise, handle it as a normal MediaLibraryService connection return super.onBind(intent) } private fun initializeExoPlayer() { exoplayer = ExoPlayer.Builder(this) .setRenderersFactory(getRenderersFactory()) .setMediaSourceFactory(getMediaSourceFactory()) .setAudioAttributes(AudioAttributes.DEFAULT, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) .setLoadControl(initializeLoadControl()) .build() exoplayer.shuffleModeEnabled = Preferences.isShuffleModeEnabled() exoplayer.repeatMode = Preferences.getRepeatMode() } private fun initializeEqualizerManager() { equalizerManager = EqualizerManager() val audioSessionId = exoplayer.audioSessionId attachEqualizerIfPossible(audioSessionId) } private fun initializeMediaLibrarySession(player: Player) { Log.d(javaClass.toString(), "initializeMediaLibrarySession") val sessionActivityPendingIntent = TaskStackBuilder.create(this).run { addNextIntent(Intent(baseContext, MainActivity::class.java)) getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) } mediaLibrarySession = MediaLibrarySession.Builder(this, player, getMediaLibrarySessionCallback()) .setSessionActivity(sessionActivityPendingIntent) .build() } private fun initializeNetworkListener() { networkCallback = CustomNetworkCallback() getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback( networkCallback ) updateMediaItems(mediaLibrarySession.player) } private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() .setBufferDurationsMs( (DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), (DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS ) .build() } private fun releaseNetworkCallback() { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) } private fun updateWidget(player: Player) { val mi = player.currentMediaItem val title = mi?.mediaMetadata?.title?.toString() ?: mi?.mediaMetadata?.extras?.getString("title") val artist = mi?.mediaMetadata?.artist?.toString() ?: mi?.mediaMetadata?.extras?.getString("artist") val album = mi?.mediaMetadata?.albumTitle?.toString() ?: mi?.mediaMetadata?.extras?.getString("album") val extras = mi?.mediaMetadata?.extras val coverId = extras?.getString("coverArtId") val songLink = extras?.getString("assetLinkSong") ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id")) val albumLink = extras?.getString("assetLinkAlbum") ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId")) val artistLink = extras?.getString("assetLinkArtist") ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId")) val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L WidgetUpdateManager.updateFromState( this, title ?: "", artist ?: "", album ?: "", coverId, player.isPlaying, player.shuffleModeEnabled, player.repeatMode, position, duration, songLink, albumLink, artistLink ) } private fun scheduleWidgetUpdates() { if (widgetUpdateScheduled) return widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) widgetUpdateScheduled = true } private fun stopWidgetUpdates() { if (!widgetUpdateScheduled) return widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) widgetUpdateScheduled = false } private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean { if (audioSessionId == 0 || audioSessionId == -1) return false val attached = equalizerManager.attachToSession(audioSessionId) if (attached) { val enabled = Preferences.isEqualizerEnabled() equalizerManager.setEnabled(enabled) val bands = equalizerManager.getNumberOfBands() val savedLevels = Preferences.getEqualizerBandLevels(bands) for (i in 0 until bands) { equalizerManager.setBandLevel(i.toShort(), savedLevels[i]) } sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED)) } return attached } private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this) @UnstableApi private class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback { private val shuffleCommands: List private val repeatCommands: List constructor(ctx: Context) { shuffleCommands = listOf( CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF ) .map { getShuffleCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) } repeatCommands = listOf( CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL ) .map { getRepeatCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) } } override fun onConnect( session: MediaSession, controller: ControllerInfo ): MediaSession.ConnectionResult { val connectionResult = super.onConnect(session, controller) val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() (shuffleCommands + repeatCommands).forEach { commandButton -> commandButton.sessionCommand?.let { availableSessionCommands.add(it) } } val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session) .setAvailableSessionCommands(availableSessionCommands.build()) .setAvailablePlayerCommands(connectionResult.availablePlayerCommands) .setMediaButtonPreferences(buildCustomLayout(session.player)) .build() return result } override fun onCustomCommand( session: MediaSession, controller: ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFuture { Log.d(javaClass.toString(), "onCustomCommand") when (customCommand.customAction) { CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> { val nextMode = when (session.player.repeatMode) { Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE else -> Player.REPEAT_MODE_OFF } session.player.repeatMode = nextMode } } session.setMediaButtonPreferences(buildCustomLayout(session.player)) return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } override fun onAddMediaItems( mediaSession: MediaSession, controller: ControllerInfo, mediaItems: List ): ListenableFuture> { Log.d(javaClass.toString(), "onAddMediaItems") val updatedMediaItems = mediaItems.map { mediaItem -> val mediaMetadata = mediaItem.mediaMetadata val newMetadata = mediaMetadata.buildUpon() .setArtist( if (mediaMetadata.artist != null) mediaMetadata.artist else mediaMetadata.extras?.getString("uri") ?: "" ) .build() mediaItem.buildUpon() .setUri(mediaItem.requestMetadata.mediaUri) .setMediaMetadata(newMetadata) .setMimeType(MimeTypes.BASE_TYPE_AUDIO) .build() } return Futures.immediateFuture(updatedMediaItems) } @SuppressLint("PrivateResource") private fun getShuffleCommandButton( sessionCommand: SessionCommand, ctx: Context ): CommandButton { val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON return CommandButton.Builder(if (isOn) CommandButton.ICON_SHUFFLE_OFF else CommandButton.ICON_SHUFFLE_ON) .setSessionCommand(sessionCommand) .setDisplayName( ctx.getString( if (isOn) R.string.exo_controls_shuffle_on_description else R.string.exo_controls_shuffle_off_description ) ) .build() } @SuppressLint("PrivateResource") private fun getRepeatCommandButton( sessionCommand: SessionCommand, ctx: Context ): CommandButton { val icon = when (sessionCommand.customAction) { CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL else -> CommandButton.ICON_REPEAT_OFF } val description = when (sessionCommand.customAction) { CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description else -> R.string.exo_controls_repeat_off_description } return CommandButton.Builder(icon) .setSessionCommand(sessionCommand) .setDisplayName(ctx.getString(description)) .build() } private fun buildCustomLayout(player: Player): ImmutableList { val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0] val repeat = when (player.repeatMode) { Player.REPEAT_MODE_ONE -> repeatCommands[1] Player.REPEAT_MODE_ALL -> repeatCommands[2] else -> repeatCommands[0] } return ImmutableList.of(shuffle, repeat) } } private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { var wasWifi = false init { val manager = getSystemService(ConnectivityManager::class.java) val network = manager.activeNetwork val capabilities = manager.getNetworkCapabilities(network) if (capabilities != null) wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) if (isWifi != wasWifi) { wasWifi = isWifi widgetUpdateHandler.post { updateMediaItems(mediaLibrarySession.player) } } } } inner class LocalBinder : Binder() { fun getEqualizerManager(): EqualizerManager { return equalizerManager } } } private const val WIDGET_UPDATE_INTERVAL_MS = 1000L