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 57892ae..8d3375d 100644 --- a/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt +++ b/data/src/commonMain/kotlin/com/maxrave/data/dataStore/DataStoreManagerImpl.kt @@ -1258,10 +1258,22 @@ internal class DataStoreManagerImpl( } } - override val localTrackingEnabled: Flow = - settingsDataStore.data.map { preferences -> - preferences[LOCAL_TRACKING_ENABLED] ?: FALSE + + 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[SMART_QUEUE_ENABLED] = if (enabled) TRUE else FALSE + } } + } + + override val localTrackingEnabled: Flow = settingsDataStore.data.map { preferences -> + preferences[LOCAL_TRACKING_ENABLED] ?: FALSE + } override suspend fun setLocalTrackingEnabled(enabled: Boolean) { withContext(Dispatchers.IO) { @@ -1410,6 +1422,7 @@ internal class DataStoreManagerImpl( val DISCORD_TOKEN = stringPreferencesKey("discord_token") val RICH_PRESENCE = stringPreferencesKey("rich_presence") + val SMART_QUEUE_ENABLED = stringPreferencesKey("smart_queue_enabled") val LOCAL_TRACKING_ENABLED = stringPreferencesKey("local_tracking_enabled") // Auto Backup 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 0286eeb..80e49b8 100644 --- a/data/src/jvmMain/kotlin/com/maxrave/data/mediaservice/JvmMediaPlayerHandlerImpl.kt +++ b/data/src/jvmMain/kotlin/com/maxrave/data/mediaservice/JvmMediaPlayerHandlerImpl.kt @@ -65,6 +65,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 @@ -198,6 +199,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 @@ -799,12 +803,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) + } } } @@ -1913,6 +1953,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, @@ -2280,6 +2449,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 mayBeTrackingListeningLocal( @@ -2359,13 +2537,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 7470024..1491d43 100644 --- a/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt +++ b/domain/src/commonMain/kotlin/com/maxrave/domain/manager/DataStoreManager.kt @@ -311,6 +311,10 @@ interface DataStoreManager { suspend fun setRichPresenceEnabled(enabled: Boolean) + val smartQueueEnabled: Flow + + suspend fun setSmartQueueEnabled(enabled: Boolean) + val localTrackingEnabled: Flow suspend fun setLocalTrackingEnabled(enabled: Boolean) 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,