mirror of
https://github.com/bbara04/Purefin.git
synced 2026-04-01 01:30:08 +02:00
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:
@@ -51,6 +51,14 @@ object AndroidDeviceProfile {
|
|||||||
codecProfiles = emptyList(),
|
codecProfiles = emptyList(),
|
||||||
|
|
||||||
subtitleProfiles = listOf(
|
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("srt", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
|
SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
|
SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL),
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
package hu.bbara.purefin.player.data
|
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.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
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.image.JellyfinImageHelper
|
||||||
import hu.bbara.purefin.session.UserSessionRepository
|
import hu.bbara.purefin.session.UserSessionRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
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
|
@ViewModelScoped
|
||||||
class PlayerMediaRepository @Inject constructor(
|
class PlayerMediaRepository @Inject constructor(
|
||||||
@@ -37,12 +44,15 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
val serverUrl = userSessionRepository.serverUrl.first()
|
val serverUrl = userSessionRepository.serverUrl.first()
|
||||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
||||||
|
|
||||||
|
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource)
|
||||||
|
|
||||||
val mediaItem = createMediaItem(
|
val mediaItem = createMediaItem(
|
||||||
mediaId = mediaId.toString(),
|
mediaId = mediaId.toString(),
|
||||||
playbackUrl = playbackUrl,
|
playbackUrl = playbackUrl,
|
||||||
title = baseItem?.name ?: selectedMediaSource.name!!,
|
title = baseItem?.name ?: selectedMediaSource.name!!,
|
||||||
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
|
subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}",
|
||||||
artworkUrl = artworkUrl
|
artworkUrl = artworkUrl,
|
||||||
|
subtitleConfigurations = subtitleConfigs
|
||||||
)
|
)
|
||||||
|
|
||||||
Pair(mediaItem, resumePositionMs)
|
Pair(mediaItem, resumePositionMs)
|
||||||
@@ -88,22 +98,81 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
mediaSource = selectedMediaSource
|
mediaSource = selectedMediaSource
|
||||||
) ?: return@mapNotNull null
|
) ?: return@mapNotNull null
|
||||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||||
|
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
|
||||||
createMediaItem(
|
createMediaItem(
|
||||||
mediaId = stringId,
|
mediaId = stringId,
|
||||||
playbackUrl = playbackUrl,
|
playbackUrl = playbackUrl,
|
||||||
title = episode.name ?: selectedMediaSource.name!!,
|
title = episode.name ?: selectedMediaSource.name!!,
|
||||||
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
|
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(
|
private fun createMediaItem(
|
||||||
mediaId: String,
|
mediaId: String,
|
||||||
playbackUrl: String,
|
playbackUrl: String,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
artworkUrl: String
|
artworkUrl: String,
|
||||||
|
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||||
): MediaItem {
|
): MediaItem {
|
||||||
val metadata = MediaMetadata.Builder()
|
val metadata = MediaMetadata.Builder()
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -114,6 +183,7 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
.setUri(playbackUrl.toUri())
|
.setUri(playbackUrl.toUri())
|
||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
.setMediaMetadata(metadata)
|
.setMediaMetadata(metadata)
|
||||||
|
.setSubtitleConfigurations(subtitleConfigurations)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user