Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial video support in playback rewrite #3557

Merged
merged 7 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class RewritePlaybackLauncher : PlaybackLauncher {
if (item == null) return false

val intent = Intent(context, PlaybackForwardingActivity::class.java)
intent.putExtra(PlaybackForwardingActivity.EXTRA_ITEM_ID, item.id)
intent.putExtra(PlaybackForwardingActivity.EXTRA_ITEM_ID, item.id.toString())
context.startActivity(intent)

return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,33 @@

import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.FragmentActivity
import androidx.activity.ComponentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.queue.Queue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.ui.PlayerSurfaceView
import org.jellyfin.playback.jellyfin.queue.createBaseItemQueueEntry
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
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.util.UUID

class PlaybackForwardingActivity : FragmentActivity() {
class PlaybackForwardingActivity : ComponentActivity() {
companion object {
const val EXTRA_ITEM_ID: String = "item_id"
}

private val videoQueueManager by inject<VideoQueueManager>()
private val playbackManager by inject<PlaybackManager>()
private val api by inject<ApiClient>()

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down Expand Up @@ -53,17 +60,30 @@
).show()
Timber.i(item.toString())

// TODO: Create queue, send to new playback manager, start new player UI
finishAfterTransition()
// TODO: Dirty hack to create a single item queue

Check warning

Code scanning / detekt

Flags a forbidden comment. Warning

Forbidden TODO todo marker in comment, please do the changes.
val queueEntry = createBaseItemQueueEntry(api, item)
playbackManager.state.queue.replaceQueue(object : Queue {
override val size: Int = 1

override suspend fun getItem(index: Int): QueueEntry? {
if (index == 0) return queueEntry
return null
}
})
}
}

// TODO: Dirty hack to display surface

Check warning

Code scanning / detekt

Flags a forbidden comment. Warning

Forbidden TODO todo marker in comment, please do the changes.
val view = PlayerSurfaceView(this)
view.playbackManager = playbackManager
setContentView(view)
}

private fun findItemId(): UUID? {
val extra = intent.getStringExtra(EXTRA_ITEM_ID)?.toUUIDOrNull()

var first: org.jellyfin.sdk.model.api.BaseItemDto? = null
var best: org.jellyfin.sdk.model.api.BaseItemDto? = null
var first: BaseItemDto? = null
var best: BaseItemDto? = null

for (item in videoQueueManager.getCurrentVideoQueue()) {
if (first == null) first = item
Expand Down
2 changes: 1 addition & 1 deletion playback/core/src/main/kotlin/PlaybackManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class PlaybackManager internal constructor(
val options: PlaybackManagerOptions,
parentJob: Job? = null,
) {
private val backendService = BackendService().also { service ->
internal val backendService = BackendService().also { service ->
service.switchBackend(backend)
}

Expand Down
27 changes: 26 additions & 1 deletion playback/core/src/main/kotlin/backend/BackendService.kt
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
package org.jellyfin.playback.core.backend

import android.view.SurfaceView
import androidx.core.view.doOnDetach
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PlayState

/**
* Service keeping track of the current playback backend.
* Service keeping track of the current playback backend and its related surface view.
*/
class BackendService {
private var _backend: PlayerBackend? = null
val backend get() = _backend

private var listeners = mutableListOf<PlayerBackendEventListener>()
private var _surfaceView: SurfaceView? = null

fun switchBackend(backend: PlayerBackend) {
_backend?.stop()
_backend?.setListener(null)
_backend?.setSurface(null)

_backend = backend.apply {
_surfaceView?.let(::setSurface)
setListener(BackendEventListener())
}
}

fun attachSurfaceView(surfaceView: SurfaceView) {
// Remove existing surface view
if (_surfaceView != null) {
_backend?.setSurface(null)
}

// Apply new surface view
_surfaceView = surfaceView.apply {
_backend?.setSurface(surfaceView)

// Automatically detach
doOnDetach {
if (surfaceView == _surfaceView) {
_surfaceView = null
_backend?.setSurface(null)
}
}
}
}

fun addListener(listener: PlayerBackendEventListener) {
listeners.add(listener)
}
Expand Down
4 changes: 4 additions & 0 deletions playback/core/src/main/kotlin/backend/PlayerBackend.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jellyfin.playback.core.backend

import android.view.SurfaceView
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.model.PositionInfo
Expand All @@ -14,6 +15,9 @@ interface PlayerBackend {
// Testing
fun supportsStream(stream: MediaStream): PlaySupportReport

// UI
fun setSurface(surfaceView: SurfaceView?)

// Data retrieval

fun setListener(eventListener: PlayerBackendEventListener?)
Expand Down
34 changes: 34 additions & 0 deletions playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.jellyfin.playback.core.ui

import android.content.Context
import android.util.AttributeSet
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import org.jellyfin.playback.core.PlaybackManager

/**
* A view that is used to display the video output of the playing media.
* The [playbackManager] must be set when the view is initialized.
*/
class PlayerSurfaceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
lateinit var playbackManager: PlaybackManager

val surface = SurfaceView(context, attrs).apply {
addView(this, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()

if (!isInEditMode) {
playbackManager.backendService.attachSurfaceView(surface)
}
}
}

1 change: 1 addition & 0 deletions playback/exoplayer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {

// ExoPlayer
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)

// Logging
Expand Down
5 changes: 5 additions & 0 deletions playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.jellyfin.playback.exoplayer

import android.app.ActivityManager
import android.content.Context
import android.view.SurfaceView
import androidx.annotation.OptIn
import androidx.core.content.getSystemService
import androidx.media3.common.C
Expand Down Expand Up @@ -103,6 +104,10 @@ class ExoPlayerBackend(
stream: MediaStream
): PlaySupportReport = exoPlayer.getPlaySupportReport(stream.toFormat())

override fun setSurface(surfaceView: SurfaceView?) {
exoPlayer.setVideoSurfaceView(surfaceView)
}

override fun prepareStream(stream: PlayableMediaStream) {
val mediaItem = MediaItem.Builder().apply {
setTag(stream)
Expand Down
2 changes: 2 additions & 0 deletions playback/jellyfin/src/main/kotlin/JellyfinPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.jellyfin.playback.jellyfin

import org.jellyfin.playback.core.plugin.playbackPlugin
import org.jellyfin.playback.jellyfin.mediastream.AudioMediaStreamResolver
import org.jellyfin.playback.jellyfin.mediastream.VideoMediaStreamResolver
import org.jellyfin.playback.jellyfin.playsession.PlaySessionService
import org.jellyfin.playback.jellyfin.playsession.PlaySessionSocketService
import org.jellyfin.sdk.api.client.ApiClient
Expand Down Expand Up @@ -40,6 +41,7 @@ fun jellyfinPlugin(
xmlRootAttributes = emptyList(),
)
provide(AudioMediaStreamResolver(api, profile))
provide(VideoMediaStreamResolver(api, profile))

val playSessionService = PlaySessionService(api)
provide(playSessionService)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.audioApi
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.constant.MediaType

class AudioMediaStreamResolver(
val api: ApiClient,
Expand Down Expand Up @@ -42,7 +42,7 @@ class AudioMediaStreamResolver(
private fun MediaInfo.getTranscodeStream() = BasicMediaStream(
identifier = playSessionId,
conversionMethod = MediaConversionMethod.Transcode,
// The server doesn't provide us with the transcode information os we return mock data
// The server doesn't provide us with the transcode information so we return mock data
container = MediaStreamContainer(format = "unknown"),
tracks = emptyList()
)
Expand All @@ -52,7 +52,7 @@ class AudioMediaStreamResolver(
testStream: (stream: MediaStream) -> PlaySupportReport,
): PlayableMediaStream? {
val baseItem = queueEntry.baseItem
if (baseItem == null || baseItem.type != BaseItemKind.AUDIO) return null
if (baseItem == null || baseItem.mediaType != MediaType.Audio) return null

val mediaInfo = getPlaybackInfo(baseItem)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package org.jellyfin.playback.jellyfin.mediastream

import org.jellyfin.playback.core.mediastream.BasicMediaStream
import org.jellyfin.playback.core.mediastream.MediaConversionMethod
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.MediaStreamContainer
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.support.PlaySupportReport
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.dynamicHlsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.constant.MediaType

class VideoMediaStreamResolver(
val api: ApiClient,
val profile: DeviceProfile,
) : JellyfinStreamResolver(api, profile) {
companion object {
private val REMUX_CONTAINERS = arrayOf("mp4", "mkv")
private const val REMUX_SEGMENT_CONTAINER = "mp4"
}

private fun MediaInfo.getDirectPlayStream() = BasicMediaStream(
identifier = playSessionId,
conversionMethod = MediaConversionMethod.None,
container = getMediaStreamContainer(),
tracks = getTracks()
)

private fun MediaInfo.getRemuxStream(container: String) = BasicMediaStream(
identifier = playSessionId,
conversionMethod = MediaConversionMethod.Remux,
container = MediaStreamContainer(
format = container
),
tracks = getTracks()
)

private fun MediaInfo.getTranscodeStream() = BasicMediaStream(
identifier = playSessionId,
conversionMethod = MediaConversionMethod.Transcode,
// The server doesn't provide us with the transcode information so we return mock data
container = MediaStreamContainer(format = "unknown"),
tracks = emptyList()
)

override suspend fun getStream(
queueEntry: QueueEntry,
testStream: (stream: MediaStream) -> PlaySupportReport,
): PlayableMediaStream? {
val baseItem = queueEntry.baseItem
if (baseItem == null || baseItem.mediaType != MediaType.Video) return null

val mediaInfo = getPlaybackInfo(baseItem)

// Test for direct play support
val directPlayStream = mediaInfo.getDirectPlayStream()
if (testStream(directPlayStream).canPlay) {
return directPlayStream.toPlayableMediaStream(
queueEntry = queueEntry,
url = api.videosApi.getVideoStreamUrl(
itemId = baseItem.id,
mediaSourceId = mediaInfo.mediaSource.id,
playSessionId = mediaInfo.playSessionId,
static = true,
)
)
}

// Try remuxing
if (mediaInfo.mediaSource.supportsDirectStream) {
for (container in REMUX_CONTAINERS) {
val remuxStream = mediaInfo.getRemuxStream(container)
if (testStream(remuxStream).canPlay) {
return remuxStream.toPlayableMediaStream(
queueEntry = queueEntry,
url = api.videosApi.getVideoStreamByContainerUrl(
itemId = baseItem.id,
mediaSourceId = mediaInfo.mediaSource.id,
playSessionId = mediaInfo.playSessionId,
container = container,
)
)
}
}
}

// Fallback to provided transcode
if (mediaInfo.mediaSource.supportsTranscoding) {
val transcodeStream = mediaInfo.getTranscodeStream()

// Skip testing transcode stream because we lack the information to do so
return transcodeStream.toPlayableMediaStream(
queueEntry = queueEntry,
url = api.dynamicHlsApi.getMasterHlsVideoPlaylistUrl(
itemId = baseItem.id,
mediaSourceId = requireNotNull(mediaInfo.mediaSource.id),
playSessionId = mediaInfo.playSessionId,
tag = mediaInfo.mediaSource.eTag,
segmentContainer = REMUX_SEGMENT_CONTAINER,
)
)
}

// Unable to find a suitable stream, return
return null
}
}