From 55f56c578e559906cdc65a39fa71608112f24014 Mon Sep 17 00:00:00 2001 From: Miquel <132332325+miquel17@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:58:31 +0100 Subject: [PATCH 1/2] feat: Spotify-like queue setting Added a setting to place queued songs behind the manually added songs instead of at the end of the automatically added ones. This commit depends on the corresponding Pull Request of the same name in the SimpMusic project. --- .../mediaservice/MediaServiceHandlerImpl.kt | 213 +++++++++++++++++- .../data/dataStore/DataStoreManagerImpl.kt | 15 +- .../mediaservice/JvmMediaPlayerHandlerImpl.kt | 206 ++++++++++++++++- .../domain/manager/DataStoreManager.kt | 4 +- .../handler/MediaPlayerHandler.kt | 2 + 5 files changed, 411 insertions(+), 29 deletions(-) diff --git a/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.kt b/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.kt index 7721cbe..466b92e 100644 --- a/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.kt +++ b/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.kt @@ -90,6 +90,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import org.koin.mp.KoinPlatform.getKoin import kotlin.math.pow @@ -176,6 +177,9 @@ internal class MediaServiceHandlerImpl( private val _currentSongIndex: MutableStateFlow = MutableStateFlow(player.currentMediaItemIndex) override val currentSongIndex: StateFlow = _currentSongIndex.asStateFlow() + private var manualQueueOffset = 0 + private var lastCurrentIndex = -1 + // List of Specific variables private var loudnessEnhancer: LoudnessEnhancer? = null @@ -753,12 +757,55 @@ internal class MediaServiceHandlerImpl( } PlayerEvent.Shuffle -> { - if (player.shuffleModeEnabled) { - player.shuffleModeEnabled = false - _controlState.value = _controlState.value.copy(isShuffle = false) + val isSmartQueue = runBlocking { dataStoreManager.smartQueueEnabled.first() == TRUE } + if (isSmartQueue) { + if (_controlState.value.isShuffle) { + _controlState.value = _controlState.value.copy(isShuffle = false) + player.shuffleModeEnabled = false + } else { + if (player.shuffleModeEnabled) { + player.shuffleModeEnabled = false + } + + val currentIndex = player.currentMediaItemIndex + val listTrack = queueData.value.data.listTracks + val shuffleStartIndex = (currentIndex + 1 + manualQueueOffset).coerceAtMost(listTrack.size) + + if (shuffleStartIndex < listTrack.size - 1) { + val indicesToShuffle = (shuffleStartIndex until listTrack.size).toMutableList() + val shuffledIndices = indicesToShuffle.shuffled() + val items = listTrack.subList(shuffleStartIndex, listTrack.size).toMutableList() + items.indices.forEach { i -> + val targetItem = items[i] + } + + val originalTail = listTrack.subList(shuffleStartIndex, listTrack.size) + val targetTail = originalTail.shuffled() + + targetTail.forEachIndexed { index, track -> + val targetPos = shuffleStartIndex + index + val currentPos = (targetPos until player.mediaItemCount).firstOrNull { + val item = player.getMediaItemAt(it) + val itemId = item?.mediaId?.removePrefix(MERGING_DATA_TYPE.VIDEO) + itemId == track.videoId + } ?: -1 + + if (currentPos != -1 && currentPos != targetPos) { + player.moveMediaItem(currentPos, targetPos) + } + } + } + + _controlState.value = _controlState.value.copy(isShuffle = true) + } } else { - player.shuffleModeEnabled = true - _controlState.value = _controlState.value.copy(isShuffle = true) + if (player.shuffleModeEnabled) { + player.shuffleModeEnabled = false + _controlState.value = _controlState.value.copy(isShuffle = false) + } else { + player.shuffleModeEnabled = true + _controlState.value = _controlState.value.copy(isShuffle = true) + } } } @@ -1868,6 +1915,136 @@ internal class MediaServiceHandlerImpl( reorderShuffledQueue(player.getCurrentMediaTimeLine()) } + override suspend fun addToQueue(track: Track) = withContext(Dispatchers.Main) { + val smartQueueEnabled = dataStoreManager.smartQueueEnabled.first() == TRUE + if (!smartQueueEnabled) { + loadMoreCatalog(arrayListOf(track), isAddToQueue = true) + return@withContext + } + + _queueData.update { + it.copy( + queueState = QueueData.StateSource.STATE_INITIALIZING, + ) + } + + val catalogMetadata: ArrayList = + queueData.value.data.listTracks.toCollection(arrayListOf()) + + var thumbUrl = + track.thumbnails?.lastOrNull()?.url + ?: "http://i.ytimg.com/vi/${track.videoId}/maxresdefault.jpg" + if (thumbUrl.contains("w120")) { + thumbUrl = Regex("([wh])120").replace(thumbUrl, "$1544") + } + val artistName: String = track.artists.toListName().connectArtists() + val isSong = + ( + track.thumbnails?.lastOrNull()?.height != 0 && + track.thumbnails?.lastOrNull()?.height == track.thumbnails?.lastOrNull()?.width && + track.thumbnails?.lastOrNull()?.height != null + ) && + ( + !thumbUrl.contains("hq720") && + !thumbUrl.contains("maxresdefault") && + !thumbUrl.contains("sddefault") + ) + + val currentIndex = player.currentMediaItemIndex + if (lastCurrentIndex == -1) { + lastCurrentIndex = currentIndex + } + + val insertIndex = (currentIndex + 1 + manualQueueOffset).coerceAtMost(player.mediaItemCount) + + if (track.artists.isNullOrEmpty()) { + songRepository.getSongInfo(track.videoId).cancellable().lastOrNull().let { songInfo -> + if (songInfo != null) { + catalogMetadata.add( + insertIndex, + track.copy( + artists = + listOf( + Artist( + songInfo.authorId, + songInfo.author ?: "", + ), + ), + ), + ) + addMediaItemNotSet( + GenericMediaItem( + mediaId = track.videoId, + uri = track.videoId, + metadata = + GenericMediaMetadata( + title = track.title, + artist = songInfo.author ?: "", + albumTitle = track.album?.name, + artworkUri = thumbUrl, + description = if (isSong) MERGING_DATA_TYPE.SONG else MERGING_DATA_TYPE.VIDEO, + ), + customCacheKey = track.videoId, + ), + insertIndex, + ) + } else { + val mediaItem = + GenericMediaItem( + mediaId = track.videoId, + uri = track.videoId, + metadata = + GenericMediaMetadata( + title = track.title, + artist = "Various Artists", + albumTitle = track.album?.name, + artworkUri = thumbUrl, + description = if (isSong) MERGING_DATA_TYPE.SONG else MERGING_DATA_TYPE.VIDEO, + ), + customCacheKey = track.videoId, + ) + addMediaItemNotSet(mediaItem, insertIndex) + catalogMetadata.add( + insertIndex, + track.copy( + artists = listOf(Artist("", "Various Artists")), + ), + ) + } + } + } else { + addMediaItemNotSet( + GenericMediaItem( + mediaId = track.videoId, + uri = track.videoId, + metadata = + GenericMediaMetadata( + title = track.title, + artist = artistName, + albumTitle = track.album?.name, + artworkUri = thumbUrl, + description = if (isSong) MERGING_DATA_TYPE.SONG else MERGING_DATA_TYPE.VIDEO, + ), + customCacheKey = track.videoId, + ), + insertIndex, + ) + catalogMetadata.add(insertIndex, track) + } + + Logger.d("MusicSource", "addToQueue (smart FIFO): ${track.title} at index $insertIndex") + + manualQueueOffset++ + + _queueData.update { + it.copy( + queueState = QueueData.StateSource.STATE_INITIALIZED, + data = it.data.copy(listTracks = catalogMetadata), + ) + } + reorderShuffledQueue(player.getCurrentMediaTimeLine()) + } + override suspend fun loadMediaItem( anyTrack: T, type: String, @@ -2223,6 +2400,14 @@ internal class MediaServiceHandlerImpl( if (player.currentMediaItemIndex == 0) { resetCrossfade() } + if (player.currentMediaItemIndex == lastCurrentIndex + 1) { + manualQueueOffset = (manualQueueOffset - 1).coerceAtLeast(0) + } else { + if (lastCurrentIndex != -1) { + manualQueueOffset = 0 + } + } + lastCurrentIndex = player.currentMediaItemIndex } private fun mayBeTrackingListeningLocal( @@ -2301,13 +2486,21 @@ internal class MediaServiceHandlerImpl( shuffleModeEnabled: Boolean, list: List, ) { - when (shuffleModeEnabled) { - true -> { - _controlState.value = _controlState.value.copy(isShuffle = true) + val isSmartQueue = runBlocking { dataStoreManager.smartQueueEnabled.first() == TRUE } + if (isSmartQueue) { + if (!shuffleModeEnabled && _controlState.value.isShuffle) { + } else { + _controlState.value = _controlState.value.copy(isShuffle = shuffleModeEnabled) } + } else { + when (shuffleModeEnabled) { + true -> { + _controlState.value = _controlState.value.copy(isShuffle = true) + } - false -> { - _controlState.value = _controlState.value.copy(isShuffle = false) + false -> { + _controlState.value = _controlState.value.copy(isShuffle = false) + } } } reorderShuffledQueue(list) diff --git a/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt b/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt index 1d6f613..01167fc 100644 --- a/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt +++ b/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt @@ -1232,15 +1232,15 @@ internal class DataStoreManagerImpl( } } - override val localTrackingEnabled: Flow = - settingsDataStore.data.map { preferences -> - preferences[LOCAL_TRACKING_ENABLED] ?: FALSE - } - override suspend fun setLocalTrackingEnabled(enabled: Boolean) { + override val smartQueueEnabled: Flow = settingsDataStore.data.map { preferences -> + preferences[SMART_QUEUE_ENABLED] ?: FALSE + } + + override suspend fun setSmartQueueEnabled(enabled: Boolean) { withContext(Dispatchers.IO) { settingsDataStore.edit { settings -> - settings[LOCAL_TRACKING_ENABLED] = if (enabled) TRUE else FALSE + settings[SMART_QUEUE_ENABLED] = if (enabled) TRUE else FALSE } } } @@ -1329,7 +1329,8 @@ internal class DataStoreManagerImpl( val DISCORD_TOKEN = stringPreferencesKey("discord_token") val RICH_PRESENCE = stringPreferencesKey("rich_presence") - val LOCAL_TRACKING_ENABLED = stringPreferencesKey("local_tracking_enabled") + + val SMART_QUEUE_ENABLED = stringPreferencesKey("smart_queue_enabled") } } diff --git a/data/src/jvmMain/kotlin/com/maxrave/data/mediaservice/JvmMediaPlayerHandlerImpl.kt b/data/src/jvmMain/kotlin/com/maxrave/data/mediaservice/JvmMediaPlayerHandlerImpl.kt index 7226c62..b90ef64 100644 --- a/data/src/jvmMain/kotlin/com/maxrave/data/mediaservice/JvmMediaPlayerHandlerImpl.kt +++ b/data/src/jvmMain/kotlin/com/maxrave/data/mediaservice/JvmMediaPlayerHandlerImpl.kt @@ -64,6 +64,7 @@ import com.maxrave.logger.Logger import com.my.kizzy.DiscordRPC import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay @@ -196,6 +197,9 @@ class JvmMediaPlayerHandlerImpl( private val _currentSongIndex: MutableStateFlow = MutableStateFlow(player.currentMediaItemIndex) override val currentSongIndex: StateFlow = _currentSongIndex.asStateFlow() + private var manualQueueOffset = 0 + private var lastCurrentIndex = -1 + // List of Specific variables private var skipSilent = false @@ -797,12 +801,48 @@ class JvmMediaPlayerHandlerImpl( } PlayerEvent.Shuffle -> { - if (player.shuffleModeEnabled) { - player.shuffleModeEnabled = false - _controlState.value = _controlState.value.copy(isShuffle = false) + val isSmartQueue = runBlocking { dataStoreManager.smartQueueEnabled.first() == TRUE } + if (isSmartQueue) { + if (_controlState.value.isShuffle) { + _controlState.value = _controlState.value.copy(isShuffle = false) + player.shuffleModeEnabled = false + } else { + if (player.shuffleModeEnabled) { + player.shuffleModeEnabled = false + } + + val currentIndex = player.currentMediaItemIndex + val listTrack = queueData.value.data.listTracks + val shuffleStartIndex = (currentIndex + 1 + manualQueueOffset).coerceAtMost(listTrack.size) + + if (shuffleStartIndex < listTrack.size - 1) { + val originalTail = listTrack.subList(shuffleStartIndex, listTrack.size) + val targetTail = originalTail.shuffled() + + targetTail.forEachIndexed { index, track -> + val targetPos = shuffleStartIndex + index + val currentPos = (targetPos until player.mediaItemCount).firstOrNull { + val item = player.getMediaItemAt(it) + val itemId = item?.mediaId?.removePrefix(MERGING_DATA_TYPE.VIDEO) + itemId == track.videoId + } ?: -1 + + if (currentPos != -1 && currentPos != targetPos) { + player.moveMediaItem(currentPos, targetPos) + } + } + } + + _controlState.value = _controlState.value.copy(isShuffle = true) + } } else { - player.shuffleModeEnabled = true - _controlState.value = _controlState.value.copy(isShuffle = true) + if (player.shuffleModeEnabled) { + player.shuffleModeEnabled = false + _controlState.value = _controlState.value.copy(isShuffle = false) + } else { + player.shuffleModeEnabled = true + _controlState.value = _controlState.value.copy(isShuffle = true) + } } } @@ -1911,6 +1951,135 @@ class JvmMediaPlayerHandlerImpl( } } + override suspend fun addToQueue(track: Track) = withContext(Dispatchers.Main) { + val smartQueueEnabled = dataStoreManager.smartQueueEnabled.first() == TRUE + if (!smartQueueEnabled) { + loadMoreCatalog(arrayListOf(track), isAddToQueue = true) + return@withContext + } + + _queueData.update { + it.copy( + queueState = QueueData.StateSource.STATE_INITIALIZING, + ) + } + + val catalogMetadata: ArrayList = + queueData.value.data.listTracks.toCollection(arrayListOf()) + + var thumbUrl = + track.thumbnails?.lastOrNull()?.url + ?: "http://i.ytimg.com/vi/${track.videoId}/maxresdefault.jpg" + if (thumbUrl.contains("w120")) { + thumbUrl = Regex("([wh])120").replace(thumbUrl, "$1544") + } + val artistName: String = track.artists.toListName().connectArtists() + val isSong = + ( + track.thumbnails?.lastOrNull()?.height != 0 && + track.thumbnails?.lastOrNull()?.height == track.thumbnails?.lastOrNull()?.width && + track.thumbnails?.lastOrNull()?.height != null + ) && + ( + !thumbUrl.contains("hq720") && + !thumbUrl.contains("maxresdefault") && + !thumbUrl.contains("sddefault") + ) + val currentIndex = player.currentMediaItemIndex + if (lastCurrentIndex == -1) { + lastCurrentIndex = currentIndex + } + + val insertIndex = (currentIndex + 1 + manualQueueOffset).coerceAtMost(player.mediaItemCount) + + if (track.artists.isNullOrEmpty()) { + songRepository.getSongInfo(track.videoId).cancellable().lastOrNull().let { songInfo -> + if (songInfo != null) { + catalogMetadata.add( + insertIndex, + track.copy( + artists = + listOf( + Artist( + songInfo.authorId, + songInfo.author ?: "", + ), + ), + ), + ) + addMediaItemNotSet( + GenericMediaItem( + mediaId = track.videoId, + uri = track.videoId, + metadata = + GenericMediaMetadata( + title = track.title, + artist = songInfo.author ?: "", + albumTitle = track.album?.name, + artworkUri = thumbUrl, + description = if (isSong) MERGING_DATA_TYPE.SONG else MERGING_DATA_TYPE.VIDEO, + ), + customCacheKey = track.videoId, + ), + insertIndex, + ) + } else { + val mediaItem = + GenericMediaItem( + mediaId = track.videoId, + uri = track.videoId, + metadata = + GenericMediaMetadata( + title = track.title, + artist = "Various Artists", + albumTitle = track.album?.name, + artworkUri = thumbUrl, + description = if (isSong) MERGING_DATA_TYPE.SONG else MERGING_DATA_TYPE.VIDEO, + ), + customCacheKey = track.videoId, + ) + addMediaItemNotSet(mediaItem, insertIndex) + catalogMetadata.add( + insertIndex, + track.copy( + artists = listOf(Artist("", "Various Artists")), + ), + ) + } + } + } else { + addMediaItemNotSet( + GenericMediaItem( + mediaId = track.videoId, + uri = track.videoId, + metadata = + GenericMediaMetadata( + title = track.title, + artist = artistName, + albumTitle = track.album?.name, + artworkUri = thumbUrl, + description = if (isSong) MERGING_DATA_TYPE.SONG else MERGING_DATA_TYPE.VIDEO, + ), + customCacheKey = track.videoId, + ), + insertIndex, + ) + catalogMetadata.add(insertIndex, track) + } + + Logger.d("MusicSource", "addToQueue (smart FIFO): ${track.title} at index $insertIndex") + + manualQueueOffset++ + + _queueData.update { + it.copy( + queueState = QueueData.StateSource.STATE_INITIALIZED, + data = it.data.copy(listTracks = catalogMetadata), + ) + } + reorderShuffledQueue(player.getCurrentMediaTimeLine()) + } + override suspend fun loadMediaItem( anyTrack: T, type: String, @@ -2272,6 +2441,15 @@ class JvmMediaPlayerHandlerImpl( if (player.currentMediaItemIndex == 0) { resetCrossfade() } + + if (player.currentMediaItemIndex == lastCurrentIndex + 1) { + manualQueueOffset = (manualQueueOffset - 1).coerceAtLeast(0) + } else { + if (lastCurrentIndex != -1) { + manualQueueOffset = 0 + } + } + lastCurrentIndex = player.currentMediaItemIndex } private fun updateDiscordRpc(song: SongEntity) { @@ -2317,13 +2495,21 @@ class JvmMediaPlayerHandlerImpl( shuffleModeEnabled: Boolean, list: List, ) { - when (shuffleModeEnabled) { - true -> { - _controlState.value = _controlState.value.copy(isShuffle = true) + val isSmartQueue = runBlocking { dataStoreManager.smartQueueEnabled.first() == TRUE } + if (isSmartQueue) { + if (!shuffleModeEnabled && _controlState.value.isShuffle) { + } else { + _controlState.value = _controlState.value.copy(isShuffle = shuffleModeEnabled) } + } else { + when (shuffleModeEnabled) { + true -> { + _controlState.value = _controlState.value.copy(isShuffle = true) + } - false -> { - _controlState.value = _controlState.value.copy(isShuffle = false) + false -> { + _controlState.value = _controlState.value.copy(isShuffle = false) + } } } reorderShuffledQueue(list) diff --git a/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt b/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt index be12ee3..10d1ce4 100644 --- a/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt +++ b/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt @@ -303,9 +303,9 @@ interface DataStoreManager { suspend fun setRichPresenceEnabled(enabled: Boolean) - val localTrackingEnabled: Flow + val smartQueueEnabled: Flow - suspend fun setLocalTrackingEnabled(enabled: Boolean) + suspend fun setSmartQueueEnabled(enabled: Boolean) enum class ProxyType { PROXY_TYPE_HTTP, diff --git a/domain/src/commonMain/kotlin/com/maxrave/domain/mediaservice/handler/MediaPlayerHandler.kt b/domain/src/commonMain/kotlin/com/maxrave/domain/mediaservice/handler/MediaPlayerHandler.kt index 5163893..48eb944 100644 --- a/domain/src/commonMain/kotlin/com/maxrave/domain/mediaservice/handler/MediaPlayerHandler.kt +++ b/domain/src/commonMain/kotlin/com/maxrave/domain/mediaservice/handler/MediaPlayerHandler.kt @@ -121,6 +121,8 @@ interface MediaPlayerHandler { suspend fun playNext(track: Track) + suspend fun addToQueue(track: Track) + suspend fun loadMediaItem( anyTrack: T, type: String, From 3c311e85b57d9597f24256b5ee4b6e248a084392 Mon Sep 17 00:00:00 2001 From: Miquel <132332325+miquel17@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:25:41 +0100 Subject: [PATCH 2/2] fix: restore removed variables --- .../maxrave/data/dataStore/DataStoreManagerImpl.kt | 12 ++++++++++++ .../com/maxrave/domain/manager/DataStoreManager.kt | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt b/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt index 32f6ed1..8d3375d 100644 --- a/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt +++ b/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt @@ -1271,6 +1271,18 @@ internal class DataStoreManagerImpl( } } + override val localTrackingEnabled: Flow = settingsDataStore.data.map { preferences -> + preferences[LOCAL_TRACKING_ENABLED] ?: FALSE + } + + override suspend fun setLocalTrackingEnabled(enabled: Boolean) { + withContext(Dispatchers.IO) { + settingsDataStore.edit { settings -> + settings[LOCAL_TRACKING_ENABLED] = if (enabled) TRUE else FALSE + } + } + } + // Auto Backup override val autoBackupEnabled: Flow = settingsDataStore.data.map { preferences -> diff --git a/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt b/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt index ec95e3d..1491d43 100644 --- a/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt +++ b/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt @@ -315,6 +315,10 @@ interface DataStoreManager { suspend fun setSmartQueueEnabled(enabled: Boolean) + val localTrackingEnabled: Flow + + suspend fun setLocalTrackingEnabled(enabled: Boolean) + // Auto Backup val autoBackupEnabled: Flow