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..d1191825f 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneExtensions.kt @@ -59,12 +59,14 @@ 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 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 @@ -75,6 +77,14 @@ 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_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 @@ -88,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 @@ -337,6 +348,148 @@ fun MediaController.getAudioFormat(): AudioFormatDetector.AudioFormats = ) } +fun MediaController.getInactiveQueues(): List = + sendCustomCommand( + SessionCommand(SERVICE_QB_GET_INACTIVE, Bundle.EMPTY), + Bundle.EMPTY + ).get().extras.run { + val binder = getBinder("allQueues")!! + BundleListRetriever.getList(binder).map { + MultiQueueObject.fromBundle(it) + } + } + +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 { + val binder = getBinder("allQueues")!! + BundleListRetriever.getList(binder).map { + MultiQueueObject.fromBundle(it) + }.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 { + 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) + }.firstOrNull() + } +} + +fun MediaController.loadQueue(index: Int) { + sendCustomCommand( + SessionCommand(SERVICE_QB_LOAD_QUEUE, Bundle.EMPTY).apply { + customExtras.putInt("index", index) + }, Bundle.EMPTY + ) +} + +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 { + 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 0104cf8bc..2be3c963c 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -45,6 +45,7 @@ import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat 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 @@ -141,11 +142,22 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis private const val PENDING_INTENT_SESSION_ID = 0 const val PENDING_INTENT_NOTIFY_ID = 1 const val PENDING_INTENT_WIDGET_ID = 2 + 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_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 } @@ -156,6 +168,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val endedWorkaroundPlayer get() = mediaSession?.player as EndedWorkaroundPlayer? private var controller: MediaBrowser? = null + lateinit var qb: QueueBoard private val sendLyrics = Runnable { scheduleSendingLyrics(false) } var lyrics: SemanticLyrics? = null private set @@ -255,6 +268,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis override fun onCreate() { Log.i(TAG, "+onCreate()") super.onCreate() + qb = QueueBoard(this) instanceForWidgetAndLyricsOnly = this internalPlaybackThread.start() playbackHandler = Handler(internalPlaybackThread.looper) @@ -683,6 +697,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_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() } @@ -865,6 +887,72 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } + SERVICE_QB_GET_INACTIVE -> { + SessionResult(SessionResult.RESULT_SUCCESS).also { res -> + 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) + } + } + + 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) + } + + 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!!.setMediaItems(mediaList, mediaItemIndex, C.TIME_UNSET) + endedWorkaroundPlayer!!.prepare() + endedWorkaroundPlayer!!.play() + } + + 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 new file mode 100644 index 000000000..395e4d177 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt @@ -0,0 +1,584 @@ +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 kotlin.random.Random + +private const val QUEUE_EXPIRY_MS = 10 * 36000000 // 10 hrs + +/** + * Multiple queues manager. + * + * Queues are ordered most recent modification, + */ +class QueueBoard( + private val player: GramophonePlaybackService, + val masterQueues: MutableList = mutableListOf(), + queues: MutableList = ArrayList(), +) { + private val QUEUE_DEBUG = true + private val TAG = QueueBoard::class.simpleName.toString() + + init { + masterQueues.clear() + if (!queues.isEmpty()) { + masterQueues.addAll(queues) + } + + // todo: remove when figure out persist and load + masterQueues.add( + MultiQueueObject( + id = Random.nextLong(), + index = 0, + title = "[LastPlayedManager]", + expiry = null, + 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, 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 + if (saveLast) { + 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, true, shouldResume) + } + + fun pinQueue(index: Int) { + masterQueues[index].expiry = null + } + + fun unpinQueue(index: Int) { + masterQueues[index].expiry = System.currentTimeMillis() + QUEUE_EXPIRY_MS + } + + /** + * Remove expired queues from the QueueBoard + */ + fun trimQB() { + val currentTimeMillis = System.currentTimeMillis() + val newQueueList = masterQueues.filter { + it.expiry == null || it.expiry!! > currentTimeMillis + } + masterQueues.clear() + masterQueues.addAll(newQueueList) + } + + + /** + * 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") + + val newQueue = MultiQueueObject( + id = Random.nextLong(), + index = -1, + title = title, + expiry = System.currentTimeMillis() + QUEUE_EXPIRY_MS, + 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 + } + + /** + * Deletes a queue. + * + * When deleting the active queue, the last inactive queue is loaded. When the active queue is + * the only queue, playback is stopped. + * + * @param index + */ + 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.endedWorkaroundPlayer?.removeMediaItems(0, Int.MAX_VALUE) + } 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 + * + * @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 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 { + 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 + } + + 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) { + // remove all songs before the currently playing one and then replace all the items after + if (playerIndex > 0) { + plr.removeMediaItems(0, playerIndex) + } + plr.replaceMediaItems(1, Int.MAX_VALUE, 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, + if (shouldResume) mq.startPositionMs else C.TIME_UNSET + ) + } + + 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) + } + forEachIndexed { index, mq -> + mq.index = index + } +} + + +/** + * @param title Queue title (and UID) + * @param queue List of media items + */ +data class MultiQueueObject( + val id: Long, // queue uid + var index: Int, // order of queue + var title: String, + var expiry: Long?, + /** + * 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) + putInt("index", index) + putString("title", title) + putString("expiry", expiry?.toString()) + +// 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() +// val epochMillis = bundle.getLong("expiry") + return MultiQueueObject( + id = bundle.getLong("id"), + index = bundle.getInt("index"), + title = bundle.getString("title") ?: "", + expiry = bundle.getString("expiry")?.toLongOrNull(), +// 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 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/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 bd2d0ac1c..8c4b8ffd0 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,6 +34,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.akanework.gramophone.R +import org.akanework.gramophone.logic.playQueue import org.akanework.gramophone.logic.getFile import org.akanework.gramophone.logic.requireMediaStoreId import org.akanework.gramophone.logic.utils.Flags @@ -177,9 +175,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, + ) } } 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..ee07354d6 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/ui/components/ComposeComponentsTemp.kt @@ -0,0 +1,462 @@ +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 +import org.akanework.gramophone.logic.utils.Flags.MQ_PREVIEW + +@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, + mqEnabled: Boolean = false, +) { + 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 = mqEnabled && !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 = mqEnabled && !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 403eeda12..6a5368b71 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,25 +1,61 @@ 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 android.widget.Chronometer +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.preference.PreferenceManager 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.getBooleanStrict +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.Flags import org.akanework.gramophone.logic.utils.convertDurationToTimeStamp +import org.akanework.gramophone.ui.GramophoneTheme import org.akanework.gramophone.ui.MainActivity import java.util.LinkedList @@ -27,17 +63,22 @@ import java.util.LinkedList class PlaylistQueueSheet( context: Context, private val activity: MainActivity ) : BottomSheetDialog(context), Player.Listener { + private var prefs: SharedPreferences private val instance: MediaBrowser? get() = activity.getPlayer() private val playlistAdapter: PlaylistCardAdapter private val touchHelper: ItemTouchHelper - private val durationView: Chronometer + private val queueHead: ComposeView + + private val durationState = mutableStateOf(false) + private val mqEnabled: Boolean init { + prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + mqEnabled = Flags.MQ_PREVIEW && prefs.getBooleanStrict("mq_preview", false) + 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( @@ -75,15 +116,7 @@ class PlaylistQueueSheet( (context.resources.getDimensionPixelOffset(R.dimen.list_height) * 0.5f).toInt() ) recyclerView.fastScroll(null, null) - findViewById