fix(android): draw the subtitles the player already decodes
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m33s
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m33s
Turning a subtitle on did nothing even after DR-259 made them load. ExoPlayer decodes subtitles and delivers them to a listener; it draws none itself. A PlayerView would supply the view that does, but native video here is a bare TextureView the WebView composites over — so nothing held the cues and every one was decoded, delivered and dropped. There was no onCues, no TextOutput and no SubtitleView anywhere in the app, and media3-ui was not even a dependency. The gap was invisible for as long as every subtitle URL 404ed: with no text track to select there was never a cue to lose, so fixing the URL is what exposed it. media3-ui's SubtitleView now takes each CueGroup and is attached at index 1 of the content view — above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is removed by the same teardown that detaches the surface (the defect DR-184 exists to prevent). Verified on a device: track selected with no "Invalid subtitle track index", SubtitleView attached at the fitted rect per the live view hierarchy, and cues legible on screen during playback. TRACES: UR-020, UR-003 | DR-260
This commit is contained in:
@@ -142,6 +142,10 @@ dependencies {
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
|
||||
implementation("androidx.media3:media3-session:1.5.0")
|
||||
implementation("androidx.media3:media3-common:1.5.0")
|
||||
// SubtitleView. ExoPlayer delivers cues to a listener and draws none of them
|
||||
// itself: without a view to hand them to, a selected subtitle track renders
|
||||
// nowhere. See JellyTauPlayer.onCues. (DR-260)
|
||||
implementation("androidx.media3:media3-ui:1.5.0")
|
||||
implementation("com.google.guava:guava:33.0.0-android")
|
||||
|
||||
// Media library for VolumeProviderCompat (remote volume control)
|
||||
|
||||
@@ -15,6 +15,9 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
object VideoOverlayManager {
|
||||
|
||||
private var attachedSurfaceView: TextureView? = null
|
||||
|
||||
/** The cue view attached alongside it, removed by the same teardown. */
|
||||
private var attachedSubtitleView: androidx.media3.ui.SubtitleView? = null
|
||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||
private var listenerContentView: ViewGroup? = null
|
||||
|
||||
@@ -56,6 +59,25 @@ object VideoOverlayManager {
|
||||
contentView.addView(surfaceView, 0, layoutParams)
|
||||
attachedSurfaceView = surfaceView
|
||||
|
||||
// Subtitles go directly above the video and still below the WebView:
|
||||
// visible through the transparent page, and under the app's own
|
||||
// controls rather than over them. Index 1 is what makes that
|
||||
// sandwich — the same reason the video is pinned to index 0.
|
||||
// TRACES: UR-020, UR-003 | DR-260
|
||||
player.getSubtitleView()?.let { subtitles ->
|
||||
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
|
||||
contentView.addView(
|
||||
subtitles,
|
||||
1,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
attachedSubtitleView = subtitles
|
||||
android.util.Log.d("VideoOverlayManager", "Subtitle view attached above the video")
|
||||
}
|
||||
|
||||
// Re-fit the video whenever the content view's bounds change (e.g. on
|
||||
// device rotation) so the video is letterboxed to fit instead of being
|
||||
// stretched/cropped by the MATCH_PARENT surface.
|
||||
@@ -101,6 +123,10 @@ object VideoOverlayManager {
|
||||
fun detachVideoSurface() {
|
||||
try {
|
||||
removeLayoutListener()
|
||||
attachedSubtitleView?.let { subtitles ->
|
||||
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
|
||||
attachedSubtitleView = null
|
||||
}
|
||||
attachedSurfaceView?.let { surfaceView ->
|
||||
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||
attachedSurfaceView = null
|
||||
|
||||
@@ -233,6 +233,23 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
|
||||
private var videoSurface: android.view.Surface? = null
|
||||
|
||||
/**
|
||||
* Draws subtitle cues over the picture.
|
||||
*
|
||||
* ExoPlayer decodes subtitles and *delivers* them to a listener; it draws
|
||||
* none of them itself. A `PlayerView` would supply this view, but native
|
||||
* video here is a bare TextureView the WebView composites over, so nothing
|
||||
* was holding the cues and a selected subtitle track rendered nowhere. That
|
||||
* gap was invisible for as long as every subtitle URL 404ed (DR-259) — with
|
||||
* no text track to select, there was never a cue to drop.
|
||||
*
|
||||
* Sized and positioned to the video rect rather than the screen, so cues sit
|
||||
* inside the picture rather than in a letterbox bar.
|
||||
*
|
||||
* TRACES: UR-020, UR-003 | DR-260
|
||||
*/
|
||||
private var subtitleView: androidx.media3.ui.SubtitleView? = null
|
||||
|
||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
@@ -539,6 +556,19 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand each cue group to the view that draws it.
|
||||
*
|
||||
* Fires with an empty list when subtitles are turned off or the
|
||||
* track has nothing to show at this moment, which is what clears the
|
||||
* previous cue — so this is the whole of both showing and hiding.
|
||||
*
|
||||
* TRACES: UR-020 | DR-260
|
||||
*/
|
||||
override fun onCues(cueGroup: androidx.media3.common.text.CueGroup) {
|
||||
subtitleView?.setCues(cueGroup.cues)
|
||||
}
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
|
||||
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
|
||||
@@ -1294,9 +1324,32 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
|
||||
}
|
||||
if (subtitleView == null) {
|
||||
subtitleView = androidx.media3.ui.SubtitleView(appContext).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Honour the viewer's system captioning preferences (size,
|
||||
// colour, edge style) and whatever styling the track carries —
|
||||
// the same defaults a PlayerView would have applied.
|
||||
setUserDefaultStyle()
|
||||
setUserDefaultTextSize()
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "SubtitleView created")
|
||||
}
|
||||
return videoView!!.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* The view that draws subtitle cues, for VideoOverlayManager to attach
|
||||
* directly above the video and below the WebView. Null before the first
|
||||
* video load.
|
||||
*/
|
||||
fun getSubtitleView(): androidx.media3.ui.SubtitleView? {
|
||||
return subtitleView
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the video view instance (for VideoOverlayManager).
|
||||
* Returns null if none has been created yet.
|
||||
@@ -1413,6 +1466,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
lp.height = targetH
|
||||
view.layoutParams = lp
|
||||
view.requestLayout()
|
||||
|
||||
// Cues belong to the picture, not to the screen: matching the
|
||||
// letterboxed rect keeps them off the black bars and moves them
|
||||
// with the video on rotation. (DR-260)
|
||||
subtitleView?.let { subs ->
|
||||
val slp = subs.layoutParams
|
||||
if (slp is FrameLayout.LayoutParams) {
|
||||
slp.gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
slp.width = targetW
|
||||
slp.height = targetH
|
||||
subs.layoutParams = slp
|
||||
subs.requestLayout()
|
||||
}
|
||||
android.util.Log.d(
|
||||
"JellyTauPlayer",
|
||||
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
|
||||
@@ -1442,6 +1509,10 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
exoPlayer.clearVideoSurface()
|
||||
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||
videoView = null
|
||||
// Released with the surface it belonged to; detachVideoSurface
|
||||
// removes it from the hierarchy, and keeping the reference would
|
||||
// leave the next video's cues going to an orphaned view.
|
||||
subtitleView = null
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user