From 79066b2ce510f3b09de31ecf8aae4382f1d6c4b9 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 11 Jul 2026 15:49:48 +0200 Subject: [PATCH] feat(player): load external subtitles including ASS/SSA via SubtitleConfiguration --- .../DefaultPlayableMediaRepository.kt | 12 +++ .../playback/SubtitleConfigurationMapper.kt | 77 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SubtitleConfigurationMapper.kt diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index bb456585..bfb88b03 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -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.playback.JellyfinPlaybackResolver 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.MediaSegment import hu.bbara.purefin.model.PlayableMedia @@ -50,6 +51,7 @@ class DefaultPlayableMediaRepository @Inject constructor( private val userSessionRepository: UserSessionRepository, private val mediaDownloadController: MediaDownloadController, private val networkMonitor: NetworkMonitor, + private val subtitleConfigurationMapper: SubtitleConfigurationMapper, @param:Offline private val offlineMediaRepository: LocalMediaRepository, private val offlineMediaManager: OfflineMediaManager, ) : PlayableMediaRepository { @@ -198,6 +200,11 @@ class DefaultPlayableMediaRepository @Inject constructor( val serverUrl = userSessionRepository.serverUrl.first() val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY) + val subtitleConfigurations = subtitleConfigurationMapper.createSubtitleConfigurations( + mediaSource = playbackSource.mediaSource, + serverUrl = serverUrl, + ) + val mediaItem = createMediaItem( mediaId = mediaId.toString(), url = playbackSource.directPlayUrl, @@ -205,6 +212,7 @@ class DefaultPlayableMediaRepository @Inject constructor( subtitle = seasonEpisodeLabel(baseItem), artworkUrl = artworkUrl, playbackTag = playbackSource.toPlaybackMediaItemTag(), + subtitleConfigurations = subtitleConfigurations, ) return@withContext mediaItem @@ -283,6 +291,7 @@ class DefaultPlayableMediaRepository @Inject constructor( subtitle: String?, artworkUrl: String, playbackTag: Any?, + subtitleConfigurations: List = emptyList(), ): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(title) @@ -294,6 +303,9 @@ class DefaultPlayableMediaRepository @Inject constructor( .setMediaId(mediaId) .setMediaMetadata(metadata) .setTag(playbackTag) + if (subtitleConfigurations.isNotEmpty()) { + builder.setSubtitleConfigurations(subtitleConfigurations) + } return builder.build() } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SubtitleConfigurationMapper.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SubtitleConfigurationMapper.kt new file mode 100644 index 00000000..aa73ef5a --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SubtitleConfigurationMapper.kt @@ -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 { + 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) +}