diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackRewriteFragment.kt b/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackRewriteFragment.kt index ffbe83b979..2a2bb63db3 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackRewriteFragment.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/rewrite/PlaybackRewriteFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -15,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.ui.PlayerSubtitleView import org.jellyfin.playback.core.ui.PlayerSurfaceView import org.jellyfin.sdk.api.client.ApiClient import org.koin.android.ext.android.inject @@ -74,9 +76,15 @@ class PlaybackRewriteFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val view = PlayerSurfaceView(requireContext()) - view.playbackManager = playbackManager - return view + return FrameLayout(requireContext()).apply { + addView(PlayerSurfaceView(requireContext()).also { view -> + view.playbackManager = playbackManager + }) + + addView(PlayerSubtitleView(requireContext()).also { view -> + view.playbackManager = playbackManager + }) + } } override fun onPause() { diff --git a/playback/core/src/main/kotlin/backend/BackendService.kt b/playback/core/src/main/kotlin/backend/BackendService.kt index faae095ad8..95a2714ad7 100644 --- a/playback/core/src/main/kotlin/backend/BackendService.kt +++ b/playback/core/src/main/kotlin/backend/BackendService.kt @@ -1,9 +1,10 @@ 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 +import org.jellyfin.playback.core.ui.PlayerSubtitleView +import org.jellyfin.playback.core.ui.PlayerSurfaceView /** * Service keeping track of the current playback backend and its related surface view. @@ -13,34 +14,57 @@ class BackendService { val backend get() = _backend private var listeners = mutableListOf() - private var _surfaceView: SurfaceView? = null + private var _surfaceView: PlayerSurfaceView? = null + private var _subtitleView: PlayerSubtitleView? = null fun switchBackend(backend: PlayerBackend) { _backend?.stop() _backend?.setListener(null) - _backend?.setSurface(null) + _backend?.setSurfaceView(null) + _backend?.setSubtitleView(null) _backend = backend.apply { - _surfaceView?.let(::setSurface) + _surfaceView?.let(::setSurfaceView) + _subtitleView?.let(::setSubtitleView) setListener(BackendEventListener()) } } - fun attachSurfaceView(surfaceView: SurfaceView) { + fun attachSurfaceView(surfaceView: PlayerSurfaceView) { // Remove existing surface view if (_surfaceView != null) { - _backend?.setSurface(null) + _backend?.setSurfaceView(null) } // Apply new surface view _surfaceView = surfaceView.apply { - _backend?.setSurface(surfaceView) + _backend?.setSurfaceView(surfaceView) // Automatically detach doOnDetach { if (surfaceView == _surfaceView) { _surfaceView = null - _backend?.setSurface(null) + _backend?.setSurfaceView(null) + } + } + } + } + + fun attachSubtitleView(subtitleView: PlayerSubtitleView) { + // Remove existing surface view + if (_subtitleView != null) { + _backend?.setSubtitleView(null) + } + + // Apply new surface view + _subtitleView = subtitleView.apply { + _backend?.setSubtitleView(subtitleView) + + // Automatically detach + doOnDetach { + if (subtitleView == _subtitleView) { + _subtitleView = null + _backend?.setSubtitleView(null) } } } diff --git a/playback/core/src/main/kotlin/backend/PlayerBackend.kt b/playback/core/src/main/kotlin/backend/PlayerBackend.kt index b984260448..f72f404552 100644 --- a/playback/core/src/main/kotlin/backend/PlayerBackend.kt +++ b/playback/core/src/main/kotlin/backend/PlayerBackend.kt @@ -1,10 +1,11 @@ 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 import org.jellyfin.playback.core.support.PlaySupportReport +import org.jellyfin.playback.core.ui.PlayerSubtitleView +import org.jellyfin.playback.core.ui.PlayerSurfaceView import kotlin.time.Duration /** @@ -16,7 +17,8 @@ interface PlayerBackend { fun supportsStream(stream: MediaStream): PlaySupportReport // UI - fun setSurface(surfaceView: SurfaceView?) + fun setSurfaceView(surfaceView: PlayerSurfaceView?) + fun setSubtitleView(surfaceView: PlayerSubtitleView?) // Data retrieval diff --git a/playback/core/src/main/kotlin/ui/PlayerSubtitleView.kt b/playback/core/src/main/kotlin/ui/PlayerSubtitleView.kt new file mode 100644 index 0000000000..b0088da602 --- /dev/null +++ b/playback/core/src/main/kotlin/ui/PlayerSubtitleView.kt @@ -0,0 +1,28 @@ +package org.jellyfin.playback.core.ui + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import org.jellyfin.playback.core.PlaybackManager + +/** + * A view that is used to display the subtitle output of the playing media. + * The [playbackManager] must be set when the view is initialized. + */ +class PlayerSubtitleView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) { + lateinit var playbackManager: PlaybackManager + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + if (!isInEditMode) { + playbackManager.backendService.attachSubtitleView(this) + } + } +} + diff --git a/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt b/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt index a09dde4b35..1ad3dfd4a4 100644 --- a/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt +++ b/playback/core/src/main/kotlin/ui/PlayerSurfaceView.kt @@ -27,7 +27,7 @@ class PlayerSurfaceView @JvmOverloads constructor( super.onAttachedToWindow() if (!isInEditMode) { - playbackManager.backendService.attachSurfaceView(surface) + playbackManager.backendService.attachSurfaceView(this) } } } diff --git a/playback/exoplayer/build.gradle.kts b/playback/exoplayer/build.gradle.kts index abeeaf0202..6c42287fca 100644 --- a/playback/exoplayer/build.gradle.kts +++ b/playback/exoplayer/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder) + implementation(libs.androidx.media3.ui) // Logging implementation(libs.timber) diff --git a/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt b/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt index d3f1ab4341..2ebcfa7035 100644 --- a/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt +++ b/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt @@ -2,7 +2,7 @@ package org.jellyfin.playback.exoplayer import android.app.ActivityManager import android.content.Context -import android.view.SurfaceView +import android.view.ViewGroup import androidx.annotation.OptIn import androidx.core.content.getSystemService import androidx.media3.common.C @@ -11,6 +11,7 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -18,12 +19,15 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ts.TsExtractor +import androidx.media3.ui.SubtitleView 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 +import org.jellyfin.playback.core.ui.PlayerSubtitleView +import org.jellyfin.playback.core.ui.PlayerSurfaceView import org.jellyfin.playback.exoplayer.support.getPlaySupportReport import org.jellyfin.playback.exoplayer.support.toFormat import timber.log.Timber @@ -41,6 +45,7 @@ class ExoPlayerBackend( } private var currentStream: PlayableMediaStream? = null + private var subtitleView: SubtitleView? = null private val exoPlayer by lazy { ExoPlayer.Builder(context) @@ -89,6 +94,10 @@ class ExoPlayerBackend( listener?.onVideoSizeChange(size.width, size.height) } + override fun onCues(cueGroup: CueGroup) { + subtitleView?.setCues(cueGroup.cues) + } + override fun onPlaybackStateChanged(playbackState: Int) { onIsPlayingChanged(exoPlayer.isPlaying) } @@ -104,8 +113,18 @@ class ExoPlayerBackend( stream: MediaStream ): PlaySupportReport = exoPlayer.getPlaySupportReport(stream.toFormat()) - override fun setSurface(surfaceView: SurfaceView?) { - exoPlayer.setVideoSurfaceView(surfaceView) + override fun setSurfaceView(surfaceView: PlayerSurfaceView?) { + exoPlayer.setVideoSurfaceView(surfaceView?.surface) + } + + override fun setSubtitleView(surfaceView: PlayerSubtitleView?) { + if (surfaceView != null) { + if (subtitleView == null) subtitleView = SubtitleView(surfaceView.context) + surfaceView.addView(subtitleView) + } else { + (subtitleView?.parent as? ViewGroup)?.removeView(subtitleView) + subtitleView = null + } } override fun prepareStream(stream: PlayableMediaStream) {