From 30b8070aa0fb43bb9c9c83296a5fe01729d4fe74 Mon Sep 17 00:00:00 2001 From: Michael Zh Date: Mon, 26 Jan 2026 23:34:58 -0500 Subject: [PATCH 01/14] mq: Introduce QueueBoard * And so it begins: My attempt to implement one of OuterTune's most cursed features (code wise), but without programming crimes. --- .../gramophone/logic/GramophoneExtensions.kt | 70 +++ .../logic/GramophonePlaybackService.kt | 43 ++ .../akanework/gramophone/logic/QueueBoard.kt | 535 ++++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt index edd5bd337..295d32d82 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt @@ -59,6 +59,7 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.children import androidx.core.view.updateLayoutParams import androidx.core.view.updateMargins +import androidx.media3.common.BundleListRetriever import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.MediaItem @@ -75,6 +76,11 @@ import org.akanework.gramophone.BuildConfig import org.akanework.gramophone.R import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_GET_AUDIO_FORMAT import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_GET_LYRICS +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_DEL +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_ENQUEUE +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_GET_ALL +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_LOAD_QUEUE +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_REORDER import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QUERY_TIMER import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_SET_TIMER import org.akanework.gramophone.logic.utils.AfFormatInfo @@ -337,6 +343,70 @@ fun MediaController.getAudioFormat(): AudioFormatDetector.AudioFormats = ) } +fun MediaController.getQueues(): List? = + sendCustomCommand( + SessionCommand(SERVICE_QB_GET_ALL, Bundle.EMPTY), + Bundle.EMPTY + ).get().extras.run { + if (containsKey("allQueues")) { + val binder = getBinder("allQueues")!! + BundleListRetriever.getList(binder).map { + MultiQueueObject.fromBundle(it) + } + } else { + throw IllegalArgumentException("expected allQueues to be set") + } + } + +fun MediaController.loadQueue(index: Int) { + sendCustomCommand( + SessionCommand(SERVICE_QB_LOAD_QUEUE, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ) +} + +fun MediaController.deleteQueue(index: Int): Boolean = + sendCustomCommand( + SessionCommand(SERVICE_QB_DEL, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ).get().extras.run { + if (containsKey("status")) + getBoolean("status") + else throw IllegalArgumentException("expected status to be set") + } + +fun MediaController.reorderQueue(from: Int, to: Int): Boolean = + sendCustomCommand( + SessionCommand(SERVICE_QB_REORDER, Bundle.EMPTY).apply { + customExtras.putInt("from", from) + customExtras.putInt("to", to) + }, Bundle.EMPTY + ).get().extras.run { + if (containsKey("status")) + getBoolean("status") + else throw IllegalArgumentException("expected status to be set") + } + +// TODO: shuffle and repeat mode +fun MediaController.playQueue( + title: String?, + mediaList: List, + mediaItemIndex: Int, + isOriginal: Boolean +) { + sendCustomCommand( + SessionCommand(SERVICE_QB_ENQUEUE, Bundle.EMPTY).apply { + customExtras.putString("title", title) + customExtras.putInt("mediaItemIndex", mediaItemIndex) + customExtras.putBoolean("isOriginal", isOriginal) + val binder = BundleListRetriever(mediaList.map { it.toBundleIncludeLocalConfiguration() }) + customExtras.putBinder("mediaList", binder) + }, Bundle.EMPTY + ) +} + fun Tracks.getFirstSelectedTrackFormatByType(type: @C.TrackType Int): Format? { for (i in groups) { if (i.type == type) { diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index acf7ce0b6..14a4295cd 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -45,8 +45,10 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat +import androidx.core.os.BundleCompat.getBinder import androidx.lifecycle.lifecycleScope import androidx.media3.common.AudioAttributes +import androidx.media3.common.BundleListRetriever import androidx.media3.common.C import androidx.media3.common.DeviceInfo import androidx.media3.common.Format @@ -153,11 +155,19 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis const val PENDING_INTENT_NOTIFY_ID = 1 const val PENDING_INTENT_WIDGET_ID = 2 const val PENDING_INTENT_FAVE_ID = 3 + const val SERVICE_SET_TIMER = "set_timer" const val SERVICE_QUERY_TIMER = "query_timer" const val SERVICE_GET_AUDIO_FORMAT = "get_audio_format" const val SERVICE_GET_LYRICS = "get_lyrics" const val SERVICE_TIMER_CHANGED = "changed_timer" + + const val SERVICE_QB_GET_ALL = "qb_get_all" + const val SERVICE_QB_LOAD_QUEUE = "qb_load" + const val SERVICE_QB_DEL = "qb_delete" + const val SERVICE_QB_REORDER = "qb_reorder" + const val SERVICE_QB_ENQUEUE = "qb_enqueue" + var instanceForWidgetAndLyricsOnly: GramophonePlaybackService? = null } @@ -168,6 +178,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val endedWorkaroundPlayer get() = mediaSession?.player as EndedWorkaroundPlayer? private var controller: MediaBrowser? = null + val qb: QueueBoard = QueueBoard(this) private val sendLyrics = Runnable { scheduleSendingLyrics(false) } var lyrics: SemanticLyrics? = null private set @@ -777,6 +788,11 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis availableSessionCommands.add(SessionCommand(SERVICE_QUERY_TIMER, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_GET_AUDIO_FORMAT, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_GET_ALL, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_LOAD_QUEUE, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_DEL, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_REORDER, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_ENQUEUE, Bundle.EMPTY)) return builder.setAvailableSessionCommands(availableSessionCommands.build()).build() } @@ -959,6 +975,33 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } + SERVICE_QB_GET_ALL -> { + SessionResult(SessionResult.RESULT_SUCCESS).also { res -> + val queueList: List = qb.getAllQueues() + val binder = BundleListRetriever(queueList.map { it.toBundle() }) + res.extras.putBinder("allQueues", binder) + } + } + + SERVICE_QB_ENQUEUE -> { + val title = customCommand.customExtras.getString("title") ?: "Queue" + val mediaItemIndex = customCommand.customExtras.getInt("mediaItemIndex") + val isOriginal = customCommand.customExtras.getBoolean("isOriginal") + val binder = customCommand.customExtras.getBinder("mediaList")!! + val mediaList = BundleListRetriever.getList(binder).map { + MediaItem.fromBundle(it) + } + + val mq = qb.addQueue(title, mediaList, mediaItemIndex, isOriginal) + qb.commitQueue(mq) + if (!mq.queue.isEmpty()) { + endedWorkaroundPlayer!!.prepare() + endedWorkaroundPlayer!!.play() + } + + SessionResult(SessionResult.RESULT_SUCCESS) + } + else -> { SessionResult(SessionError.ERROR_BAD_VALUE) } diff --git a/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt b/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt new file mode 100644 index 000000000..e9b432337 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt @@ -0,0 +1,535 @@ +package org.akanework.gramophone.logic + +import android.os.Bundle +import android.os.Parcelable +import android.util.Log +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastSumBy +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player.REPEAT_MODE_OFF +import org.akanework.gramophone.logic.utils.CircularShuffleOrder +import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer +import java.util.LinkedList +import kotlin.math.max +import kotlin.math.min +import kotlin.random.Random + + +/** + * Multiple queues manager. + * + * Queues are ordered most recent modification, + */ +class QueueBoard( + private val player: GramophonePlaybackService, + val masterQueues: MutableList = mutableListOf(), + queues: MutableList = ArrayList(), + private var maxQueues: Int = 20 +) { + private val QUEUE_DEBUG = true + private val TAG = QueueBoard::class.simpleName.toString() + + init { + masterQueues.clear() + if (maxQueues < 0) { + maxQueues = 1 + } + if (!queues.isEmpty()) { + masterQueues.addAll( + queues.subList( + (queues.size - maxQueues).coerceAtLeast(0), + queues.size + ) + ) + } + + // todo: remove when figure out persist and load + masterQueues.add( + MultiQueueObject( + id = Random.nextLong(), + title = "[Existing queue]", + queue = ArrayList(), + startIndex = C.INDEX_UNSET, + startPositionMs = C.TIME_UNSET, + repeatMode = REPEAT_MODE_OFF, + shuffleModeEnabled = false, + shuffleOrder = null, + ended = false, + ) + ) + } + + /** + * ======================== + * Data structure management + * ======================== + */ + + + /** + * Push this queue to the player, and save the player queue back to QueueBoard + * + * @param mq + */ + fun commitQueue(mq: MultiQueueObject, shouldResume: Boolean = true) = + commitQueue(masterQueues.indexOf(mq), shouldResume) + + /** + * Push this queue to the player, and save the player queue back to QueueBoard. The last queue + * is assumed to be the active queue, and second last is assumed to be the queue to load. + * + * @param index + */ + fun commitQueue(index: Int, shouldResume: Boolean = true) { + if (index < 0 || index >= masterQueues.size) { + Log.w(TAG, "commitQueue() index out of bounds. Aborting") + return + } + + // assume last == active queue, second last == to load. No save when no active queue + val old = masterQueues.lastIndex + if (masterQueues.size > 1 && old >= 0) { + syncQueueFromPlayer(masterQueues[old]) + } + + val new = masterQueues[index] + masterQueues.remove(new) + masterQueues.add(new) + setCurrQueue(new, false, shouldResume) + } + + /** + * Add a new queue to the QueueBoard, or add to a queue if it exists. + * + * Depending on the state of the QueueBoard and player, this result in differing behaviour: + * + * Queue already exists: + * 1. Contents (by songID) are a perfect match: Update metadata (currentMediaItemIndex, shuffle + * order). + * 2. Contents are different and given "isOriginal" flag: Update metadata, replace all existing + * queue content with new content. + * 3. Contents are different: Update metadata, add all new content to the end of the old content. + * Queue title gets a "+" suffix if not already present. + * + * Queue does not exist: + * 4. Queue is added as a new queue. + * + * + * @param title Title (effective uid) of the queue. + * @param mediaList Media items to add to the queue. + * @param player + * @param shuffled media3 isShuffleEnabled + * @param mediaItemIndex media3 startIndex + * @param isOriginal Specifies if the queue is an original copy of a library media list (ex. + * folder, search results, playlist, etc.). Original copies will sync existing queue's media + * items with the provided media items. "Un-original" queues will append media items to the + * end of the queue, or create a new queue if an existing queue does not exist. + * + */ + fun addQueue( + title: String, + mediaList: List, + mediaItemIndex: Int = 0, + isOriginal: Boolean = false, + ): MultiQueueObject { + if (QUEUE_DEBUG) + Log.d(TAG, "Queue data: $masterQueues") + if (QUEUE_DEBUG) + Log.d( + TAG, "Adding to queue \"$title\". medialist size = ${mediaList.size}. " + + "replace/startIndex = $isOriginal/$mediaItemIndex" + ) + + // look for matching queue. Title is (effectively) uid + val match = masterQueues.firstOrNull { it.title.trimEnd() == title } + + if (match != null) { + val containsAll = + mediaList.size == match.getSize() && mediaList.all { s -> + match.queue.any { s.mediaId == it.mediaId } + } + if (containsAll) { + // (1) perfect match + if (QUEUE_DEBUG) + Log.d(TAG, "Adding: (1) perfect match") + + match.startIndex = mediaItemIndex + + masterQueues.bubbleUp(match) + return match + } else if (isOriginal) { + // (2) replace all in queue + if (QUEUE_DEBUG) + Log.d(TAG, "Adding: (2) perfect match") + + match.startIndex = mediaItemIndex + match.queue.clear() + match.queue.addAll(mediaList) + + masterQueues.bubbleUp(match) + return match + } else { + // (3) add all to end of the queue. Create extension queue + if (QUEUE_DEBUG) + Log.d(TAG, "Adding: (3) perfect match") + + match.startIndex = mediaItemIndex + match.queue.addAll(mediaList) + + // Titles ending in "+​" aka \u200B signify a extension queue + // Original copies will transion into an extention queue when media items are added + if (!match.title.endsWith("(+\u200B)")) { + match.title = match.title + "(+\u200B)" + } + + masterQueues.bubbleUp(match) + return match + } + } else { + // (4) add new queue + if (QUEUE_DEBUG) + Log.d(TAG, "Adding: (4) new queue") + if (masterQueues.size >= maxQueues) { + deleteQueue(masterQueues.first()) + } + + val newQueue = MultiQueueObject( + id = Random.nextLong(), + title = title, + queue = ArrayList(mediaList), + startIndex = mediaItemIndex, + startPositionMs = C.TIME_UNSET, + repeatMode = player.endedWorkaroundPlayer!!.repeatMode, + shuffleModeEnabled = false, + shuffleOrder = null, + ended = false, + ) + + masterQueues.bubbleUp(newQueue) + return newQueue + } + } + + /** + * Deletes a queue + * + * @param mq + */ + fun deleteQueue(mq: MultiQueueObject): Int { + if (QUEUE_DEBUG) + Log.d(TAG, "DELETING QUEUE ${mq.title}") + + val match = masterQueues.firstOrNull { it.title == mq.title } + if (match != null) { + masterQueues.remove(match) + } else { + Log.w(TAG, "Cannot find queue to delete: ${mq.title}") + } + + return masterQueues.size + } + + /** + * Move a queue in masterQueues + * + * @param fromIndex + * @param toIndex + * + * @return New current position tracker + */ + fun move(fromIndex: Int, toIndex: Int): Boolean { + if (fromIndex == masterQueues.lastIndex || toIndex == masterQueues.lastIndex) { + return false + } + + if (fromIndex < toIndex) { + masterQueues.add(toIndex - 1, masterQueues.removeAt(fromIndex)) + } else { + masterQueues.add(toIndex, masterQueues.removeAt(fromIndex)) + } + return true + } + + /** + * ================= + * Player management + * ================= + */ + + /** + * Get all copy of all queues + */ + fun getAllQueues() = masterQueues.dropLast(1) + + + fun renameQueue(mq: MultiQueueObject, newName: String): Boolean { + if (masterQueues.any { it.title == newName }) { + if (QUEUE_DEBUG) + Log.d(TAG, "Failed to rename queue to \"$newName\". Already exists") + return false + } + val found = masterQueues.any { it == mq } + if (found) { + val oldIndex = masterQueues.indexOf(mq) + val q = masterQueues.removeAt(oldIndex) + masterQueues.add(oldIndex, q.copy(title = newName)) + + if (QUEUE_DEBUG) + Log.d(TAG, "Successfully renamed queue from \"${mq.title}\" to \"$newName\"") + return true + } else { + if (QUEUE_DEBUG) + Log.d(TAG, "Failed to rename queue. Not found") + return false + } + } + + /** + * Load a queue into the media player. This should ran exclusively on the main thread. + * + * @param mq Queue object + * @param shouldResume Set to true for the player should resume playing at the current song's last save position or + * false to start from the beginning. + * @return New current position tracker + */ + // TODO: OuterTune hacks around shuffleModeEnabled by replacing all media items in the queue when shuffleModeEnabled changes, + // so setCurrQueue was created to allows for seamless transitions. The side effect is that seamless transitions were also + // extendable to *any* queue change. In theory, this should work for Gramophone too, but I have not tested it at all + private fun setCurrQueue( + mq: MultiQueueObject?, + seamlessAllowed: Boolean, + shouldResume: Boolean + ): Int? { + Log.d( + TAG, + "Loading queue ${mq?.title ?: "null"} into player. Shuffle state = ${mq?.shuffleModeEnabled}" + ) + + val plr = player.endedWorkaroundPlayer!! + + if (mq == null || mq.queue.isEmpty()) { + plr.setMediaItems(ArrayList()) + return null + } + + // I have no idea why this value gets reset to 0 by the end... but ig this works + val startPositionMs = if (shouldResume) mq.startPositionMs else C.TIME_UNSET + val startIndex = mq.startIndex + + val mediaItems: MutableList = mq.queue + + Log.d( + TAG, + "Setting current queue; $mq; ids: ${plr.currentMediaItem?.mediaId}, ${mediaItems[startIndex].mediaId}" + ) + /** + * current playing == jump target, do seamlessly + */ + val seamlessSupported = seamlessAllowed && (startIndex < mediaItems.size) + && plr.currentMediaItem?.mediaId == mediaItems[startIndex].mediaId + if (seamlessSupported) { + Log.d(TAG, "Trying seamless queue switch. Is first song?: ${startIndex == 0}") + val playerIndex = plr.currentMediaItemIndex + + if (startIndex == 0) { + val playerItemCount = plr.mediaItemCount + // player.player.replaceMediaItems seems to stop playback so we + // remove all songs except the currently playing one and then add the list of new items + if (playerIndex < playerItemCount - 1) { + plr.removeMediaItems( + playerIndex + 1, + playerItemCount + ) + } + if (playerIndex > 0) { + plr.removeMediaItems(0, playerIndex) + } + // add all songs except the first one since it is already present and playing + plr.addMediaItems(mediaItems.drop(1)) + } else { + // replace items up to current playing, then replace items after current + plr.replaceMediaItems( + 0, playerIndex, + mediaItems.subList(0, startIndex) + ) + plr.replaceMediaItems( + startIndex + 1, Int.MAX_VALUE, + mediaItems.subList(startIndex + 1, mediaItems.size) + ) + } + } else { + Log.d(TAG, "Seamless is not supported. Loading songs in directly") + plr.setMediaItems(mediaItems, startIndex, startPositionMs) + } + + if (plr.shuffleModeEnabled != mq.shuffleModeEnabled) { + if (plr.shuffleModeEnabled && mq.shuffleOrder == null) { + Log.w(TAG, "Shuffle mode is enabled but no shuffle order is provided") + } + plr.shuffleModeEnabled = mq.shuffleModeEnabled + mq.shuffleOrder?.let { + if (it != plr.exoPlayer.shuffleOrder) { + plr.exoPlayer.setShuffleOrder(it) + } + } + } + if (plr.repeatMode != mq.repeatMode) { + plr.repeatMode = mq.repeatMode + } + + return startIndex + } + + + /** + * ================= + * Util + * ================= + */ + + + private fun dumpPlaylist(): MutableList { + val items = ArrayList() + val instance = player.endedWorkaroundPlayer!! + for (i in 0 until instance.mediaItemCount) { + items.add(instance.getMediaItemAt(i)) + } + + return items + } + + private fun syncQueueFromPlayer(mq: MultiQueueObject) { + val plr = player.endedWorkaroundPlayer!! + mq.startIndex = plr.currentMediaItemIndex + mq.startPositionMs = plr.currentPosition + mq.repeatMode = plr.repeatMode + mq.shuffleModeEnabled = plr.shuffleModeEnabled + mq.shuffleOrder = plr.exoPlayer.shuffleOrder as CircularShuffleOrder + mq.queue.clear() + mq.queue.addAll(dumpPlaylist()) + } + +} + +/** + * Move this queue to the last non-active spot + */ +private fun MutableList.bubbleUp(mq: MultiQueueObject) { + remove(mq) + if (lastIndex >= 0) { + add(lastIndex, mq) + } +} + + +/** + * @param title Queue title (and UID) + * @param queue List of media items + */ +data class MultiQueueObject( + val id: Long, +// var index: Int, // order of queue if saved to database + var title: String, + /** + * The order of songs are dynamic. This should not be accessed from outside QueueBoard. + */ + val queue: MutableList, + + var startIndex: Int = C.INDEX_UNSET, // position of current song + var startPositionMs: Long = C.TIME_UNSET, + var repeatMode: Int = 0, + var shuffleModeEnabled: Boolean = false, + + var shuffleOrder: CircularShuffleOrder? = null, + var ended: Boolean = false, +) { + override fun toString() = + "$title ($id) startIndex=$startIndex, startPositionMs=$startPositionMs, repeatMode=$repeatMode, shuffleModeEnabled=$shuffleModeEnabled, ended=$ended, mediaItems_size=${queue.size}" + + + /** + * Retrieve the song at current position in the queue + */ + fun getCurrentSong(): MediaItem? { + return queue.getOrNull(startIndex) + } + + /** + * Retrieve a song given a song ID. Returns null if no song is found + */ + fun findSong(mediaId: String): MediaItem? { + val currentSong = getCurrentSong() + if (currentSong?.mediaId == mediaId) { + return currentSong + } + + return queue.fastFirstOrNull { it.mediaId == mediaId } + } + + fun setCurrentQueuePos(index: Int) { + // TODO: uhhh figure out shffle + startIndex = index + } + + /** + * Retrieve the total duration of all songs + * + * @return Duration in seconds + */ + fun getDuration(): Int { + return queue.fastSumBy { + ((it.mediaMetadata.durationMs ?: 0L) / 1000).toInt() // seconds + } + } + + /** + * Get the length of the queue + */ + fun getSize() = queue.size + + + fun toBundle(): Bundle = + Bundle().apply { +// val binder = BundleListRetriever(queue.map { it.toBundle() }) + + putLong("id", id) + putString("title", title) + +// putBinder("queue", binder) + putParcelableArrayList("queue", ArrayList(queue.map { it.toBundle() })) + + putInt("startIndex", startIndex) + putLong("startPositionMs", startPositionMs) + putInt("repeatMode", repeatMode) + putBoolean("shuffleModeEnabled", shuffleModeEnabled) + putBoolean("ended", ended) + +// TODO: shuffleOrder + } + + companion object { + fun fromBundle(bundle: Bundle): MultiQueueObject { +// val binder = bundle.getBinder("queue")!! +// val queue = BundleListRetriever.getList(binder).map { MediaItem.fromBundle(it) } +// .toMutableList() + + return MultiQueueObject( + id = bundle.getLong("id"), + title = bundle.getString("title") ?: "", + +// queue = queue, + queue = (bundle.getParcelableArrayList("queue") + ?: emptyList()).map { MediaItem.fromBundle(it) }.toMutableList(), + + startIndex = bundle.getInt("startIndex", C.INDEX_UNSET), + startPositionMs = bundle.getLong("startPositionMs", C.TIME_UNSET), + repeatMode = bundle.getInt("repeatMode", REPEAT_MODE_OFF), + shuffleModeEnabled = bundle.getBoolean("shuffleModeEnabled"), + ended = bundle.getBoolean("ended"), + +// TODO: shuffleOrder = + ) + } + } +} \ No newline at end of file From 3da9516da6a77deae4cff77cc1158c5cb3a3eca3 Mon Sep 17 00:00:00 2001 From: Michael Zh Date: Tue, 27 Jan 2026 19:33:50 -0500 Subject: [PATCH 02/14] mq: Hijack setMediaItems --- .../ui/adapters/BaseDecorAdapter.kt | 41 +++++++++++-------- .../gramophone/ui/adapters/SongAdapter.kt | 15 ++++--- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt b/app/src/main/java/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt index 758eff5c2..606e4ada1 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt @@ -31,6 +31,7 @@ import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import org.akanework.gramophone.R +import org.akanework.gramophone.logic.playQueue import org.akanework.gramophone.logic.ui.ItemHeightHelper import org.akanework.gramophone.logic.ui.MyRecyclerView import org.akanework.gramophone.ui.fragments.AdapterFragment @@ -163,11 +164,12 @@ open class BaseDecorAdapter>( mediaController?.apply { shuffleModeEnabled = false repeatMode = REPEAT_MODE_OFF - setMediaItems(songList) - if (songList.isNotEmpty()) { - prepare() - play() - } + playQueue( + title = "All Songs", // TODO: title + mediaList = songList, + mediaItemIndex = position, + isOriginal = true, + ) } } else if (adapter is AlbumAdapter) { val list = adapter.getAlbumList() @@ -175,9 +177,12 @@ open class BaseDecorAdapter>( controller?.repeatMode = REPEAT_MODE_OFF controller?.shuffleModeEnabled = false list.takeIf { it.isNotEmpty() }?.also { albums -> - controller?.setMediaItems(albums.flatMap { it.songList }) - controller?.prepare() - controller?.play() + controller?.playQueue( + title = "All Albums", // TODO: title + mediaList = albums.flatMap { it.songList }, + mediaItemIndex = position, + isOriginal = true, + ) } ?: controller?.setMediaItems(listOf()) } } @@ -187,20 +192,24 @@ open class BaseDecorAdapter>( val songList = adapter.getSongList() val controller = adapter.getActivity().getPlayer() controller?.shuffleModeEnabled = true - controller?.setMediaItems(songList) - if (songList.isNotEmpty()) { - controller?.prepare() - controller?.play() - } + controller?.playQueue( + title = "Shuffle All Songs", // TODO: title + mediaList = songList, + mediaItemIndex = position, + isOriginal = true, + ) } else if (adapter is AlbumAdapter) { val list = adapter.getAlbumList() val controller = adapter.getActivity().getPlayer() controller?.repeatMode = REPEAT_MODE_OFF controller?.shuffleModeEnabled = false list.takeIf { it.isNotEmpty() }?.also { albums -> - controller?.setMediaItems(albums.shuffled().flatMap { it.songList }) - controller?.prepare() - controller?.play() + controller?.playQueue( + title = "Shuffle All Albums", // TODO: title + mediaList = albums.shuffled().flatMap { it.songList }, + mediaItemIndex = position, + isOriginal = true, + ) } ?: controller?.setMediaItems(listOf()) } } diff --git a/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt b/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt index 7509d7c72..93de402c5 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt @@ -20,16 +20,13 @@ package org.akanework.gramophone.ui.adapters import android.net.Uri import android.view.View import android.widget.Toast -import androidx.activity.result.IntentSenderRequest import androidx.appcompat.widget.PopupMenu import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.common.util.Log import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,7 +34,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.akanework.gramophone.R +import org.akanework.gramophone.logic.GramophonePlaybackService +import org.akanework.gramophone.logic.playQueue import org.akanework.gramophone.logic.getFile +import org.akanework.gramophone.logic.getQueues import org.akanework.gramophone.logic.requireMediaStoreId import org.akanework.gramophone.logic.utils.Flags import org.akanework.gramophone.ui.MainActivity @@ -177,9 +177,12 @@ class SongAdapter( val mediaController = mainActivity.getPlayer() mediaController?.apply { val songList = getSongList() - setMediaItems(songList, position, C.TIME_UNSET) - prepare() - play() + playQueue( + title = "Song: " + item.mediaMetadata.title, // TODO: title + mediaList = songList, + mediaItemIndex = position, + isOriginal = true, + ) } } From 2ff38a78b1d14c9277f5e4600c7fb4657291de9d Mon Sep 17 00:00:00 2001 From: Michael Zh Date: Fri, 30 Jan 2026 15:51:23 -0500 Subject: [PATCH 03/14] mq: Initial multi queue ui mq: wip queue loading ui mg: Guard behind flag mq: Queue expiry * LocalDateTime methods shouldn't need sdk 26. Figure it out later --- app/build.gradle.kts | 2 +- .../gramophone/logic/GramophoneExtensions.kt | 101 +++- .../logic/GramophonePlaybackService.kt | 59 ++- .../akanework/gramophone/logic/QueueBoard.kt | 101 +++- .../akanework/gramophone/logic/utils/Flags.kt | 3 + .../gramophone/ui/adapters/SongAdapter.kt | 2 - .../ui/components/ComposeComponentsTemp.kt | 460 ++++++++++++++++++ .../ui/components/PlaylistQueueSheet.kt | 232 +++++++-- app/src/main/res/drawable/mq_title_box_bg.xml | 11 + .../main/res/layout/playlist_bottom_sheet.xml | 36 +- .../layout/playlist_bottom_sheet_actions.xml | 46 ++ app/src/main/res/values/strings.xml | 1 + .../main/res/xml/settings_experimental.xml | 8 + 13 files changed, 950 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/org/akanework/gramophone/ui/components/ComposeComponentsTemp.kt create mode 100644 app/src/main/res/drawable/mq_title_box_bg.xml create mode 100644 app/src/main/res/layout/playlist_bottom_sheet_actions.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 52a9eedd0..2fa92b301 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,7 +98,7 @@ android { // bottom sheet padding, ExoPlayer requiring multidex, vector drawables and poor SD support // That said, supporting Android 5.0 costs tolerable amounts of tech debt, and we plan to // keep support for it for a while. - minSdk = 21 + minSdk = 26 // TODO: revert later targetSdk = 35 versionCode = 20 versionName = "1.0.17" diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt index 295d32d82..9fc221dd3 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt @@ -66,6 +66,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Tracks import androidx.media3.common.util.Log +import androidx.media3.exoplayer.source.ShuffleOrder import androidx.media3.session.MediaController import androidx.media3.session.SessionCommand import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat @@ -78,9 +79,12 @@ import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVIC import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_GET_LYRICS import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_DEL import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_ENQUEUE -import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_GET_ALL +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_GET_INACTIVE +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_GET_QUEUE import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_LOAD_QUEUE +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_PIN_QUEUE import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_REORDER +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_UNPIN_QUEUE import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QUERY_TIMER import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_SET_TIMER import org.akanework.gramophone.logic.utils.AfFormatInfo @@ -94,6 +98,7 @@ import org.akanework.gramophone.ui.MainActivity import org.jetbrains.annotations.Contract import java.io.File import java.io.FileInputStream +import java.util.LinkedList import java.util.Locale import kotlin.math.max @@ -343,9 +348,9 @@ fun MediaController.getAudioFormat(): AudioFormatDetector.AudioFormats = ) } -fun MediaController.getQueues(): List? = +fun MediaController.getInactiveQueues(): List = sendCustomCommand( - SessionCommand(SERVICE_QB_GET_ALL, Bundle.EMPTY), + SessionCommand(SERVICE_QB_GET_INACTIVE, Bundle.EMPTY), Bundle.EMPTY ).get().extras.run { if (containsKey("allQueues")) { @@ -358,6 +363,78 @@ fun MediaController.getQueues(): List? = } } +fun MediaController.getQueue(index: Int = C.INDEX_UNSET): MultiQueueObject? = + sendCustomCommand( + SessionCommand(SERVICE_QB_GET_QUEUE, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ).get().extras.run { + if (containsKey("allQueues")) { + val binder = getBinder("allQueues")!! + BundleListRetriever.getList(binder).map { + MultiQueueObject.fromBundle(it) + } + } else { + throw IllegalArgumentException("expected allQueues to be set") + }.firstOrNull() + } + + +fun shuffledItems( + items: List, + order: ShuffleOrder +): List { + val result = mutableListOf() + + var i = order.firstIndex + while (i != C.INDEX_UNSET) { + result.add(items[i]) + i = order.getNextIndex(i) + } + + return result +} + +fun shuffledIndices(order: ShuffleOrder): MutableList { + val result = mutableListOf() + + var i = order.firstIndex + while (i != C.INDEX_UNSET) { + result.add(i) + i = order.getNextIndex(i) + } + + return result +} + +fun MediaController.getQueueForUi(index: Int = C.INDEX_UNSET): Pair, MutableList>? { + if (index == -1) { + return null + } + return sendCustomCommand( + SessionCommand(SERVICE_QB_GET_QUEUE, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ).get().extras.run { + if (containsKey("allQueues")) { + val binder = getBinder("allQueues")!! + BundleListRetriever.getList(binder).map { + val mq = MultiQueueObject.fromBundle(it) + val items = mq.queue + val indexes: MutableList = if (mq.shuffleOrder == null) { + (0 until mq.getSize()).toMutableList() + } else { + shuffledIndices(mq.shuffleOrder!!) + } + + Pair(indexes, items) + } + } else { + throw IllegalArgumentException("expected allQueues to be set") + }.firstOrNull() + } +} + fun MediaController.loadQueue(index: Int) { sendCustomCommand( SessionCommand(SERVICE_QB_LOAD_QUEUE, Bundle.EMPTY).apply { @@ -366,6 +443,24 @@ fun MediaController.loadQueue(index: Int) { ) } +fun MediaController.pinQueue(index: Int) { + sendCustomCommand( + SessionCommand(SERVICE_QB_PIN_QUEUE, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ) +} + + +fun MediaController.unQueue(index: Int) { + sendCustomCommand( + SessionCommand(SERVICE_QB_UNPIN_QUEUE, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ) +} + + fun MediaController.deleteQueue(index: Int): Boolean = sendCustomCommand( SessionCommand(SERVICE_QB_DEL, Bundle.EMPTY).apply { diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index 14a4295cd..16f4ff54e 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -45,7 +45,6 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat -import androidx.core.os.BundleCompat.getBinder import androidx.lifecycle.lifecycleScope import androidx.media3.common.AudioAttributes import androidx.media3.common.BundleListRetriever @@ -162,11 +161,14 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis const val SERVICE_GET_LYRICS = "get_lyrics" const val SERVICE_TIMER_CHANGED = "changed_timer" - const val SERVICE_QB_GET_ALL = "qb_get_all" + const val SERVICE_QB_GET_INACTIVE = "qb_get_all" const val SERVICE_QB_LOAD_QUEUE = "qb_load" + const val SERVICE_QB_GET_QUEUE = "qb_get_curr_queue" const val SERVICE_QB_DEL = "qb_delete" const val SERVICE_QB_REORDER = "qb_reorder" const val SERVICE_QB_ENQUEUE = "qb_enqueue" + const val SERVICE_QB_PIN_QUEUE ="qb_pin_queue" + const val SERVICE_QB_UNPIN_QUEUE ="qb_unpin_queue" var instanceForWidgetAndLyricsOnly: GramophonePlaybackService? = null } @@ -788,11 +790,14 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis availableSessionCommands.add(SessionCommand(SERVICE_QUERY_TIMER, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_GET_LYRICS, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_GET_AUDIO_FORMAT, Bundle.EMPTY)) - availableSessionCommands.add(SessionCommand(SERVICE_QB_GET_ALL, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_GET_INACTIVE, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_GET_QUEUE, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_QB_LOAD_QUEUE, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_QB_DEL, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_QB_REORDER, Bundle.EMPTY)) availableSessionCommands.add(SessionCommand(SERVICE_QB_ENQUEUE, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_PIN_QUEUE, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_UNPIN_QUEUE, Bundle.EMPTY)) return builder.setAvailableSessionCommands(availableSessionCommands.build()).build() } @@ -975,9 +980,18 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } - SERVICE_QB_GET_ALL -> { + SERVICE_QB_GET_INACTIVE -> { SessionResult(SessionResult.RESULT_SUCCESS).also { res -> - val queueList: List = qb.getAllQueues() + val queueList: List = qb.getInactiveQueues() + val binder = BundleListRetriever(queueList.map { it.toBundle() }) + res.extras.putBinder("allQueues", binder) + } + } + + SERVICE_QB_GET_QUEUE -> { + SessionResult(SessionResult.RESULT_SUCCESS).also { res -> + val index = customCommand.customExtras.getInt("index") + val queueList: List = qb.getQueue(index) val binder = BundleListRetriever(queueList.map { it.toBundle() }) res.extras.putBinder("allQueues", binder) } @@ -992,9 +1006,14 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis MediaItem.fromBundle(it) } - val mq = qb.addQueue(title, mediaList, mediaItemIndex, isOriginal) - qb.commitQueue(mq) - if (!mq.queue.isEmpty()) { + if (Flags.MQ_PREVIEW && prefs.getBooleanStrict("mq_preview", false)) { + val mq = qb.addQueue(title, mediaList, mediaItemIndex, isOriginal) + qb.commitQueue(mq) + if (!mq.queue.isEmpty()) { + endedWorkaroundPlayer!!.prepare() + endedWorkaroundPlayer!!.play() + } + } else { endedWorkaroundPlayer!!.prepare() endedWorkaroundPlayer!!.play() } @@ -1002,6 +1021,30 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis SessionResult(SessionResult.RESULT_SUCCESS) } + SERVICE_QB_LOAD_QUEUE -> { + val index = customCommand.customExtras.getInt("index") + qb.commitQueue(index) + SessionResult(SessionResult.RESULT_SUCCESS) + } + + SERVICE_QB_PIN_QUEUE -> { + val index = customCommand.customExtras.getInt("index") + qb.pinQueue(index) + SessionResult(SessionResult.RESULT_SUCCESS) + } + + SERVICE_QB_UNPIN_QUEUE -> { + val index = customCommand.customExtras.getInt("index") + qb.unpinQueue(index) + SessionResult(SessionResult.RESULT_SUCCESS) + } + + SERVICE_QB_DEL -> { + val index = customCommand.customExtras.getInt("index") + qb.deleteQueue(index) + SessionResult(SessionResult.RESULT_SUCCESS) + } + else -> { SessionResult(SessionError.ERROR_BAD_VALUE) } diff --git a/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt b/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt index e9b432337..ae8dea8b2 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt @@ -1,18 +1,17 @@ package org.akanework.gramophone.logic +import android.os.Build import android.os.Bundle import android.os.Parcelable import android.util.Log +import androidx.annotation.RequiresApi import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastSumBy import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player.REPEAT_MODE_OFF import org.akanework.gramophone.logic.utils.CircularShuffleOrder -import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer -import java.util.LinkedList -import kotlin.math.max -import kotlin.math.min +import java.time.LocalDateTime import kotlin.random.Random @@ -21,34 +20,28 @@ import kotlin.random.Random * * Queues are ordered most recent modification, */ +//@RequiresApi(Build.VERSION_CODES.O) // TODO: LocalDateTime methods requires API level 26... but I don't think so...? class QueueBoard( private val player: GramophonePlaybackService, val masterQueues: MutableList = mutableListOf(), queues: MutableList = ArrayList(), - private var maxQueues: Int = 20 ) { private val QUEUE_DEBUG = true private val TAG = QueueBoard::class.simpleName.toString() init { masterQueues.clear() - if (maxQueues < 0) { - maxQueues = 1 - } if (!queues.isEmpty()) { - masterQueues.addAll( - queues.subList( - (queues.size - maxQueues).coerceAtLeast(0), - queues.size - ) - ) + masterQueues.addAll(queues) } // todo: remove when figure out persist and load masterQueues.add( MultiQueueObject( id = Random.nextLong(), - title = "[Existing queue]", + index = 0, + title = "[LastPlayedManager]", + expiry = null, queue = ArrayList(), startIndex = C.INDEX_UNSET, startPositionMs = C.TIME_UNSET, @@ -81,16 +74,18 @@ class QueueBoard( * * @param index */ - fun commitQueue(index: Int, shouldResume: Boolean = true) { + fun commitQueue(index: Int, shouldResume: Boolean = true, saveLast: Boolean = true) { if (index < 0 || index >= masterQueues.size) { Log.w(TAG, "commitQueue() index out of bounds. Aborting") return } // assume last == active queue, second last == to load. No save when no active queue - val old = masterQueues.lastIndex - if (masterQueues.size > 1 && old >= 0) { - syncQueueFromPlayer(masterQueues[old]) + if (saveLast) { + val old = masterQueues.lastIndex + if (masterQueues.size > 1 && old >= 0) { + syncQueueFromPlayer(masterQueues[old]) + } } val new = masterQueues[index] @@ -99,6 +94,15 @@ class QueueBoard( setCurrQueue(new, false, shouldResume) } + fun pinQueue(index: Int) { + masterQueues[index].expiry = null + } + + fun unpinQueue(index: Int) { + masterQueues[index].expiry = LocalDateTime.now() + } + + /** * Add a new queue to the QueueBoard, or add to a queue if it exists. * @@ -190,13 +194,12 @@ class QueueBoard( // (4) add new queue if (QUEUE_DEBUG) Log.d(TAG, "Adding: (4) new queue") - if (masterQueues.size >= maxQueues) { - deleteQueue(masterQueues.first()) - } val newQueue = MultiQueueObject( id = Random.nextLong(), + index = -1, title = title, + expiry = LocalDateTime.now().plusHours(10L), queue = ArrayList(mediaList), startIndex = mediaItemIndex, startPositionMs = C.TIME_UNSET, @@ -230,6 +233,32 @@ class QueueBoard( return masterQueues.size } + /** + * Deletes a queue. + * + * When deleting the active queue, + * + * @param mq + */ + fun deleteQueue(index: Int): Int { + if (QUEUE_DEBUG) + Log.d(TAG, "DELETING QUEUE AT INDEX: $index") + if (index == masterQueues.lastIndex) { + masterQueues.removeAt(index) + if (index <= 0) { + player.pauseAllPlayersAndStopSelf() // TODO: correct way to stop playback + } else { + commitQueue(index - 1, false) + } + } else if (index < masterQueues.lastIndex - 1) { + masterQueues.removeAt(index) + } else { + throw IndexOutOfBoundsException("Index of queue $index to delete OOB of 0-${masterQueues.size - 1}") + } + + return masterQueues.size + } + /** * Move a queue in masterQueues * @@ -260,7 +289,18 @@ class QueueBoard( /** * Get all copy of all queues */ - fun getAllQueues() = masterQueues.dropLast(1) + fun getInactiveQueues() = masterQueues.dropLast(1) + + /** + * Get a single queue (or several queues in the future) + */ + fun getQueue(index: Int): List { + return if (index == C.INDEX_UNSET) { + masterQueues.lastOrNull() + } else { + masterQueues.getOrNull(index) + }?.let { listOf(it) } ?: emptyList() + } fun renameQueue(mq: MultiQueueObject, newName: String): Boolean { @@ -420,6 +460,9 @@ private fun MutableList.bubbleUp(mq: MultiQueueObject) { if (lastIndex >= 0) { add(lastIndex, mq) } + forEachIndexed { index, mq -> + mq.index = index + } } @@ -428,9 +471,10 @@ private fun MutableList.bubbleUp(mq: MultiQueueObject) { * @param queue List of media items */ data class MultiQueueObject( - val id: Long, -// var index: Int, // order of queue if saved to database + val id: Long, // queue uid + var index: Int, // order of queue var title: String, + var expiry: LocalDateTime?, /** * The order of songs are dynamic. This should not be accessed from outside QueueBoard. */ @@ -494,7 +538,9 @@ data class MultiQueueObject( // val binder = BundleListRetriever(queue.map { it.toBundle() }) putLong("id", id) + putInt("index", index) putString("title", title) + putString("expiry", expiry?.toString()) // putBinder("queue", binder) putParcelableArrayList("queue", ArrayList(queue.map { it.toBundle() })) @@ -513,11 +559,12 @@ data class MultiQueueObject( // val binder = bundle.getBinder("queue")!! // val queue = BundleListRetriever.getList(binder).map { MediaItem.fromBundle(it) } // .toMutableList() - +// val epochMillis = bundle.getLong("expiry") return MultiQueueObject( id = bundle.getLong("id"), + index = bundle.getInt("index"), title = bundle.getString("title") ?: "", - + expiry = bundle.getString("expiry")?.let { LocalDateTime.parse(it) }, // queue = queue, queue = (bundle.getParcelableArrayList("queue") ?: emptyList()).map { MediaItem.fromBundle(it) }.toMutableList(), diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/Flags.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/Flags.kt index e39070539..3dd52451f 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/utils/Flags.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/Flags.kt @@ -15,4 +15,7 @@ object Flags { // It uses MediaStore favorites and I'm not sure if that was a good idea const val FAVORITE_SONGS = false // TODO(ASAP) var PLAYLIST_EDITING: Boolean? = null // TODO(ASAP) + + // Multiple queues + const val MQ_PREVIEW: Boolean = false } diff --git a/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt b/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt index 93de402c5..cfb8b9f8c 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/adapters/SongAdapter.kt @@ -34,10 +34,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.akanework.gramophone.R -import org.akanework.gramophone.logic.GramophonePlaybackService import org.akanework.gramophone.logic.playQueue import org.akanework.gramophone.logic.getFile -import org.akanework.gramophone.logic.getQueues import org.akanework.gramophone.logic.requireMediaStoreId import org.akanework.gramophone.logic.utils.Flags import org.akanework.gramophone.ui.MainActivity diff --git a/app/src/main/java/org/akanework/gramophone/ui/components/ComposeComponentsTemp.kt b/app/src/main/java/org/akanework/gramophone/ui/components/ComposeComponentsTemp.kt new file mode 100644 index 000000000..2145a576c --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/ui/components/ComposeComponentsTemp.kt @@ -0,0 +1,460 @@ +package org.akanework.gramophone.ui.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.List +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.session.MediaBrowser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.akanework.gramophone.R +import org.akanework.gramophone.logic.MultiQueueObject +import org.akanework.gramophone.logic.deleteQueue +import org.akanework.gramophone.logic.getInactiveQueues +import org.akanework.gramophone.logic.getQueue +import org.akanework.gramophone.logic.loadQueue + +@Composable +fun MqListItem( + mqState: MqState, +// queueListState: ReorderableLazyListState, // sh.calvin.reorderable.ReorderableLazyListState + index: Int, + mq: MultiQueueObject, + modifier: Modifier = Modifier, + isActiveQueue: Boolean = false, + isInactiveActiveQueue: Boolean = false, + isEditAllowed: Boolean = true, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, +) { + Row( // wrapper + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isActiveQueue) { + MaterialTheme.colorScheme.tertiary.copy(0.3f) + } else if (isInactiveActiveQueue) { + MaterialTheme.colorScheme.tertiary.copy(0.1f) + } else { + Color.Transparent + } + ) + .combinedClickable( +// enabled = !inSelectMode, + onClick = onClick, + onLongClick = onLongClick + ) + ) { + Row( // row contents (wrapper is needed for margin) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f, false) + ) { + if (isEditAllowed) { + IconButton( + onClick = { + mqState.removeQueue(index) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = null + ) + } + } + Text( + text = "${index + 1}. ${mq.title}", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 0.dp) + ) + } + + if (isEditAllowed) { + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = null, +// modifier = Modifier.draggableHandle() + ) + } + } + } +} + + +@Composable +fun MqContent( + mqState: MqState, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + + val mqExpand = mqState.expanded + val animatedMinHeight by animateDpAsState( + targetValue = if (mqExpand) 300.dp else 0.dp, + label = "queueListHeight" + ) + + // clean up later + val MediumCornerRadius = 12.dp + val landscape = false + // clean up later + + Column( + modifier = modifier + .fillMaxWidth(), + ) { + + // queue info + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp, 4.dp) + ) { + // queue title and show multiqueue button + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colorScheme.secondary, + RoundedCornerShape(MediumCornerRadius) + ) + .padding(2.dp) + .weight(1f) + .clickable(enabled = !landscape) { + mqState.toggleExpand() + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + ) { + Text( + text = mqState.getQueueTitle() ?: "", + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + IconButton( + enabled = !landscape, + onClick = { + mqState.toggleExpand() + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + }, + modifier = Modifier.padding(vertical = 6.dp) + ) { + Icon( + painter = painterResource(if (mqExpand) R.drawable.baseline_arrow_upward_24 else R.drawable.ic_expand_more), + contentDescription = null, + ) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = mqState.getQueuePositionStr(), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = makeTimeString(mqState.getQueueLength()), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + val lazyQueuesListState = rememberLazyListState() + LazyColumn( + state = lazyQueuesListState, + modifier = Modifier + .fillMaxWidth() + .heightIn(0.dp, animatedMinHeight), + ) { + if (mqState.getQueueListSize() == 0) { + item { + EmptyPlaceholder( + icon = Icons.AutoMirrored.Rounded.List, + text = stringResource(R.string.oh_no), + modifier = Modifier.animateItem() + ) + } + } + itemsIndexed( + items = mqState.inactiveQueues, + key = { _, item -> item.id }, + ) { index, mq -> + MqListItem( + mqState = mqState, + index = index, + mq = mq, + isActiveQueue = false, + isInactiveActiveQueue = mq == mqState.detachedQueue, + onClick = { + mqState.detach(mq) + }, + ) + } + mqState.activeQueue?.let { + item { + MqListItem( + mqState = mqState, + index = mqState.getQueueListSize() - 1, + mq = it, + isActiveQueue = true, + isInactiveActiveQueue = false, + onClick = { + mqState.resetHead() + }, + ) + } + } + if (mqState.isDetached()) + item { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + IconButton( + onClick = { + mqState.loadDetached() + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_play_arrow), + contentDescription = null + ) + } + } + } + } + } +} + +// clean up later +fun makeTimeString(duration: Long?): String { + if (duration == null || duration < 0) return "" + var sec = duration / 1000 + val day = sec / 86400 + sec %= 86400 + val hour = sec / 3600 + sec %= 3600 + val minute = sec / 60 + sec %= 60 + return when { + day > 0 -> "%d:%02d:%02d:%02d".format(day, hour, minute, sec) + hour > 0 -> "%d:%02d:%02d".format(hour, minute, sec) + else -> "%d:%02d".format(minute, sec) + } +} + +@Composable +fun EmptyPlaceholder( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(12.dp) + ) { + Image( + icon, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(64.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge + ) + } +} + +// clean up later + + +class MqState( + private val coroutineScope: CoroutineScope, + private val instance: MediaBrowser?, + private val playlistQueueSheet: PlaylistQueueSheet?, +) { + var expanded by mutableStateOf(false) + private set + + var detachedQueue: MultiQueueObject? by mutableStateOf(null) + private set + + var activeQueue: MultiQueueObject? by mutableStateOf(null) + private set + + var inactiveQueues = mutableStateListOf() + private set + + init { + init() + } + + private fun init() { + coroutineScope.launch { + activeQueue = null + detachedQueue = null + inactiveQueues.clear() + + instance?.getQueue()?.let { + activeQueue = it + } + instance?.getInactiveQueues()?.toMutableList()?.let { + inactiveQueues.addAll(it) + } + } + } + + fun getQueueListSize(): Int = inactiveQueues.size + if (activeQueue == null) 0 else 1 + + fun getQueueTitle(): String? { + return if (!isDetached()) { + activeQueue?.title + } else { + detachedQueue?.title + } + } + + fun getQueueLength(): Long { + return if (!isDetached()) { + activeQueue?.queue?.sumOf { it.mediaMetadata.durationMs ?: 0L } ?: 0L + } else detachedQueue?.queue?.sumOf { it.mediaMetadata.durationMs ?: 0L } ?: 0L + } + + fun getQueuePositionStr(): String { + return if (!isDetached()) { + activeQueue?.let { + "${(instance?.currentMediaItemIndex ?: -1) + 1} / ${it.getSize()}" + } + } else { + detachedQueue?.let { + "${it.startIndex + 1} / ${it.getSize()}" + } + } ?: "–/–" + } + + fun isDetached(): Boolean = detachedQueue != null + + fun detach(index: Int) { + detachedQueue = inactiveQueues.getOrNull(index) + } + + fun detach(mq: MultiQueueObject) { + detachedQueue = mq + playlistQueueSheet?.forceUpdate(inactiveQueues.indexOf(mq)) + } + + fun resetHead() { + detachedQueue = null + playlistQueueSheet?.forceUpdate(-1) + } + + fun toggleExpand() { + if (!expanded) { + expand() + } else { + collapse() + } + } + + private fun expand() { + expanded = true + } + + private fun collapse() { + expanded = false + resetHead() + } + + fun removeQueue(index: Int) { + instance?.deleteQueue(index) + } + + fun loadDetached() { + instance?.loadQueue(inactiveQueues.indexOf(detachedQueue)) + expanded = false + resetHead() + coroutineScope.launch { + delay(500) + init() + } + } +} + +@Composable +fun rememberMqState( + coroutineScope: CoroutineScope, + instance: MediaBrowser?, + playlistQueueSheet: PlaylistQueueSheet?, +): MqState { + return remember { + MqState(coroutineScope, instance, playlistQueueSheet) + } // TODO: rememberSaveable +} diff --git a/app/src/main/java/org/akanework/gramophone/ui/components/PlaylistQueueSheet.kt b/app/src/main/java/org/akanework/gramophone/ui/components/PlaylistQueueSheet.kt index e126ed60b..28dd58612 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/components/PlaylistQueueSheet.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/components/PlaylistQueueSheet.kt @@ -1,26 +1,60 @@ package org.akanework.gramophone.ui.components import android.content.Context +import android.content.SharedPreferences import android.os.SystemClock +import android.view.LayoutInflater import android.view.View import android.widget.Button +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.session.MediaBrowser import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog +import kotlinx.coroutines.launch import org.akanework.gramophone.R +import org.akanework.gramophone.logic.getQueueForUi import org.akanework.gramophone.logic.replaceAllSupport import org.akanework.gramophone.logic.ui.MyRecyclerView import org.akanework.gramophone.logic.utils.convertDurationToTimeStamp +import org.akanework.gramophone.ui.GramophoneTheme import org.akanework.gramophone.ui.MainActivity import java.util.LinkedList +import kotlin.text.format // TODO: support listening to externally caused changes to playlist (ie MCT). class PlaylistQueueSheet( @@ -30,13 +64,13 @@ class PlaylistQueueSheet( get() = activity.getPlayer() private val playlistAdapter: PlaylistCardAdapter private val touchHelper: ItemTouchHelper - private val durationView: Chronometer + private val queueHead: ComposeView + + private val durationState = mutableStateOf(false) init { setContentView(R.layout.playlist_bottom_sheet) behavior.state = BottomSheetBehavior.STATE_EXPANDED - durationView = findViewById(R.id.duration)!! - durationView.isCountDown = true val recyclerView = findViewById(R.id.recyclerview)!! ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, ic -> val i = ic.getInsets( @@ -74,15 +108,7 @@ class PlaylistQueueSheet( (context.resources.getDimensionPixelOffset(R.dimen.list_height) * 0.5f).toInt() ) recyclerView.fastScroll(null, null) - findViewById