Merge branch 'eddyizm:development' into development

This commit is contained in:
skajmer 2025-11-30 19:52:39 +01:00 committed by GitHub
commit ea76afee09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1067 additions and 1140 deletions

View file

@ -1,579 +1,6 @@
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.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.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
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
class MediaService : MediaLibraryService() {
private val TAG = "MediaService"
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var shuffleCommands: List<CommandButton>
private lateinit var repeatCommands: List<CommandButton>
private lateinit var networkCallback: CustomNetworkCallback
lateinit var equalizerManager: EqualizerManager
private var customLayout = ImmutableList.of<CommandButton>()
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
private var widgetUpdateScheduled = false
private val widgetUpdateRunnable = object : Runnable {
override fun run() {
if (!player.isPlaying) {
widgetUpdateScheduled = false
return
}
updateWidget()
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
}
}
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
return this@MediaService.equalizerManager
}
}
private val binder = LocalBinder()
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"
}
fun updateMediaItems() {
Log.d(TAG, "update items");
val n = player.mediaItemCount
val k = player.currentMediaItemIndex
val current = player.currentPosition
val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))}
player.clearMediaItems()
player.setMediaItems(items, k, current)
}
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(Runnable {
updateMediaItems()
})
}
}
}
override fun onCreate() {
super.onCreate()
initializeCustomCommands()
initializePlayer()
initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener()
initializeEqualizerManager()
initializeNetworkListener()
setPlayer(player)
}
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
override fun onDestroy() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
releasePlayer()
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 inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
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) }
}
customLayout = buildCustomLayout(session.player)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(availableSessionCommands.build())
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
.setCustomLayout(customLayout)
.build()
}
override fun onPostConnect(session: MediaSession, controller: ControllerInfo) {
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
ignoreFuture(mediaLibrarySession.setCustomLayout(controller, customLayout))
}
}
fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
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)
}
override fun onCustomCommand(
session: MediaSession,
controller: ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> player.shuffleModeEnabled = true
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> player.shuffleModeEnabled = false
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
val nextMode = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
player.repeatMode = nextMode
}
}
customLayout = librarySessionCallback.buildCustomLayout(player)
session.setCustomLayout(customLayout)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
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)
}
}
private fun initializeCustomCommands() {
shuffleCommands = listOf(
getShuffleCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY)
),
getShuffleCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY)
)
)
repeatCommands = listOf(
getRepeatCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY)
),
getRepeatCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY)
),
getRepeatCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY)
)
)
customLayout = ImmutableList.of(shuffleCommands[0], repeatCommands[0])
}
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(getMediaSourceFactory())
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setLoadControl(initializeLoadControl())
.build()
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
player.repeatMode = Preferences.getRepeatMode()
}
private fun initializeEqualizerManager() {
equalizerManager = EqualizerManager()
val audioSessionId = player.audioSessionId
attachEqualizerIfPossible(audioSessionId)
}
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
mediaLibrarySession =
MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivityPendingIntent)
.build()
if (!customLayout.isEmpty()) {
mediaLibrarySession.setCustomLayout(customLayout)
}
}
private fun initializeNetworkListener() {
networkCallback = CustomNetworkCallback()
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback)
updateMediaItems()
}
private fun restorePlayerFromQueue() {
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()
}
private fun initializePlayerListener() {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (mediaItem == null) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
Log.d(TAG, "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)
}
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
if (MediaManager.justStarted.get()) {
Log.d(TAG, "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())
run { val tmp = shuffledList[0]; shuffledList[0] = shuffledList[index]; shuffledList[index] = tmp}
player.shuffleOrder = DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
if (isPlaying) {
scheduleWidgetUpdates()
} else {
stopWidgetUpdates()
}
updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
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()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
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)
customLayout = librarySessionCallback.buildCustomLayout(player)
mediaLibrarySession.setCustomLayout(customLayout)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
customLayout = librarySessionCallback.buildCustomLayout(player)
mediaLibrarySession.setCustomLayout(customLayout)
}
override fun onAudioSessionIdChanged(audioSessionId: Int) {
attachEqualizerIfPossible(audioSessionId)
}
})
if (player.isPlaying) {
scheduleWidgetUpdates()
}
}
private fun setPlayer(player: Player) {
mediaLibrarySession.player = player
}
private fun releasePlayer() {
player.release()
mediaLibrarySession.release()
}
private fun releaseNetworkCallback() {
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
}
@SuppressLint("PrivateResource")
private fun getShuffleCommandButton(sessionCommand: SessionCommand): CommandButton {
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
return CommandButton.Builder()
.setDisplayName(
getString(
if (isOn) R.string.exo_controls_shuffle_on_description
else R.string.exo_controls_shuffle_off_description
)
)
.setSessionCommand(sessionCommand)
.setIconResId(if (isOn) R.drawable.exo_icon_shuffle_off else R.drawable.exo_icon_shuffle_on)
.build()
}
@SuppressLint("PrivateResource")
private fun getRepeatCommandButton(sessionCommand: SessionCommand): CommandButton {
val icon = when (sessionCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.drawable.exo_icon_repeat_one
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.drawable.exo_icon_repeat_all
else -> R.drawable.exo_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()
.setDisplayName(getString(description))
.setSessionCommand(sessionCommand)
.setIconResId(icon)
.build()
}
private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture<SessionResult>) {
/* Do nothing. */
}
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 updateWidget() {
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)
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
class MediaService : BaseMediaService()

View file

@ -1,8 +1,11 @@
package com.cappielloantonio.tempo.repository;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.database.AppDatabase;
@ -52,6 +55,8 @@ public class QueueRepository {
public MutableLiveData<PlayQueue> getPlayQueue() {
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
Log.d(TAG, "Getting play queue from server...");
App.getSubsonicClientInstance(false)
.getBookmarksClient()
.getPlayQueue()
@ -59,12 +64,19 @@ public class QueueRepository {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue());
PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue();
Log.d(TAG, "Server returned play queue with " +
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
playQueue.setValue(serverQueue);
} else {
Log.d(TAG, "Server returned no play queue");
playQueue.setValue(null);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Failed to get play queue", t);
playQueue.setValue(null);
}
});
@ -73,18 +85,24 @@ public class QueueRepository {
}
public void savePlayQueue(List<String> ids, String current, long position) {
Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current);
App.getSubsonicClientInstance(false)
.getBookmarksClient()
.savePlayQueue(ids, current, position)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Play queue saved successfully");
} else {
Log.d(TAG, "Play queue save failed with code: " + response.code());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Play queue save failed", t);
}
});
}
@ -123,10 +141,9 @@ public class QueueRepository {
private boolean isMediaInQueue(List<Queue> queue, Child media) {
if (queue == null || media == null) return false;
return queue.stream().anyMatch(queueItem ->
queueItem != null && media.getId() != null &&
queueItem.getId().equals(media.getId())
queueItem != null && media.getId() != null &&
queueItem.getId().equals(media.getId())
);
}
@ -146,8 +163,8 @@ public class QueueRepository {
List<Child> filteredToAdd = toAdd;
final List<Queue> finalMedia = media;
filteredToAdd = toAdd.stream()
.filter(child -> !isMediaInQueue(finalMedia, child))
.collect(Collectors.toList());
.filter(child -> !isMediaInQueue(finalMedia, child))
.collect(Collectors.toList());
for (int i = 0; i < filteredToAdd.size(); i++) {
Queue queueItem = new Queue(filteredToAdd.get(i));

View file

@ -0,0 +1,590 @@
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<MediaItem> {
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<CommandButton>
private val repeatCommands: List<CommandButton>
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<SessionResult> {
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<MediaItem>
): ListenableFuture<List<MediaItem>> {
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<CommandButton> {
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

View file

@ -1,14 +1,13 @@
package com.cappielloantonio.tempo.service;
import android.content.ComponentName;
import android.util.Log;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.MediaItem;
@ -188,27 +187,28 @@ public class MediaManager {
try {
if (mediaBrowserListenableFuture.isDone()) {
final MediaBrowser browser = mediaBrowserListenableFuture.get();
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
new Handler(Looper.getMainLooper()).post(() -> {
justStarted.set(true);
browser.setMediaItems(items, startIndex, 0);
browser.prepare();
Player.Listener timelineListener = new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0);
browser.play();
browser.removeListener(this);
}
}
};
browser.addListener(timelineListener);
});
backgroundExecutor.execute(() -> {
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
enqueueDatabase(media, true, 0);
new Handler(Looper.getMainLooper()).post(() -> {
justStarted.set(true);
browser.setMediaItems(items, startIndex, 0);
browser.prepare();
Player.Listener timelineListener = new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0);
browser.play();
browser.removeListener(this);
}
}
};
browser.addListener(timelineListener);
});
});
}
} catch (ExecutionException | InterruptedException e) {

View file

@ -18,8 +18,10 @@ import com.cappielloantonio.tempo.databinding.ItemPlayerQueueSongBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.interfaces.MediaIndexCallback;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
@ -94,6 +96,20 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
}
});
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(holder.itemView.getContext());
if (downloaderManager != null) {
boolean isDownloaded = downloaderManager.isDownloaded(song.getId());
if (isDownloaded) {
holder.item.downloadIndicatorIcon.setVisibility(View.VISIBLE);
} else {
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
}
} else {
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
}
if (Preferences.showItemRating()) {
if (song.getStarred() == null && song.getUserRating() == null) {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);

View file

@ -19,6 +19,7 @@ import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.service.EqualizerManager
import com.cappielloantonio.tempo.service.BaseMediaService
import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.util.Preferences
@ -35,7 +36,7 @@ class EqualizerFragment : Fragment() {
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == MediaService.ACTION_EQUALIZER_UPDATED) {
if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) {
initUI()
restoreEqualizerPreferences()
}
@ -45,7 +46,7 @@ class EqualizerFragment : Fragment() {
private val connection = object : ServiceConnection {
@OptIn(UnstableApi::class)
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as MediaService.LocalBinder
val binder = service as BaseMediaService.LocalBinder
equalizerManager = binder.getEqualizerManager()
initUI()
restoreEqualizerPreferences()
@ -60,14 +61,14 @@ class EqualizerFragment : Fragment() {
override fun onStart() {
super.onStart()
Intent(requireContext(), MediaService::class.java).also { intent ->
intent.action = MediaService.ACTION_BIND_EQUALIZER
intent.action = BaseMediaService.ACTION_BIND_EQUALIZER
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
if (!receiverRegistered) {
ContextCompat.registerReceiver(
requireContext(),
equalizerUpdatedReceiver,
IntentFilter(MediaService.ACTION_EQUALIZER_UPDATED),
IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED
)
receiverRegistered = true

View file

@ -2,16 +2,20 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.common.MediaItem;
import androidx.media3.session.SessionToken;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
@ -19,11 +23,17 @@ import androidx.recyclerview.widget.RecyclerView;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.PlayQueue;
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@ -31,6 +41,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@UnstableApi
@ -39,6 +50,18 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
private InnerFragmentPlayerQueueBinding bind;
private com.google.android.material.floatingactionbutton.FloatingActionButton fabMenuToggle;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabClearQueue;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabShuffleQueue;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveToPlaylist;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabDownloadAll;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabLoadQueue;
private boolean isMenuOpen = false;
private final int ANIMATION_DURATION = 250;
private final float FAB_VERTICAL_SPACING_DP = 70f;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private PlaybackViewModel playbackViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@ -53,6 +76,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
fabMenuToggle = bind.fabMenuToggle;
fabClearQueue = bind.fabClearQueue;
fabShuffleQueue = bind.fabShuffleQueue;
fabSaveToPlaylist = bind.fabSaveToPlaylist;
fabDownloadAll = bind.fabDownloadAll;
fabLoadQueue = bind.fabLoadQueue;
fabMenuToggle.setOnClickListener(v -> toggleFabMenu());
fabClearQueue.setOnClickListener(v -> handleClearQueueClick());
fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick());
fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick());
fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick());
fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick());
// Hide Load Queue FAB if sync is disabled
if (!Preferences.isSyncronizationEnabled()) {
fabLoadQueue.setVisibility(View.GONE);
}
initQueueRecyclerView();
return view;
@ -62,8 +106,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeBrowser();
bindMediaController();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@ -105,18 +147,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
}
private void bindMediaController() {
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
initShuffleButton(mediaBrowser);
initCleanButton(mediaBrowser);
} catch (Exception exception) {
exception.printStackTrace();
}
}, MoreExecutors.directExecutor());
}
private void setMediaBrowserListenableFuture() {
playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
@ -149,18 +179,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
fromPosition = viewHolder.getBindingAdapterPosition();
toPosition = target.getBindingAdapterPosition();
/*
* Per spostare un elemento nella coda devo:
* - Spostare graficamente la traccia da una posizione all'altra con Collections.swap()
* - Spostare nel db la traccia, tramite QueueRepository
* - Notificare il Service dell'avvenuto spostamento con MusicPlayerRemote.moveSong()
*
* In onMove prendo la posizione di inizio e fine, ma solo al rilascio dell'elemento procedo allo spostamento
* In questo modo evito che ad ogni cambio di posizione vada a riscrivere nel db
* Al rilascio dell'elemento chiamo il metodo clearView()
*/
Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition);
recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition);
@ -188,46 +206,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
}).attachToRecyclerView(bind.playerQueueRecyclerView);
}
private void initShuffleButton(MediaBrowser mediaBrowser) {
bind.playerShuffleQueueFab.setOnClickListener(view -> {
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
if (startPosition < endPosition) {
ArrayList<Integer> pool = new ArrayList<>();
for (int i = startPosition; i <= endPosition; i++) {
pool.add(i);
}
while (pool.size() >= 2) {
int fromPosition = (int) (Math.random() * (pool.size()));
int positionA = pool.get(fromPosition);
pool.remove(fromPosition);
int toPosition = (int) (Math.random() * (pool.size()));
int positionB = pool.get(toPosition);
pool.remove(toPosition);
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
}
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
}
});
}
private void initCleanButton(MediaBrowser mediaBrowser) {
bind.playerCleanQueueButton.setOnClickListener(view -> {
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size();
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition);
});
}
private void updateNowPlayingItem() {
playerSongQueueAdapter.notifyDataSetChanged();
}
@ -259,4 +237,216 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
}
/**
* Toggles the visibility and animates all six secondary FABs.
*/
private void toggleFabMenu() {
if (isMenuOpen) {
// CLOSE MENU (Reverse order for visual effect)
if (Preferences.isSyncronizationEnabled()) {
closeFab(fabLoadQueue, 4);
}
closeFab(fabSaveToPlaylist, 3);
closeFab(fabClearQueue, 2);
closeFab(fabDownloadAll, 1);
closeFab(fabShuffleQueue, 0);
fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start();
} else {
// OPEN MENU (lowest index at bottom)
openFab(fabShuffleQueue, 0);
openFab(fabDownloadAll, 1);
openFab(fabClearQueue, 2);
openFab(fabSaveToPlaylist, 3);
if (Preferences.isSyncronizationEnabled()) {
openFab(fabLoadQueue, 4);
}
fabMenuToggle.animate().rotation(45f).setDuration(ANIMATION_DURATION).start();
}
isMenuOpen = !isMenuOpen;
}
private void openFab(View fab, int index) {
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
fab.setVisibility(View.VISIBLE);
fab.setAlpha(0f);
fab.setTranslationY(displacement); // Start at the hidden (closed) position
fab.animate()
.translationY(0f)
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.start();
}
private void closeFab(View fab, int index) {
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
fab.animate()
.translationY(displacement)
.alpha(0f)
.setDuration(ANIMATION_DURATION)
.withEndAction(() -> fab.setVisibility(View.GONE))
.start();
}
private void handleShuffleQueueClick() {
Log.d(TAG, "Shuffle Queue Clicked!");
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
if (startPosition < endPosition) {
ArrayList<Integer> pool = new ArrayList<>();
for (int i = startPosition; i <= endPosition; i++) {
pool.add(i);
}
while (pool.size() >= 2) {
int fromPosition = (int) (Math.random() * (pool.size()));
int positionA = pool.get(fromPosition);
pool.remove(fromPosition);
int toPosition = (int) (Math.random() * (pool.size()));
int positionB = pool.get(toPosition);
pool.remove(toPosition);
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
}
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
}
} catch (Exception e) {
Log.e(TAG, "Error shuffling queue", e);
}
toggleFabMenu();
}, MoreExecutors.directExecutor());
}
private void handleClearQueueClick() {
Log.d(TAG, "Clear Queue Clicked!");
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size();
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition - startPosition);
} catch (Exception e) {
Log.e(TAG, "Error clearing queue", e);
}
toggleFabMenu();
}, MoreExecutors.directExecutor());
}
private void handleSaveToPlaylistClick() {
Log.d(TAG, "Save to Playlist Clicked!");
List<Child> queueSongs = playerSongQueueAdapter.getItems();
if (queueSongs == null || queueSongs.isEmpty()) {
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
toggleFabMenu();
return;
}
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(queueSongs));
PlaylistChooserDialog dialog = new PlaylistChooserDialog();
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
toggleFabMenu();
}
private void handleDownloadAllClick() {
Log.d(TAG, "Download All Clicked!");
List<Child> queueSongs = playerSongQueueAdapter.getItems();
if (queueSongs == null || queueSongs.isEmpty()) {
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
toggleFabMenu();
return;
}
List<MediaItem> mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs);
List<com.cappielloantonio.tempo.model.Download> downloadModels = new ArrayList<>();
for (Child child : queueSongs) {
com.cappielloantonio.tempo.model.Download downloadModel =
new com.cappielloantonio.tempo.model.Download(child);
downloadModel.setArtist(child.getArtist());
downloadModel.setAlbum(child.getAlbum());
downloadModel.setCoverArtId(child.getCoverArtId());
downloadModels.add(downloadModel);
}
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(requireContext());
if (downloaderManager != null) {
downloaderManager.download(mediaItemsToDownload, downloadModels);
Toast.makeText(requireContext(), "Starting download of " + queueSongs.size() + " songs in the background.", Toast.LENGTH_SHORT).show();
} else {
Log.e(TAG, "DownloaderManager not initialized. Check DownloadUtil.");
Toast.makeText(requireContext(), "Download service unavailable.", Toast.LENGTH_SHORT).show();
}
toggleFabMenu();
}
private void handleLoadQueueClick() {
Log.d(TAG, "Load Queue Clicked!");
if (!Preferences.isSyncronizationEnabled()) {
toggleFabMenu();
return;
}
PlayerBottomSheetViewModel playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playerBottomSheetViewModel.getPlayQueue().observe(getViewLifecycleOwner(), new Observer<PlayQueue>() {
@Override
public void onChanged(PlayQueue playQueue) {
playerBottomSheetViewModel.getPlayQueue().removeObserver(this);
if (playQueue != null && playQueue.getEntries() != null && !playQueue.getEntries().isEmpty()) {
int currentIndex = 0;
for (int i = 0; i < playQueue.getEntries().size(); i++) {
if (playQueue.getEntries().get(i).getId().equals(playQueue.getCurrent())) {
currentIndex = i;
break;
}
}
MediaManager.startQueue(mediaBrowserListenableFuture, playQueue.getEntries(), currentIndex);
Toast.makeText(requireContext(), "Queue loaded", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "No saved queue found", Toast.LENGTH_SHORT).show();
}
toggleFabMenu();
}
});
new Handler().postDelayed(() -> {
if (isMenuOpen) {
toggleFabMenu();
}
}, 1000);
}
}

View file

@ -1,11 +1,12 @@
package com.cappielloantonio.tempo.util;
import androidx.annotation.OptIn;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Metadata;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.common.Player;
import com.cappielloantonio.tempo.model.ReplayGain;
@ -17,7 +18,7 @@ import java.util.Objects;
public class ReplayGainUtil {
private static final String[] tags = {"REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN", "R128_TRACK_GAIN", "R128_ALBUM_GAIN"};
public static void setReplayGain(ExoPlayer player, Tracks tracks) {
public static void setReplayGain(Player player, Tracks tracks) {
List<Metadata> metadata = getMetadata(tracks);
List<ReplayGain> gains = getReplayGains(metadata);
@ -62,7 +63,7 @@ public class ReplayGainUtil {
}
}
if (gains.size() == 0) gains.add(0, new ReplayGain());
if (gains.isEmpty()) gains.add(0, new ReplayGain());
if (gains.size() == 1) gains.add(1, new ReplayGain());
return gains;
@ -108,7 +109,7 @@ public class ReplayGainUtil {
}
}
private static void applyReplayGain(ExoPlayer player, List<ReplayGain> gains) {
private static void applyReplayGain(Player player, List<ReplayGain> gains) {
if (Objects.equals(Preferences.getReplayGainMode(), "disabled") || gains == null || gains.isEmpty()) {
setNoReplayGain(player);
return;
@ -137,33 +138,33 @@ public class ReplayGainUtil {
setNoReplayGain(player);
}
private static void setNoReplayGain(ExoPlayer player) {
private static void setNoReplayGain(Player player) {
setReplayGain(player, 0f);
}
private static void setTrackReplayGain(ExoPlayer player, List<ReplayGain> gains) {
private static void setTrackReplayGain(Player player, List<ReplayGain> gains) {
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
setReplayGain(player, trackGain != 0f ? trackGain : 0f);
}
private static void setAlbumReplayGain(ExoPlayer player, List<ReplayGain> gains) {
private static void setAlbumReplayGain(Player player, List<ReplayGain> gains) {
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
setReplayGain(player, albumGain != 0f ? albumGain : 0f);
}
private static void setAutoReplayGain(ExoPlayer player, List<ReplayGain> gains) {
private static void setAutoReplayGain(Player player, List<ReplayGain> gains) {
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
setReplayGain(player, albumGain != 0f ? albumGain : trackGain);
}
private static boolean areTracksConsecutive(ExoPlayer player) {
private static boolean areTracksConsecutive(Player player) {
MediaItem currentMediaItem = player.getCurrentMediaItem();
int currentMediaItemIndex = player.getCurrentMediaItemIndex();
MediaItem pastMediaItem = currentMediaItemIndex > 0 ? player.getMediaItemAt(currentMediaItemIndex - 1) : null;
int prevMediaItemIndex = player.getPreviousMediaItemIndex();
MediaItem pastMediaItem = prevMediaItemIndex == C.INDEX_UNSET ? null : player.getMediaItemAt(prevMediaItemIndex);
return currentMediaItem != null &&
pastMediaItem != null &&
@ -172,7 +173,7 @@ public class ReplayGainUtil {
pastMediaItem.mediaMetadata.albumTitle.toString().equals(currentMediaItem.mediaMetadata.albumTitle.toString());
}
private static void setReplayGain(ExoPlayer player, float gain) {
private static void setReplayGain(Player player, float gain) {
player.setVolume((float) Math.pow(10f, gain / 20f));
}
}

View file

@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.util.Constants;
@ -21,7 +20,6 @@ import java.util.List;
public class AlbumListPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository;
private final DownloadRepository downloadRepository;
public String title;
public ArtistID3 artist;
@ -32,9 +30,7 @@ public class AlbumListPageViewModel extends AndroidViewModel {
public AlbumListPageViewModel(@NonNull Application application) {
super(application);
albumRepository = new AlbumRepository();
downloadRepository = new DownloadRepository();
}
public LiveData<List<AlbumID3>> getAlbumList(LifecycleOwner owner) {

View file

@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@ -12,6 +13,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download;
@ -291,13 +293,13 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
List<String> ids = queue.stream().map(Child::getId).collect(Collectors.toList());
if (media != null) {
queueRepository.savePlayQueue(ids, media.getId(), 0);
// TODO: We need to get the actual playback position here
Log.d(TAG, "Saving play queue - Current: " + media.getId() + ", Items: " + ids.size());
queueRepository.savePlayQueue(ids, media.getId(), 0); // Still hardcoded to 0 for now
return true;
}
return false;
}
private void observeCachedLyrics(LifecycleOwner owner, String songId) {
if (TextUtils.isEmpty(songId)) {
return;

View file

@ -1,18 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/player_clean_queue_button"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/player_queue_clean_all_button" />
<com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -21,20 +14,74 @@
android:id="@+id/player_queue_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="40dp"
android:paddingTop="8dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/player_shuffle_queue_fab"
<LinearLayout
android:id="@+id/fab_menu_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/content_description_shuffle_button"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:srcCompat="@drawable/ic_shuffle" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior">
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_save_to_playlist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_save_to_playlist"
app:icon="@android:drawable/ic_menu_edit" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_clear_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_clean_all_button"
app:icon="@android:drawable/ic_menu_delete" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_download_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/menu_download_all_button"
app:icon="@android:drawable/stat_sys_download_done" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_load_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_load_queue"
app:icon="@android:drawable/ic_menu_revert" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_shuffle_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/content_description_shuffle_button"
app:icon="@drawable/ic_shuffle" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_menu_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Toggle FAB Action menu"
tools:ignore="HardcodedText"
app:srcCompat="@drawable/ic_add" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -139,6 +139,17 @@
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/download_indicator_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:visibility="gone"
android:src="@drawable/ic_download" app:layout_constraintBottom_toBottomOf="@+id/queue_song_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/queue_song_holder_image"
app:layout_constraintTop_toTopOf="@+id/queue_song_cover_image_view"
tools:visibility="visible" />
<ImageView
android:id="@+id/queue_song_holder_image"
android:layout_width="wrap_content"

View file

@ -32,6 +32,8 @@
<item name="android:statusBarColor">?attr/colorSurface</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<item name="android:scrollbars">none</item>
<item name="floatingActionButtonStyle">@style/FloatingActionButtonStyle</item>
</style>
<style name="Divider">
@ -40,6 +42,21 @@
<item name="android:background">@color/dividerColor</item>
</style>
<style name="FloatingActionButtonStyle" parent="Widget.MaterialComponents.FloatingActionButton">
<item name="backgroundTint">?attr/colorSecondary</item>
<item name="tint">?attr/colorOnPrimary</item>
<item name="shapeAppearanceOverlay">@style/FabShapeStyle</item>
</style>
<style name="FabShapeStyle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerSize">50%</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerFamilyTopLeft">rounded</item>
<item name="cornerFamilyTopRight">rounded</item>
<item name="cornerFamilyBottomLeft">rounded</item>
<item name="cornerFamilyBottomRight">rounded</item>
</style>
<style name="NoConnectionTextView">
<item name="background">?attr/colorErrorContainer</item>
<item name="android:textColor">?attr/colorOnErrorContainer</item>

View file

@ -212,6 +212,8 @@
<string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">Clean play queue</string>
<string name="player_queue_save_queue_success">Saved play queue</string>
<string name="player_queue_save_to_playlist">Save Queue to Playlist</string>
<string name="player_queue_load_queue">Load Queue</string>
<string name="player_lyrics_download_content_description">Download lyrics for offline playback</string>
<string name="player_lyrics_downloaded_content_description">Lyrics downloaded for offline playback</string>
<string name="player_lyrics_download_success">Lyrics saved for offline playback.</string>

View file

@ -39,6 +39,8 @@
<item name="android:statusBarColor">?attr/colorSurface</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<item name="android:scrollbars">none</item>
<item name="floatingActionButtonStyle">@style/FloatingActionButtonStyle</item>
</style>
<style name="Divider">
@ -47,6 +49,21 @@
<item name="android:background">@color/dividerColor</item>
</style>
<style name="FloatingActionButtonStyle" parent="Widget.MaterialComponents.FloatingActionButton">
<item name="backgroundTint">?attr/colorSecondary</item>
<item name="tint">?attr/colorOnPrimary</item>
<item name="shapeAppearanceOverlay">@style/FabShapeStyle</item>
</style>
<style name="FabShapeStyle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerSize">50%</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerFamilyTopLeft">rounded</item>
<item name="cornerFamilyTopRight">rounded</item>
<item name="cornerFamilyBottomLeft">rounded</item>
<item name="cornerFamilyBottomRight">rounded</item>
</style>
<style name="NoConnectionTextView">
<item name="background">?attr/colorErrorContainer</item>
<item name="android:textColor">?attr/colorOnErrorContainer</item>

View file

@ -1,182 +1,18 @@
package com.cappielloantonio.tempo.service
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.TaskStackBuilder
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Binder
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
@UnstableApi
class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private lateinit var automotiveRepository: AutomotiveRepository
private lateinit var player: ExoPlayer
class MediaService : BaseMediaService(), SessionAvailabilityListener {
private val automotiveRepository = AutomotiveRepository()
private lateinit var castPlayer: CastPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
private lateinit var networkCallback: CustomNetworkCallback
lateinit var equalizerManager: EqualizerManager
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
return this@MediaService.equalizerManager
}
}
private val binder = LocalBinder()
companion object {
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
}
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
private var widgetUpdateScheduled = false
private val widgetUpdateRunnable = object : Runnable {
override fun run() {
if (!player.isPlaying) {
widgetUpdateScheduled = false
return
}
updateWidget()
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
}
}
fun updateMediaItems() {
Log.d("MediaService", "update items");
val n = player.mediaItemCount
val k = player.currentMediaItemIndex
val current = player.currentPosition
val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))}
player.clearMediaItems()
player.setMediaItems(items, k, current)
}
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(Runnable {
updateMediaItems()
})
}
}
}
override fun onCreate() {
super.onCreate()
initializeRepository()
initializePlayer()
initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener()
initializeCastPlayer()
initializeEqualizerManager()
initializeNetworkListener()
setPlayer(
null,
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
)
}
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaLibrarySession.player
if (!player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
override fun onDestroy() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
releasePlayer()
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 initializeRepository() {
automotiveRepository = AutomotiveRepository()
}
private fun initializeEqualizerManager() {
equalizerManager = EqualizerManager()
val audioSessionId = player.audioSessionId
attachEqualizerIfPossible(audioSessionId)
}
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setLoadControl(initializeLoadControl())
.build()
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
player.repeatMode = Preferences.getRepeatMode()
}
@Suppress("DEPRECATION")
private fun initializeCastPlayer() {
@ -184,284 +20,41 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
) {
CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this))
.addOnSuccessListener { castContext ->
castPlayer = CastPlayer(castContext)
castPlayer.setSessionAvailabilityListener(this@MediaService)
if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) {
setPlayer(player, castPlayer)
}
}
.addOnSuccessListener { castContext ->
castPlayer = CastPlayer(castContext)
castPlayer.setSessionAvailabilityListener(this@MediaService)
initializePlayerListener(castPlayer)
if (castPlayer.isCastSessionAvailable)
setPlayer(mediaLibrarySession.player, castPlayer)
}
}
}
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
librarySessionCallback = createLibrarySessionCallback()
mediaLibrarySession =
MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivityPendingIntent)
.build()
}
private fun initializeNetworkListener() {
networkCallback = CustomNetworkCallback()
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback)
updateMediaItems()
}
private fun restorePlayerFromQueue() {
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()
}
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
override fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback {
return MediaLibrarySessionCallback(this, automotiveRepository)
}
private fun initializePlayerListener() {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (mediaItem == null) return
override fun playerInitHook() {
super.playerInitHook()
initializeCastPlayer()
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable)
setPlayer(null, castPlayer)
}
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
MediaManager.scrobble(currentMediaItem, false)
}
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
MediaManager.continuousPlay(player.currentMediaItem)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
if (isPlaying) {
scheduleWidgetUpdates()
} else {
stopWidgetUpdates()
}
updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
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()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
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) {
attachEqualizerIfPossible(audioSessionId)
}
})
if (player.isPlaying) {
scheduleWidgetUpdates()
override fun releasePlayers() {
if (this::castPlayer.isInitialized) {
castPlayer.setSessionAvailabilityListener(null)
castPlayer.release()
}
}
private fun updateWidget() {
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 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 getQueueFromPlayer(player: Player): List<MediaItem> {
val queue = mutableListOf<MediaItem>()
for (i in 0 until player.mediaItemCount) {
queue.add(player.getMediaItemAt(i))
}
return queue
}
private fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
if (oldPlayer === newPlayer) return
oldPlayer?.stop()
mediaLibrarySession.player = newPlayer
}
private fun releasePlayer() {
if (this::castPlayer.isInitialized) castPlayer.setSessionAvailabilityListener(null)
if (this::castPlayer.isInitialized) castPlayer.release()
player.release()
mediaLibrarySession.release()
automotiveRepository.deleteMetadata()
super.releasePlayers()
}
private fun releaseNetworkCallback() {
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
override fun onCastSessionAvailable() {
val currentQueue = getQueueFromPlayer(player)
val currentIndex = player.currentMediaItemIndex
val currentPosition = player.currentPosition
val isPlaying = player.playWhenReady
setPlayer(player, castPlayer)
castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
castPlayer.playWhenReady = isPlaying
castPlayer.prepare()
setPlayer(exoplayer, castPlayer)
}
override fun onCastSessionUnavailable() {
val currentQueue = getQueueFromPlayer(castPlayer)
val currentIndex = castPlayer.currentMediaItemIndex
val currentPosition = castPlayer.currentPosition
val isPlaying = castPlayer.playWhenReady
setPlayer(castPlayer, player)
player.setMediaItems(currentQueue, currentIndex, currentPosition)
player.playWhenReady = isPlaying
player.prepare()
}
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
setPlayer(castPlayer, exoplayer)
}
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L