Rewrite queuing

This commit is contained in:
Niels van Velzen 2024-08-04 17:27:40 +02:00 committed by Niels van Velzen
parent a8c9f054ab
commit 0f38bd1691
25 changed files with 298 additions and 223 deletions

View File

@ -252,7 +252,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onQueueStatusChanged(boolean hasQueue) {
Timber.d("Queue status changed");
Timber.d("Queue status changed (hasQueue=%s)", hasQueue);
if (hasQueue) {
loadItem();
if (mediaManager.getValue().isAudioPlayerInitialized()) {

View File

@ -16,6 +16,7 @@ import org.jellyfin.androidtv.ui.ScreensaverViewModel
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.core.ui.PlayerSubtitleView
import org.jellyfin.playback.core.ui.PlayerSurfaceView
import org.jellyfin.sdk.api.client.ApiClient
@ -43,17 +44,18 @@ class PlaybackRewriteFragment : Fragment() {
super.onCreate(savedInstanceState)
// Create a queue from the items added to the legacy video queue
val queue = RewriteMediaManager.BaseItemQueue(api)
queue.items.addAll(videoQueueManager.getCurrentVideoQueue())
Timber.i("Created a queue with ${queue.items.size} items")
playbackManager.state.queue.replaceQueue(queue)
val queueSupplier = RewriteMediaManager.BaseItemQueueSupplier(api)
queueSupplier.items.addAll(videoQueueManager.getCurrentVideoQueue())
Timber.i("Created a queue with ${queueSupplier.items.size} items")
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
// Set position
val position = arguments?.getInt(EXTRA_POSITION) ?: 0
if (position != 0) {
lifecycleScope.launch {
Timber.i("Skipping to queue item $position")
playbackManager.state.queue.setIndex(position, false)
playbackManager.queue.setIndex(position, false)
}
}

View File

@ -23,8 +23,9 @@ import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.RepeatMode
import org.jellyfin.playback.core.queue.Queue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.core.queue.supplier.QueueSupplier
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.playback.jellyfin.queue.createBaseItemQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
@ -39,7 +40,7 @@ class RewriteMediaManager(
private val navigationRepository: NavigationRepository,
private val playbackManager: PlaybackManager,
) : MediaManager {
private val queue = BaseItemQueue(api)
private val queueSupplier = BaseItemQueueSupplier(api)
override fun hasAudioQueueItems(): Boolean = currentAudioQueue.size() > 0 && currentAudioItem != null
@ -47,7 +48,7 @@ class RewriteMediaManager(
get() = currentAudioQueue.size()
override val currentAudioQueuePosition: Int
get() = if ((playbackManager.state.queue.entryIndex.value) >= 0) 0 else -1
get() = if ((playbackManager.queue.entryIndex.value) >= 0) 0 else -1
override val currentAudioPosition: Long
get() = playbackManager.state.positionInfo.active.inWholeMilliseconds
@ -56,11 +57,10 @@ class RewriteMediaManager(
get() = (currentAudioQueuePosition + 1).toString()
override val currentAudioQueueDisplaySize: String
get() = ((playbackManager.state.queue.current.value as? BaseItemQueue)?.items?.size
?: currentAudioQueue.size()).toString()
get() = playbackManager.queue.estimatedSize.toString()
override val currentAudioItem: BaseItemDto?
get() = playbackManager.state.queue.entry.value?.baseItem
get() = playbackManager.queue.entry.value?.baseItem
?.takeIf { it.mediaType == MediaType.AUDIO }
override fun toggleRepeat(): Boolean {
@ -108,12 +108,14 @@ class RewriteMediaManager(
val firstItem = currentAudioQueue.get(0) as? AudioQueueBaseRowItem
firstItem?.playing = playState == PlayState.PLAYING
onPlaybackStateChange(when (playState) {
PlayState.STOPPED -> PlaybackController.PlaybackState.IDLE
PlayState.PLAYING -> PlaybackController.PlaybackState.PLAYING
PlayState.PAUSED -> PlaybackController.PlaybackState.PAUSED
PlayState.ERROR -> PlaybackController.PlaybackState.ERROR
}, currentAudioItem)
onPlaybackStateChange(
when (playState) {
PlayState.STOPPED -> PlaybackController.PlaybackState.IDLE
PlayState.PLAYING -> PlaybackController.PlaybackState.PLAYING
PlayState.PAUSED -> PlaybackController.PlaybackState.PAUSED
PlayState.ERROR -> PlaybackController.PlaybackState.ERROR
}, currentAudioItem
)
}
}.launchIn(this)
@ -126,27 +128,27 @@ class RewriteMediaManager(
}
}
playbackManager.state.queue.current.onEach {
playbackManager.queue.entry.onEach { entry ->
notifyListeners {
onQueueStatusChanged(hasAudioQueueItems())
onQueueStatusChanged(entry != null)
}
}.launchIn(this)
playbackManager.state.queue.entry.onEach { updateAdapter() }.launchIn(this)
playbackManager.queue.entry.onEach { updateAdapter() }.launchIn(this)
}
private fun updateAdapter() {
// Get all items as BaseRowItem
val items = queue
val items = queueSupplier
.items
// Map to audio queue items
.mapIndexed { index, item ->
AudioQueueBaseRowItem(item).apply {
playing = playbackManager.state.queue.entryIndex.value == index
playing = playbackManager.queue.entryIndex.value == index
}
}
// Remove items before currently playing item
.drop(max(0, playbackManager.state.queue.entryIndex.value))
.drop(max(0, playbackManager.queue.entryIndex.value))
// Update item row
currentAudioQueue.replaceAll(
@ -186,31 +188,30 @@ class RewriteMediaManager(
if (items.isEmpty()) return
val addIndex = when (playbackManager.state.playState.value) {
PlayState.PLAYING -> playbackManager.state.queue.entryIndex.value + 1
PlayState.PLAYING -> playbackManager.queue.entryIndex.value + 1
else -> 0
}
queue.items.addAll(addIndex, items)
queueSupplier.items.addAll(addIndex, items)
if (
playbackManager.state.queue.current.value != queue ||
playbackManager.state.playState.value != PlayState.PLAYING
) {
if (playbackManager.state.playState.value != PlayState.PLAYING) {
playbackManager.state.setPlaybackOrder(if (isShuffleMode) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
playbackManager.state.play(queue)
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
playbackManager.state.play()
}
updateAdapter()
}
override fun removeFromAudioQueue(item: BaseItemDto) {
val index = queue.items.indexOf(item)
val index = queueSupplier.items.indexOf(item)
if (index == -1) return
// Disallow removing currently playing item (legacy UI cannot keep up)
if (playbackManager.state.queue.entryIndex.value == index) return
if (playbackManager.queue.entryIndex.value == index) return
queue.items.removeAt(index)
queueSupplier.items.removeAt(index)
updateAdapter()
}
@ -219,19 +220,21 @@ class RewriteMediaManager(
override fun playNow(context: Context, items: List<BaseItemDto>, position: Int, shuffle: Boolean) {
val filteredItems = items.drop(position)
queue.items.clear()
queue.items.addAll(filteredItems)
queueSupplier.items.clear()
queueSupplier.items.addAll(filteredItems)
playbackManager.state.setPlaybackOrder(if (shuffle) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
playbackManager.state.play(queue)
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
playbackManager.state.play()
navigationRepository.navigate(Destinations.nowPlaying)
}
override fun playFrom(item: BaseItemDto): Boolean {
val index = queue.items.indexOf(item)
val index = queueSupplier.items.indexOf(item)
if (index == -1) return false
return runBlocking {
playbackManager.state.queue.setIndex(index) != null
playbackManager.queue.setIndex(index) != null
}
}
@ -245,23 +248,23 @@ class RewriteMediaManager(
}
override fun hasNextAudioItem(): Boolean = runBlocking {
playbackManager.state.queue.peekNext() != null
playbackManager.queue.peekNext() != null
}
override fun hasPrevAudioItem(): Boolean = playbackManager.state.queue.entryIndex.value > 0
override fun hasPrevAudioItem(): Boolean = playbackManager.queue.entryIndex.value > 0
override fun nextAudioItem(): Int {
runBlocking { playbackManager.state.queue.next() }
runBlocking { playbackManager.queue.next() }
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
return playbackManager.state.queue.entryIndex.value
return playbackManager.queue.entryIndex.value
}
override fun prevAudioItem(): Int {
runBlocking { playbackManager.state.queue.previous() }
runBlocking { playbackManager.queue.previous() }
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
return playbackManager.state.queue.entryIndex.value
return playbackManager.queue.entryIndex.value
}
override fun stopAudio(releasePlayer: Boolean) {
@ -275,12 +278,12 @@ class RewriteMediaManager(
}
/**
* A simple [Queue] implementation for compatibility with existing UI/playback code. It contains
* A simple [QueueSupplier] implementation for compatibility with existing UI/playback code. It contains
* a mutable BaseItemDto list that is used to retrieve items from.
*/
class BaseItemQueue(
class BaseItemQueueSupplier(
private val api: ApiClient,
) : Queue {
) : QueueSupplier {
val items = mutableListOf<BaseItemDto>()
override val size: Int

View File

@ -1,13 +1,10 @@
package org.jellyfin.playback.core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.jellyfin.playback.core.backend.BackendService
import org.jellyfin.playback.core.backend.PlayerBackend
import org.jellyfin.playback.core.mediastream.MediaStreamResolver
import org.jellyfin.playback.core.mediastream.MediaStreamState
import org.jellyfin.playback.core.plugin.PlayerService
import timber.log.Timber
import kotlin.reflect.KClass
@ -15,7 +12,6 @@ import kotlin.reflect.KClass
class PlaybackManager internal constructor(
val backend: PlayerBackend,
private val services: MutableList<PlayerService>,
mediaStreamResolvers: List<MediaStreamResolver>,
val options: PlaybackManagerOptions,
parentJob: Job? = null,
) {
@ -26,15 +22,12 @@ class PlaybackManager internal constructor(
private val job = SupervisorJob(parentJob)
val state: PlayerState = MutablePlayerState(
options = options,
scope = CoroutineScope(Job(job)),
backendService = backendService,
queue = getService()
)
init {
services.forEach { it.initialize(this, state, Job(job)) }
// FIXME: This should be more integrated in the future
MediaStreamState(state, CoroutineScope(job), mediaStreamResolvers, backendService)
}
fun addService(service: PlayerService) {

View File

@ -5,8 +5,10 @@ import android.os.Build
import androidx.core.content.getSystemService
import org.jellyfin.playback.core.backend.PlayerBackend
import org.jellyfin.playback.core.mediastream.MediaStreamResolver
import org.jellyfin.playback.core.mediastream.MediaStreamService
import org.jellyfin.playback.core.plugin.PlaybackPlugin
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.QueueService
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@ -28,6 +30,7 @@ class PlaybackManagerBuilder(context: Context) {
val services = mutableListOf<PlayerService>()
val mediaStreamResolvers = mutableListOf<MediaStreamResolver>()
// Add plugins
val installContext = object : PlaybackPlugin.InstallContext {
override fun provide(backend: PlayerBackend) {
backends.add(backend)
@ -44,6 +47,10 @@ class PlaybackManagerBuilder(context: Context) {
for (factory in factories) factory.install(installContext)
// Add default services
services.add(QueueService())
services.add(MediaStreamService(mediaStreamResolvers))
// Only support a single backend right now
require(backends.size == 1)
val options = PlaybackManagerOptions(
@ -51,7 +58,7 @@ class PlaybackManagerBuilder(context: Context) {
defaultRewindAmount = defaultRewindAmount ?: { 10.seconds },
defaultFastForwardAmount = defaultFastForwardAmount ?: { 10.seconds },
)
return PlaybackManager(backends.first(), services, mediaStreamResolvers, options)
return PlaybackManager(backends.first(), services, options)
}
}

View File

@ -1,6 +1,5 @@
package org.jellyfin.playback.core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -12,14 +11,10 @@ import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.PositionInfo
import org.jellyfin.playback.core.model.RepeatMode
import org.jellyfin.playback.core.model.VideoSize
import org.jellyfin.playback.core.queue.DefaultPlayerQueueState
import org.jellyfin.playback.core.queue.EmptyQueue
import org.jellyfin.playback.core.queue.PlayerQueueState
import org.jellyfin.playback.core.queue.Queue
import org.jellyfin.playback.core.queue.QueueService
import kotlin.time.Duration
interface PlayerState {
val queue: PlayerQueueState
val volume: PlayerVolumeState
val playState: StateFlow<PlayState>
val speed: StateFlow<Float>
@ -35,7 +30,7 @@ interface PlayerState {
val positionInfo: PositionInfo
// Queue management
fun play(playQueue: Queue)
fun play()
fun stop()
// Pausing
@ -60,10 +55,9 @@ interface PlayerState {
class MutablePlayerState(
private val options: PlaybackManagerOptions,
scope: CoroutineScope,
private val backendService: BackendService,
private val queue: QueueService?,
) : PlayerState {
override val queue: PlayerQueueState
override val volume: PlayerVolumeState
private val _playState = MutableStateFlow(PlayState.STOPPED)
@ -97,12 +91,10 @@ class MutablePlayerState(
override fun onMediaStreamEnd(mediaStream: PlayableMediaStream) = Unit
})
queue = DefaultPlayerQueueState(this, scope, backendService)
volume = options.playerVolumeState
}
override fun play(playQueue: Queue) {
queue.replaceQueue(playQueue)
override fun play() {
backendService.backend?.play()
}
@ -117,7 +109,7 @@ class MutablePlayerState(
override fun stop() {
backendService.backend?.stop()
queue.replaceQueue(EmptyQueue)
queue?.clear()
}
override fun seek(to: Duration) {

View File

@ -1,26 +1,22 @@
package org.jellyfin.playback.core.mediastream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.jellyfin.playback.core.PlayerState
import org.jellyfin.playback.core.backend.BackendService
import org.jellyfin.playback.core.backend.PlayerBackend
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.queue
import timber.log.Timber
internal class MediaStreamState(
state: PlayerState,
coroutineScope: CoroutineScope,
internal class MediaStreamService(
private val mediaStreamResolvers: Collection<MediaStreamResolver>,
private val backendService: BackendService,
) {
init {
state.queue.entry.onEach { entry ->
) : PlayerService() {
override suspend fun onInitialize() {
manager.queue.entry.onEach { entry ->
Timber.d("Queue entry changed to $entry")
val backend = requireNotNull(backendService.backend)
val backend = requireNotNull(manager.backend)
if (entry == null) {
backend.setCurrent(null)
@ -33,8 +29,8 @@ internal class MediaStreamState(
Timber.e("Unable to resolve stream for entry $entry")
// TODO: Somehow notify the user that we skipped an unplayable entry
if (state.queue.peekNext() != null) {
state.queue.next(usePlaybackOrder = true, useRepeatMode = false)
if (manager.queue.peekNext() != null) {
manager.queue.next(usePlaybackOrder = true, useRepeatMode = false)
} else {
backend.setCurrent(null)
}

View File

@ -1,6 +0,0 @@
package org.jellyfin.playback.core.queue
data object EmptyQueue : Queue {
override val size: Int = 0
override suspend fun getItem(index: Int): QueueEntry? = null
}

View File

@ -1,14 +1,85 @@
package org.jellyfin.playback.core.queue
/**
* A queue contains all items in the current playback session. This includes already played items,
* the currently playing item and future items.
*/
interface Queue {
/**
* The total size of the queue.
*/
val size: Int
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.playback.core.queue.supplier.QueueSupplier
suspend fun getItem(index: Int): QueueEntry?
interface Queue {
companion object {
const val INDEX_NONE = -1
}
/**
* Get an estimated size of the queue. This may be off when the used suppliers are guessing their size.
*/
val estimatedSize: Int
/**
* Index of the currently playing entry, or -1 if none.
*/
val entryIndex: StateFlow<Int>
/**
* Currently playing entry or null.
*/
val entry: StateFlow<QueueEntry?>
/**
* Add a supplier of queue items to the end of the queue. Will automatically fetch the first item if there is no current entry.
*/
fun addSupplier(supplier: QueueSupplier)
/**
* Clear all queue state, including suppliers, entries and currently playing entry.
*/
fun clear()
/**
* Set the current entry to the previously played entry. Does nothing if there is no previous entry.
*/
suspend fun previous(): QueueEntry?
/**
* Play the next entry in the queue.
*
* @param usePlaybackOrder Whether to use the playback order from the [PlayerState]. Default to true.
* @param useRepeatMode Whether to use the repeat mode from the [PlayerState]. Default to false.
*/
suspend fun next(usePlaybackOrder: Boolean = true, useRepeatMode: Boolean = false): QueueEntry?
/**
* Skip to the given index.
*
* @param index The index of the entry to play
* @param saveHistory Whether to save the current entry to the play history
*/
suspend fun setIndex(index: Int, saveHistory: Boolean = false): QueueEntry?
/**
* Get the previously playing entry or null if none.
*/
suspend fun peekPrevious(): QueueEntry?
/**
* Get the next entry or null if none.
*
* @param usePlaybackOrder Whether to use the playback order from the [PlayerState]. Default to true.
* @param useRepeatMode Whether to use the repeat mode from the [PlayerState]. Default to false.
*/
suspend fun peekNext(
usePlaybackOrder: Boolean = true,
useRepeatMode: Boolean = false,
): QueueEntry?
/**
* Get the next n entries in the queue. Where n is the amount to fetch. The returned collection may be smaller or empty depending on
* the entries in the queue.
*
* @param usePlaybackOrder Whether to use the playback order from the [PlayerState]. Default to true.
* @param useRepeatMode Whether to use the repeat mode from the [PlayerState]. Default to false.
*/
suspend fun peekNext(
amount: Int,
usePlaybackOrder: Boolean = true,
useRepeatMode: Boolean = false,
): Collection<QueueEntry>
}

View File

@ -1,71 +1,41 @@
package org.jellyfin.playback.core.queue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.playback.core.PlayerState
import org.jellyfin.playback.core.backend.BackendService
import org.jellyfin.playback.core.backend.PlayerBackendEventListener
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.RepeatMode
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.order.DefaultOrderIndexProvider
import org.jellyfin.playback.core.queue.order.OrderIndexProvider
import org.jellyfin.playback.core.queue.order.RandomOrderIndexProvider
import org.jellyfin.playback.core.queue.order.ShuffleOrderIndexProvider
import timber.log.Timber
import org.jellyfin.playback.core.queue.supplier.QueueSupplier
import kotlin.math.max
interface PlayerQueueState {
companion object {
const val INDEX_NONE = -1
}
val current: StateFlow<Queue>
val entryIndex: StateFlow<Int>
val entry: StateFlow<QueueEntry?>
// Queue Management
fun replaceQueue(queue: Queue)
// Queue Seeking
suspend fun previous(): QueueEntry?
suspend fun next(usePlaybackOrder: Boolean = true, useRepeatMode: Boolean = false): QueueEntry?
suspend fun setIndex(index: Int, saveHistory: Boolean = false): QueueEntry?
// Peeking
suspend fun peekPrevious(): QueueEntry?
suspend fun peekNext(usePlaybackOrder: Boolean = true, useRepeatMode: Boolean = false): QueueEntry?
suspend fun peekNext(
amount: Int,
usePlaybackOrder: Boolean = true,
useRepeatMode: Boolean = false
): Collection<QueueEntry>
}
class DefaultPlayerQueueState(
private val state: PlayerState,
private val coroutineScope: CoroutineScope,
backendService: BackendService,
) : PlayerQueueState {
private val _current = MutableStateFlow<Queue>(EmptyQueue)
override val current: StateFlow<Queue> get() = _current.asStateFlow()
private val _entryIndex = MutableStateFlow(PlayerQueueState.INDEX_NONE)
override val entryIndex: StateFlow<Int> get() = _entryIndex.asStateFlow()
private val _entry = MutableStateFlow<QueueEntry?>(null)
override val entry: StateFlow<QueueEntry?> get() = _entry.asStateFlow()
class QueueService internal constructor() : PlayerService(), Queue {
private val suppliers = mutableListOf<QueueSupplier>()
private var currentSupplierIndex = 0
private var currentSupplierItemIndex = 0
private val fetchedItems: MutableList<QueueEntry> = mutableListOf()
private var defaultOrderIndexProvider = DefaultOrderIndexProvider()
private var orderIndexProvider: OrderIndexProvider = defaultOrderIndexProvider
private var currentQueueIndicesPlayed = mutableListOf<Int>()
init {
override val estimatedSize get() = max(fetchedItems.size, suppliers.sumOf { it.size })
private val _entryIndex = MutableStateFlow(Queue.INDEX_NONE)
override val entryIndex: StateFlow<Int> get() = _entryIndex.asStateFlow()
private val _entry = MutableStateFlow<QueueEntry?>(null)
override val entry: StateFlow<QueueEntry?> get() = _entry.asStateFlow()
override suspend fun onInitialize() {
// Reset calculated next-up indices when playback order changes
state.playbackOrder.onEach { playbackOrder ->
orderIndexProvider = when (playbackOrder) {
@ -74,43 +44,65 @@ class DefaultPlayerQueueState(
PlaybackOrder.SHUFFLE -> ShuffleOrderIndexProvider()
}
}.launchIn(coroutineScope)
backendService.addListener(object : PlayerBackendEventListener {
override fun onPlayStateChange(state: PlayState) = Unit
override fun onVideoSizeChange(width: Int, height: Int) = Unit
override fun onMediaStreamEnd(mediaStream: PlayableMediaStream) {
// TODO: Find position based on $mediaStream instead
// TODO: This doesn't work as expected
coroutineScope.launch { next(usePlaybackOrder = true, useRepeatMode = true) }
}
})
}
override fun replaceQueue(queue: Queue) {
Timber.d("Queue changed, setting index to 0")
// Entry management
coroutineScope.launch {
_current.value = queue
orderIndexProvider.reset()
if (orderIndexProvider != defaultOrderIndexProvider) defaultOrderIndexProvider.reset()
override fun addSupplier(supplier: QueueSupplier) {
suppliers.add(supplier)
currentQueueIndicesPlayed.clear()
setIndex(0)
if (_entryIndex.value == Queue.INDEX_NONE) {
coroutineScope.launch { setIndex(0) }
}
}
private suspend fun getOrSupplyItem(index: Int): QueueEntry? {
// Fetch additional items from suppliers until we reach the desired index
while (index >= fetchedItems.size) {
// No more suppliers to try
if (currentSupplierIndex >= suppliers.size) break
val supplier = suppliers[currentSupplierIndex]
val nextItem = supplier.getItem(currentSupplierItemIndex)
if (nextItem != null) {
// Add item to cache and icnrease item index
fetchedItems.add(nextItem)
currentSupplierItemIndex++
} else {
// Move to the next supplier if current one is exhausted
currentSupplierIndex++
currentSupplierItemIndex = 0
}
}
// Return item or null if not found
return if (index < fetchedItems.size) fetchedItems[index]
else null
}
override fun clear() {
suppliers.clear()
currentSupplierIndex = 0
currentSupplierItemIndex = 0
fetchedItems.clear()
_entry.value = null
_entryIndex.value = Queue.INDEX_NONE
currentQueueIndicesPlayed.clear()
}
// Preloading
private fun getNextIndices(amount: Int, usePlaybackOrder: Boolean, useRepeatMode: Boolean): Collection<Int> {
val provider = if (usePlaybackOrder) orderIndexProvider else defaultOrderIndexProvider
val repeatMode = if (useRepeatMode) state.repeatMode.value else RepeatMode.NONE
return when (repeatMode) {
RepeatMode.NONE -> provider.provideIndices(amount, _current.value, currentQueueIndicesPlayed, entryIndex.value)
RepeatMode.NONE -> provider.provideIndices(amount, estimatedSize, currentQueueIndicesPlayed, entryIndex.value)
RepeatMode.REPEAT_ENTRY_ONCE -> buildList {
add(entryIndex.value)
addAll(provider.provideIndices(amount, _current.value, currentQueueIndicesPlayed, entryIndex.value))
addAll(provider.provideIndices(amount, estimatedSize, currentQueueIndicesPlayed, entryIndex.value))
}.take(amount)
RepeatMode.REPEAT_ENTRY_INFINITE -> List(amount) { entryIndex.value }
@ -141,13 +133,13 @@ class DefaultPlayerQueueState(
if (index < 0) return null
// Save previous index
if (saveHistory && _entryIndex.value != PlayerQueueState.INDEX_NONE) {
if (saveHistory && _entryIndex.value != Queue.INDEX_NONE) {
currentQueueIndicesPlayed.add(_entryIndex.value)
}
// Set new index
val currentEntry = _current.value.getItem(index)
_entryIndex.value = if (currentEntry == null) PlayerQueueState.INDEX_NONE else index
val currentEntry = getOrSupplyItem(index)
_entryIndex.value = if (currentEntry == null) Queue.INDEX_NONE else index
_entry.value = currentEntry
return currentEntry
@ -156,7 +148,7 @@ class DefaultPlayerQueueState(
// Peeking
override suspend fun peekPrevious(): QueueEntry? = currentQueueIndicesPlayed.lastOrNull()?.let {
_current.value.getItem(it)
getOrSupplyItem(it)
}
override suspend fun peekNext(
@ -169,8 +161,9 @@ class DefaultPlayerQueueState(
usePlaybackOrder: Boolean,
useRepeatMode: Boolean,
): Collection<QueueEntry> {
val queue = _current.value
return getNextIndices(amount, usePlaybackOrder, useRepeatMode)
.mapNotNull { index -> queue.getItem(index) }
.mapNotNull { index -> getOrSupplyItem(index) }
}
}
val PlaybackManager.queue: Queue get() = requireNotNull(getService<QueueService>())

View File

@ -1,17 +1,16 @@
package org.jellyfin.playback.core.queue.order
import org.jellyfin.playback.core.queue.Queue
import kotlin.math.min
internal class DefaultOrderIndexProvider : OrderIndexProvider {
override fun provideIndices(
amount: Int,
queue: Queue,
size: Int,
playedIndices: Collection<Int>,
currentIndex: Int,
): Collection<Int> {
// No need to use currentQueueNextIndices because we can efficiently calculate the next items
val remainingItemsSize = queue.size - currentIndex - 1
val remainingItemsSize = size - currentIndex - 1
return if (remainingItemsSize <= 0) emptyList()
else Array(min(amount, remainingItemsSize)) { i -> currentIndex + i + 1 }.toList()

View File

@ -1,7 +1,6 @@
package org.jellyfin.playback.core.queue.order
import org.jellyfin.playback.core.queue.PlayerQueueState
import org.jellyfin.playback.core.queue.Queue
import org.jellyfin.playback.core.queue.QueueService
internal interface OrderIndexProvider {
/**
@ -13,15 +12,15 @@ internal interface OrderIndexProvider {
* Collect the next [amount] of indices to play.
*
* @param amount The maximum amount of indices to retrieve. May be less if there are none left.
* @param queue The queue to generate indices for.
* @param size The size of the queue to generate indices for.
* @param playedIndices The previously played indices, this may include the [currentIndex].
* @param currentIndex The currently playing index or [PlayerQueueState.INDEX_NONE].
* @param currentIndex The currently playing index or [QueueService.INDEX_NONE].
*
* @return A collection no more than [amount] items of indices to play next.
*/
fun provideIndices(
amount: Int,
queue: Queue,
size: Int,
playedIndices: Collection<Int>,
currentIndex: Int,
): Collection<Int>

View File

@ -1,6 +1,5 @@
package org.jellyfin.playback.core.queue.order
import org.jellyfin.playback.core.queue.Queue
import kotlin.random.Random
internal class RandomOrderIndexProvider : OrderIndexProvider {
@ -10,14 +9,14 @@ internal class RandomOrderIndexProvider : OrderIndexProvider {
override fun provideIndices(
amount: Int,
queue: Queue,
size: Int,
playedIndices: Collection<Int>,
currentIndex: Int,
) = List(amount) { i ->
if (i <= nextIndices.lastIndex) {
nextIndices[i]
} else {
val index = Random.nextInt(queue.size)
val index = Random.nextInt(size)
nextIndices.add(index)
index
}

View File

@ -1,6 +1,5 @@
package org.jellyfin.playback.core.queue.order
import org.jellyfin.playback.core.queue.Queue
import kotlin.math.min
internal class ShuffleOrderIndexProvider : OrderIndexProvider {
@ -10,15 +9,15 @@ internal class ShuffleOrderIndexProvider : OrderIndexProvider {
override fun provideIndices(
amount: Int,
queue: Queue,
size: Int,
playedIndices: Collection<Int>,
currentIndex: Int,
): Collection<Int> {
val remainingItemsSize = queue.size - playedIndices.size
val remainingItemsSize = size - playedIndices.size
return if (remainingItemsSize <= 0) {
emptyList()
} else {
val remainingIndices = (0..queue.size).filterNot {
val remainingIndices = (0..size).filterNot {
it in playedIndices || it in nextIndices
}

View File

@ -1,12 +1,18 @@
package org.jellyfin.playback.core.queue
package org.jellyfin.playback.core.queue.supplier
abstract class PagedQueue(
import org.jellyfin.playback.core.queue.QueueEntry
abstract class PagedQueueSupplier(
private val pageSize: Int = 10,
) : Queue {
) : QueueSupplier {
companion object {
const val MAX_SIZE = 100
}
private val buffer: MutableList<QueueEntry> = mutableListOf()
override suspend fun getItem(index: Int): QueueEntry? {
require(index in 0 until SequenceQueue.MAX_SIZE)
require(index in 0 until MAX_SIZE)
var page: Collection<QueueEntry>
var pageOffset = buffer.size

View File

@ -0,0 +1,16 @@
package org.jellyfin.playback.core.queue.supplier
import org.jellyfin.playback.core.queue.QueueEntry
/**
* A queue contains all items in the current playback session. This includes already played items,
* the currently playing item and future items.
*/
interface QueueSupplier {
/**
* The total size of the queue.
*/
val size: Int
suspend fun getItem(index: Int): QueueEntry?
}

View File

@ -1,6 +1,8 @@
package org.jellyfin.playback.core.queue
package org.jellyfin.playback.core.queue.supplier
abstract class SequenceQueue : Queue {
import org.jellyfin.playback.core.queue.QueueEntry
abstract class SequenceQueueSupplier : QueueSupplier {
companion object {
const val MAX_SIZE = 100
}

View File

@ -10,6 +10,7 @@ import org.jellyfin.playback.core.mediastream.mediaStream
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.RepeatMode
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
@ -58,14 +59,14 @@ class PlaySessionService(
private suspend fun getQueue(): List<QueueItem> {
// The queues are lazy loaded so we only load a small amount of items to set as queue on the
// backend.
return state.queue
return manager.queue
.peekNext(15)
.mapNotNull { it.baseItem }
.map { QueueItem(id = it.id, playlistItemId = it.playlistItemId) }
}
private suspend fun sendStreamStart() {
val entry = state.queue.entry.value ?: return
val entry = manager.queue.entry.value ?: return
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
@ -93,7 +94,7 @@ class PlaySessionService(
}
private suspend fun sendStreamUpdate() {
val entry = state.queue.entry.value ?: return
val entry = manager.queue.entry.value ?: return
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
@ -121,7 +122,7 @@ class PlaySessionService(
}
private suspend fun sendStreamStop() {
val entry = state.queue.entry.value ?: return
val entry = manager.queue.entry.value ?: return
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return

View File

@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
@ -28,8 +29,8 @@ class PlaySessionSocketService(
PlaystateCommand.STOP -> state.stop()
PlaystateCommand.PAUSE -> state.pause()
PlaystateCommand.UNPAUSE -> state.unpause()
PlaystateCommand.NEXT_TRACK -> state.queue.next()
PlaystateCommand.PREVIOUS_TRACK -> state.queue.previous()
PlaystateCommand.NEXT_TRACK -> manager.queue.next()
PlaystateCommand.PREVIOUS_TRACK -> manager.queue.previous()
PlaystateCommand.SEEK -> {
val to = message.data?.seekPositionTicks?.ticks ?: Duration.ZERO
state.seek(to)

View File

@ -1,7 +1,7 @@
package org.jellyfin.playback.jellyfin.queue
import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.supplier.PagedQueueSupplier
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
@ -10,10 +10,10 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
class AudioAlbumQueue(
class AudioAlbumQueueSupplier(
private val album: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
) : PagedQueueSupplier() {
init {
require(album.type == BaseItemKind.MUSIC_ALBUM)
}

View File

@ -1,17 +1,17 @@
package org.jellyfin.playback.jellyfin.queue
import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.supplier.PagedQueueSupplier
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.instantMixApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
class AudioInstantMixQueue(
class AudioInstantMixQueueSupplier(
private val item: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
) : PagedQueueSupplier() {
companion object {
val instantMixableItems = arrayOf(
BaseItemKind.MUSIC_GENRE,

View File

@ -1,16 +1,16 @@
package org.jellyfin.playback.jellyfin.queue
import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.supplier.PagedQueueSupplier
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
class AudioTrackQueue(
class AudioTrackQueueSupplier(
private val item: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
) : PagedQueueSupplier() {
init {
require(item.type == BaseItemKind.AUDIO)
}

View File

@ -1,7 +1,7 @@
package org.jellyfin.playback.jellyfin.queue
import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.supplier.PagedQueueSupplier
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
@ -10,10 +10,10 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
class EpisodeQueue(
class EpisodeQueueSupplier(
private val episode: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
) : PagedQueueSupplier() {
init {
require(episode.type == BaseItemKind.EPISODE)
}

View File

@ -23,6 +23,7 @@ import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.RepeatMode
import org.jellyfin.playback.core.queue.metadata
import org.jellyfin.playback.core.queue.queue
import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@ -36,7 +37,7 @@ internal class MediaSessionPlayer(
) : SimpleBasePlayer(looper) {
init {
// Invalidate mediasession state when certain player state changes
state.queue.entry.invalidateStateOnEach(scope)
manager.queue.entry.invalidateStateOnEach(scope)
state.playState.invalidateStateOnEach(scope)
state.videoSize.invalidateStateOnEach(scope)
state.speed.invalidateStateOnEach(scope)
@ -57,10 +58,10 @@ internal class MediaSessionPlayer(
add(COMMAND_STOP)
add(COMMAND_SEEK_TO_DEFAULT_POSITION)
add(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
val allowPrevious = state.queue.entryIndex.value > 0
val allowPrevious = manager.queue.entryIndex.value > 0
addIf(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, allowPrevious)
addIf(COMMAND_SEEK_TO_PREVIOUS, allowPrevious)
val allowNext = state.queue.entryIndex.value < (state.queue.current.value.size - 1)
val allowNext = manager.queue.entryIndex.value < (manager.queue.estimatedSize - 1)
addIf(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, allowNext)
addIf(COMMAND_SEEK_TO_NEXT, allowNext)
// add(COMMAND_SEEK_TO_MEDIA_ITEM)
@ -88,11 +89,11 @@ internal class MediaSessionPlayer(
}.build())
runBlocking {
val current = state.queue.entry.value
val current = manager.queue.entry.value
if (current != null) {
val previous = state.queue.peekPrevious()
val next = state.queue.peekNext()
val previous = manager.queue.peekPrevious()
val next = manager.queue.peekNext()
val playlist = listOfNotNull(previous, current, next)
.distinctBy { it.metadata.mediaId }
@ -162,10 +163,10 @@ internal class MediaSessionPlayer(
@Suppress("SwitchIntDef")
when (seekCommand) {
COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
COMMAND_SEEK_TO_PREVIOUS -> state.queue.previous()
COMMAND_SEEK_TO_PREVIOUS -> manager.queue.previous()
COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
COMMAND_SEEK_TO_NEXT -> state.queue.next()
COMMAND_SEEK_TO_NEXT -> manager.queue.next()
}
// Seeking

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.onEach
import org.jellyfin.playback.core.plugin.PlayerService
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.metadata
import org.jellyfin.playback.core.queue.queue
class MediaSessionService(
private val androidContext: Context,
@ -34,7 +35,7 @@ class MediaSessionService(
setSessionActivity(options.openIntent)
}.build()
state.queue.entry.onEach { item ->
manager.queue.entry.onEach { item ->
if (item != null) updateNotification(session, item)
else if (notifiedNotificationId != null) {
notificationManager.cancel(notifiedNotificationId!!)