From b34f827bc0eb7e70995b90348abc8f718425f843 Mon Sep 17 00:00:00 2001 From: skajmer <64442855+skajmer@users.noreply.github.com> Date: Sun, 23 Nov 2025 20:29:20 +0100 Subject: [PATCH 001/149] Add #276 --- app/src/main/res/values-pl/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 61c200ec..df5fe7ec 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -522,4 +522,7 @@ Jeżeli włączone, pokaż szczegóły albumu takie jak gatunek, ilość piosenek itp. na stronie albumu Sortuj wykonawców po ilości albumów Jeżeli włączone, sortuje wykonawców po ilości albumów. Jeżeli wyłączone, sortuje albumy po nazwach. + Zbieranie piosenek z folderu… + Odtwarzanie %d piosenek + Nie znaleziono piosenek w folderze From 38fb2c69f16ff7a3f3d6f5c6ec1a1ec591d96284 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Mon, 24 Nov 2025 11:36:56 -0800 Subject: [PATCH 002/149] wip: added fab, need to implement actions --- .../ui/fragment/PlayerQueueFragment.java | 184 ++++++++++++++---- .../layout/inner_fragment_player_queue.xml | 84 +++++++- 2 files changed, 228 insertions(+), 40 deletions(-) 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..baecb480 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 @@ -39,6 +39,20 @@ 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 fabSaveQueue; + 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 +67,24 @@ 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; + fabSaveQueue = bind.fabSaveQueue; + fabLoadQueue = bind.fabLoadQueue; + + fabMenuToggle.setOnClickListener(v -> toggleFabMenu()); + fabClearQueue.setOnClickListener(v -> handleClearQueueClick()); + fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick()); + + fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick()); + fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick()); + fabSaveQueue.setOnClickListener(v -> handleSaveQueueClick()); + fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick()); + initQueueRecyclerView(); return view; @@ -109,8 +141,8 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { mediaBrowserListenableFuture.addListener(() -> { try { MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); - initShuffleButton(mediaBrowser); - initCleanButton(mediaBrowser); + // initShuffleButton(mediaBrowser); + // initCleanButton(mediaBrowser); } catch (Exception exception) { exception.printStackTrace(); } @@ -188,45 +220,45 @@ 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; + // 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<>(); + // if (startPosition < endPosition) { + // ArrayList pool = new ArrayList<>(); - for (int i = startPosition; i <= endPosition; i++) { - pool.add(i); - } + // 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); + // 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); + // 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); - } + // Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB); + // bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB); + // } - MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition); - } - }); - } + // 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(); + // 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); - }); - } + // MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition); + // bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition); + // }); + // } private void updateNowPlayingItem() { playerSongQueueAdapter.notifyDataSetChanged(); @@ -259,4 +291,90 @@ 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) + closeFab(fabSaveToPlaylist, 5); + closeFab(fabDownloadAll, 4); + closeFab(fabSaveQueue, 3); + closeFab(fabLoadQueue, 2); + closeFab(fabClearQueue, 1); + closeFab(fabShuffleQueue, 0); + + fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start(); + } else { + // OPEN MENU (lowest index at bottom) + openFab(fabShuffleQueue, 0); + openFab(fabClearQueue, 1); + openFab(fabLoadQueue, 2); + openFab(fabSaveQueue, 3); + openFab(fabDownloadAll, 4); + openFab(fabSaveToPlaylist, 5); + + 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!"); + toggleFabMenu(); + // TODO: Insert existing shuffle logic here + } + + private void handleClearQueueClick() { + Log.d(TAG, "Clear Queue Clicked!"); + toggleFabMenu(); + // TODO: Insert existing clear queue logic here + } + + private void handleSaveToPlaylistClick() { + Log.d(TAG, "Save to Playlist Clicked! (Placeholder)"); + toggleFabMenu(); + } + + private void handleDownloadAllClick() { + Log.d(TAG, "Download All Clicked! (Placeholder)"); + toggleFabMenu(); + } + + private void handleSaveQueueClick() { + Log.d(TAG, "Save Queue Clicked! (Placeholder)"); + toggleFabMenu(); + } + + private void handleLoadQueueClick() { + Log.d(TAG, "Load Queue Clicked! (Placeholder)"); + toggleFabMenu(); + } + } \ No newline at end of file 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..e343f4f3 100644 --- a/app/src/main/res/layout/inner_fragment_player_queue.xml +++ b/app/src/main/res/layout/inner_fragment_player_queue.xml @@ -1,6 +1,8 @@ - @@ -27,14 +29,82 @@ - - + app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"> + + + + + + + + + + + + + + + + + \ No newline at end of file From 79dc1cc93bf8cda7c59edff7d814bca10aa4780a Mon Sep 17 00:00:00 2001 From: eddyizm Date: Mon, 24 Nov 2025 13:11:29 -0800 Subject: [PATCH 003/149] chore: bringing in media service refactor for more testing --- .../tempo/service/MediaService.kt | 575 +---------------- .../tempo/service/BaseMediaService.kt | 590 ++++++++++++++++++ .../tempo/ui/fragment/EqualizerFragment.kt | 9 +- .../tempo/util/ReplayGainUtil.java | 25 +- .../tempo/service/MediaService.kt | 455 +------------- 5 files changed, 633 insertions(+), 1021 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt 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/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/ui/fragment/EqualizerFragment.kt b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt index 0b97d516..ac6608c7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt @@ -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 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/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt index 52fa8c1d..784b1986 100644 --- a/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -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 { - val queue = mutableListOf() - 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() + setPlayer(castPlayer, exoplayer) } - - 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 const val WIDGET_UPDATE_INTERVAL_MS = 1000L +} \ No newline at end of file From 1bfadb066984f760c46f3955df2f72111bbaf0a5 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Mon, 24 Nov 2025 20:46:46 -0800 Subject: [PATCH 004/149] fix: refactor start queue to put the db writing in the background --- .../tempo/service/MediaManager.java | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) 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) { From 732b6ad09d7eb73ff9d2bea17734e359ac71d759 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Tue, 25 Nov 2025 15:48:48 -0800 Subject: [PATCH 005/149] fix: moved existing functionality to fab buttons, removed queue text/button from top --- .../ui/fragment/PlayerQueueFragment.java | 110 +++++++++--------- .../layout/inner_fragment_player_queue.xml | 10 -- 2 files changed, 52 insertions(+), 68 deletions(-) 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 baecb480..2fa80702 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 @@ -141,8 +141,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { mediaBrowserListenableFuture.addListener(() -> { try { MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); - // initShuffleButton(mediaBrowser); - // initCleanButton(mediaBrowser); } catch (Exception exception) { exception.printStackTrace(); } @@ -181,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); @@ -220,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(); } @@ -347,14 +293,62 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { private void handleShuffleQueueClick() { Log.d(TAG, "Shuffle Queue Clicked!"); - toggleFabMenu(); - // TODO: Insert existing shuffle logic here + + 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!"); - toggleFabMenu(); - // TODO: Insert existing clear queue logic here + + 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() { 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 e343f4f3..1f3eec9f 100644 --- a/app/src/main/res/layout/inner_fragment_player_queue.xml +++ b/app/src/main/res/layout/inner_fragment_player_queue.xml @@ -6,15 +6,6 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - @@ -23,7 +14,6 @@ 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" /> From 27f5a47cc962270c73b745b484d1973b0de653c2 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Thu, 27 Nov 2025 08:04:40 -0800 Subject: [PATCH 006/149] feat: save q to playlist, removed save queue button, added style to fab. --- .../ui/fragment/PlayerQueueFragment.java | 30 ++++++++++++------- .../layout/inner_fragment_player_queue.xml | 11 +------ app/src/main/res/values-night/styles.xml | 17 +++++++++++ app/src/main/res/values/styles.xml | 17 +++++++++++ 4 files changed, 55 insertions(+), 20 deletions(-) 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 2fa80702..809b1a18 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 @@ -6,6 +6,7 @@ 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; @@ -23,6 +24,7 @@ import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter; +import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; @@ -31,6 +33,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 @@ -46,7 +49,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveToPlaylist; private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabDownloadAll; - private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveQueue; private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabLoadQueue; private boolean isMenuOpen = false; @@ -73,7 +75,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { fabSaveToPlaylist = bind.fabSaveToPlaylist; fabDownloadAll = bind.fabDownloadAll; - fabSaveQueue = bind.fabSaveQueue; fabLoadQueue = bind.fabLoadQueue; fabMenuToggle.setOnClickListener(v -> toggleFabMenu()); @@ -82,7 +83,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick()); fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick()); - fabSaveQueue.setOnClickListener(v -> handleSaveQueueClick()); fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick()); initQueueRecyclerView(); @@ -246,7 +246,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { // CLOSE MENU (Reverse order for visual effect) closeFab(fabSaveToPlaylist, 5); closeFab(fabDownloadAll, 4); - closeFab(fabSaveQueue, 3); closeFab(fabLoadQueue, 2); closeFab(fabClearQueue, 1); closeFab(fabShuffleQueue, 0); @@ -257,7 +256,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { openFab(fabShuffleQueue, 0); openFab(fabClearQueue, 1); openFab(fabLoadQueue, 2); - openFab(fabSaveQueue, 3); openFab(fabDownloadAll, 4); openFab(fabSaveToPlaylist, 5); @@ -352,7 +350,23 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { } private void handleSaveToPlaylistClick() { - Log.d(TAG, "Save to Playlist Clicked! (Placeholder)"); + 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(); } @@ -361,10 +375,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { toggleFabMenu(); } - private void handleSaveQueueClick() { - Log.d(TAG, "Save Queue Clicked! (Placeholder)"); - toggleFabMenu(); - } private void handleLoadQueueClick() { Log.d(TAG, "Load Queue Clicked! (Placeholder)"); 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 1f3eec9f..16fb9b44 100644 --- a/app/src/main/res/layout/inner_fragment_player_queue.xml +++ b/app/src/main/res/layout/inner_fragment_player_queue.xml @@ -35,7 +35,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:visibility="gone" - android:text="Save to Playlist (TODO)" + android:text="Save Queue to Playlist" tools:ignore="HardcodedText" app:icon="@android:drawable/ic_menu_edit" /> @@ -49,15 +49,6 @@ tools:ignore="HardcodedText" app:icon="@android:drawable/stat_sys_download_done" /> - ?attr/colorSurface ?attr/colorSurface none + + @style/FloatingActionButtonStyle + + + + + + + +