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..77db33a91 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,13 @@ 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_GET_INACTIVE_LIST +import org.akanework.gramophone.logic.GramophonePlaybackService.Companion.SERVICE_QB_GET_QUEUE_FOR_UI +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 @@ -337,6 +346,129 @@ fun MediaController.getAudioFormat(): AudioFormatDetector.AudioFormats = ) } +fun MediaController.getInactiveQueues(): List = + sendCustomCommand( + SessionCommand(SERVICE_QB_GET_INACTIVE_LIST, 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_FOR_UI, 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 = -1): Pair, MultiQueueObject>? { + if (index == -1) { + return null + } + return sendCustomCommand( + SessionCommand(SERVICE_QB_GET_QUEUE_FOR_UI, 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 indexes: MutableList = if (mq.shuffleOrder == null) { + (0 until mq.getSize()).toMutableList() + } else { + getIntArray("shuffleIndexes")!!.toMutableList() + } + + Pair(indexes, mq) + }.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") + } + fun Tracks.getFirstSelectedTrackFormatByType(type: @C.TrackType Int): Format? { for (i in groups) { if (i.type == type) { @@ -503,6 +635,16 @@ fun WindowInsetsCompat.clone(): WindowInsetsCompat = it.unconsumeIfNeeded() }) +fun Context.supportsWideScreen() : Boolean { + val config = resources.configuration + return config.screenWidthDp >= 780 +} + +fun Context.isTablet() : Boolean { + val config = resources.configuration + return config.smallestScreenWidthDp >= 780 +} + val Context.gramophoneApplication get() = this.applicationContext as GramophoneApplication 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..28cdf7b74 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -47,6 +47,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 @@ -122,6 +123,7 @@ import org.akanework.gramophone.logic.utils.ReplayGainAudioProcessor import org.akanework.gramophone.logic.utils.ReplayGainUtil import org.akanework.gramophone.logic.utils.SemanticLyrics import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer +import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer.Companion.queueWithTitle import org.akanework.gramophone.logic.utils.exoplayer.GramophoneExtractorsFactory import org.akanework.gramophone.logic.utils.exoplayer.GramophoneMediaSourceFactory import org.akanework.gramophone.logic.utils.exoplayer.GramophoneRenderFactory @@ -153,11 +155,21 @@ 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_INACTIVE_LIST = "qb_get_inactive_list" + const val SERVICE_QB_LOAD_QUEUE = "qb_load" + const val SERVICE_QB_GET_QUEUE_FOR_UI = "qb_get_queue_for_ui" + const val SERVICE_QB_DEL = "qb_delete" + const val SERVICE_QB_REORDER = "qb_reorder" + const val SERVICE_QB_PIN_QUEUE ="qb_pin_queue" + const val SERVICE_QB_UNPIN_QUEUE ="qb_unpin_queue" + var instanceForWidgetAndLyricsOnly: GramophonePlaybackService? = null } @@ -168,6 +180,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 @@ -273,6 +286,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis handler = Handler(Looper.getMainLooper()) nm = NotificationManagerCompat.from(this) prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + qb = QueueBoard(this) setListener(this) setMediaNotificationProvider( MeiZuLyricsMediaNotificationProvider(this) { lastSentHighlightedLyric } @@ -346,7 +360,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis prefs.registerOnSharedPreferenceChangeListener(this) onSharedPreferenceChanged(prefs, null) // read initial values val player = EndedWorkaroundPlayer( - ExoPlayer.Builder( + exoPlayer = ExoPlayer.Builder( this, GramophoneRenderFactory( this, rgAp, this::onAudioSinkInputFormatChanged, @@ -391,7 +405,8 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis .build())) }) .setPlaybackLooper(internalPlaybackThread.looper) - .build() + .build(), + queueBoard = qb, ) player.exoPlayer.addAnalyticsListener(EventLogger()) player.exoPlayer.addAnalyticsListener(afFormatTracker) @@ -545,7 +560,9 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val list = runBlocking { mapMediaItemsForFavorites(items.mediaItems) } try { mediaSession?.player?.setMediaItems( - list, items.startIndex, items.startPositionMs + queueWithTitle(list, "lastPlayedManager"), + items.startIndex, + items.startPositionMs ) } catch (e: IllegalSeekPositionException) { try { @@ -777,6 +794,13 @@ 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_LIST, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_GET_QUEUE_FOR_UI, 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_PIN_QUEUE, Bundle.EMPTY)) + availableSessionCommands.add(SessionCommand(SERVICE_QB_UNPIN_QUEUE, Bundle.EMPTY)) return builder.setAvailableSessionCommands(availableSessionCommands.build()).build() } @@ -959,6 +983,64 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } + SERVICE_QB_GET_INACTIVE_LIST -> { + 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_FOR_UI -> { + 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) + + // assume ui does not expect shuffleIndexes if shuffle is off + if (!queueList.isEmpty()) { + val mq = queueList.first() + val factory = + CircularShuffleOrder.Persistent.deserialize(mq.shuffleOrder) + .toFactory() + val shuffleOrder = factory(0, mq.getSize(), endedWorkaroundPlayer!!) + val shuffleIndexesList: List = shuffledIndices(shuffleOrder) + res.extras.putIntArray("shuffleIndexes", shuffleIndexesList.toIntArray()) + } + } + } + + 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).also { res -> + res.extras.putBoolean("status", false) + } + } + + SERVICE_QB_UNPIN_QUEUE -> { + val index = customCommand.customExtras.getInt("index") + qb.unpinQueue(index) + SessionResult(SessionResult.RESULT_SUCCESS).also { res -> + res.extras.putBoolean("status", false) + } + } + + SERVICE_QB_DEL -> { + val index = customCommand.customExtras.getInt("index") + qb.deleteQueue(index) + SessionResult(SessionResult.RESULT_SUCCESS).also { res -> + res.extras.putBoolean("status", false) + } + } + 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..261baaeb3 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/QueueBoard.kt @@ -0,0 +1,592 @@ +package org.akanework.gramophone.logic + +import android.content.Context +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) + } + } + + /** + * ======================== + * Data structure management + * ======================== + */ + + + /** + * Push this queue to the player, and save the player queue back to QueueBoard + * + * @param mq + */ + fun commitQueue( + mq: MultiQueueObject, + setMediaItems: Boolean = true, + shouldResume: Boolean = true + ) = + commitQueue(masterQueues.indexOf(mq), setMediaItems, 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, + setMediaItems: Boolean = true, + shouldResume: Boolean = true, + saveLast: Boolean = true + ) { + Log.v(TAG, "commitQueue() called") + if (index < 0 || index >= masterQueues.size) { + Log.w(TAG, "commitQueue() index $index out of bounds (size = ${masterQueues.size}). 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) + if (setMediaItems) { + 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, + startPositionMs: Long?, + isOriginal: Boolean = true, + shouldPin: 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") + } else if (isOriginal) { + // (2) replace all in queue + if (QUEUE_DEBUG) + Log.d(TAG, "Adding: (2) perfect match") + + match.queue.clear() + match.queue.addAll(mediaList) + } else { + // (3) add all to end of the queue. Create extension queue + if (QUEUE_DEBUG) + Log.d(TAG, "Adding: (3) perfect match") + + 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)" + } + } + + match.startIndex = mediaItemIndex + startPositionMs?.let { + match.startPositionMs = it + } + + 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 = if (!shouldPin) System.currentTimeMillis() + QUEUE_EXPIRY_MS else null, + queue = ArrayList(mediaList), + startIndex = mediaItemIndex, + startPositionMs = startPositionMs ?: C.TIME_UNSET, + repeatMode = player.endedWorkaroundPlayer!!.repeatMode, + 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).map { it.copy(queue = ArrayList()) } + + /** + * 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.realSetMediaItems(ArrayList(), C.INDEX_UNSET, C.TIME_UNSET) + 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}" + ) + + val seed = try { + CircularShuffleOrder.Persistent.deserialize(mq.shuffleOrder) + } catch (e: Exception) { + plr.nextShuffleOrder = null + throw e + } + + /** + * 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 + + plr.replaceMediaItem(playerIndex, mediaItems[playerIndex]) // update current's metadata + 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) + ) + } + + plr.exoPlayer.setShuffleOrder(seed.toFactory()(mq.startIndex, mq.getSize(), plr)) + } else { + Log.d(TAG, "Seamless is not supported. Loading songs in directly") + + if (plr.nextShuffleOrder != null) + throw IllegalStateException("shuffleFactory was found orphaned") + + plr.nextShuffleOrder = seed.toFactory() + + plr.realSetMediaItems( + mediaItems, startIndex, + if (shouldResume) mq.startPositionMs else C.TIME_UNSET + ) + if (plr.nextShuffleOrder != null) + throw IllegalStateException("shuffleFactory was not consumed during restore") + } + + plr.shuffleModeEnabled = mq.shuffleModeEnabled + 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 + } + + /** + * Update the queue in QueueBoard with player attributes + */ + private fun syncQueueFromPlayer(mq: MultiQueueObject) { + val plr = player.endedWorkaroundPlayer!! + mq.startIndex = plr.currentMediaItemIndex + mq.startPositionMs = plr.currentPosition + mq.repeatMode = plr.repeatMode + val persistent = if (mq.shuffleModeEnabled) { + CircularShuffleOrder.Persistent(plr.exoPlayer.shuffleOrder as CircularShuffleOrder) + } else { + null + } + mq.shuffleOrder = persistent?.toString() + mq.queue.clear() + mq.queue.addAll(dumpPlaylist()) + } + + val context + get() = player as Context + +} + +/** + * Move this queue to the last non-active spot. If there are no queues, this queue gets added to the + * active slot + */ +private fun MutableList.bubbleUp(mq: MultiQueueObject) { + if (!isEmpty()) { + remove(mq) + if (lastIndex >= 0) { + add(lastIndex, mq) + } + } else { + add(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 shuffleOrder: String? = null, + // TODO: why did i need this again? + var ended: Boolean = false, +) { + override fun toString() = + "$title ($id) startIndex=$startIndex, startPositionMs=$startPositionMs, repeatMode=$repeatMode, shuffleModeEnabled=$shuffleModeEnabled, ended=$ended, mediaItems_size=${queue.size}" + + val shuffleModeEnabled + get() = shuffleOrder != null + + /** + * 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 } + } + + /** + * 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) + putString("shuffleOrder", 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), + ended = bundle.getBoolean("ended"), + shuffleOrder = bundle.getString("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/logic/utils/exoplayer/EndedWorkaroundPlayer.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt index 56494a65a..7df61f866 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/EndedWorkaroundPlayer.kt @@ -1,11 +1,18 @@ package org.akanework.gramophone.logic.utils.exoplayer +import android.os.Bundle +import androidx.media3.common.C import androidx.media3.common.DeviceInfo import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.Log import androidx.media3.exoplayer.ExoPlayer +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import org.akanework.gramophone.BuildConfig +import org.akanework.gramophone.R +import org.akanework.gramophone.logic.QueueBoard import org.akanework.gramophone.logic.utils.CircularShuffleOrder @@ -14,11 +21,27 @@ import org.akanework.gramophone.logic.utils.CircularShuffleOrder * update to STATE_ENDED and only then media3 will wrap around playlist for us. This is a workaround * to restore STATE_ENDED as well and fake it for media3 until it indeed wraps around playlist. */ -class EndedWorkaroundPlayer(exoPlayer: ExoPlayer) : ForwardingSimpleBasePlayer(exoPlayer), +class EndedWorkaroundPlayer( + exoPlayer: ExoPlayer, + val queueBoard: QueueBoard +) : ForwardingSimpleBasePlayer(exoPlayer), Player.Listener { companion object { private const val TAG = "EndedWorkaroundPlayer" + + fun queueWithTitle(mediaItems: List, mqTitle: String?): List { + if (mediaItems.isEmpty() || mqTitle == null) return mediaItems + val firstMediaItem = mediaItems.first() + val newFirstMediaItem = firstMediaItem.buildUpon().setMediaMetadata( + firstMediaItem.mediaMetadata.buildUpon().setExtras( + Bundle().apply { + putString("mq_title", mqTitle) + } + ).build() + ).build() + return listOf(newFirstMediaItem) + mediaItems.drop(1) + } } private val remoteDeviceInfo = DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build() @@ -67,4 +90,29 @@ class EndedWorkaroundPlayer(exoPlayer: ExoPlayer) : ForwardingSimpleBasePlayer(e } return super.getState() } + + fun realSetMediaItems( + mediaItems: List, + startIndex: Int, + startPositionMs: Long + ) = super.handleSetMediaItems(mediaItems, startIndex, startPositionMs) + + override fun handleSetMediaItems( + mediaItems: List, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture<*> { + val defaultQueueTitle = queueBoard.context.getString(R.string.unknown_playlist) + val firstMediaItem = mediaItems.firstOrNull() + val mqTitle = firstMediaItem?.mediaMetadata?.extras?.getString("mq_title") + + val mq = queueBoard.addQueue( + title = mqTitle ?: defaultQueueTitle, + mediaList = ArrayList(), + mediaItemIndex = C.INDEX_UNSET, + startPositionMs = C.TIME_UNSET, + ) + queueBoard.commitQueue(mq, setMediaItems = false) + return super.handleSetMediaItems(mediaItems, startIndex, startPositionMs) + } } \ No newline at end of file diff --git a/app/src/main/java/org/akanework/gramophone/ui/Compose.kt b/app/src/main/java/org/akanework/gramophone/ui/Compose.kt index aea9ec124..d906aebd2 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/Compose.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/Compose.kt @@ -5,12 +5,15 @@ import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color @@ -90,6 +93,12 @@ fun GramophoneTheme( dynamicLightColorScheme(LocalContext.current) else lightColorScheme() - }), content = content + }), content = { + CompositionLocalProvider( + LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.surface), + ) { + content() + } + } ) } 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..e095dc943 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 @@ -18,6 +18,7 @@ package org.akanework.gramophone.ui.adapters import android.content.Context +import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -25,6 +26,7 @@ import android.widget.TextView import androidx.appcompat.widget.PopupMenu import androidx.core.content.edit import androidx.core.content.pm.ShortcutManagerCompat +import androidx.media3.common.MediaItem import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearSmoothScroller @@ -33,6 +35,7 @@ import com.google.android.material.button.MaterialButton import org.akanework.gramophone.R import org.akanework.gramophone.logic.ui.ItemHeightHelper import org.akanework.gramophone.logic.ui.MyRecyclerView +import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer.Companion.queueWithTitle import org.akanework.gramophone.ui.fragments.AdapterFragment import org.akanework.gramophone.ui.getAdapterType @@ -158,12 +161,17 @@ open class BaseDecorAdapter>( } holder.playAll.setOnClickListener { if (adapter is SongAdapter) { - val mediaController = adapter.getActivity().getPlayer() + val controller = adapter.getActivity().getPlayer() val songList = adapter.getSongList() - mediaController?.apply { + controller?.apply { shuffleModeEnabled = false repeatMode = REPEAT_MODE_OFF - setMediaItems(songList) + setMediaItems( + queueWithTitle( + songList, + context.getString(R.string.category_songs) + ) + ) if (songList.isNotEmpty()) { prepare() play() @@ -172,13 +180,20 @@ open class BaseDecorAdapter>( } 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.flatMap { it.songList }) - controller?.prepare() - controller?.play() - } ?: controller?.setMediaItems(listOf()) + controller?.apply { + repeatMode = REPEAT_MODE_OFF + shuffleModeEnabled = false + list.takeIf { it.isNotEmpty() }?.also { albums -> + setMediaItems( + queueWithTitle( + albums.flatMap { it.songList }, + context.getString(R.string.category_albums) + ) + ) + prepare() + play() + } ?: setMediaItems(listOf()) + } } } holder.shuffleAll.setOnClickListener { @@ -186,22 +201,36 @@ open class BaseDecorAdapter>( if (adapter is SongAdapter) { val songList = adapter.getSongList() val controller = adapter.getActivity().getPlayer() - controller?.shuffleModeEnabled = true - controller?.setMediaItems(songList) - if (songList.isNotEmpty()) { - controller?.prepare() - controller?.play() + controller?.apply { + shuffleModeEnabled = true + setMediaItems( + queueWithTitle( + songList, + context.getString(R.string.category_songs) + ) + ) + if (songList.isNotEmpty()) { + prepare() + play() + } } } 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?.setMediaItems(listOf()) + controller?.apply { + repeatMode = REPEAT_MODE_OFF + shuffleModeEnabled = false + list.takeIf { it.isNotEmpty() }?.also { albums -> + setMediaItems( + queueWithTitle( + albums.shuffled().flatMap { it.songList }, + context.getString(R.string.category_songs) + ) + ) + prepare() + play() + } ?: setMediaItems(listOf()) + } } } holder.jumpUp.visibility = if (jumpUpPos != null) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/akanework/gramophone/ui/adapters/PlaylistAdapter.kt b/app/src/main/java/org/akanework/gramophone/ui/adapters/PlaylistAdapter.kt index f0f4b58c2..4b8b1ad0f 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/adapters/PlaylistAdapter.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/adapters/PlaylistAdapter.kt @@ -59,7 +59,7 @@ import uk.akane.libphonograph.manipulator.ItemManipulator import java.io.File /** - * [PlaylistAdapter] is an adapter for displaying artists. + * [PlaylistAdapter] is an adapter for displaying playlists. */ class PlaylistAdapter( fragment: AdapterFragment, 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..88d73adec 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,7 +20,6 @@ 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 @@ -29,7 +28,6 @@ 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 @@ -40,6 +38,7 @@ import org.akanework.gramophone.R import org.akanework.gramophone.logic.getFile import org.akanework.gramophone.logic.requireMediaStoreId import org.akanework.gramophone.logic.utils.Flags +import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer.Companion.queueWithTitle import org.akanework.gramophone.ui.MainActivity import org.akanework.gramophone.ui.MediaControllerViewModel import org.akanework.gramophone.ui.components.NowPlayingDrawable @@ -177,7 +176,7 @@ class SongAdapter( val mediaController = mainActivity.getPlayer() mediaController?.apply { val songList = getSongList() - setMediaItems(songList, position, C.TIME_UNSET) + setMediaItems(queueWithTitle(songList, "What is this???"), position, C.TIME_UNSET) prepare() play() } 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..d60b65339 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,50 +1,95 @@ 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.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.viewinterop.AndroidView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope 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.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import org.akanework.gramophone.R +import org.akanework.gramophone.logic.dpToPx +import org.akanework.gramophone.logic.getBooleanStrict +import org.akanework.gramophone.logic.getQueueForUi +import org.akanework.gramophone.logic.isTablet 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 org.akanework.gramophone.ui.fragments.compose.QueueRoot +import org.akanework.gramophone.ui.fragments.compose.rememberMqState import java.util.LinkedList // TODO: support listening to externally caused changes to playlist (ie MCT). +// TODO: Playing indicator does not update when shuffling 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 queueActionsView: ConstraintLayout private val durationView: Chronometer + private val queueHead: ComposeView + 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)!! + if (mqEnabled) { + behavior.maxWidth = 900.dpToPx(context) + } + + val recyclerView = MyRecyclerView(context) ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, ic -> val i = ic.getInsets( WindowInsetsCompat.Type.systemBars() + or WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.displayCutout() ) val i2 = ic.getInsetsIgnoringVisibility( WindowInsetsCompat.Type.systemBars() + or WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.displayCutout() ) v.setPadding(i.left, 0, i.right, i.bottom) @@ -74,15 +119,122 @@ class PlaylistQueueSheet( (context.resources.getDimensionPixelOffset(R.dimen.list_height) * 0.5f).toInt() ) recyclerView.fastScroll(null, null) - findViewById