fix: correct subtitle display after seeking by preferring embedded delivery

Subtitles were not showing the active cue after rewinding/seeking because
they were delivered externally and Media3 only picks up forward-starting
cues. Changed device profile to prefer EMBED delivery so subtitles stay
in the container where Media3's extraction-time parser handles them
correctly. Also added support for attaching external subtitle tracks to
MediaItem when embedding isn't possible.
This commit is contained in:
2026-02-16 20:19:22 +01:00
parent 43307229cb
commit 733a8b651f
2 changed files with 86 additions and 8 deletions

View File

@@ -51,6 +51,14 @@ object AndroidDeviceProfile {
codecProfiles = emptyList(),
subtitleProfiles = listOf(
// Prefer EMBED so subtitles stay in the container — this gives
// correct cues after seeking (Media3 parses them at extraction time).
SubtitleProfile("srt", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("ass", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("subrip", SubtitleDeliveryMethod.EMBED),
SubtitleProfile("sub", SubtitleDeliveryMethod.EMBED),
// EXTERNAL fallback for when embedding isn't possible
SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),

View File

@@ -1,20 +1,27 @@
package hu.bbara.purefin.player.data
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.client.JellyfinApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
import javax.inject.Inject
import androidx.core.net.toUri
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import java.util.UUID
import javax.inject.Inject
@ViewModelScoped
class PlayerMediaRepository @Inject constructor(
@@ -37,12 +44,15 @@ class PlayerMediaRepository @Inject constructor(
val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
title = baseItem?.name ?: selectedMediaSource.name!!,
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
artworkUrl = artworkUrl
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs
)
Pair(mediaItem, resumePositionMs)
@@ -88,22 +98,81 @@ class PlayerMediaRepository @Inject constructor(
mediaSource = selectedMediaSource
) ?: return@mapNotNull null
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
createMediaItem(
mediaId = stringId,
playbackUrl = playbackUrl,
title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
artworkUrl = artworkUrl
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs
)
}
}
@OptIn(UnstableApi::class)
private fun buildExternalSubtitleConfigs(
serverUrl: String,
mediaId: UUID,
mediaSource: MediaSourceInfo
): List<MediaItem.SubtitleConfiguration> {
val streams = mediaSource.mediaStreams ?: return emptyList()
val mediaSourceId = mediaSource.id ?: return emptyList()
val baseUrl = serverUrl.trimEnd('/')
return streams
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL }
.mapNotNull { stream ->
val codec = stream.codec ?: return@mapNotNull null
val mimeType = subtitleCodecToMimeType(codec) ?: return@mapNotNull null
// Use deliveryUrl from server if available, otherwise construct it
val url = if (!stream.deliveryUrl.isNullOrBlank()) {
if (stream.deliveryUrl!!.startsWith("http")) {
stream.deliveryUrl!!
} else {
"$baseUrl${stream.deliveryUrl}"
}
} else {
val format = if (codec == "subrip") "srt" else codec
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
}
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(stream.language)
.setLabel(stream.displayTitle ?: stream.language ?: "Track ${stream.index}")
.setSelectionFlags(
if (stream.isForced) C.SELECTION_FLAG_FORCED
else if (stream.isDefault) C.SELECTION_FLAG_DEFAULT
else 0
)
.build()
}
}
@OptIn(UnstableApi::class)
private fun subtitleCodecToMimeType(codec: String): String? = when (codec.lowercase()) {
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
"ass", "ssa" -> MimeTypes.TEXT_SSA
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"ttml", "dfxp" -> MimeTypes.APPLICATION_TTML
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> {
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
null
}
}
private fun createMediaItem(
mediaId: String,
playbackUrl: String,
title: String,
subtitle: String?,
artworkUrl: String
artworkUrl: String,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
@@ -114,6 +183,7 @@ class PlayerMediaRepository @Inject constructor(
.setUri(playbackUrl.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setSubtitleConfigurations(subtitleConfigurations)
.build()
}
}