Skip to content

Commit

Permalink
Support remux/transcode fallback in new music player
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsvanvelzen committed Oct 11, 2023
1 parent d18922f commit 655b9ce
Show file tree
Hide file tree
Showing 13 changed files with 194 additions and 125 deletions.
4 changes: 2 additions & 2 deletions playback/core/src/main/kotlin/PlayerState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import kotlinx.coroutines.flow.asStateFlow
import org.jellyfin.playback.core.backend.BackendService
import org.jellyfin.playback.core.backend.PlayerBackendEventListener
import org.jellyfin.playback.core.mediastream.DefaultMediaStreamState
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.MediaStreamResolver
import org.jellyfin.playback.core.mediastream.MediaStreamState
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.PositionInfo
Expand Down Expand Up @@ -100,7 +100,7 @@ class MutablePlayerState(
_videoSize.value = VideoSize(width, height)
}

override fun onMediaStreamEnd(mediaStream: MediaStream) = Unit
override fun onMediaStreamEnd(mediaStream: PlayableMediaStream) = Unit
})

queue = DefaultPlayerQueueState(this, scope, backendService)
Expand Down
4 changes: 2 additions & 2 deletions playback/core/src/main/kotlin/backend/BackendService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.jellyfin.playback.core.backend

import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState

/**
Expand Down Expand Up @@ -42,7 +42,7 @@ class BackendService {
callListeners { onVideoSizeChange(width, height) }
}

override fun onMediaStreamEnd(mediaStream: MediaStream) {
override fun onMediaStreamEnd(mediaStream: PlayableMediaStream) {
callListeners { onMediaStreamEnd(mediaStream) }
}
}
Expand Down
5 changes: 3 additions & 2 deletions playback/core/src/main/kotlin/backend/PlayerBackend.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.jellyfin.playback.core.backend

import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PositionInfo
import org.jellyfin.playback.core.support.PlaySupportReport
import kotlin.time.Duration
Expand All @@ -20,8 +21,8 @@ interface PlayerBackend {

// Mutation

fun prepareStream(stream: MediaStream)
fun playStream(stream: MediaStream)
fun prepareStream(stream: PlayableMediaStream)
fun playStream(stream: PlayableMediaStream)

fun play()
fun pause()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package org.jellyfin.playback.core.backend

import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState

interface PlayerBackendEventListener {
fun onPlayStateChange(state: PlayState)
fun onVideoSizeChange(width: Int, height: Int)
fun onMediaStreamEnd(mediaStream: MediaStream)
fun onMediaStreamEnd(mediaStream: PlayableMediaStream)
}
38 changes: 32 additions & 6 deletions playback/core/src/main/kotlin/mediastream/MediaStream.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,40 @@ package org.jellyfin.playback.core.mediastream

import org.jellyfin.playback.core.queue.item.QueueEntry

data class MediaStream(
val identifier: String,
interface MediaStream {
val identifier: String
val conversionMethod: MediaConversionMethod
val container: MediaStreamContainer
val tracks: Collection<MediaStreamTrack>
}

data class BasicMediaStream(
override val identifier: String,
override val conversionMethod: MediaConversionMethod,
override val container: MediaStreamContainer,
override val tracks: Collection<MediaStreamTrack>,
) : MediaStream {
fun toPlayableMediaStream(
queueEntry: QueueEntry,
url: String,
) = PlayableMediaStream(
identifier = identifier,
conversionMethod = conversionMethod,
container = container,
tracks = tracks,
queueEntry = queueEntry,
url = url,
)
}

data class PlayableMediaStream(
override val identifier: String,
override val conversionMethod: MediaConversionMethod,
override val container: MediaStreamContainer,
override val tracks: Collection<MediaStreamTrack>,
val queueEntry: QueueEntry,
val conversionMethod: MediaConversionMethod,
val url: String,
val container: MediaStreamContainer,
val tracks: Collection<MediaStreamTrack>,
)
) : MediaStream

data class MediaStreamContainer(
val format: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package org.jellyfin.playback.core.mediastream

import org.jellyfin.playback.core.queue.item.QueueEntry
import org.jellyfin.playback.core.support.PlaySupportReport

/**
* Determine the media stream for a given queue item.
*/
interface MediaStreamResolver {
/**
* @return [MediaStream] or null if no stream can be determined by this resolver
* @return [PlayableMediaStream] or null if no stream can be determined by this resolver
*/
suspend fun getStream(queueEntry: QueueEntry): MediaStream?
suspend fun getStream(
queueEntry: QueueEntry,
testStream: (stream: MediaStream) -> PlaySupportReport,
): PlayableMediaStream?
}
70 changes: 32 additions & 38 deletions playback/core/src/main/kotlin/mediastream/MediaStreamState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
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 timber.log.Timber

interface MediaStreamState {
val current: StateFlow<MediaStream?>
val next: StateFlow<MediaStream?>
val current: StateFlow<PlayableMediaStream?>
val next: StateFlow<PlayableMediaStream?>
}

class DefaultMediaStreamState(
Expand All @@ -23,63 +24,56 @@ class DefaultMediaStreamState(
private val mediaStreamResolvers: Collection<MediaStreamResolver>,
private val backendService: BackendService,
) : MediaStreamState {
private val _current = MutableStateFlow<MediaStream?>(null)
override val current: StateFlow<MediaStream?> get() = _current.asStateFlow()
private val _current = MutableStateFlow<PlayableMediaStream?>(null)
override val current: StateFlow<PlayableMediaStream?> get() = _current.asStateFlow()

private val _next = MutableStateFlow<MediaStream?>(null)
override val next: StateFlow<MediaStream?> get() = _next.asStateFlow()
private val _next = MutableStateFlow<PlayableMediaStream?>(null)
override val next: StateFlow<PlayableMediaStream?> get() = _next.asStateFlow()

init {
state.queue.entry.onEach { entry ->
Timber.d("Queue entry changed to $entry")
val backend = requireNotNull(backendService.backend)

if (entry == null) {
setCurrent(null)
backend.setCurrent(null)
} else {
val streamResult = runCatching {
mediaStreamResolvers.firstNotNullOfOrNull { resolver -> resolver.getStream(entry) }
val stream = mediaStreamResolvers.firstNotNullOfOrNull { resolver ->
runCatching {
resolver.getStream(entry, backend::supportsStream)
}.onFailure {
Timber.e(it, "Media stream resolver failed for $entry")
}.getOrNull()
}
val stream = streamResult.getOrNull()
when {
streamResult.isFailure -> Timber.e(streamResult.exceptionOrNull(), "Media stream resolver failed for $entry")
stream == null -> Timber.e("Unable to resolve stream for entry $entry")
else -> {
if (!canPlayStream(stream)) {
Timber.w("Playback of the received media stream for $entry is not supported")
}

setCurrent(stream)
if (stream == null) {
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)
} else {
backend.setCurrent(null)
}
} else {
backend.setCurrent(stream)
}
}
}.launchIn(coroutineScope)
}.launchIn(coroutineScope + Dispatchers.Main)

// TODO Register some kind of event when $current item is at -30 seconds to setNext()
}

private suspend fun canPlayStream(stream: MediaStream) = withContext(Dispatchers.Main) {
backendService.backend?.supportsStream(stream)?.canPlay == true
}

private suspend fun setCurrent(stream: MediaStream?) {
private fun PlayerBackend.setCurrent(stream: PlayableMediaStream?) {
Timber.d("Current stream changed to $stream")
val backend = requireNotNull(backendService.backend)

_current.value = stream

withContext(Dispatchers.Main) {
if (stream == null) backend.stop()
else backend.playStream(stream)
}
if (stream == null) stop()
else playStream(stream)
}

private suspend fun setNext(stream: MediaStream) {
val backend = requireNotNull(backendService.backend)

private fun PlayerBackend.setNext(stream: PlayableMediaStream) {
_current.value = stream

withContext(Dispatchers.Main) {
backend.prepareStream(stream)
}
prepareStream(stream)
}
}
4 changes: 2 additions & 2 deletions playback/core/src/main/kotlin/queue/PlayerQueueState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.RepeatMode
Expand Down Expand Up @@ -80,7 +80,7 @@ class DefaultPlayerQueueState(
override fun onPlayStateChange(state: PlayState) = Unit
override fun onVideoSizeChange(width: Int, height: Int) = Unit

override fun onMediaStreamEnd(mediaStream: MediaStream) {
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) }
Expand Down
7 changes: 4 additions & 3 deletions playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.video.VideoSize
import org.jellyfin.playback.core.backend.BasePlayerBackend
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PositionInfo
import org.jellyfin.playback.core.support.PlaySupportReport
Expand All @@ -23,7 +24,7 @@ import kotlin.time.Duration.Companion.milliseconds
class ExoPlayerBackend(
private val context: Context,
) : BasePlayerBackend() {
private var currentStream: MediaStream? = null
private var currentStream: PlayableMediaStream? = null

private val exoPlayer by lazy {
val renderersFactory = DefaultRenderersFactory(context).apply {
Expand Down Expand Up @@ -74,7 +75,7 @@ class ExoPlayerBackend(
stream: MediaStream
): PlaySupportReport = exoPlayer.getPlaySupportReport(stream.toFormat())

override fun prepareStream(stream: MediaStream) {
override fun prepareStream(stream: PlayableMediaStream) {
val mediaItem = MediaItem.Builder().apply {
setTag(stream)
setMediaId(stream.hashCode().toString())
Expand All @@ -89,7 +90,7 @@ class ExoPlayerBackend(
exoPlayer.prepare()
}

override fun playStream(stream: MediaStream) {
override fun playStream(stream: PlayableMediaStream) {
if (currentStream == stream) return

currentStream = stream
Expand Down
23 changes: 18 additions & 5 deletions playback/jellyfin/src/main/kotlin/JellyfinPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import org.jellyfin.playback.jellyfin.playsession.PlaySessionSocketService
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.sockets.SocketInstance
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.TranscodingProfile

fun jellyfinPlugin(
api: ApiClient,
Expand All @@ -20,13 +23,23 @@ fun jellyfinPlugin(
responseProfiles = emptyList(),
subtitleProfiles = emptyList(),
supportedMediaTypes = "",
transcodingProfiles = emptyList(),
// Add at least one transcoding profile so the server returns a value
// for "SupportsTranscoding" based on the user policy
// We don't actually use this profile in the client
transcodingProfiles = listOf(
TranscodingProfile(
type = DlnaProfileType.AUDIO,
context = EncodingContext.STREAMING,
protocol = "hls",
container = "mp3",
audioCodec = "mp3",
videoCodec = "",
conditions = emptyList()
)
),
xmlRootAttributes = emptyList(),
)
provide(AudioMediaStreamResolver(api, profile).apply {
// TODO: Remove once we have a proper device profile
forceDirectPlay = true
})
provide(AudioMediaStreamResolver(api, profile))

val playSessionService = PlaySessionService(api)
provide(playSessionService)
Expand Down
Loading

0 comments on commit 655b9ce

Please sign in to comment.