mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(player): load external subtitles including ASS/SSA via SubtitleConfiguration
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user