diff --git a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt index 595aea9ece..0f4043823a 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt @@ -409,6 +409,7 @@ class MusicService : // Tracks the original queue size to distinguish original items from auto-added ones private var originalQueueSize: Int = 0 + private var manualQueueCount: Int = 0 private var consecutivePlaybackErr = 0 private var retryJob: Job? = null @@ -1422,6 +1423,7 @@ class MusicService : } // Reset original queue size when starting a new queue originalQueueSize = 0 + manualQueueCount = 0 if (queue.preloadItem != null) { player.setMediaItem(queue.preloadItem!!.toMediaItem()) player.prepare() @@ -1683,11 +1685,49 @@ class MusicService : fun clearAutomix() { automixItems.value = emptyList() } + // Stamps IS_MANUAL_QUEUE on each item so recomputeManualQueueCount() can see + // items inserted by both addToQueue() and playNext() + private fun stampManualQueueItems(items: List): List = + items.map { item -> + item.buildUpon() + .setMediaMetadata( + item.mediaMetadata + .buildUpon() + .setExtras( + (item.mediaMetadata.extras ?: android.os.Bundle()).apply { + putBoolean(IS_MANUAL_QUEUE, true) + } + ) + .build() + ) + .build() + } + + // Counts consecutive manual-queue items immediately ahead of the current index. + // Stops at the first item without the flag so items behind the current position + // or non-manual items further ahead are never counted. + private fun recomputeManualQueueCount(): Int { + val startIndex = player.currentMediaItemIndex + 1 + var count = 0 + for (i in startIndex until player.mediaItemCount) { + val extras = player.getMediaItemAt(i).mediaMetadata.extras + if (extras?.getBoolean(IS_MANUAL_QUEUE, false) == true) { + count++ + } else { + // Manual items are always a contiguous block; stop at the first non-manual item + break + } + } + return count + } fun playNext(items: List) { + // Stamp items to include them in the manual queue block tracking + val stampedItems = stampManualQueueItems(items) + // If queue is empty or player is idle, play immediately instead if (player.mediaItemCount == 0 || player.playbackState == STATE_IDLE) { - player.setMediaItems(items) + player.setMediaItems(stampedItems) player.prepare() // Don't start local playback if casting if (castConnectionHandler?.isCasting?.value != true) { @@ -1698,7 +1738,7 @@ class MusicService : // Remove duplicates if enabled if (dataStore.get(PreventDuplicateTracksInQueueKey, false)) { - val itemIds = items.map { it.mediaId }.toSet() + val itemIds = stampedItems.map { it.mediaId }.toSet() val indicesToRemove = mutableListOf() val currentIndex = player.currentMediaItemIndex @@ -1718,7 +1758,7 @@ class MusicService : val shuffleEnabled = player.shuffleModeEnabled // Insert items immediately after the current item in the window/index space - player.addMediaItems(insertIndex, items) + player.addMediaItems(insertIndex, stampedItems) player.prepare() if (shuffleEnabled) { @@ -1729,15 +1769,15 @@ class MusicService : val currentIndex = player.currentMediaItemIndex // Newly inserted indices are a contiguous range [insertIndex, insertIndex + items.size) - val newIndices = (insertIndex until (insertIndex + items.size)).toSet() + val newIndices = (insertIndex until (insertIndex + stampedItems.size)).toSet() - // Collect existing shuffle traversal order excluding current index + // Collect existing shuffle traversal order excluding current index and new items val orderAfter = mutableListOf() var idx = currentIndex while (true) { idx = timeline.getNextWindowIndex(idx, Player.REPEAT_MODE_OFF, /*shuffleModeEnabled=*/true) if (idx == C.INDEX_UNSET) break - if (idx != currentIndex) orderAfter.add(idx) + if (idx != currentIndex && idx !in newIndices) orderAfter.add(idx) } val prevList = mutableListOf() @@ -1745,29 +1785,33 @@ class MusicService : while (true) { pIdx = timeline.getPreviousWindowIndex(pIdx, Player.REPEAT_MODE_OFF, /*shuffleModeEnabled=*/true) if (pIdx == C.INDEX_UNSET) break - if (pIdx != currentIndex) prevList.add(pIdx) + if (pIdx != currentIndex && pIdx !in newIndices) prevList.add(pIdx) } prevList.reverse() // preserve original forward order - val existingOrder = (prevList + orderAfter).filter { it != currentIndex && it !in newIndices } - - // Build new shuffle order: current -> newly inserted (in insertion order) -> rest - val nextBlock = (insertIndex until (insertIndex + items.size)).toList() + // Build new shuffle order: [Past] -> [Current] -> [New Items] -> [Future] val finalOrder = IntArray(size) var pos = 0 - prevList - .filter { it !in newIndices } - .forEach { if (it in 0 until size) finalOrder[pos++] = it } + + // 1. Add previous items + prevList.forEach { if (it in 0 until size) finalOrder[pos++] = it } + + // 2. Add current item finalOrder[pos++] = currentIndex - nextBlock.forEach { if (it in 0 until size) finalOrder[pos++] = it } - orderAfter - .filter { it !in newIndices } - .forEach { if (pos < size) finalOrder[pos++] = it } + + // 3. Add newly inserted items (Play Next priority) + for (i in insertIndex until (insertIndex + stampedItems.size)) { + if (i in 0 until size) finalOrder[pos++] = i + } + + // 4. Add remaining items + orderAfter.forEach { if (pos < size) finalOrder[pos++] = it } // Fill any missing indices (safety) to ensure a full permutation if (pos < size) { + val existingSet = finalOrder.take(pos).toSet() for (i in 0 until size) { - if (!finalOrder.contains(i)) { + if (i !in existingSet) { finalOrder[pos++] = i if (pos == size) break } @@ -1778,11 +1822,13 @@ class MusicService : } } } - fun addToQueue(items: List) { + // Stamp items so the manual block can be recomputed accurately after seeks or auto-advances + val stampedItems = stampManualQueueItems(items) + // Remove duplicates if enabled if (dataStore.get(PreventDuplicateTracksInQueueKey, false)) { - val itemIds = items.map { it.mediaId }.toSet() + val itemIds = stampedItems.map { it.mediaId }.toSet() val indicesToRemove = mutableListOf() val currentIndex = player.currentMediaItemIndex @@ -1798,14 +1844,52 @@ class MusicService : } } - player.addMediaItems(items) + val repeatMode = player.repeatMode + val isRepeatActive = repeatMode != REPEAT_MODE_OFF + val currentIndex = player.currentMediaItemIndex + + // Calculate where items will be inserted (FIFO logic) + val insertIndex = if (isRepeatActive) { + currentIndex + manualQueueCount + 1 + } else { + player.mediaItemCount + } + + // Insert items based on repeat mode + if (isRepeatActive) { + player.addMediaItems(insertIndex, stampedItems) + } else { + player.addMediaItems(stampedItems) + } + + // Update manual queue count (FIFO order) + manualQueueCount += stampedItems.size + + // Professional shuffle management if (player.shuffleModeEnabled) { - val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) - applyShuffleOrder(player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst) + val totalItems = player.mediaItemCount + val random = java.util.Random() + + // Remove indices that must have FIXED position: [Passed] + [Current] + [Manual Queue] + val fixedPathCount = currentIndex + manualQueueCount + 1 + + val flexibleIndices = (fixedPathCount until totalItems).toMutableList() + flexibleIndices.shuffle(random) + + val finalShuffleOrder = IntArray(totalItems) + // Put first indices in linear order (Absolute priority) + for (i in 0 until fixedPathCount) { + finalShuffleOrder[i] = i + } + // Rest of playlist is shuffled after the queue + for (i in flexibleIndices.indices) { + finalShuffleOrder[fixedPathCount + i] = flexibleIndices[i] + } + + player.setShuffleOrder(DefaultShuffleOrder(finalShuffleOrder, random.nextLong())) } player.prepare() } - fun toggleLibrary() { scope.launch { val songToToggle = currentSong.first() @@ -2230,6 +2314,10 @@ class MusicService : } previousMediaItemIndex = player.currentMediaItemIndex + // Rescan instead of decrementing: a seek can cross any number of items + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + manualQueueCount = recomputeManualQueueCount() + } lastPlaybackSpeed = -1.0f // force update song setupLoudnessEnhancer() @@ -4160,7 +4248,7 @@ class MusicService : private const val MIN_GAIN_MB = -1500 // Minimum gain in millibels (-15 dB) private const val TAG = "MusicService" - + private const val IS_MANUAL_QUEUE = "is_manual_queue" @Volatile var isRunning = false private set