diff --git a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt index d595a8a3..f1e68f5d 100644 --- a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -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 - private lateinit var repeatCommands: List - private lateinit var networkCallback: CustomNetworkCallback - lateinit var equalizerManager: EqualizerManager - - private var customLayout = ImmutableList.of() - 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 { - 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 { - 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 - ): ListenableFuture> { - 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) { - /* 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() diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java index 6b3d6252..9d7af95c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java @@ -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 getPlayQueue() { MutableLiveData 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 call, @NonNull Response 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 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 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() { @Override public void onResponse(@NonNull Call call, @NonNull Response 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 call, @NonNull Throwable t) { - + Log.e(TAG, "Play queue save failed", t); } }); } @@ -123,10 +141,9 @@ public class QueueRepository { private boolean isMediaInQueue(List 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()) + return queue.stream().anyMatch(queueItem -> + queueItem != null && media.getId() != null && + queueItem.getId().equals(media.getId()) ); } @@ -146,8 +163,8 @@ public class QueueRepository { List filteredToAdd = toAdd; final List 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)); diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt new file mode 100644 index 00000000..6f5fc52d --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt @@ -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 { + 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 + diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java index 2a551512..02cbd239 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java @@ -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 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 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) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java index 4db3a572..14269b1c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java @@ -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 - 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 diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java index b8b1326f..b2b13d90 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java @@ -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 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 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 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 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 queueSongs = playerSongQueueAdapter.getItems(); + + if (queueSongs == null || queueSongs.isEmpty()) { + Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show(); + toggleFabMenu(); + return; + } + + List mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs); + + List 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() { + @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); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java index 510673cb..48d727ee 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java @@ -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 = getMetadata(tracks); List 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 gains) { + private static void applyReplayGain(Player player, List 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 gains) { + private static void setTrackReplayGain(Player player, List 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 gains) { + private static void setAlbumReplayGain(Player player, List 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 gains) { + private static void setAutoReplayGain(Player player, List 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)); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java index 956ba6fa..8e88cec8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java @@ -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> getAlbumList(LifecycleOwner owner) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java index 2a100fbf..df571690 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java @@ -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 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; diff --git a/app/src/main/res/layout/inner_fragment_player_queue.xml b/app/src/main/res/layout/inner_fragment_player_queue.xml index 72a70a22..3ddd112b 100644 --- a/app/src/main/res/layout/inner_fragment_player_queue.xml +++ b/app/src/main/res/layout/inner_fragment_player_queue.xml @@ -1,18 +1,11 @@ - - - @@ -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" /> - - + app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_player_queue_song.xml b/app/src/main/res/layout/item_player_queue_song.xml index baf28acc..22e3bc60 100644 --- a/app/src/main/res/layout/item_player_queue_song.xml +++ b/app/src/main/res/layout/item_player_queue_song.xml @@ -139,6 +139,17 @@ + + ?attr/colorSurface ?attr/colorSurface none + + @style/FloatingActionButtonStyle + + + + + + + +