diff --git a/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt b/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt index 9001798..28d432f 100644 --- a/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt +++ b/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt @@ -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), diff --git a/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt index 80decf5..ce76df0 100644 --- a/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt @@ -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 { + 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 = 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() } }