package com.github.libretube.util import androidx.media3.common.Player import com.github.libretube.api.MediaServiceRepository import com.github.libretube.api.PlaylistsHelper import com.github.libretube.api.obj.StreamItem import com.github.libretube.extensions.move import com.github.libretube.extensions.runCatchingIO import com.github.libretube.extensions.toID import com.github.libretube.helpers.PlayerHelper import com.github.libretube.util.PlayingQueue.queueMode import kotlinx.coroutines.Job import java.util.Collections object PlayingQueue { // queue is a synchronized list to be safely accessible from different coroutine threads private val queue = Collections.synchronizedList(mutableListOf()) private var currentStream: StreamItem? = null private var currentStreamIndex: Int = -1 // Cache current index for better reliability private val playedVideoIds = mutableSetOf() private val queueJobs = mutableListOf() /** * Current use case of the queue. Do NOT add any offline videos while the [queueMode] is online * or vice versa. */ var queueMode: PlayingQueueMode = PlayingQueueMode.ONLINE // wrapper around PlayerHelper#repeatMode for compatibility var repeatMode: Int get() = PlayerHelper.repeatMode set(value) { PlayerHelper.repeatMode = value } private fun clearJobs() { queueJobs.forEach { it.cancel() } queueJobs.clear() } fun clear() { clearJobs() queue.clear() currentStream = null currentStreamIndex = -1 playedVideoIds.clear() } /** * Remove all items after the current [StreamItem] from the queue * * I.e., the current and all previous streams are kept */ fun clearAfterCurrent() { clearJobs() synchronized(queue) { val newQueue = queue.filterIndexed { index, item -> index <= currentIndex() } setStreams(newQueue) // Recalculate index since queue was modified currentStreamIndex = queue.indexOfLast { it.url?.toID() == currentStream?.url?.toID() } } } /** * @param skipExisting Whether to skip the [streamItem] if it's already part of the queue */ fun add(vararg streamItem: StreamItem, skipExisting: Boolean = false) { synchronized(queue) { for (stream in streamItem) { if ((skipExisting && contains(stream)) || stream.title.isNullOrBlank()) continue queue.remove(stream) queue.add(stream) } } } fun addAsNext(streamItem: StreamItem) { synchronized(queue) { if (currentStream == streamItem) return if (queue.contains(streamItem)) queue.remove(streamItem) queue.add(currentIndex() + 1, streamItem) } } // return the next item, or if repeating enabled and no video left, the first one of the queue fun getNext(): String? { return synchronized(queue) { if (repeatMode == Player.REPEAT_MODE_ONE) { return@synchronized currentStream?.url?.toID() } val nextUnplayed = queue.drop(currentIndex() + 1).firstOrNull { it.url?.toID() !in playedVideoIds } if (nextUnplayed != null) return@synchronized nextUnplayed.url?.toID() if (repeatMode == Player.REPEAT_MODE_ALL) { val wrappedUnplayed = queue.take(currentIndex() + 1).firstOrNull { it.url?.toID() !in playedVideoIds } if (wrappedUnplayed != null) return@synchronized wrappedUnplayed.url?.toID() playedVideoIds.clear() return@synchronized queue.firstOrNull()?.url?.toID() } return@synchronized null } } // return the previous item, or if repeating enabled and no video left, the last one of the queue fun getPrev(): String? { return synchronized(queue) { val prevItem = queue.getOrNull(currentIndex() - 1) if (prevItem != null) return@synchronized prevItem.url?.toID() if (repeatMode == Player.REPEAT_MODE_ALL) return@synchronized queue.lastOrNull()?.url?.toID() return@synchronized null } } fun hasPrev() = getPrev() != null fun hasNext() = getNext() != null fun updateCurrent(streamItem: StreamItem) = synchronized(queue) { currentStream = streamItem streamItem.url?.toID()?.let { playedVideoIds.add(it) } if (!contains(streamItem)) add(streamItem) // Update the cached index currentStreamIndex = queue.indexOfLast { it.url?.toID() == streamItem.url?.toID() } } fun isNotEmpty() = queue.isNotEmpty() fun isEmpty() = queue.isEmpty() fun size() = queue.size fun isLast() = currentIndex() == size() - 1 fun currentIndex(): Int = synchronized(queue) { // Validate cached index first - if it's still valid, use it if (currentStreamIndex >= 0 && currentStreamIndex < queue.size && queue[currentStreamIndex].url?.toID() == currentStream?.url?.toID()) { return currentStreamIndex } // If cached index is invalid, search for current stream val idx = queue.indexOfFirst { it.url?.toID() == currentStream?.url?.toID() } if (idx >= 0) { currentStreamIndex = idx return idx } // If current stream not found, keep last valid index or default to 0 return if (currentStreamIndex >= 0 && currentStreamIndex < queue.size) { currentStreamIndex } else { 0 } } fun getCurrent(): StreamItem? = currentStream fun contains(streamItem: StreamItem) = synchronized(queue) { queue.any { it.url?.toID() == streamItem.url?.toID() } } // only returns a copy of the queue, no write access fun getStreams() = queue.toList() fun setStreams(streams: List) = synchronized(queue) { queue.clear() queue.addAll(streams) currentStreamIndex = currentStream?.url?.toID()?.let { currentId -> queue.indexOfFirst { it.url?.toID() == currentId } } ?: -1 } fun remove(index: Int) = synchronized(queue) { queue.removeAt(index) return@synchronized } fun move(from: Int, to: Int) = synchronized(queue) { queue.move(from, to) } /** * Adds a list of videos to the current queue while updating the position of the current stream * @param isMainList whether the videos are part of the list that initially has been used to * start the queue, either from a channel or playlist. If it's false, the current stream won't * be touched, since it's an independent list. */ private fun addToQueueAsync( streams: List, currentStreamItem: StreamItem? = null, isMainList: Boolean = true ) { synchronized(queue) { if (!isMainList) { add(*streams.toTypedArray()) return@synchronized } val currentStream = currentStreamItem ?: this.currentStream // if the stream already got added to the queue earlier, although it's not yet // been found in the playlist, remove it and re-add it later var reAddStream = true if (currentStream != null && streams.any { it.url?.toID() == currentStream.url?.toID() }) { queue.removeAll { it.url?.toID() == currentStream.url?.toID() } reAddStream = false } // add all new stream items to the queue add(*streams.toTypedArray()) if (currentStream != null && reAddStream) { // re-add the stream to the end of the queue updateCurrent(currentStream) } } } private suspend fun fetchMoreFromPlaylist( playlistId: String, nextPage: String?, isMainList: Boolean ) { var playlistNextPage = nextPage while (playlistNextPage != null) { MediaServiceRepository.instance.getPlaylistNextPage(playlistId, playlistNextPage).run { addToQueueAsync(relatedStreams, isMainList = isMainList) playlistNextPage = this.nextpage } } } fun insertPlaylist(playlistId: String, newCurrentStream: StreamItem?) = runCatchingIO { val playlist = PlaylistsHelper.getPlaylist(playlistId) val isMainList = newCurrentStream != null addToQueueAsync(playlist.relatedStreams, newCurrentStream, isMainList) if (playlist.nextpage == null) return@runCatchingIO fetchMoreFromPlaylist(playlistId, playlist.nextpage, isMainList) }.let { queueJobs.add(it) } private suspend fun fetchMoreFromChannel(channelId: String, nextPage: String?) { var channelNextPage = nextPage var pageIndex = 1 while (channelNextPage != null && pageIndex < 10) { MediaServiceRepository.instance.getChannelNextPage(channelId, channelNextPage).run { addToQueueAsync(relatedStreams) channelNextPage = this.nextpage pageIndex++ } } } private fun insertChannel(channelId: String, newCurrentStream: StreamItem) = runCatchingIO { val channel = MediaServiceRepository.instance.getChannel(channelId) addToQueueAsync(channel.relatedStreams, newCurrentStream) if (channel.nextpage == null) return@runCatchingIO fetchMoreFromChannel(channelId, channel.nextpage) }.let { queueJobs.add(it) } fun insertByVideoId(videoId: String) = runCatchingIO { val streams = MediaServiceRepository.instance.getStreams(videoId.toID()) add(streams.toStreamItem(videoId)) } fun updateQueue( streamItem: StreamItem, playlistId: String?, channelId: String?, relatedStreams: List = emptyList() ) { updateCurrent(streamItem) if (playlistId != null) { insertPlaylist(playlistId, streamItem) } else if (channelId != null) { insertChannel(channelId, streamItem) } else if (relatedStreams.isNotEmpty()) { insertRelatedStreams(relatedStreams) } } fun insertRelatedStreams(streams: List) { if (!PlayerHelper.autoInsertRelatedVideos) return // don't add new videos to the queue if the user chose to repeat only the current queue if (isLast() && repeatMode == Player.REPEAT_MODE_ALL) return val unplayedStreams = streams.filter { !it.isLive && it.url?.toID() !in playedVideoIds } add(*unplayedStreams.toTypedArray(), skipExisting = true) } } enum class PlayingQueueMode { ONLINE, OFFLINE }