feat(player): load external subtitles including ASS/SSA via SubtitleConfiguration

This commit is contained in:
2026-07-11 15:49:48 +02:00
parent dfab094782
commit 79066b2ce5
2 changed files with 89 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
import hu.bbara.purefin.data.jellyfin.playback.SubtitleConfigurationMapper
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.PlayableMedia
@@ -50,6 +51,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val mediaDownloadController: MediaDownloadController, private val mediaDownloadController: MediaDownloadController,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val subtitleConfigurationMapper: SubtitleConfigurationMapper,
@param:Offline private val offlineMediaRepository: LocalMediaRepository, @param:Offline private val offlineMediaRepository: LocalMediaRepository,
private val offlineMediaManager: OfflineMediaManager, private val offlineMediaManager: OfflineMediaManager,
) : PlayableMediaRepository { ) : PlayableMediaRepository {
@@ -198,6 +200,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY) val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY)
val subtitleConfigurations = subtitleConfigurationMapper.createSubtitleConfigurations(
mediaSource = playbackSource.mediaSource,
serverUrl = serverUrl,
)
val mediaItem = createMediaItem( val mediaItem = createMediaItem(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
url = playbackSource.directPlayUrl, url = playbackSource.directPlayUrl,
@@ -205,6 +212,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
subtitle = seasonEpisodeLabel(baseItem), subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
playbackTag = playbackSource.toPlaybackMediaItemTag(), playbackTag = playbackSource.toPlaybackMediaItemTag(),
subtitleConfigurations = subtitleConfigurations,
) )
return@withContext mediaItem return@withContext mediaItem
@@ -283,6 +291,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
subtitle: String?, subtitle: String?,
artworkUrl: String, artworkUrl: String,
playbackTag: Any?, playbackTag: Any?,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
): MediaItem { ): MediaItem {
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
.setTitle(title) .setTitle(title)
@@ -294,6 +303,9 @@ class DefaultPlayableMediaRepository @Inject constructor(
.setMediaId(mediaId) .setMediaId(mediaId)
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.setTag(playbackTag) .setTag(playbackTag)
if (subtitleConfigurations.isNotEmpty()) {
builder.setSubtitleConfigurations(subtitleConfigurations)
}
return builder.build() return builder.build()
} }

View File

@@ -0,0 +1,77 @@
package hu.bbara.purefin.data.jellyfin.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import javax.inject.Inject
class SubtitleConfigurationMapper @Inject constructor() {
@OptIn(UnstableApi::class)
fun createSubtitleConfigurations(
mediaSource: MediaSourceInfo,
serverUrl: String,
): List<MediaItem.SubtitleConfiguration> {
val streams = mediaSource.mediaStreams.orEmpty()
if (streams.isEmpty()) return emptyList()
return streams.mapNotNull { stream ->
if (stream.type != MediaStreamType.SUBTITLE) return@mapNotNull null
if (stream.deliveryMethod != SubtitleDeliveryMethod.EXTERNAL) return@mapNotNull null
val deliveryUrl = stream.deliveryUrl?.trim()?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val mimeType = mimeTypeForCodec(stream.codec)
?: return@mapNotNull null
val resolvedUri = resolveUrl(serverUrl, deliveryUrl)
val selectionFlags = buildSelectionFlags(stream.isForced, stream.isDefault)
MediaItem.SubtitleConfiguration.Builder(resolvedUri.toUri())
.setMimeType(mimeType)
.setLanguage(stream.language)
.setLabel(stream.displayTitle)
.setSelectionFlags(selectionFlags)
.build()
}
}
@OptIn(UnstableApi::class)
private fun mimeTypeForCodec(codec: String?): String? {
if (codec.isNullOrBlank()) return null
return when (codec.lowercase().trim()) {
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"ttml" -> MimeTypes.APPLICATION_TTML
"ass", "ssa" -> MimeTypes.TEXT_SSA
else -> null
}
}
@OptIn(UnstableApi::class)
private fun buildSelectionFlags(isForced: Boolean?, isDefault: Boolean?): Int {
var flags = 0
if (isForced == true) flags = flags or C.SELECTION_FLAG_FORCED
if (isDefault == true) flags = flags or C.SELECTION_FLAG_DEFAULT
return flags
}
private fun resolveUrl(serverUrl: String, url: String): String {
if (
url.startsWith("http://", ignoreCase = true) ||
url.startsWith("https://", ignoreCase = true)
) {
return url
}
return "${serverUrl.trimEnd('/')}/${url.trimStart('/')}"
}
private fun String.toUri() = android.net.Uri.parse(this)
}