diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackLauncher.kt b/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackLauncher.kt index 3f71f53f26..78c6c42dcc 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackLauncher.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackLauncher.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackForwardingActivity.kt b/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackForwardingActivity.kt index c0f715609b..ec2cad712f 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackForwardingActivity.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackForwardingActivity.kt @@ -2,26 +2,33 @@ package org.jellyfin.androidtv.ui.playback.rewrite 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() + private val playbackManager by inject() private val api by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -53,17 +60,30 @@ class PlaybackForwardingActivity : FragmentActivity() { ).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 + 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 + 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 diff --git a/playback/core/src/main/kotlin/PlaybackManager.kt b/playback/core/src/main/kotlin/PlaybackManager.kt index 909db11c5b..af538eb779 100644 --- a/playback/core/src/main/kotlin/PlaybackManager.kt +++ b/playback/core/src/main/kotlin/PlaybackManager.kt @@ -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) } diff --git a/playback/core/src/main/kotlin/backend/BackendService.kt b/playback/core/src/main/kotlin/backend/BackendService.kt index 756964eb75..faae095ad8 100644 --- a/playback/core/src/main/kotlin/backend/BackendService.kt +++ b/playback/core/src/main/kotlin/backend/BackendService.kt @@ -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() + 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) } diff --git a/playback/core/src/main/kotlin/backend/PlayerBackend.kt b/playback/core/src/main/kotlin/backend/PlayerBackend.kt index bf16aca4ef..b984260448 100644 --- a/playback/core/src/main/kotlin/backend/PlayerBackend.kt +++ b/playback/core/src/main/kotlin/backend/PlayerBackend.kt @@ -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 @@ -14,6 +15,9 @@ interface PlayerBackend { // Testing fun supportsStream(stream: MediaStream): PlaySupportReport + // UI + fun setSurface(surfaceView: SurfaceView?) + // Data retrieval fun setListener(eventListener: PlayerBackendEventListener?) diff --git a/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt b/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt new file mode 100644 index 0000000000..a09dde4b35 --- /dev/null +++ b/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt @@ -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) + } + } +} + diff --git a/playback/exoplayer/build.gradle.kts b/playback/exoplayer/build.gradle.kts index 111dbef130..abeeaf0202 100644 --- a/playback/exoplayer/build.gradle.kts +++ b/playback/exoplayer/build.gradle.kts @@ -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 diff --git a/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt b/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt index d2de3dbb81..d3f1ab4341 100644 --- a/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt +++ b/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt @@ -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 @@ -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) diff --git a/playback/jellyfin/src/main/kotlin/JellyfinPlugin.kt b/playback/jellyfin/src/main/kotlin/JellyfinPlugin.kt index 8f30a90214..5bd93d290a 100644 --- a/playback/jellyfin/src/main/kotlin/JellyfinPlugin.kt +++ b/playback/jellyfin/src/main/kotlin/JellyfinPlugin.kt @@ -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 @@ -40,6 +41,7 @@ fun jellyfinPlugin( xmlRootAttributes = emptyList(), ) provide(AudioMediaStreamResolver(api, profile)) + provide(VideoMediaStreamResolver(api, profile)) val playSessionService = PlaySessionService(api) provide(playSessionService) diff --git a/playback/jellyfin/src/main/kotlin/mediastream/AudioMediaStreamResolver.kt b/playback/jellyfin/src/main/kotlin/mediastream/AudioMediaStreamResolver.kt index e9fd4181b4..9e0eba9f69 100644 --- a/playback/jellyfin/src/main/kotlin/mediastream/AudioMediaStreamResolver.kt +++ b/playback/jellyfin/src/main/kotlin/mediastream/AudioMediaStreamResolver.kt @@ -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, @@ -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() ) @@ -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) diff --git a/playback/jellyfin/src/main/kotlin/mediastream/VideoMediaStreamResolver.kt b/playback/jellyfin/src/main/kotlin/mediastream/VideoMediaStreamResolver.kt new file mode 100644 index 0000000000..2a28d53712 --- /dev/null +++ b/playback/jellyfin/src/main/kotlin/mediastream/VideoMediaStreamResolver.kt @@ -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 + } +}